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 <287639636+mrchatam@users.noreply.github.com>
This commit is contained in:
mrchatam
2026-09-12 12:44:53 +03:30
committed by GitHub
parent b467d4c676
commit 51e0afdd90
16 changed files with 114 additions and 50 deletions
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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']),
@@ -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,
@@ -697,7 +697,7 @@ export default function InboundFormModal({
t('pages.inbounds.form.subSortIndexHelp'),
)}
>
<InputNumber min={1} />
<InputNumber />
</FormField>
{protocol === Protocols.VLESS && (
@@ -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
View File
@@ -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>;
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+49 -21
View File
@@ -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
update := *ib
update.SubSortIndex = 0
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)
}
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 for input %d, want 1", reloaded.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, 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)
}
}
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
}