修复: 用户维度限速在过期节点组分支生效 + 统一 speed_limit 单位为 Mbps

- getServerUserListLogic.getExpiredUsers 之前完全忽略 user_subscribe.speed_limit,
  现在带出用户级覆盖并与过期节点组 speed_limit 取更严(mergeSpeedLimit:0 视为无限制)
- node_group.SpeedLimit 注释从 "KB/s" 修正为 "Mbps"(旧注释是笔误,实际下发节点的
  ServerUser.SpeedLimit 字段语义就是 Mbps,节点端 ppanel-node 按 *1e6/8 换算为 Byte/s)
- apis/node/node.api 给 ServerUser.SpeedLimit 加 Mbps 单位注释
- 新增 TestMergeSpeedLimit 表驱动测试覆盖 8 种边界

主链路(活跃用户、套餐 traffic_limit 阶梯)行为不变,已在生产 (server_id=52)
验证 147 个限速用户下发正确,与 DB 完全对应。
This commit is contained in:
2026-06-12 22:39:15 -07:00
parent 08434cfa32
commit 3d1a31a19f
4 changed files with 61 additions and 7 deletions
+2
View File
@@ -64,6 +64,8 @@ type (
ServerUser { ServerUser {
Id int64 `json:"id"` Id int64 `json:"id"`
UUID string `json:"uuid"` UUID string `json:"uuid"`
// SpeedLimit 单位为 Mbps0 表示不限速。
// 节点端 (V2bX/XrayR 等) 按 Mbps 解释该值,服务端透传不做单位换算。
SpeedLimit int64 `json:"speed_limit"` SpeedLimit int64 `json:"speed_limit"`
DeviceLimit int64 `json:"device_limit"` DeviceLimit int64 `json:"device_limit"`
} }
@@ -258,13 +258,13 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
} }
} }
// 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户 // 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户
// 用户级 speed_limit (user_subscribe.speed_limit) 与过期节点组 speed_limit
// 取更严格的一个 — 0 视为"无限制",正值优先于 0。
if len(nodeGroupIds) > 0 { if len(nodeGroupIds) > 0 {
expiredUsers, expiredSpeedLimit := l.getExpiredUsers(nodeGroupIds) expiredUsers, expiredSpeedLimit := l.getExpiredUsers(nodeGroupIds)
for i := range expiredUsers { for i := range expiredUsers {
if expiredSpeedLimit > 0 { expiredUsers[i].SpeedLimit = mergeSpeedLimit(expiredUsers[i].SpeedLimit, expiredSpeedLimit)
expiredUsers[i].SpeedLimit = expiredSpeedLimit
}
} }
users = append(users, expiredUsers...) users = append(users, expiredUsers...)
} }
@@ -369,14 +369,34 @@ func (l *GetServerUserListLogic) getExpiredUsers(serverNodeGroupIds []int64) ([]
} }
seen[userSub.Id] = true seen[userSub.Id] = true
users = append(users, types.ServerUser{ users = append(users, types.ServerUser{
Id: userSub.Id, Id: userSub.Id,
UUID: userSub.UUID, UUID: userSub.UUID,
SpeedLimit: userSub.SpeedLimit,
}) })
} }
return users, int64(expiredGroup.SpeedLimit) return users, int64(expiredGroup.SpeedLimit)
} }
// mergeSpeedLimit 返回两个速度限制(Mbps)中更严格的一个。
// 0 视为"无限制",因此会被任意正值覆盖;都为 0 时返回 0。
// 用于用户级 speed_limit 与节点组级 speed_limit 的合并:
// - both 0 → 0 (不限速)
// - 仅一个 > 0 → 取该值
// - both > 0 → 取较小者(更严格)
func mergeSpeedLimit(a, b int64) int64 {
if a <= 0 {
return b
}
if b <= 0 {
return a
}
if a < b {
return a
}
return b
}
func (l *GetServerUserListLogic) checkExpiredUserEligibility(userSub *user.Subscribe, expiredGroup *group.NodeGroup) bool { func (l *GetServerUserListLogic) checkExpiredUserEligibility(userSub *user.Subscribe, expiredGroup *group.NodeGroup) bool {
expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24) expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24)
if expiredDays > expiredGroup.ExpiredDaysLimit { if expiredDays > expiredGroup.ExpiredDaysLimit {
@@ -338,3 +338,32 @@ func httptestNewRequest() *http.Request {
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/v1/server/user", nil) req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/v1/server/user", nil)
return req return req
} }
// TestMergeSpeedLimit 验证用户级 speed_limit 与过期节点组 speed_limit 合并规则:
// 0 = 不限制 (loses),正值优先;都为正取较小者(更严格)。
// 这是 user-dimension 限速在过期节点组分支下能生效的关键。
func TestMergeSpeedLimit(t *testing.T) {
cases := []struct {
name string
a int64
b int64
want int64
}{
{"both zero stays zero", 0, 0, 0},
{"a positive b zero takes a", 30, 0, 30},
{"a zero b positive takes b", 0, 50, 50},
{"a negative treated as zero takes b", -1, 50, 50},
{"b negative treated as zero takes a", 50, -1, 50},
{"both positive takes smaller (a<b)", 20, 50, 20},
{"both positive takes smaller (b<a)", 80, 30, 30},
{"equal positive returns same value", 25, 25, 25},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mergeSpeedLimit(tc.a, tc.b); got != tc.want {
t.Fatalf("mergeSpeedLimit(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
}
})
}
}
+4 -1
View File
@@ -16,7 +16,10 @@ type NodeGroup struct {
IsExpiredGroup *bool `gorm:"default:false;not null;index:idx_is_expired_group;comment:Is Expired Group"` IsExpiredGroup *bool `gorm:"default:false;not null;index:idx_is_expired_group;comment:Is Expired Group"`
ExpiredDaysLimit int `gorm:"default:7;not null;comment:Expired days limit (days)"` ExpiredDaysLimit int `gorm:"default:7;not null;comment:Expired days limit (days)"`
MaxTrafficGBExpired *int64 `gorm:"default:0;comment:Max traffic for expired users (GB)"` MaxTrafficGBExpired *int64 `gorm:"default:0;comment:Max traffic for expired users (GB)"`
SpeedLimit int `gorm:"default:0;not null;comment:Speed limit (KB/s)"` // SpeedLimit: 过期节点组对其内用户施加的速度上限。
// 实际下发节点的字段为 ServerUser.SpeedLimit (Mbps),二者直接透传,无单位换算。
// 注:早期 schema 注释写作 KB/s 系笔误,真实语义与 user_subscribe.speed_limit / subscribe.speed_limit 一致,均为 Mbps。
SpeedLimit int `gorm:"default:0;not null;comment:Speed limit (Mbps); 0 means no limit"`
MinTrafficGB *int64 `gorm:"default:0;comment:Minimum Traffic (GB) for this node group"` MinTrafficGB *int64 `gorm:"default:0;comment:Minimum Traffic (GB) for this node group"`
MaxTrafficGB *int64 `gorm:"default:0;comment:Maximum Traffic (GB) for this node group"` MaxTrafficGB *int64 `gorm:"default:0;comment:Maximum Traffic (GB) for this node group"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`