634b5a7bd0
- model: SimNet protocol fields + NormalizeSimnet; type+port protocol uniqueness - server config: OmnXT runtime config delivery via compatible() simnet case - credentials: derive per-user psk/key_id from subscription (pkg/simnet), no new table - subscription: adapter buildOmnxtSimnetConfigs + base64 buildOmnxtProtocolLinks + OmnXT SimNet application (migration 02161) - UA gating: hide experimental protocols from non first-party clients (download + JSON node-list) - admin: normalize simnet on create/update and on GET responses - tests: 21 simnet unit tests; full suite green
371 lines
11 KiB
Go
371 lines
11 KiB
Go
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"},
|
||
{"simnet unchanged", "simnet", "simnet"},
|
||
{"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 形态与 ServerUserListCacheKeysForServer(Del 路径)枚举出的 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)
|
||
}
|
||
})
|
||
}
|
||
}
|