Просмотр исходного кода

fix(inbounds): allow negative subSortIndex for subscription order (#6465)

* fix(inbounds): allow negative subSortIndex for subscription order

Preserve explicitly set negative indices so primary inbounds can sort
ahead of the default without renumbering peers; keep 0/omitted → 1.

* fix(inbounds): gofumpt model.go and trim subSortIndex comments

---------

Co-authored-by: mrchatam <[email protected]>
mrchatam 3 часов назад
Родитель
Сommit
51e0afdd90

+ 1 - 2
docs/public/openapi.json

@@ -2630,9 +2630,8 @@
           "sniffing": {},
           "streamSettings": {},
           "subSortIndex": {
-            "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
+            "description": "Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)",
             "example": 1,
-            "minimum": 1,
             "type": "integer"
           },
           "tag": {

+ 1 - 2
frontend/public/openapi.json

@@ -2630,9 +2630,8 @@
           "sniffing": {},
           "streamSettings": {},
           "subSortIndex": {
-            "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
+            "description": "Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)",
             "example": 1,
-            "minimum": 1,
             "type": "integer"
           },
           "tag": {

+ 1 - 2
frontend/src/generated/schemas.ts

@@ -2604,9 +2604,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "sniffing": {},
       "streamSettings": {},
       "subSortIndex": {
-        "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
+        "description": "Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)",
         "example": 1,
-        "minimum": 1,
         "type": "integer"
       },
       "tag": {

+ 1 - 1
frontend/src/generated/zod.ts

@@ -638,7 +638,7 @@ export const InboundSchema = z.object({
   shareAddrStrategy: z.enum(['node', 'listen', 'custom']),
   sniffing: z.unknown(),
   streamSettings: z.unknown(),
-  subSortIndex: z.number().int().min(1),
+  subSortIndex: z.number().int(),
   tag: z.string(),
   total: z.number().int(),
   trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),

+ 1 - 1
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -210,7 +210,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),
+    subSortIndex: row.subSortIndex == null || row.subSortIndex === 0 ? 1 : row.subSortIndex,
     disableFlow: row.disableFlow ?? false,
     protocol,
     settings,

+ 1 - 1
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -697,7 +697,7 @@ export default function InboundFormModal({
           t('pages.inbounds.form.subSortIndexHelp'),
         )}
       >
-        <InputNumber min={1} />
+        <InputNumber />
       </FormField>
 
       {protocol === Protocols.VLESS && (

+ 1 - 1
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -119,7 +119,7 @@ export default function InboundList({
   );
 
   const hasAnySubSortIndex = useMemo(
-    () => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1),
+    () => dbInbounds.some((i) => (i.subSortIndex ?? 1) !== 1),
     [dbInbounds],
   );
 

+ 1 - 1
frontend/src/schemas/forms/inbound-form.ts

@@ -80,7 +80,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),
+  subSortIndex: z.number().int().default(1),
   disableFlow: z.boolean().default(false),
 });
 export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;

+ 5 - 8
frontend/src/test/inbound-form-adapter.test.ts

@@ -329,10 +329,10 @@ describe('subSortIndex', () => {
     expect(values.subSortIndex).toBe(1);
   });
 
-  it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => {
+  it('rawInboundToFormValues preserves positives and negatives; maps 0/absent 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);
+    expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(-10);
   });
 
   it('formValuesToWirePayload includes subSortIndex in the payload', () => {
@@ -348,18 +348,15 @@ describe('subSortIndex', () => {
     expect(replay.subSortIndex).toBe(42);
   });
 
-  it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
+  it('InboundDbFieldsSchema accepts integers including negatives and defaults to 1', () => {
     // Reject for the RIGHT reason: the issue must be about subSortIndex, not some
     // unrelated field — otherwise a schema that rejects everything would pass.
     const nonInt = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 });
     expect(nonInt.success).toBe(false);
     if (!nonInt.success) expect(nonInt.error.issues[0]?.path).toContain('subSortIndex');
 
-    const belowMin = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 });
-    expect(belowMin.success).toBe(false);
-    if (!belowMin.success) expect(belowMin.error.issues[0]?.path).toContain('subSortIndex');
-
-    // A valid integer >= 1 must pass (guards against a mutant rejecting all values).
+    expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(true);
+    expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: -1 }).success).toBe(true);
     expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 5 }).success).toBe(true);
     expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
   });

+ 3 - 4
internal/database/db.go

@@ -1031,11 +1031,10 @@ func migrateTgIDIndex() error {
 	return db.Migrator().CreateIndex(&model.ClientRecord{}, "TgID")
 }
 
