feat(sub): per-inbound sort order for subscription links

Add a subSortIndex field to inbounds that controls the order of links
in subscription output only: the raw sub body, the HTML sub page, and
the JSON/Clash formats (all served from the same query). Lower values
come first; ties keep id order. The panel inbound list is unaffected.

The value is editable in the inbound form next to the share-address
fields, propagates to nodes via wireInbound, and follows the usual
node-sync rules (copied on import, mirrored while not dirty, never a
structural change).

Rescoped from #5214 by @Ponywka.
This commit is contained in:
MHSanaei
2026-06-12 12:03:22 +02:00
parent 7ae3ea66d1
commit f1a4286e2f
36 changed files with 367 additions and 4 deletions
+3
View File
@@ -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
+2
View File
@@ -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
@@ -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)
}
}
+10
View File
@@ -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 {