合并 internal → main: 退款/提现/激活/CI 全面优化 #4
@@ -37,7 +37,9 @@ func NewGetServerUserListLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *Ge
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListRequest) (resp *types.GetServerUserListResponse, err error) {
|
||||
cacheKey := fmt.Sprintf("%s%d", node.ServerUserListCacheKey, req.ServerId)
|
||||
protocolRequest := normalizeServerUserListProtocol(req.Protocol)
|
||||
|
||||
cacheKey := fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, req.ServerId, protocolRequest)
|
||||
cache, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if cache != "" {
|
||||
etag := tool.GenerateETag([]byte(cache))
|
||||
@@ -64,7 +66,7 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
Page: 1,
|
||||
Size: 1000,
|
||||
ServerId: []int64{server.Id},
|
||||
Protocol: req.Protocol,
|
||||
Protocol: protocolRequest,
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorw("FilterNodeList error", logger.Field("error", err.Error()))
|
||||
@@ -72,6 +74,10 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
l.Errorw("[ServerUserList] fallback: no nodes matched server+protocol, returning placeholder without cache",
|
||||
logger.Field("server_id", req.ServerId),
|
||||
logger.Field("protocol", req.Protocol),
|
||||
)
|
||||
return &types.GetServerUserListResponse{
|
||||
Users: []types.ServerUser{
|
||||
{
|
||||
@@ -139,6 +145,10 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
}
|
||||
|
||||
if len(subs) == 0 {
|
||||
l.Errorw("[ServerUserList] fallback: no subscriptions matched node group/tags, returning placeholder without cache",
|
||||
logger.Field("server_id", req.ServerId),
|
||||
logger.Field("protocol", req.Protocol),
|
||||
)
|
||||
return &types.GetServerUserListResponse{
|
||||
Users: []types.ServerUser{
|
||||
{
|
||||
@@ -183,10 +193,18 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
users = append(users, types.ServerUser{
|
||||
Id: 1,
|
||||
UUID: uuidx.NewUUID().String(),
|
||||
})
|
||||
l.Errorw("[ServerUserList] fallback: matched subs returned zero eligible users, returning placeholder without cache",
|
||||
logger.Field("server_id", req.ServerId),
|
||||
logger.Field("protocol", req.Protocol),
|
||||
)
|
||||
return &types.GetServerUserListResponse{
|
||||
Users: []types.ServerUser{
|
||||
{
|
||||
Id: 1,
|
||||
UUID: uuidx.NewUUID().String(),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
resp = &types.GetServerUserListResponse{
|
||||
Users: users,
|
||||
@@ -205,6 +223,17 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// normalizeServerUserListProtocol 将客户端可能携带的 hysteria2 兼容字段映射回
|
||||
// DB 中存储的规范名 "hysteria"。其它协议原样返回。
|
||||
// 缓存 key 与 FilterNodeList 查询都必须用归一化后的值,
|
||||
// 否则 hysteria2 永远查不到节点,永远走兜底。
|
||||
func normalizeServerUserListProtocol(protocol string) string {
|
||||
if protocol == Hysteria2 {
|
||||
return Hysteria
|
||||
}
|
||||
return protocol
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) serverUserListCacheTTL() time.Duration {
|
||||
pullInterval := l.svcCtx.Config.Node.NodePullInterval
|
||||
if pullInterval <= 0 {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
)
|
||||
|
||||
// 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 形态与 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)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,31 @@ const (
|
||||
ServerConfigCacheKey = "server:config:"
|
||||
)
|
||||
|
||||
// AllProtocols 枚举所有客户端可能携带的 protocol。
|
||||
// 用户列表缓存 key 形态为 `server:user:{server_id}:{protocol}`,按 server_id
|
||||
// 失效时需要按协议精确删除。SCAN 在增量 rehash 期间可能漏 key,因此采用显式枚举。
|
||||
// 包含 hysteria2(兼容字段,与 hysteria 同语义),多余的 Del 是 no-op,over-deletion 安全。
|
||||
var AllProtocols = []string{
|
||||
"shadowsocks",
|
||||
"vmess",
|
||||
"vless",
|
||||
"trojan",
|
||||
"anytls",
|
||||
"tuic",
|
||||
"hysteria",
|
||||
"hysteria2",
|
||||
}
|
||||
|
||||
// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。
|
||||
// 用于 Del 路径——节点 / 订阅 / 流量统计触发缓存失效时一次性清掉该 server 下所有协议条目。
|
||||
func ServerUserListCacheKeysForServer(serverId int64) []string {
|
||||
keys := make([]string, 0, len(AllProtocols))
|
||||
for _, protocol := range AllProtocols {
|
||||
keys = append(keys, fmt.Sprintf("%s%d:%s", ServerUserListCacheKey, serverId, protocol))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// FilterParams Filter Server Params
|
||||
type FilterParams struct {
|
||||
Page int
|
||||
@@ -134,7 +159,7 @@ func (m *customServerModel) ClearNodeCache(ctx context.Context, params *FilterNo
|
||||
}
|
||||
var cacheKeys []string
|
||||
for _, node := range nodes {
|
||||
cacheKeys = append(cacheKeys, fmt.Sprintf("%s%d", ServerUserListCacheKey, node.ServerId))
|
||||
cacheKeys = append(cacheKeys, ServerUserListCacheKeysForServer(node.ServerId)...)
|
||||
if node.Protocol != "" {
|
||||
var cursor uint64
|
||||
for {
|
||||
@@ -162,8 +187,7 @@ func (m *customServerModel) ClearNodeCache(ctx context.Context, params *FilterNo
|
||||
|
||||
// ClearServerCache Clear Server Cache
|
||||
func (m *customServerModel) ClearServerCache(ctx context.Context, serverId int64) error {
|
||||
var cacheKeys []string
|
||||
cacheKeys = append(cacheKeys, fmt.Sprintf("%s%d", ServerUserListCacheKey, serverId))
|
||||
cacheKeys := ServerUserListCacheKeysForServer(serverId)
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, newCursor, err := m.Cache.Scan(ctx, cursor, fmt.Sprintf("%s%d*", ServerConfigCacheKey, serverId), 100).Result()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestServerUserListCacheKeysForServer 验证按 server 枚举出的协议缓存 key 覆盖
|
||||
// 所有客户端可能携带的 protocol(包括 hysteria2 兼容字段),形态为
|
||||
// `server:user:{server_id}:{protocol}`。HIF-1: Del 路径必须用枚举而非 SCAN。
|
||||
func TestServerUserListCacheKeysForServer(t *testing.T) {
|
||||
const serverId int64 = 42
|
||||
|
||||
keys := ServerUserListCacheKeysForServer(serverId)
|
||||
if len(keys) != len(AllProtocols) {
|
||||
t.Fatalf("expected %d keys, got %d: %v", len(AllProtocols), len(keys), keys)
|
||||
}
|
||||
|
||||
got := make([]string, len(keys))
|
||||
copy(got, keys)
|
||||
sort.Strings(got)
|
||||
|
||||
want := make([]string, 0, len(AllProtocols))
|
||||
for _, p := range AllProtocols {
|
||||
want = append(want, fmt.Sprintf("%s%d:%s", ServerUserListCacheKey, serverId, p))
|
||||
}
|
||||
sort.Strings(want)
|
||||
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("key[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerUserListCacheKeysForServer_NoLegacyFlatKey 验证清缓存路径不再使用
|
||||
// 旧的扁平 key `server:user:{server_id}`(无 protocol 维度)——旧 key 一旦再出现,
|
||||
// 与 P0-1 写入的带 protocol key 形态不一致,Del 会失效,回归 HIF-1 缺陷。
|
||||
func TestServerUserListCacheKeysForServer_NoLegacyFlatKey(t *testing.T) {
|
||||
const serverId int64 = 7
|
||||
|
||||
legacy := fmt.Sprintf("%s%d", ServerUserListCacheKey, serverId)
|
||||
for _, key := range ServerUserListCacheKeysForServer(serverId) {
|
||||
if key == legacy {
|
||||
t.Fatalf("Del path still emits legacy flat key %q without protocol", legacy)
|
||||
}
|
||||
if !strings.HasPrefix(key, legacy+":") {
|
||||
t.Errorf("key %q does not match `<flat>:<protocol>` shape", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllProtocolsContainsKnownProtocols 兜底保证 AllProtocols 至少覆盖关键协议;
|
||||
// 缺失任一会让对应协议的用户列表缓存清不掉。
|
||||
func TestAllProtocolsContainsKnownProtocols(t *testing.T) {
|
||||
required := []string{
|
||||
"shadowsocks",
|
||||
"vmess",
|
||||
"vless",
|
||||
"trojan",
|
||||
"tuic",
|
||||
"hysteria",
|
||||
"hysteria2",
|
||||
}
|
||||
set := make(map[string]struct{}, len(AllProtocols))
|
||||
for _, p := range AllProtocols {
|
||||
set[p] = struct{}{}
|
||||
}
|
||||
for _, r := range required {
|
||||
if _, ok := set[r]; !ok {
|
||||
t.Errorf("AllProtocols missing %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func (m *defaultSubscribeModel) getCacheKeys(data *Subscribe) []string {
|
||||
})
|
||||
if err == nil {
|
||||
for _, n := range nodes {
|
||||
keys = append(keys, fmt.Sprintf("%s%d", node.ServerUserListCacheKey, n.ServerId))
|
||||
keys = append(keys, node.ServerUserListCacheKeysForServer(n.ServerId)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func (m *defaultSubscribeModel) getCacheKeys(data *Subscribe) []string {
|
||||
})
|
||||
if err == nil {
|
||||
for _, n := range nodes {
|
||||
keys = append(keys, fmt.Sprintf("%s%d", node.ServerUserListCacheKey, n.ServerId))
|
||||
keys = append(keys, node.ServerUserListCacheKeysForServer(n.ServerId)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package traffic
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -140,8 +139,8 @@ func (l *TrafficStatisticsLogic) ProcessTask(ctx context.Context, task *asynq.Ta
|
||||
logger.Field("error", delErr.Error()),
|
||||
)
|
||||
}
|
||||
cacheKey := fmt.Sprintf("%s%d", node.ServerUserListCacheKey, payload.ServerId)
|
||||
if delErr := l.svc.Redis.Del(ctx, cacheKey).Err(); delErr != nil {
|
||||
cacheKeys := node.ServerUserListCacheKeysForServer(payload.ServerId)
|
||||
if delErr := l.svc.Redis.Del(ctx, cacheKeys...).Err(); delErr != nil {
|
||||
logger.WithContext(ctx).Error("[TrafficStatistics] Clear server user cache failed",
|
||||
logger.Field("serverId", payload.ServerId),
|
||||
logger.Field("error", delErr.Error()),
|
||||
|
||||
Reference in New Issue
Block a user