-// 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.
+// normalizeInboundSubSortIndex lifts legacy zero defaults to 1.
+// Explicit negatives are left alone so primary inbounds can sort first.
 func normalizeInboundSubSortIndex() error {
-	res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index < 1")
+	res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index = 0")
 	if res.Error != nil {
 		log.Printf("Error normalizing inbound sub_sort_index: %v", res.Error)
 		return res.Error

+ 1 - 1
internal/database/model/model.go

@@ -52,7 +52,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)
+	SubSortIndex         int                  `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty" example:"1"`                                                                           // Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; 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

+ 1 - 1
internal/web/controller/inbound.go

@@ -274,7 +274,7 @@ func (a *InboundController) setInboundSubSortIndex(c *gin.Context) {
 		return
 	}
 	type form struct {
-		SubSortIndex int `json:"subSortIndex" form:"subSortIndex" binding:"required,min=1"`
+		SubSortIndex int `json:"subSortIndex" form:"subSortIndex" binding:"required"`
 	}
 	var f form
 	if err := c.ShouldBind(&f); err != nil {

+ 51 - 23
internal/web/service/inbound_sub_sort_test.go

@@ -52,10 +52,9 @@ func TestUpdateInbound_PersistsSubSortIndex(t *testing.T) {
 	}
 }
 
-// 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) {
+// TestUpdateInbound_SubSortIndexZeroMapsToDefault verifies that 0 from clients
+// that predate the field is normalized to 1 instead of being stored.
+func TestUpdateInbound_SubSortIndexZeroMapsToDefault(t *testing.T) {
 	setupConflictDB(t)
 
 	ib := makeInboundWithSubSortIndex("in-7002-tcp", 7002, 5)
@@ -64,25 +63,54 @@ func TestUpdateInbound_SubSortIndexClampedToMinimum(t *testing.T) {
 	}
 
 	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)
-		}
+	update := *ib
+	update.SubSortIndex = 0
+
+	got, _, err := svc.UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %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, ib.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if reloaded.SubSortIndex != 1 {
+		t.Fatalf("persisted SubSortIndex = %d, want 1", reloaded.SubSortIndex)
+	}
+}
+
+// TestUpdateInbound_PreservesNegativeSubSortIndex verifies that an explicitly
+// set negative index is stored (so it can sort ahead of the default of 1).
+func TestUpdateInbound_PreservesNegativeSubSortIndex(t *testing.T) {
+	setupConflictDB(t)
+
+	ib := makeInboundWithSubSortIndex("in-7004-tcp", 7004, 5)
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	svc := &InboundService{}
+	update := *ib
+	update.SubSortIndex = -1
+
+	got, _, err := svc.UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %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, ib.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if reloaded.SubSortIndex != -1 {
+		t.Fatalf("persisted SubSortIndex = %d, want -1", reloaded.SubSortIndex)
 	}
 }
 

+ 24 - 0
internal/web/service/inbound_subsort_test.go

@@ -55,3 +55,27 @@ func TestSetInboundSubSortIndexUsesNarrowNodeUpdate(t *testing.T) {
 		t.Fatalf("full snapshot node updates = %d, want 0", got)
 	}
 }
+
+func TestSetInboundSubSortIndexPreservesNegative(t *testing.T) {
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	ib := &model.Inbound{UserId: 1, Remark: "r", Port: 21003, Protocol: model.VLESS, Settings: `{"clients":[]}`, SubSortIndex: 1, Enable: true}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+
+	if err := (&InboundService{}).SetInboundSubSortIndex(ib.Id, -2); err != nil {
+		t.Fatalf("set: %v", err)
+	}
+
+	var got model.Inbound
+	if err := database.GetDB().First(&got, ib.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if got.SubSortIndex != -2 {
+		t.Fatalf("subSortIndex = %d, want -2", got.SubSortIndex)
+	}
+}

+ 3 - 4
internal/web/service/inbound_util.go

@@ -9,11 +9,10 @@ import "strings"
 // 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.
+// normalizeSubSortIndex maps omitted/zero to 1; explicit negatives are kept
+// so a primary inbound can sort ahead of the default.
 func normalizeSubSortIndex(v int) int {
-	if v < 1 {
+	if v == 0 {
 		return 1
 	}
 	return v

+ 20 - 0
internal/web/service/inbound_util_test.go

@@ -0,0 +1,20 @@
+package service
+
+import "testing"
+
+func TestNormalizeSubSortIndex(t *testing.T) {
+	cases := []struct {
+		in, want int
+	}{
+		{0, 1},
+		{1, 1},
+		{7, 7},
+		{-1, -1},
+		{-10, -10},
+	}
+	for _, tc := range cases {
+		if got := normalizeSubSortIndex(tc.in); got != tc.want {
+			t.Fatalf("normalizeSubSortIndex(%d) = %d, want %d", tc.in, got, tc.want)
+		}
+	}
+}