mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-12 14:21:01 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user