Files
hi-server/internal/logic/server/getServerUserListLogic_test.go
T
shanshanzhong147 3d1a31a19f 修复: 用户维度限速在过期节点组分支生效 + 统一 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 完全对应。
2026-06-12 22:39:15 -07:00

370 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"fmt"
"net/http"
"regexp"
"slices"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/model/node"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/speedlimit"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// TestNormalizeServerUserListProtocol 验证客户端 hysteria2 兼容字段被映射回
// DB 规范名 "hysteria";其他协议必须原样返回,不可被误改。
// 缺这一步会导致 hysteria2 永远查不到节点、永远走兜底,HIF-1 表象。
func TestNormalizeServerUserListProtocol(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"hysteria2 maps to hysteria", "hysteria2", "hysteria"},
{"hysteria unchanged", "hysteria", "hysteria"},
{"trojan unchanged", "trojan", "trojan"},
{"vless unchanged", "vless", "vless"},
{"vmess unchanged", "vmess", "vmess"},
{"tuic unchanged", "tuic", "tuic"},
{"shadowsocks unchanged", "shadowsocks", "shadowsocks"},
{"anytls unchanged", "anytls", "anytls"},
{"empty unchanged", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := normalizeServerUserListProtocol(c.in)
if got != c.want {
t.Errorf("normalizeServerUserListProtocol(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
// TestServerUserListCacheKey_ProtocolIsolation 验证缓存 key 在 server_id 相同、
// protocol 不同(如 trojan vs tuic)时一定不同——这是 HIF-1 的根因防回归。
// 若 key 重叠,tuic 的兜底假用户会污染 trojan 的真实用户列表。
func TestServerUserListCacheKey_ProtocolIsolation(t *testing.T) {
const serverId int64 = 42
build := func(protocol string) string {
return fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, serverId, normalizeServerUserListProtocol(protocol))
}
protocols := []string{"trojan", "vless", "vmess", "tuic", "shadowsocks", "anytls"}
seen := make(map[string]string, len(protocols))
for _, p := range protocols {
key := build(p)
if other, exists := seen[key]; exists {
t.Fatalf("cache key collision: %q (%q) == %q (%q)", p, key, other, key)
}
seen[key] = p
}
// hysteria 与 hysteria2 必须落到同一 key,否则 hysteria2 客户端拿不到 hysteria 节点列表
hysteriaKey := build("hysteria")
hysteria2Key := build("hysteria2")
if hysteriaKey != hysteria2Key {
t.Errorf("hysteria/hysteria2 expected to share cache key, got %q vs %q", hysteriaKey, hysteria2Key)
}
}
// TestServerUserListCacheKey_ShapeMatchesDelPath 验证 GetServerUserList 写入的
// cache key 形态与 ServerUserListCacheKeysForServerDel 路径)枚举出的 key
// 形态完全一致。形态一旦漂移,Del 会清不到刚写入的 key,HIF-1 重现。
func TestServerUserListCacheKey_ShapeMatchesDelPath(t *testing.T) {
const serverId int64 = 7
writeShape := fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, serverId, normalizeServerUserListProtocol("trojan"))
delKeys := node.ServerUserListCacheKeysForServer(serverId)
if !slices.Contains(delKeys, writeShape) {
t.Fatalf("write-path key %q not present in Del-path enumeration %v", writeShape, delKeys)
}
}
func TestServerUserSpeedLimitInputs(t *testing.T) {
planTrafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]`
userTrafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":1,"speed_limit":3}]`
cases := []struct {
name string
sub *subscribe.Subscribe
userSub *user.Subscribe
wantSpeedLimit int64
wantTrafficLimit string
}{
{
name: "未设置用户覆盖时返回套餐基础限速",
sub: &subscribe.Subscribe{
SpeedLimit: 100,
TrafficLimit: planTrafficLimit,
},
userSub: &user.Subscribe{},
wantSpeedLimit: 100,
wantTrafficLimit: planTrafficLimit,
},
{
name: "用户 speed_limit 覆盖套餐基础限速",
sub: &subscribe.Subscribe{
SpeedLimit: 100,
TrafficLimit: planTrafficLimit,
},
userSub: &user.Subscribe{
SpeedLimit: 30,
},
wantSpeedLimit: 30,
wantTrafficLimit: planTrafficLimit,
},
{
name: "用户 traffic_limit 覆盖套餐规则",
sub: &subscribe.Subscribe{
SpeedLimit: 100,
TrafficLimit: planTrafficLimit,
},
userSub: &user.Subscribe{
TrafficLimit: &userTrafficLimit,
},
wantSpeedLimit: 100,
wantTrafficLimit: userTrafficLimit,
},
{
name: "用户 traffic_limit 为空时保留套餐规则",
sub: &subscribe.Subscribe{
SpeedLimit: 100,
TrafficLimit: planTrafficLimit,
},
userSub: &user.Subscribe{
TrafficLimit: ptrString(""),
},
wantSpeedLimit: 100,
wantTrafficLimit: planTrafficLimit,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotSpeedLimit, gotTrafficLimit := serverUserSpeedLimitInputs(tc.sub, tc.userSub)
if gotSpeedLimit != tc.wantSpeedLimit {
t.Fatalf("speedLimit = %d, want %d", gotSpeedLimit, tc.wantSpeedLimit)
}
if gotTrafficLimit != tc.wantTrafficLimit {
t.Fatalf("trafficLimit = %q, want %q", gotTrafficLimit, tc.wantTrafficLimit)
}
})
}
}
func TestCalculateServerUserSpeedLimits_BatchTrafficRules(t *testing.T) {
db, mock, cleanup := newServerUserListTestDB(t)
defer cleanup()
ctx, _ := gin.CreateTestContext(nil)
ctx.Request = httptestNewRequest()
logic := &GetServerUserListLogic{
Logger: logger.WithContext(ctx.Request.Context()),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
trafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]`
candidates := map[int64]serverUserSpeedLimitCandidate{
101: {userID: 1001, userSubscribeID: 101, baseSpeed: 0, trafficLimit: trafficLimit},
102: {userID: 1002, userSubscribeID: 102, baseSpeed: 0, trafficLimit: trafficLimit},
103: {userID: 1003, userSubscribeID: 103, baseSpeed: 20, trafficLimit: trafficLimit},
}
mock.ExpectQuery("FROM `traffic_log`").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"user_id", "user_subscribe_id", "upload", "download"}).
AddRow(1001, 101, gb(4), gb(5)).
AddRow(1002, 102, gb(4), gb(6)).
AddRow(1003, 103, gb(12), int64(0)))
got := logic.calculateServerUserSpeedLimits(candidates)
assertSpeedLimit(t, got, 101, 0)
assertSpeedLimit(t, got, 102, 5)
assertSpeedLimit(t, got, 103, 5)
assertServerUserListExpectations(t, mock)
}
func TestCalculateServerUserSpeedLimits_UserTrafficLimitOverride(t *testing.T) {
db, mock, cleanup := newServerUserListTestDB(t)
defer cleanup()
ctx, _ := gin.CreateTestContext(nil)
ctx.Request = httptestNewRequest()
logic := &GetServerUserListLogic{
Logger: logger.WithContext(ctx.Request.Context()),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
candidates := map[int64]serverUserSpeedLimitCandidate{
201: {
userID: 2001,
userSubscribeID: 201,
baseSpeed: 50,
trafficLimit: `[{"stat_type":"day","stat_value":1,"traffic_usage":2,"speed_limit":3}]`,
},
202: {
userID: 2002,
userSubscribeID: 202,
baseSpeed: 50,
trafficLimit: "",
},
}
mock.ExpectQuery("FROM `traffic_log`").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"user_id", "user_subscribe_id", "upload", "download"}).
AddRow(2001, 201, gb(2), int64(0)))
got := logic.calculateServerUserSpeedLimits(candidates)
assertSpeedLimit(t, got, 201, 3)
assertSpeedLimit(t, got, 202, 50)
assertServerUserListExpectations(t, mock)
}
func TestServerUserTrafficRuleWindow(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
rule speedlimit.TrafficLimitRule
want time.Time
ok bool
}{
{
name: "hour window",
rule: speedlimit.TrafficLimitRule{StatType: "hour", StatValue: 2},
want: now.Add(-2 * time.Hour),
ok: true,
},
{
name: "day window",
rule: speedlimit.TrafficLimitRule{StatType: "day", StatValue: 1},
want: now.AddDate(0, 0, -1),
ok: true,
},
{
name: "invalid stat type",
rule: speedlimit.TrafficLimitRule{StatType: "week", StatValue: 1},
ok: false,
},
{
name: "invalid stat value",
rule: speedlimit.TrafficLimitRule{StatType: "day", StatValue: 0},
ok: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := serverUserTrafficRuleWindow(tc.rule, now)
if ok != tc.ok {
t.Fatalf("ok = %v, want %v", ok, tc.ok)
}
if !ok {
return
}
if !got.start.Equal(tc.want) || !got.end.Equal(now) {
t.Fatalf("window = [%s, %s), want [%s, %s)", got.start, got.end, tc.want, now)
}
})
}
}
func newServerUserListTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if matched, _ := regexp.MatchString(expectedSQL, actualSQL); matched {
return nil
}
t.Fatalf("unexpected SQL\nexpected pattern: %s\nactual: %s", expectedSQL, actualSQL)
return nil
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
func assertServerUserListExpectations(t *testing.T, mock sqlmock.Sqlmock) {
t.Helper()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func assertSpeedLimit(t *testing.T, got map[int64]int64, userSubscribeID int64, want int64) {
t.Helper()
if got[userSubscribeID] != want {
t.Fatalf("speed limit for user_subscribe_id=%d = %d, want %d", userSubscribeID, got[userSubscribeID], want)
}
}
func gb(value int64) int64 {
return value * 1024 * 1024 * 1024
}
func ptrString(value string) *string {
return &value
}
func httptestNewRequest() *http.Request {
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/v1/server/user", nil)
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)
}
})
}
}