diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index af5e24ffd..c25ce6ee9 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1345,6 +1345,12 @@ }, "sniffing": {}, "streamSettings": {}, + "subSortIndex": { + "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)", + "example": 1, + "minimum": 1, + "type": "integer" + }, "tag": { "example": "in-443-tcp", "type": "string" @@ -1385,6 +1391,7 @@ "shareAddrStrategy", "sniffing", "streamSettings", + "subSortIndex", "tag", "total", "trafficReset", @@ -2153,6 +2160,7 @@ "shareAddrStrategy": "node", "sniffing": null, "streamSettings": null, + "subSortIndex": 1, "tag": "in-443-tcp", "total": 0, "trafficReset": "never", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 77ba68441..fa76c9e63 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -292,6 +292,7 @@ export const EXAMPLES: Record = { "shareAddrStrategy": "node", "sniffing": null, "streamSettings": null, + "subSortIndex": 1, "tag": "in-443-tcp", "total": 0, "trafficReset": "never", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index ba3f23efd..3dea9c489 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1319,6 +1319,12 @@ export const SCHEMAS: Record = { }, "sniffing": {}, "streamSettings": {}, + "subSortIndex": { + "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)", + "example": 1, + "minimum": 1, + "type": "integer" + }, "tag": { "example": "in-443-tcp", "type": "string" @@ -1359,6 +1365,7 @@ export const SCHEMAS: Record = { "shareAddrStrategy", "sniffing", "streamSettings", + "subSortIndex", "tag", "total", "trafficReset", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 0f7b6c0ef..f38e609b8 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -293,6 +293,7 @@ export interface Inbound { shareAddrStrategy: string; sniffing: unknown; streamSettings: unknown; + subSortIndex: number; tag: string; total: number; trafficReset: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index d0a89e63a..9a49eefec 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -314,6 +314,7 @@ export const InboundSchema = z.object({ shareAddrStrategy: z.enum(['node', 'listen', 'custom']), sniffing: z.unknown(), streamSettings: z.unknown(), + subSortIndex: z.number().int().min(1), tag: z.string(), total: z.number().int(), trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']), diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 6c53175c8..8264ea89d 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -39,6 +39,7 @@ export interface RawInboundRow { nodeId?: number | null; shareAddrStrategy?: string; shareAddr?: string; + subSortIndex?: number; clientStats?: unknown; } @@ -65,6 +66,7 @@ export interface WireInboundPayload { nodeId?: number; shareAddrStrategy: ShareAddrStrategy; shareAddr: string; + subSortIndex: number; } function coerceJsonObject(value: unknown): Record { @@ -175,6 +177,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { nodeId: row.nodeId ?? null, shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy), shareAddr: row.shareAddr ?? '', + subSortIndex: Math.max(1, row.subSortIndex ?? 1), protocol, settings, } as InboundFormValues; @@ -322,6 +325,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP tag: values.tag, shareAddrStrategy: values.shareAddrStrategy, shareAddr: values.shareAddr, + subSortIndex: values.subSortIndex, }; if (values.nodeId != null) payload.nodeId = values.nodeId; return payload; diff --git a/frontend/src/models/dbinbound.ts b/frontend/src/models/dbinbound.ts index badb35106..81eecbdb5 100644 --- a/frontend/src/models/dbinbound.ts +++ b/frontend/src/models/dbinbound.ts @@ -42,6 +42,7 @@ export type DBInboundInit = Partial<{ nodeId: number | null; shareAddrStrategy: string; shareAddr: string; + subSortIndex: number; originNodeGuid: string; fallbackParent: FallbackParentRef | null; }>; @@ -88,6 +89,7 @@ export class DBInbound { nodeId: number | null; shareAddrStrategy: string; shareAddr: string; + subSortIndex: number; originNodeGuid: string; fallbackParent: FallbackParentRef | null; @@ -116,6 +118,7 @@ export class DBInbound { this.nodeId = null; this.shareAddrStrategy = "node"; this.shareAddr = ""; + this.subSortIndex = 1; this.originNodeGuid = ""; this.fallbackParent = null; if (data == null) { diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index ef11d3ae5..07a444f53 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -575,6 +575,14 @@ export default function InboundFormModal({ )} + + + + dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1), + [dbInbounds], + ); + const toggleSelect = useCallback((id: number, checked: boolean) => { setSelectedRowKeys((prev) => { const next = new Set(prev); @@ -115,6 +120,7 @@ export default function InboundList({ const columns = useInboundColumns({ hasAnyRemark, + hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, diff --git a/frontend/src/pages/inbounds/list/types.ts b/frontend/src/pages/inbounds/list/types.ts index 3efdf4bdd..149269db0 100644 --- a/frontend/src/pages/inbounds/list/types.ts +++ b/frontend/src/pages/inbounds/list/types.ts @@ -22,6 +22,7 @@ export interface DBInboundRecord extends ProtocolFlags { id: number; enable: boolean; remark: string; + subSortIndex: number; port: number; protocol: string; up: number; diff --git a/frontend/src/pages/inbounds/list/useInboundColumns.tsx b/frontend/src/pages/inbounds/list/useInboundColumns.tsx index 2c9f3d63a..00d6fd41d 100644 --- a/frontend/src/pages/inbounds/list/useInboundColumns.tsx +++ b/frontend/src/pages/inbounds/list/useInboundColumns.tsx @@ -1,6 +1,6 @@ import { useMemo, type ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; -import { Popover, Switch, Tag, type TableColumnType } from 'antd'; +import { Popover, Switch, Tag, Tooltip, type TableColumnType } from 'antd'; import { TeamOutlined } from '@ant-design/icons'; import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils'; @@ -21,6 +21,7 @@ import type { ClientCountEntry, DBInboundRecord, RowAction } from './types'; interface UseInboundColumnsParams { hasAnyRemark: boolean; + hasAnySubSortIndex: boolean; hasActiveNode: boolean; nodesById: Map; clientCount: Record; @@ -33,6 +34,7 @@ interface UseInboundColumnsParams { export function useInboundColumns({ hasAnyRemark, + hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, @@ -113,6 +115,20 @@ export function useInboundColumns({ }); } + if (hasAnySubSortIndex) { + cols.push({ + title: ( + + {t('pages.inbounds.subSortIndex')} + + ), + dataIndex: 'subSortIndex', + key: 'subSortIndex', + align: 'right', + width: 70, + }); + } + cols.push( { title: t('pages.inbounds.port'), @@ -267,5 +283,5 @@ export function useInboundColumns({ ); return cols; - }, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]); + }, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]); } diff --git a/frontend/src/schemas/forms/inbound-form.ts b/frontend/src/schemas/forms/inbound-form.ts index d5a926631..ae10581bc 100644 --- a/frontend/src/schemas/forms/inbound-form.ts +++ b/frontend/src/schemas/forms/inbound-form.ts @@ -39,6 +39,7 @@ export const InboundDbFieldsSchema = z.object({ nodeId: z.number().int().nullable().optional(), shareAddrStrategy: ShareAddrStrategySchema.default('node'), shareAddr: z.string().default(''), + subSortIndex: z.number().int().min(1).default(1), }); export type InboundDbFields = z.infer; diff --git a/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap b/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap index d18a905f4..6f5326857 100644 --- a/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap +++ b/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap @@ -7,6 +7,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > http "Protocol", "Address", "Share address strategy", + "Subscription sort order", "Port", "Total Flow", "Traffic Reset", @@ -22,6 +23,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > hyste "Protocol", "Address", "Share address strategy", + "Subscription sort order", "Port", "Total Flow", "Traffic Reset", @@ -37,6 +39,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > mixed "Protocol", "Address", "Share address strategy", + "Subscription sort order", "Port", "Total Flow", "Traffic Reset", @@ -52,6 +55,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > shado "Protocol", "Address", "Share address strategy", + "Subscription sort order", "Port", "Total Flow", "Traffic Reset", @@ -67,6 +71,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > troja "Protocol", "Address", "Share address strategy", + "Subscription sort order", "Port", "Total Flow", "Traffic Reset", @@ -82,6 +87,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tun 1 "Protocol", "Address", "Share address strategy", + "Subscription sort order", "Port", "Total Flow", "Traffic Reset", @@ -97,6 +103,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tunne "Protocol", "Address", "Share address strategy", + "Subscription sort order", "Port", "Total Flow", "Traffic Reset", @@ -112,6 +119,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vless "Protocol", "Address", "Share address strategy", + "Subscription sort order", "Port", "Total Flow", "Traffic Reset", @@ -127,6 +135,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vmess "Protocol", "Address", "Share address strategy", + "Subscription sort order", "Port", "Total Flow", "Traffic Reset", @@ -142,6 +151,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > wireg "Protocol", "Address", "Share address strategy", + "Subscription sort order", "Port", "Total Flow", "Traffic Reset", diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index 201bf42b0..1b15cfaff 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -6,7 +6,7 @@ import { formValuesToWirePayload, type RawInboundRow, } from '@/lib/xray/inbound-form-adapter'; -import { InboundFormSchema } from '@/schemas/forms/inbound-form'; +import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form'; import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; // Round-trip: raw DB row → InboundFormValues → wire payload, asserting @@ -262,3 +262,35 @@ describe('formValuesToWirePayload', () => { expect(replay.streamSettings).toEqual(original.streamSettings); }); }); + +describe('subSortIndex', () => { + it('rawInboundToFormValues defaults to 1 when field is absent', () => { + const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined }); + expect(values.subSortIndex).toBe(1); + }); + + it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => { + expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 5 }).subSortIndex).toBe(5); + expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 0 }).subSortIndex).toBe(1); + expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(1); + }); + + it('formValuesToWirePayload includes subSortIndex in the payload', () => { + const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 3 }); + const payload = formValuesToWirePayload(values); + expect(payload.subSortIndex).toBe(3); + }); + + it('subSortIndex round-trips through raw → values → payload', () => { + const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 42 }); + const payload = formValuesToWirePayload(values); + const replay = rawInboundToFormValues({ ...vlessRow, subSortIndex: payload.subSortIndex }); + expect(replay.subSortIndex).toBe(42); + }); + + it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => { + expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false); + expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false); + expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1); + }); +}); diff --git a/internal/database/db.go b/internal/database/db.go index 15cf2debf..53150f579 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -91,6 +91,9 @@ func initModels() error { if err := pruneOrphanedClientInbounds(); err != nil { return err } + if err := normalizeInboundSubSortIndex(); err != nil { + return err + } if IsPostgres() { if err := resyncPostgresSequences(db, models); err != nil { log.Printf("Error resyncing postgres sequences: %v", err) @@ -123,6 +126,21 @@ func pruneOrphanedClientInbounds() error { return nil } +// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based +// minimum (rows written by builds that defaulted the column to 0, or by nodes +// predating the field) so they cannot sort ahead of explicitly ranked inbounds. +func normalizeInboundSubSortIndex() error { + res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index < 1") + if res.Error != nil { + log.Printf("Error normalizing inbound sub_sort_index: %v", res.Error) + return res.Error + } + if res.RowsAffected > 0 { + log.Printf("Normalized sub_sort_index on %d inbound(s)", res.RowsAffected) + } + return nil +} + func isIgnorableDuplicateColumnErr(err error, mdl any) bool { if err == nil { return false diff --git a/internal/database/model/model.go b/internal/database/model/model.go index df477fe78..faaff5bc2 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -50,6 +50,7 @@ type Inbound struct { Down int64 `json:"down" form:"down"` // Download traffic in bytes Total int64 `json:"total" form:"total"` // Total traffic limit in bytes Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark + SubSortIndex int `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty,gte=1" example:"1"` // 1-based sort order of this inbound's links in subscription output only (lower first; ties by id) Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule diff --git a/internal/sub/service.go b/internal/sub/service.go index 3d322e03a..ff94bbc13 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -284,7 +284,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) WHERE inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria') AND clients.sub_id = ? AND inbounds.enable = ? - )`, subId, true).Find(&inbounds).Error + )`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error if err != nil { return nil, err } diff --git a/internal/sub/service_sort_test.go b/internal/sub/service_sort_test.go new file mode 100644 index 000000000..74cae7a0c --- /dev/null +++ b/internal/sub/service_sort_test.go @@ -0,0 +1,79 @@ +package sub + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// TestGetSubs_OrdersBySubSortIndexThenId verifies that subscription output +// lists inbound links ordered by sub_sort_index ASC, breaking ties by id ASC. +// The same query feeds the raw body, the HTML sub page, and the JSON/Clash +// formats, so asserting on GetSubs covers all of them. +func TestGetSubs_OrdersBySubSortIndexThenId(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + const subId = "sub-sort" + db := database.GetDB() + + seed := []struct { + tag string + port int + subSortIndex int + email string + uuid string + }{ + // Created in this order on purpose: without the ORDER BY the links + // would come out s3, s1, s2a, s2b (creation order). + {"sort-3", 42101, 3, "s3@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf001"}, + {"sort-1", 42102, 1, "s1@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf002"}, + {"sort-2a", 42103, 2, "s2a@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf003"}, + {"sort-2b", 42104, 2, "s2b@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf004"}, + } + for _, s := range seed { + settings := fmt.Sprintf(`{"clients": [{"id": %q, "email": %q, "subId": %q, "enable": true}]}`, s.uuid, s.email, subId) + ib := &model.Inbound{ + UserId: 1, + Tag: s.tag, + Enable: true, + Port: s.port, + Protocol: model.VLESS, + Settings: settings, + StreamSettings: `{"network": "tcp", "security": "none"}`, + SubSortIndex: s.subSortIndex, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed inbound %s: %v", s.tag, err) + } + client := &model.ClientRecord{Email: s.email, SubID: subId, UUID: s.uuid, Enable: true} + if err := db.Create(client).Error; err != nil { + t.Fatalf("seed client %s: %v", s.email, err) + } + if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil { + t.Fatalf("seed client_inbound %s: %v", s.email, err) + } + } + + s := NewSubService(false, "-ieo") + links, emails, _, _, err := s.GetSubs(subId, "sub.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + if len(links) != len(seed) { + t.Fatalf("links = %d, want %d", len(links), len(seed)) + } + want := []string{"s1@example.com", "s2a@example.com", "s2b@example.com", "s3@example.com"} + for i, email := range want { + if emails[i] != email { + t.Fatalf("emails order = %v, want %v (sub_sort_index ASC, id ASC)", emails, want) + } + } +} diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index 404d088cc..ec7b37ddf 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -490,6 +490,7 @@ func wireInbound(ib *model.Inbound) url.Values { v := url.Values{} v.Set("total", strconv.FormatInt(ib.Total, 10)) v.Set("remark", ib.Remark) + v.Set("subSortIndex", strconv.Itoa(ib.SubSortIndex)) v.Set("enable", strconv.FormatBool(ib.Enable)) v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10)) v.Set("listen", ib.Listen) diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 3f763ee92..58d719406 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -458,6 +458,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo // Normalize streamSettings based on protocol s.normalizeStreamSettings(inbound) s.normalizeMtprotoSecret(inbound) + inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) if err := normalizeInboundShareAddressStrict(inbound); err != nil { return inbound, false, err } @@ -786,6 +787,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, // Normalize streamSettings based on protocol s.normalizeStreamSettings(inbound) s.normalizeMtprotoSecret(inbound) + inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) conflict, err := s.checkPortConflict(inbound, inbound.Id) if err != nil { @@ -888,6 +890,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, oldInbound.Total = inbound.Total oldInbound.Remark = inbound.Remark + oldInbound.SubSortIndex = inbound.SubSortIndex oldInbound.Enable = inbound.Enable oldInbound.ExpiryTime = inbound.ExpiryTime oldInbound.TrafficReset = inbound.TrafficReset diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 9b6522b7a..1d9e86f46 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -358,6 +358,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi LastTrafficResetTime: snapIb.LastTrafficResetTime, Enable: snapIb.Enable, Remark: snapIb.Remark, + SubSortIndex: normalizeSubSortIndex(snapIb.SubSortIndex), Total: snapIb.Total, ExpiryTime: snapIb.ExpiryTime, Up: snapIb.Up, @@ -382,6 +383,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if !dirty { updates["enable"] = snapIb.Enable updates["remark"] = snapIb.Remark + updates["sub_sort_index"] = normalizeSubSortIndex(snapIb.SubSortIndex) updates["listen"] = snapIb.Listen updates["port"] = snapIb.Port updates["protocol"] = snapIb.Protocol diff --git a/internal/web/service/inbound_sub_sort_test.go b/internal/web/service/inbound_sub_sort_test.go new file mode 100644 index 000000000..c52d084b0 --- /dev/null +++ b/internal/web/service/inbound_sub_sort_test.go @@ -0,0 +1,111 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func makeInboundWithSubSortIndex(tag string, port int, subSortIndex int) *model.Inbound { + return &model.Inbound{ + UserId: 1, + Tag: tag, + Enable: true, + Listen: "0.0.0.0", + Port: port, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp"}`, + Settings: `{"clients":[]}`, + SubSortIndex: subSortIndex, + } +} + +// TestUpdateInbound_PersistsSubSortIndex verifies that UpdateInbound copies +// SubSortIndex from the incoming update payload to the persisted row. +func TestUpdateInbound_PersistsSubSortIndex(t *testing.T) { + setupConflictDB(t) + + ib := makeInboundWithSubSortIndex("in-7001-tcp", 7001, 1) + if err := database.GetDB().Create(ib).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + + update := *ib + update.SubSortIndex = 7 + + svc := &InboundService{} + got, _, err := svc.UpdateInbound(&update) + if err != nil { + t.Fatalf("UpdateInbound: %v", err) + } + if got.SubSortIndex != 7 { + t.Fatalf("returned SubSortIndex = %d, want 7", got.SubSortIndex) + } + + var reloaded model.Inbound + if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.SubSortIndex != 7 { + t.Fatalf("persisted SubSortIndex = %d, want 7", reloaded.SubSortIndex) + } +} + +// TestUpdateInbound_SubSortIndexClampedToMinimum verifies that values below +// the 1-based minimum (0 from clients that predate the field, or negatives) +// are clamped to 1 instead of being stored. +func TestUpdateInbound_SubSortIndexClampedToMinimum(t *testing.T) { + setupConflictDB(t) + + ib := makeInboundWithSubSortIndex("in-7002-tcp", 7002, 5) + if err := database.GetDB().Create(ib).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + + svc := &InboundService{} + for _, below := range []int{0, -3} { + update := *ib + update.SubSortIndex = below + + got, _, err := svc.UpdateInbound(&update) + if err != nil { + t.Fatalf("UpdateInbound(%d): %v", below, err) + } + if got.SubSortIndex != 1 { + t.Fatalf("returned SubSortIndex = %d for input %d, want 1", got.SubSortIndex, below) + } + + var reloaded model.Inbound + if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.SubSortIndex != 1 { + t.Fatalf("persisted SubSortIndex = %d for input %d, want 1", reloaded.SubSortIndex, below) + } + } +} + +// TestAddInbound_SubSortIndexClampedToMinimum verifies the same clamping on +// the create path (an omitted form field binds to 0). +func TestAddInbound_SubSortIndexClampedToMinimum(t *testing.T) { + setupConflictDB(t) + + svc := &InboundService{} + ib := makeInboundWithSubSortIndex("in-7003-tcp", 7003, 0) + got, _, err := svc.AddInbound(ib) + if err != nil { + t.Fatalf("AddInbound: %v", err) + } + if got.SubSortIndex != 1 { + t.Fatalf("returned SubSortIndex = %d, want 1", got.SubSortIndex) + } + + var reloaded model.Inbound + if err := database.GetDB().First(&reloaded, got.Id).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.SubSortIndex != 1 { + t.Fatalf("persisted SubSortIndex = %d, want 1", reloaded.SubSortIndex) + } +} diff --git a/internal/web/service/inbound_util.go b/internal/web/service/inbound_util.go index 20e3aef7a..4c343ab9c 100644 --- a/internal/web/service/inbound_util.go +++ b/internal/web/service/inbound_util.go @@ -7,6 +7,16 @@ package service // installs (>32k clients) where even modern SQLite would refuse a single IN. const sqliteMaxVars = 900 +// normalizeSubSortIndex clamps the 1-based subscription sort order. Values +// below 1 arrive from clients that predate the field (omitted form key binds +// to 0) and must not sort ahead of explicitly ranked inbounds. +func normalizeSubSortIndex(v int) int { + if v < 1 { + return 1 + } + return v +} + // uniqueNonEmptyStrings returns a deduplicated copy of in with empty strings // removed, preserving the order of first occurrence. func uniqueNonEmptyStrings(in []string) []string { diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 79693c79d..c77aeba01 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -416,6 +416,7 @@ }, "telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو ({'@'}userinfobot)", "subscriptionDesc": "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء.", + "subSortIndex": "ترتيب الاشتراك", "same": "نفسه", "inboundInfo": "معلومات الإدخال", "exportInbound": "تصدير الإدخال", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "تحدد العنوان الذي يُكتب في روابط المشاركة المصدّرة ورموز QR ومخرجات الاشتراك.", "shareAddr": "عنوان مشاركة مخصص", "shareAddrHelp": "يُستخدم فقط عندما تكون استراتيجية عنوان المشاركة مخصصة. أدخل اسم مضيف أو عنوان IP بدون بروتوكول أو منفذ.", + "subSortIndex": "ترتيب الروابط في الاشتراك", + "subSortIndexHelp": "موضع روابط هذا الوارد في مخرجات الاشتراك (صفحة الاشتراك وتطبيقات العملاء). القيم الأقل تظهر أولاً، والقيم المتساوية تحافظ على ترتيب الإنشاء. لا يؤثر على قائمة الواردات في اللوحة.", "shareAddrStrategyOptions": { "node": "عنوان العقدة", "listen": "عنوان استماع الوارد", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index d799922bf..ae5dc6b27 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -416,6 +416,7 @@ }, "telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)", "subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.", + "subSortIndex": "Sub order", "same": "Same", "inboundInfo": "Inbound Information", "exportInbound": "Export Inbound", @@ -596,6 +597,8 @@ "shareAddrStrategyHelp": "Controls which address is written into exported share links, QR codes, and subscription output.", "shareAddr": "Custom share address", "shareAddrHelp": "Used only when the share address strategy is Custom. Enter a host or IP without a scheme or port.", + "subSortIndex": "Subscription sort order", + "subSortIndexHelp": "Position of this inbound's links in subscription output (sub page and client apps). Lower values come first; equal values keep creation order. Does not affect the panel inbound list.", "shareAddrStrategyOptions": { "node": "Node address", "listen": "Inbound listen", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index fd8ee220c..42b9f0a7b 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -416,6 +416,7 @@ }, "telegramDesc": "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o ({'@'}userinfobot)", "subscriptionDesc": "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones.", + "subSortIndex": "Orden sub", "same": "misma", "inboundInfo": "Información de entrada", "exportInbound": "Exportación entrante", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "Controla qué dirección se escribe en los enlaces compartidos exportados, códigos QR y la salida de suscripción.", "shareAddr": "Dirección compartida personalizada", "shareAddrHelp": "Solo se usa cuando la estrategia de dirección para compartir es Personalizada. Introduce un host o IP sin esquema ni puerto.", + "subSortIndex": "Orden en la suscripción", + "subSortIndexHelp": "Posición de los enlaces de esta entrada en la salida de la suscripción (página de suscripción y apps cliente). Los valores más bajos van primero; con valores iguales se mantiene el orden de creación. No afecta a la lista de entradas del panel.", "shareAddrStrategyOptions": { "node": "Dirección del nodo", "listen": "Dirección de escucha del inbound", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index e5495c368..46fec282a 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -416,6 +416,7 @@ }, "telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا ({'@'}userinfobot)", "subscriptionDesc": "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید", + "subSortIndex": "ترتیب اشتراک", "same": "همسان", "inboundInfo": "اطلاعات ورودی", "exportInbound": "استخراج ورودی", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "مشخص می‌کند کدام آدرس در لینک‌های اشتراک‌گذاری خروجی، کدهای QR و خروجی اشتراک نوشته شود.", "shareAddr": "آدرس اشتراک‌گذاری سفارشی", "shareAddrHelp": "فقط زمانی استفاده می‌شود که راهبرد آدرس اشتراک‌گذاری روی سفارشی باشد. میزبان یا IP را بدون طرح و پورت وارد کنید.", + "subSortIndex": "ترتیب در اشتراک", + "subSortIndexHelp": "جایگاه لینک‌های این ورودی در خروجی اشتراک (صفحه اشتراک و برنامه‌های کلاینت). مقدار کمتر اول می‌آید و مقدارهای برابر ترتیب ایجاد را حفظ می‌کنند. روی فهرست ورودی‌های پنل تأثیری ندارد.", "shareAddrStrategyOptions": { "node": "آدرس نود", "listen": "آدرس شنود ورودی", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 65972a4dd..6f4213cbb 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -416,6 +416,7 @@ }, "telegramDesc": "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau ({'@'}userinfobot)", "subscriptionDesc": "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien.", + "subSortIndex": "Urutan sub", "same": "Sama", "inboundInfo": "Informasi Inbound", "exportInbound": "Ekspor Masuk", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "Menentukan alamat yang ditulis ke tautan berbagi yang diekspor, kode QR, dan keluaran langganan.", "shareAddr": "Alamat berbagi kustom", "shareAddrHelp": "Hanya digunakan saat strategi alamat berbagi adalah Kustom. Masukkan host atau IP tanpa skema atau port.", + "subSortIndex": "Urutan dalam langganan", + "subSortIndexHelp": "Posisi tautan inbound ini dalam keluaran langganan (halaman langganan dan aplikasi klien). Nilai lebih kecil tampil lebih dulu; nilai sama mempertahankan urutan pembuatan. Tidak memengaruhi daftar inbound di panel.", "shareAddrStrategyOptions": { "node": "Alamat node", "listen": "Alamat listen inbound", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 51979af11..6d9f6b8bf 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -416,6 +416,7 @@ }, "telegramDesc": "TelegramチャットIDを提供してください。(ボットで'/id'コマンドを使用)または({'@'}userinfobot)", "subscriptionDesc": "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。", + "subSortIndex": "サブ並び順", "same": "同じ", "inboundInfo": "インバウンド情報", "exportInbound": "インバウンドルールをエクスポート", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "エクスポートされる共有リンク、QRコード、サブスクリプション出力に書き込むアドレスを制御します。", "shareAddr": "カスタム共有アドレス", "shareAddrHelp": "共有アドレス戦略がカスタムの場合のみ使用されます。スキームやポートを含めずにホスト名またはIPを入力してください。", + "subSortIndex": "サブスクリプションでの並び順", + "subSortIndexHelp": "サブスクリプション出力(サブスクリプションページおよびクライアントアプリ)におけるこのインバウンドのリンクの位置。値が小さいほど先頭に表示され、同じ値の場合は作成順が維持されます。パネルのインバウンド一覧には影響しません。", "shareAddrStrategyOptions": { "node": "ノードアドレス", "listen": "インバウンドのリッスンアドレス", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 4f7cc2e25..1a41d79d5 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -416,6 +416,7 @@ }, "telegramDesc": "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou ({'@'}userinfobot)", "subscriptionDesc": "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes.", + "subSortIndex": "Ordem sub", "same": "Igual", "inboundInfo": "Informações do Inbound", "exportInbound": "Exportar Inbound", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "Controla qual endereço é gravado nos links de compartilhamento exportados, códigos QR e na saída de assinatura.", "shareAddr": "Endereço de compartilhamento personalizado", "shareAddrHelp": "Usado apenas quando a estratégia de endereço de compartilhamento é Personalizada. Informe um host ou IP sem esquema nem porta.", + "subSortIndex": "Ordem na assinatura", + "subSortIndexHelp": "Posição dos links desta entrada na saída da assinatura (página de assinatura e aplicativos cliente). Valores menores vêm primeiro; valores iguais mantêm a ordem de criação. Não afeta a lista de entradas do painel.", "shareAddrStrategyOptions": { "node": "Endereço do nó", "listen": "Endereço de escuta do inbound", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 96f555f32..59ab160e8 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -416,6 +416,7 @@ }, "telegramDesc": "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или ({'@'}userinfobot)", "subscriptionDesc": "Вы можете найти свою ссылку подписки в разделе 'Подробнее'", + "subSortIndex": "Порядок", "same": "Тот же", "inboundInfo": "Информация о подключении", "exportInbound": "Экспорт подключений", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "Определяет, какой адрес записывать в экспортируемые ссылки, QR-коды и выдачу подписки.", "shareAddr": "Пользовательский адрес для ссылок", "shareAddrHelp": "Используется только когда стратегия адреса для ссылок — пользовательская. Укажите хост или IP без схемы и порта.", + "subSortIndex": "Порядок в подписке", + "subSortIndexHelp": "Позиция ссылок этого входящего в выдаче подписки (страница подписки и клиентские приложения). Меньшие значения идут первыми; при равных значениях сохраняется порядок создания. Не влияет на список входящих в панели.", "shareAddrStrategyOptions": { "node": "Адрес узла", "listen": "Адрес прослушивания inbound", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index fe20e0219..d1e12679f 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -416,6 +416,7 @@ }, "telegramDesc": "Lütfen Telegram Sohbet Kimliği (Chat ID) sağlayın. ({'@'}userinfobot'tan öğrenebilir veya botta '/id' komutunu kullanabilirsiniz.)", "subscriptionDesc": "Abonelik URL'nizi bulmak için 'Detaylar'a gidin. Aynı adı birden fazla kullanıcı için kullanabilirsiniz.", + "subSortIndex": "Sıralama", "same": "Aynı", "inboundInfo": "Gelen Bağlantı Bilgileri", "exportInbound": "Gelen Bağlantını Dışa Aktar", @@ -596,6 +597,8 @@ "shareAddrStrategyHelp": "Dışa aktarılan paylaşım bağlantılarına, QR kodlarına ve abonelik çıktısına hangi adresin yazılacağını belirler.", "shareAddr": "Özel paylaşım adresi", "shareAddrHelp": "Yalnızca paylaşım adresi stratejisi Özel olduğunda kullanılır. Şema veya port olmadan bir ana makine ya da IP girin.", + "subSortIndex": "Abonelikte sıralama", + "subSortIndexHelp": "Bu gelen bağlantının linklerinin abonelik çıktısındaki (abonelik sayfası ve istemci uygulamaları) konumu. Küçük değerler önce gelir; eşit değerlerde oluşturulma sırası korunur. Paneldeki gelen bağlantı listesini etkilemez.", "shareAddrStrategyOptions": { "node": "Düğüm adresi", "listen": "Inbound dinleme adresi", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 3c0be5b42..e1afa6c77 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -416,6 +416,7 @@ }, "telegramDesc": "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або ({'@'}userinfobot)", "subscriptionDesc": "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів.", + "subSortIndex": "Порядок", "same": "Те саме", "inboundInfo": "Інформація про підключення", "exportInbound": "Експортувати вхідні", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "Визначає, яку адресу записувати в експортовані посилання поширення, QR-коди та вивід підписки.", "shareAddr": "Користувацька адреса поширення", "shareAddrHelp": "Використовується лише коли стратегія адреси поширення — користувацька. Введіть хост або IP без схеми та порту.", + "subSortIndex": "Порядок у підписці", + "subSortIndexHelp": "Позиція посилань цього вхідного у виводі підписки (сторінка підписки та клієнтські застосунки). Менші значення йдуть першими; за однакових значень зберігається порядок створення. Не впливає на список вхідних у панелі.", "shareAddrStrategyOptions": { "node": "Адреса вузла", "listen": "Адреса прослуховування inbound", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 0dcecb847..03db7d615 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -416,6 +416,7 @@ }, "telegramDesc": "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc ({'@'}userinfobot)", "subscriptionDesc": "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau", + "subSortIndex": "Thứ tự sub", "same": "Giống nhau", "inboundInfo": "Thông tin Inbound", "exportInbound": "Xuất nhập khẩu", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "Kiểm soát địa chỉ được ghi vào liên kết chia sẻ đã xuất, mã QR và nội dung đăng ký.", "shareAddr": "Địa chỉ chia sẻ tùy chỉnh", "shareAddrHelp": "Chỉ dùng khi chiến lược địa chỉ chia sẻ là Tùy chỉnh. Nhập host hoặc IP không kèm giao thức hoặc cổng.", + "subSortIndex": "Thứ tự trong gói đăng ký", + "subSortIndexHelp": "Vị trí liên kết của inbound này trong nội dung gói đăng ký (trang đăng ký và ứng dụng khách). Giá trị nhỏ hơn xếp trước; giá trị bằng nhau giữ thứ tự tạo. Không ảnh hưởng đến danh sách inbound trong bảng điều khiển.", "shareAddrStrategyOptions": { "node": "Địa chỉ node", "listen": "Địa chỉ listen inbound", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 3957f8038..61d007e00 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -416,6 +416,7 @@ }, "telegramDesc": "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或({'@'}userinfobot", "subscriptionDesc": "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。", + "subSortIndex": "订阅排序", "same": "相同", "inboundInfo": "入站信息", "exportInbound": "导出入站规则", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "控制导出分享链接、二维码和订阅输出时写入哪个地址。", "shareAddr": "自定义分享地址", "shareAddrHelp": "仅在分享地址策略为自定义时使用。填写不带协议和端口的域名或 IP。", + "subSortIndex": "订阅排序", + "subSortIndexHelp": "此入站的链接在订阅输出(订阅页面和客户端应用)中的位置。数值越小越靠前;数值相同时保持创建顺序。不影响面板中的入站列表。", "shareAddrStrategyOptions": { "node": "节点地址", "listen": "入站监听地址", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 2df907762..24555e66b 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -416,6 +416,7 @@ }, "telegramDesc": "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或({'@'}userinfobot", "subscriptionDesc": "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。", + "subSortIndex": "訂閱排序", "same": "相同", "inboundInfo": "入站資訊", "exportInbound": "匯出入站規則", @@ -595,6 +596,8 @@ "shareAddrStrategyHelp": "控制匯出分享連結、QR Code 和訂閱輸出時寫入哪個地址。", "shareAddr": "自訂分享地址", "shareAddrHelp": "僅在分享地址策略為自訂時使用。填寫不帶協定和連接埠的網域或 IP。", + "subSortIndex": "訂閱排序", + "subSortIndexHelp": "此入站的連結在訂閱輸出(訂閱頁面和客戶端應用)中的位置。數值越小越靠前;數值相同時保持建立順序。不影響面板中的入站清單。", "shareAddrStrategyOptions": { "node": "節點地址", "listen": "入站監聽地址",