合并 internal → main: 退款/提现/激活/CI 全面优化 #4

Open
shanshanzhong147 wants to merge 76 commits from internal into main
6 changed files with 220 additions and 14 deletions
Showing only changes of commit 19d28a8f89 - Show all commits
@@ -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 形态与 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)
}
}
+27 -3
View File
@@ -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-opover-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()
+76
View File
@@ -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)
}
}
}
+2 -2
View File
@@ -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()),