From 19d28a8f895a33b5465788cce57dbc1530bbc2be Mon Sep 17 00:00:00 2001 From: shanshanzhong147 Date: Wed, 3 Jun 2026 05:37:03 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(#1):=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E7=94=A8=E6=88=B7=E5=88=97=E8=A1=A8=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E6=8C=89=20protocol=20=E9=9A=94=E7=A6=BB=20+=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E4=B8=8D=E5=86=99=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务器用户列表缓存跨协议污染修复(HIF-1 / 详见 PR #5 四件套): 1. 缓存 key 加 protocol 维度(`server:user:{server_id}:{protocol}`),对齐 ServerConfig 已有约定 2. 显式枚举协议清除用户列表缓存(AllProtocols + ServerUserListCacheKeysForServer),不用 SCAN 3. 三个兜底分支不写缓存 + Errorw 日志(带 server_id + protocol 字段) 4. hysteria2 → hysteria 兼容归一化 + 6 个新单测 Closes HIF-1 --- .../logic/server/getServerUserListLogic.go | 41 ++++++++-- .../server/getServerUserListLogic_test.go | 78 +++++++++++++++++++ internal/model/node/model.go | 30 ++++++- internal/model/node/model_test.go | 76 ++++++++++++++++++ internal/model/subscribe/default.go | 4 +- queue/logic/traffic/trafficStatisticsLogic.go | 5 +- 6 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 internal/logic/server/getServerUserListLogic_test.go create mode 100644 internal/model/node/model_test.go diff --git a/internal/logic/server/getServerUserListLogic.go b/internal/logic/server/getServerUserListLogic.go index 8b51dd8..a15f660 100644 --- a/internal/logic/server/getServerUserListLogic.go +++ b/internal/logic/server/getServerUserListLogic.go @@ -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 { diff --git a/internal/logic/server/getServerUserListLogic_test.go b/internal/logic/server/getServerUserListLogic_test.go new file mode 100644 index 0000000..def0509 --- /dev/null +++ b/internal/logic/server/getServerUserListLogic_test.go @@ -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) + } +} diff --git a/internal/model/node/model.go b/internal/model/node/model.go index e6961c1..0f5366c 100644 --- a/internal/model/node/model.go +++ b/internal/model/node/model.go @@ -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() diff --git a/internal/model/node/model_test.go b/internal/model/node/model_test.go new file mode 100644 index 0000000..b8a98ee --- /dev/null +++ b/internal/model/node/model_test.go @@ -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 `:` 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) + } + } +} diff --git a/internal/model/subscribe/default.go b/internal/model/subscribe/default.go index a8d63bc..945eb02 100644 --- a/internal/model/subscribe/default.go +++ b/internal/model/subscribe/default.go @@ -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)...) } } } diff --git a/queue/logic/traffic/trafficStatisticsLogic.go b/queue/logic/traffic/trafficStatisticsLogic.go index c7d9307..be5b32e 100644 --- a/queue/logic/traffic/trafficStatisticsLogic.go +++ b/queue/logic/traffic/trafficStatisticsLogic.go @@ -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()),