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
+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)
}
}
}