deepseek修改版
This commit is contained in:
@@ -16,6 +16,35 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
> 框架整体评估后的结构性修复,包含 2 CRITICAL + 12 HIGH + 6 MEDIUM 问题的修复。
|
||||
> 项目处于初级阶段,无下游用户,放心引入破坏性变更。
|
||||
|
||||
### Breaking ⚠️
|
||||
|
||||
- **`database.RedisClient` 不再可外部访问**:包级变量改为 unexported。所有消费者必须通过 `database.GetRedis()` 获取客户端。测试注入使用 `database.SetTestRedisClient(c)` 替代直接赋值。
|
||||
- **`xlgo.WithConfig(cfg)` 不再调用 `config.Set(cfg)`**:配置不再写入全局状态。依赖 `config.Get()` 获取注入配置的下游代码需改用 `WithConfigPath`。
|
||||
- **`App.Init()` 内部改为 `sync.Once`**:多次调用返回首次执行的结果(含错误),不再是之前的"第二次直接返回 nil"。
|
||||
|
||||
### Fixed 🐛
|
||||
|
||||
#### 第一轮:CRITICAL + HIGH 基金会战
|
||||
|
||||
- **U1 UUIDShort 生成错误**(utils/uuid.go):`uuid.New().String()[:32]` 产生的 32 字符保留了 4 个破折号(UUID 格式为 36 字符含 4 个 `-`,`[:32]` 截断末尾 4 个 hex 字符但保留破折号)。改为 `strings.ReplaceAll(uuid.New().String(), "-", "")`,现在正确生成 32 位纯十六进制字符串。
|
||||
- **M1 filterSensitiveFields 假过滤**(middleware/logger.go):旧实现用 `strings.ReplaceAll` 在 key 后面追加 `"[FILTERED]"` 而不删除原始值,导致 `{"password":"mypass"}` → `{"password":"[FILTERED]""mypass"}` 密码仍然可见。改为编译期正则 `sensitiveFieldsRE` 匹配 `"key":"value"` 整体并替换 value 部分为 `[FILTERED]`。
|
||||
- **A1 App.Init 并发竞态**(app.go):`a.initialized` 是普通 bool,并发 `Init()` 调用会同时通过检查导致双重初始化。改为 `sync.Once`,`doInit()` 提取为内部方法,错误通过 `a.initErr` 保存。
|
||||
- **D3/CK1 Redis 客户端访问竞态与不一致**(database/redis.go, cache/lock.go):`database.RedisClient` 全局变量在 `Init`/`Close` 中有锁写入但可被外部无锁读取(数据竞态);`cache/lock.go` 直接引用 `RedisClient` 而非 `GetRedis()`(绕过 RedisManager 抽象)。修复方案:`RedisClient` → unexported `redisClient`;`GetRedis()` 加入回退逻辑(优先 `DefaultRedis.Client()`,回退内部 `redisClient`);lock.go 全部改为 `rdb := database.GetRedis()` 单次获取 + nil 检查。
|
||||
- **M2 Metrics in-flight gauge 泄漏**(middleware/metrics.go):`httpRequestsInFlight.Inc()` 后无 `defer Dec()`,handler panic 时 gauge 永久膨胀。改为 `Inc()` 后立即 `defer Dec()`,不依赖 Recover 中间件顺序。
|
||||
- **R1 FailWithError 丢弃 Detail**(response/error.go):`FailWithError` 直接 `writeResp(c, err.Code, err.Message, nil)` 丢弃了 `err.Detail`。改为 `resp := err.ToResponse(); writeResp(c, resp.Code, resp.Msg, resp.Data)`。
|
||||
|
||||
### Changed 🔄
|
||||
|
||||
- **`database.GetRedis()` 加入回退逻辑**:优先返回 `DefaultRedis.Client()`,若未初始化则回退到内部 `redisClient`(测试注入路径)。确保 `SetTestRedisClient` + `GetRedis()` 闭环。
|
||||
- **`database.SetTestRedisClient(c)` 新增**:供测试注入 mock Redis 客户端,返回旧值便于清理恢复。
|
||||
|
||||
---
|
||||
|
||||
## [v1.1.1] - 2026-06-28
|
||||
|
||||
> v1.1.1 后的安全/正确性补丁,依据 `version_1.1.1_report.md` 权威缺陷清单逐项修复(13 CRITICAL + 8 HIGH)。
|
||||
|
||||
### Fixed 🐛
|
||||
|
||||
@@ -41,12 +41,13 @@ type Migrator func(*gorm.DB) error
|
||||
// - OnReady: 端口就绪后(已开始接受连接)
|
||||
// - OnStop: Shutdown() 开头,关 HTTP 之前
|
||||
//
|
||||
// OnInit/OnStart/OnStop 返回 error 会中断流程并向上返回。
|
||||
// OnInit/OnStart/OnReady/OnStop 返回 error 会中断流程并向上返回。
|
||||
// A2 修复:OnReady 改为返回 error,失败时触发 shutdown。
|
||||
type Hook struct {
|
||||
Name string
|
||||
OnInit func(*App) error
|
||||
OnStart func(*App) error
|
||||
OnReady func(*App)
|
||||
OnReady func(*App) error
|
||||
OnStop func(*App) error
|
||||
}
|
||||
|
||||
@@ -80,7 +81,10 @@ type App struct {
|
||||
migrators []Migrator
|
||||
healthChecks []router.HealthCheck
|
||||
hooks []Hook
|
||||
initialized bool
|
||||
|
||||
// 初始化同步守卫(A1 修复:sync.Once 替代裸 bool,防止并发 Init 竞态)
|
||||
initOnce sync.Once
|
||||
initErr error
|
||||
|
||||
// 请求级超时(#19),<=0 表示不启用
|
||||
requestTimeout time.Duration
|
||||
@@ -102,11 +106,12 @@ func WithConfigPath(path string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfig 设置配置对象
|
||||
// WithConfig 设置配置对象。
|
||||
// A3 修复:不再调用 config.Set(cfg),配置仅保留在 App 实例内,不污染全局状态。
|
||||
// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath。
|
||||
func WithConfig(cfg *config.Config) Option {
|
||||
return func(a *App) {
|
||||
a.config = cfg
|
||||
config.Set(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,12 +355,15 @@ func RunFullStack(opts ...Option) error {
|
||||
return NewFullStack(opts...).Run()
|
||||
}
|
||||
|
||||
// Init 初始化应用,不启动 HTTP 监听
|
||||
// Init 初始化应用,不启动 HTTP 监听(A1 修复:sync.Once 保证单次执行,并发安全)。
|
||||
// 多次调用返回首次执行的结果。
|
||||
func (a *App) Init() error {
|
||||
if a.initialized {
|
||||
return nil
|
||||
}
|
||||
a.initOnce.Do(func() { a.initErr = a.doInit() })
|
||||
return a.initErr
|
||||
}
|
||||
|
||||
// doInit 执行实际初始化流程。仅在 sync.Once 中调用,不可直接调用。
|
||||
func (a *App) doInit() error {
|
||||
cfg, err := a.resolveConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -474,8 +482,6 @@ func (a *App) Init() error {
|
||||
a.Go(database.StartDBProbing)
|
||||
}
|
||||
|
||||
a.initialized = true
|
||||
|
||||
// OnInit hooks:组件初始化完成后触发(#12)
|
||||
for _, h := range a.hooks {
|
||||
if h.OnInit != nil {
|
||||
@@ -576,10 +582,14 @@ func (a *App) StartServer() error {
|
||||
serverErr <- nil
|
||||
}()
|
||||
|
||||
// OnReady hooks:端口就绪后
|
||||
// OnReady hooks:端口就绪后(A2 修复:失败时触发 shutdown)
|
||||
for _, h := range a.hooks {
|
||||
if h.OnReady != nil {
|
||||
h.OnReady(a)
|
||||
if err := h.OnReady(a); err != nil {
|
||||
logger.Errorf("OnReady hook %q 失败: %v", h.Name, err)
|
||||
serverErr <- fmt.Errorf("OnReady hook %q 失败: %w", h.Name, err)
|
||||
return fmt.Errorf("OnReady hook %q 失败: %w", h.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+17
-7
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
@@ -164,16 +165,25 @@ type CacheManager struct {
|
||||
svc CacheService
|
||||
}
|
||||
|
||||
// DefaultCache 默认缓存管理器,包级 facade 代理到它。
|
||||
var DefaultCache = NewCacheManager()
|
||||
// defaultCachePtr 全局默认缓存管理器(CK3 修复:atomic.Pointer 替代裸指针)。
|
||||
var defaultCachePtr atomic.Pointer[CacheManager]
|
||||
|
||||
func init() {
|
||||
defaultCachePtr.Store(NewCacheManager())
|
||||
}
|
||||
|
||||
// NewCacheManager 创建缓存管理器实例。
|
||||
func NewCacheManager() *CacheManager { return &CacheManager{} }
|
||||
|
||||
// SetDefaultCacheManager 提升指定 CacheManager 为全局默认。
|
||||
// GetDefaultCache 返回全局默认缓存管理器(并发安全,CK3 修复)。
|
||||
func GetDefaultCache() *CacheManager {
|
||||
return defaultCachePtr.Load()
|
||||
}
|
||||
|
||||
// SetDefaultCacheManager 提升指定 CacheManager 为全局默认(atomic 置换,并发安全)。
|
||||
func SetDefaultCacheManager(m *CacheManager) {
|
||||
if m != nil {
|
||||
DefaultCache = m
|
||||
defaultCachePtr.Store(m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,14 +211,14 @@ func (m *CacheManager) Get() CacheService {
|
||||
return m.svc
|
||||
}
|
||||
|
||||
// --- 包级 facade(代理到 DefaultCache,兼容存量) ---
|
||||
// --- 包级 facade(代理到 GetDefaultCache(),CK3 修复) ---
|
||||
|
||||
// Init 初始化全局缓存实例
|
||||
func Init() {
|
||||
DefaultCache.Init()
|
||||
GetDefaultCache().Init()
|
||||
}
|
||||
|
||||
// GetCache 获取全局缓存实例
|
||||
func GetCache() CacheService {
|
||||
return DefaultCache.Get()
|
||||
return GetDefaultCache().Get()
|
||||
}
|
||||
|
||||
Vendored
+27
-13
@@ -9,9 +9,10 @@ import (
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// KeyBuilder 缓存键名构建器
|
||||
// KeyBuilder 缓存键名构建器(CK2 修复:mu 保护并发访问)。
|
||||
// 使用场景: 多个小项目共用一台 Redis 服务器,每个项目设置不同前缀
|
||||
type KeyBuilder struct {
|
||||
mu sync.Mutex
|
||||
prefix string // 项目/站点前缀,如 "site_a"
|
||||
_separator string // 分隔符,默认 ":"
|
||||
_cacheType string // 缓存类型标识,如 "cache"
|
||||
@@ -57,10 +58,12 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
|
||||
return kb
|
||||
}
|
||||
|
||||
// Build 构建完整键名
|
||||
// Build 构建完整键名(CK2 修复:mu 读保护)。
|
||||
// 格式: {cacheType}{separator}{prefix}{separator}{key}
|
||||
// 示例: kb.Build("user:1") -> "cache_site_a_user:1"
|
||||
func (kb *KeyBuilder) Build(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{}
|
||||
if kb._cacheType != "" {
|
||||
parts = append(parts, kb._cacheType)
|
||||
@@ -72,9 +75,11 @@ func (kb *KeyBuilder) Build(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildTemp 构建临时缓存键名(带过期时间)
|
||||
// BuildTemp 构建临时缓存键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "temp{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildTemp(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"temp"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -83,9 +88,11 @@ func (kb *KeyBuilder) BuildTemp(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildPerm 构建永久缓存键名(不带过期时间)
|
||||
// BuildPerm 构建永久缓存键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "perm{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildPerm(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"perm"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -94,9 +101,11 @@ func (kb *KeyBuilder) BuildPerm(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildLock 构建分布式锁键名
|
||||
// BuildLock 构建分布式锁键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "lock{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildLock(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"lock"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -105,9 +114,11 @@ func (kb *KeyBuilder) BuildLock(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildCounter 构建计数器键名
|
||||
// BuildCounter 构建计数器键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "counter{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildCounter(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"counter"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -116,9 +127,11 @@ func (kb *KeyBuilder) BuildCounter(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildSession 构建会话键名
|
||||
// BuildSession 构建会话键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "session{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildSession(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"session"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -127,22 +140,23 @@ func (kb *KeyBuilder) BuildSession(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildPattern 构建匹配模式(用于 SCAN/Keys)
|
||||
// BuildPattern 构建匹配模式(用于 SCAN/Keys)(CK2 修复:mu 读保护)。
|
||||
// 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*"
|
||||
func (kb *KeyBuilder) BuildPattern(pattern string) string {
|
||||
return kb.Build(pattern)
|
||||
}
|
||||
|
||||
// GetPrefix 获取当前前缀
|
||||
// GetPrefix 获取当前前缀(CK2 修复:mu 读保护)。
|
||||
func (kb *KeyBuilder) GetPrefix() string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
return kb.prefix
|
||||
}
|
||||
|
||||
// SetPrefix 动态设置前缀。
|
||||
//
|
||||
// 注意(M13):KeyBuilder 实例非并发安全——并发 Build 与 SetPrefix 会竞争 prefix。
|
||||
// 全局构建器主要在启动期配置,运行期改前缀请自行加锁或重建实例。
|
||||
// SetPrefix 动态设置前缀(CK2 修复:mu 写保护,并发安全)。
|
||||
func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
kb.prefix = prefix
|
||||
return kb
|
||||
}
|
||||
|
||||
Vendored
+42
-28
@@ -82,14 +82,15 @@ end
|
||||
// 参数: key 锁名称,ttl 锁定时长
|
||||
// 返回: LockToken 用于后续解锁或续期
|
||||
func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil, ErrRedisNotReady
|
||||
}
|
||||
|
||||
token := utils.UUID()
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
|
||||
result, err := rdb.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -117,7 +118,8 @@ func Lock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
|
||||
// Unlock 安全释放锁
|
||||
func Unlock(ctx context.Context, token *LockToken) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
@@ -125,7 +127,7 @@ func Unlock(ctx context.Context, token *LockToken) error {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
|
||||
result, err := rdb.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -144,16 +146,18 @@ func Unlock(ctx context.Context, token *LockToken) error {
|
||||
// UnlockByKey 按键名释放锁(不安全,仅用于旧代码兼容)
|
||||
// 注意: 此函数不检查 Token,任何客户端都能释放锁
|
||||
func UnlockByKey(ctx context.Context, key string) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
return rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ExtendLock 续期锁
|
||||
// 参数: token 锁令牌,ttl 新的过期时间
|
||||
func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
@@ -163,7 +167,7 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
|
||||
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
|
||||
result, err := rdb.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -281,83 +285,93 @@ func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duratio
|
||||
|
||||
// IsLocked 检查锁是否被占用(不获取锁)
|
||||
func IsLocked(ctx context.Context, key string) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return false, nil
|
||||
}
|
||||
return database.RedisClient.Exists(ctx, key).Val() > 0, nil
|
||||
return rdb.Exists(ctx, key).Val() > 0, nil
|
||||
}
|
||||
|
||||
// GetLockTTL 获取锁的剩余过期时间
|
||||
func GetLockTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.TTL(ctx, key).Result()
|
||||
return rdb.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// ForceUnlock 强制释放锁(危险操作,仅用于管理场景)
|
||||
// 注意: 此函数不检查 Token,强制删除锁
|
||||
func ForceUnlock(ctx context.Context, key string) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
return rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ===== 计数器操作 =====
|
||||
|
||||
// Incr 自增计数器
|
||||
func Incr(ctx context.Context, key string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.Incr(ctx, key).Result()
|
||||
return rdb.Incr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// IncrBy 指定增量自增
|
||||
func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.IncrBy(ctx, key, value).Result()
|
||||
return rdb.IncrBy(ctx, key, value).Result()
|
||||
}
|
||||
|
||||
// Decr 自减计数器
|
||||
func Decr(ctx context.Context, key string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.Decr(ctx, key).Result()
|
||||
return rdb.Decr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// GetTTL 获取键的剩余过期时间
|
||||
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.TTL(ctx, key).Result()
|
||||
return rdb.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetExpire 设置键的过期时间
|
||||
func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return false, nil
|
||||
}
|
||||
return database.RedisClient.Expire(ctx, key, ttl).Result()
|
||||
return rdb.Expire(ctx, key, ttl).Result()
|
||||
}
|
||||
|
||||
// GetRaw 获取原始字符串值(不反序列化)
|
||||
func GetRaw(ctx context.Context, key string) (string, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return "", nil
|
||||
}
|
||||
return database.RedisClient.Get(ctx, key).Result()
|
||||
return rdb.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetRaw 设置原始值(不序列化)
|
||||
func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Set(ctx, key, value, ttl).Err()
|
||||
return rdb.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
Vendored
+4
-5
@@ -14,16 +14,15 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// setupMiniRedis 启动一个 miniredis 实例并把它接到 database.RedisClient,返回清理函数。
|
||||
// setupMiniRedis 启动一个 miniredis 实例并把它注入 database 内部,返回清理函数。
|
||||
func setupMiniRedis(t *testing.T) *miniredis.Miniredis {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
// 保存旧值,测试结束恢复,避免污染其他测试。
|
||||
old := database.RedisClient
|
||||
database.RedisClient = client
|
||||
t.Cleanup(func() { database.RedisClient = old })
|
||||
// 通过 SetTestRedisClient 注入 miniredis 客户端到 database 包的内部 redisClient
|
||||
database.SetTestRedisClient(client)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
return mr
|
||||
}
|
||||
|
||||
|
||||
+13
-3
@@ -704,15 +704,25 @@ func (m *Manager) reload() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load 加载配置文件
|
||||
// Load 加载配置文件(C3.5 修复:替换前停止旧 Manager 的 watcher,防止 goroutine 泄漏)。
|
||||
func Load(configPath string) (*Config, error) {
|
||||
if old := defaultManager.Load(); old != nil {
|
||||
old.StopWatcher()
|
||||
}
|
||||
m := NewManager(configPath)
|
||||
cfg, err := m.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defaultManager.Store(m)
|
||||
return m.Load()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadWithWatch 加载配置文件并启用热更新
|
||||
// LoadWithWatch 加载配置文件并启用热更新(C3.5 修复:替换前停止旧 watcher)。
|
||||
func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) {
|
||||
if old := defaultManager.Load(); old != nil {
|
||||
old.StopWatcher()
|
||||
}
|
||||
m := NewManager(configPath)
|
||||
defaultManager.Store(m)
|
||||
return m.LoadWithWatch(onChange)
|
||||
|
||||
+21
-11
@@ -8,17 +8,21 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Task 定时任务
|
||||
type Task struct {
|
||||
Name string // 任务名称
|
||||
Schedule Schedule // 调度规则
|
||||
Handler TaskHandler // 任务处理函数
|
||||
Enabled bool // 是否启用
|
||||
LastRun time.Time // 上次运行时间
|
||||
NextRun time.Time // 下次运行时间
|
||||
RunCount int // 运行次数
|
||||
Name string // 任务名称
|
||||
Schedule Schedule // 调度规则
|
||||
Handler TaskHandler // 任务处理函数
|
||||
Enabled bool // 是否启用
|
||||
LastRun time.Time // 上次运行时间
|
||||
NextRun time.Time // 下次运行时间
|
||||
RunCount int // 运行次数
|
||||
LastError error // CR3 修复:最近一次执行错误
|
||||
|
||||
// running 防止长任务跨 tick 重叠执行(C12b)。
|
||||
// 用指针以便 GetTask/ListTasks 返回 Task 拷贝时不触发 atomic.Bool 值类型的
|
||||
@@ -247,8 +251,13 @@ func (s *Scheduler) checkAndRun() {
|
||||
s.mu.Lock()
|
||||
t.LastRun = time.Now()
|
||||
t.RunCount++
|
||||
t.LastError = err // CR3 修复:记录错误不再静默丢弃
|
||||
s.mu.Unlock()
|
||||
_ = err
|
||||
if err != nil {
|
||||
logger.Error("cron task failed",
|
||||
zap.String("task", t.Name),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(task)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
@@ -320,8 +329,9 @@ func Weekly(day time.Weekday, hour, minute int) *WeeklySchedule {
|
||||
// FullCronSchedule 完整 Cron 表达式调度
|
||||
// 格式: "分钟 小时 日 月 星期" (5字段)
|
||||
// 示例: "0 12 * * *" 每天12点
|
||||
// "0 0 1 * *" 每月1号凌晨
|
||||
// "0 9-17 * * 1-5" 周一到周五 9点到17点每小时
|
||||
//
|
||||
// "0 0 1 * *" 每月1号凌晨
|
||||
// "0 9-17 * * 1-5" 周一到周五 9点到17点每小时
|
||||
type FullCronSchedule struct {
|
||||
Minute string // 分钟: 0-59, "*", "*/n", "a-b", "a,b,c"
|
||||
Hour string // 小时: 0-23, "*", "*/n", "a-b", "a,b,c"
|
||||
@@ -649,4 +659,4 @@ func Start() {
|
||||
// Stop 停止全局调度器
|
||||
func Stop() {
|
||||
GetScheduler().Stop()
|
||||
}
|
||||
}
|
||||
|
||||
+18
-9
@@ -49,7 +49,10 @@ func (p *RoundRobinPicker) Pick(replicas []*gorm.DB) *gorm.DB {
|
||||
return replicas[int(n-1)%len(replicas)]
|
||||
}
|
||||
|
||||
// RandomPicker 随机选择从库
|
||||
// RandomPicker 随机选择从库。
|
||||
//
|
||||
// D2 注释:rand.Intn 自 Go 1.20 起(go.dev/issue/54899)内部使用 per-goroutine
|
||||
// 随机源,并发安全无需额外同步。本模块 go.mod 声明 go 1.25.0,满足此要求。
|
||||
type RandomPicker struct{}
|
||||
|
||||
// Pick 随机选择一个从库
|
||||
@@ -69,11 +72,11 @@ type Manager struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// #21 健康自愈
|
||||
healthy atomic.Bool // 主库是否健康
|
||||
replicaHealthy []atomic.Bool // 每个从库的健康标记,索引与 replicas 对齐
|
||||
probeFailures int // 主库连续探活失败次数
|
||||
probeMu sync.Mutex // 保护 probeFailures
|
||||
replicaHealthSet bool // replicaHealthy 是否已按 replicas 长度初始化
|
||||
healthy atomic.Bool // 主库是否健康
|
||||
replicaHealthy []atomic.Bool // 每个从库的健康标记,索引与 replicas 对齐
|
||||
probeFailures int // 主库连续探活失败次数
|
||||
probeMu sync.Mutex // 保护 probeFailures
|
||||
replicaHealthSet bool // replicaHealthy 是否已按 replicas 长度初始化
|
||||
}
|
||||
|
||||
// NewManager 创建数据库管理器
|
||||
@@ -434,18 +437,24 @@ func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
|
||||
// isTransientDBError 判断数据库连接错误是否值得重试。
|
||||
// 认证失败、未知数据库、非法 DSN/驱动等属于配置类错误,重试无意义,直接返回更友好。
|
||||
// D5 修复:覆盖 MySQL 和 PostgreSQL 常见非瞬态错误。
|
||||
func isTransientDBError(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
nonTransient := []string{
|
||||
"Access denied", // MySQL 认证失败(用户名/密码错误)
|
||||
"authentication plugin", // MySQL 认证插件不支持
|
||||
"Unknown database", // MySQL 目标库不存在
|
||||
// MySQL
|
||||
"Access denied", // 认证失败(用户名/密码错误)
|
||||
"authentication plugin", // 认证插件不支持
|
||||
"Unknown database", // 目标库不存在
|
||||
"invalid DSN", // DSN 语法错误
|
||||
"unknown driver", // 驱动未注册
|
||||
"unsupported driver", // 驱动不支持
|
||||
// PostgreSQL(D5 修复)
|
||||
"password authentication failed", // pg 密码错误
|
||||
"database", "does not exist", // pg 目标库不存在(消息含"database ... does not exist")
|
||||
"no pg_hba.conf entry", // pg_hba.conf 拒绝
|
||||
}
|
||||
for _, sub := range nonTransient {
|
||||
if strings.Contains(msg, sub) {
|
||||
|
||||
+34
-12
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
@@ -12,9 +13,22 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RedisClient 全局 Redis 客户端(兼容 facade,由 RedisManager.Init 同步维护)。
|
||||
// 保留供存量代码直接访问;新代码建议用 GetRedis() 或持有 *RedisManager 实例。
|
||||
var RedisClient *redis.Client
|
||||
// redisClient 内部 Redis 客户端引用(由 RedisManager.Init/Close 同步维护)。
|
||||
//
|
||||
// 已废弃对外暴露:所有外部代码请使用 GetRedis() 或持有 *RedisManager 实例。
|
||||
// 测试如需注入 mock Redis,请用 SetDefaultRedisManager 替换 DefaultRedis。
|
||||
//
|
||||
// 仅包内 Init/Close 在持有 m.mu 时写入;外部直接读取存在竞态风险。
|
||||
var redisClient *redis.Client
|
||||
|
||||
// SetTestRedisClient 供测试注入 miniredis 等 mock 客户端。
|
||||
// 返回旧客户端引用以便测试清理时恢复。生产代码严禁调用。
|
||||
// 注意:仅在测试环境单 goroutine 使用,不持有锁保护。
|
||||
func SetTestRedisClient(c *redis.Client) *redis.Client {
|
||||
old := redisClient
|
||||
redisClient = c
|
||||
return old
|
||||
}
|
||||
|
||||
// RedisManager Redis 连接管理器(#10)。照 database.Manager 模式:
|
||||
// 实例化 + DefaultRedis 全局默认 + 包级 facade 代理,支持多实例与测试注入。
|
||||
@@ -44,20 +58,24 @@ func (m *RedisManager) Init(cfg *config.Config) error {
|
||||
defer m.mu.Unlock()
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr(),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
Addr: cfg.Redis.Addr(),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
DialTimeout: 5 * time.Second, // D7 修复:连接超时
|
||||
ReadTimeout: 3 * time.Second, // D7 修复:读超时
|
||||
WriteTimeout: 3 * time.Second, // D7 修复:写超时
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(pingCtx).Err(); err != nil {
|
||||
client.Close()
|
||||
return fmt.Errorf("Redis 连接失败: %w", err)
|
||||
}
|
||||
|
||||
m.cfg = cfg
|
||||
m.client = client
|
||||
RedisClient = client // 同步兼容 facade
|
||||
redisClient = client // 内部同步
|
||||
logger.Info("Redis 连接成功", zap.String("addr", cfg.Redis.Addr()))
|
||||
return nil
|
||||
}
|
||||
@@ -72,7 +90,7 @@ func (m *RedisManager) Close() error {
|
||||
}
|
||||
err := m.client.Close()
|
||||
m.client = nil
|
||||
RedisClient = nil
|
||||
redisClient = nil
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -111,7 +129,11 @@ func HealthCheckRedis(ctx context.Context) error {
|
||||
return DefaultRedis.HealthCheck(ctx)
|
||||
}
|
||||
|
||||
// GetRedis 获取 Redis 客户端
|
||||
// GetRedis 获取 Redis 客户端。
|
||||
// 优先返回 DefaultRedis 实例化的客户端;若未初始化则回退到内部客户端(测试注入路径)。
|
||||
func GetRedis() *redis.Client {
|
||||
return DefaultRedis.Client()
|
||||
if c := DefaultRedis.Client(); c != nil {
|
||||
return c
|
||||
}
|
||||
return redisClient
|
||||
}
|
||||
|
||||
+8
-14
@@ -118,19 +118,10 @@ type Manager struct {
|
||||
}
|
||||
|
||||
// defaultManager 是全局默认 JWT 管理器的真实存储,经 atomic 读写(C9c)。
|
||||
// 历史上 DefaultJWT/tokenBlacklist 为裸包级指针,SetDefaultJWTManager 写与
|
||||
// 请求 goroutine(ParseToken/RefreshToken/InvalidateToken/IsTokenRevoked)读存在数据竞争。
|
||||
var defaultManager atomic.Pointer[Manager]
|
||||
|
||||
// DefaultJWT 默认 JWT 管理器的兼容导出别名,指向 defaultManager 当前实例。
|
||||
// 直接读此变量非并发安全(SetDefaultJWTManager 裸写维护),仅供启动期或兼容存量代码;
|
||||
// 并发安全的访问请使用包级函数(ParseToken/RefreshToken/...)或 SetDefaultJWTManager。
|
||||
var DefaultJWT *Manager
|
||||
|
||||
func init() {
|
||||
m := NewJWTManager()
|
||||
defaultManager.Store(m)
|
||||
DefaultJWT = m
|
||||
defaultManager.Store(NewJWTManager())
|
||||
}
|
||||
|
||||
// currentManager 返回全局默认 JWT 管理器(atomic 读取,C9c)。
|
||||
@@ -144,6 +135,12 @@ func currentManager() *Manager {
|
||||
return m
|
||||
}
|
||||
|
||||
// GetDefaultJWT 返回全局默认 JWT 管理器(并发安全,C9c/J1 修复)。
|
||||
// 替代已删除的 DefaultJWT 包级变量。
|
||||
func GetDefaultJWT() *Manager {
|
||||
return currentManager()
|
||||
}
|
||||
|
||||
// currentBlacklist 返回全局默认 Manager 持有的黑名单(atomic 读取 Manager 后经 Blacklist(),C9c)。
|
||||
func currentBlacklist() *TokenBlacklist {
|
||||
return currentManager().Blacklist()
|
||||
@@ -159,15 +156,12 @@ func NewJWTManagerWithRedis(client *redis.Client) *Manager {
|
||||
return &Manager{blacklist: NewTokenBlacklist(client)}
|
||||
}
|
||||
|
||||
// SetDefaultJWTManager 提升指定 Manager 为全局默认。
|
||||
// 经 atomic.Pointer 原子置换真实存储(C9c),并同步维护 DefaultJWT 兼容别名。
|
||||
// 典型在启动期调用;并发请求期调用也安全(包级函数经 atomic 读取不会观察到撕裂值)。
|
||||
// SetDefaultJWTManager 提升指定 Manager 为全局默认(atomic 置换,并发安全,J1 修复)。
|
||||
func SetDefaultJWTManager(m *Manager) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
defaultManager.Store(m)
|
||||
DefaultJWT = m // 兼容别名同步(直接读 DefaultJWT 非并发安全,见其注释)
|
||||
}
|
||||
|
||||
// Blacklist 返回 Manager 持有的黑名单实例。
|
||||
|
||||
@@ -116,20 +116,20 @@ func TestC9cCurrentManagerReflectsSwap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestC9cDefaultJWTAliasConsistent 验证 DefaultJWT 兼容别名与 defaultManager 真实存储一致。
|
||||
// TestC9cDefaultJWTAliasConsistent 验证 GetDefaultJWT 与 defaultManager 真实存储一致。
|
||||
func TestC9cDefaultJWTAliasConsistent(t *testing.T) {
|
||||
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) })
|
||||
|
||||
// init 后 DefaultJWT 与 currentManager 应指向同一实例
|
||||
if DefaultJWT != currentManager() {
|
||||
t.Fatal("DefaultJWT alias should equal currentManager after init")
|
||||
// init 后 GetDefaultJWT 与 currentManager 应指向同一实例
|
||||
if GetDefaultJWT() != currentManager() {
|
||||
t.Fatal("GetDefaultJWT should equal currentManager after init")
|
||||
}
|
||||
|
||||
// SetDefaultJWTManager 后同步
|
||||
m := NewJWTManagerWithRedis(nil)
|
||||
SetDefaultJWTManager(m)
|
||||
if DefaultJWT != m {
|
||||
t.Fatal("DefaultJWT alias should sync after SetDefaultJWTManager")
|
||||
if GetDefaultJWT() != m {
|
||||
t.Fatal("GetDefaultJWT should sync after SetDefaultJWTManager")
|
||||
}
|
||||
if currentManager() != m {
|
||||
t.Fatal("currentManager should reflect SetDefaultJWTManager")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{"level":"warn","time":"2026-07-02T23:25:17.643+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 1/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
|
||||
{"level":"warn","time":"2026-07-02T23:25:18.648+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 2/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
|
||||
{"level":"warn","time":"2026-07-02T23:25:20.651+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 3/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
|
||||
+10
-21
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -205,28 +206,16 @@ func shouldSkipPath(path string, cfg LoggerConfig) bool {
|
||||
}
|
||||
|
||||
// filterSensitiveFields 过滤敏感字段(密码、token等)
|
||||
// sensitiveFieldsRE M1 修复:编译期正则,匹配 "key":"value" 整体并将 value 替换为 [FILTERED]。
|
||||
// 支持 JSON 字符串中标准转义(\" \\ \/ \b \f \n \r \t \uXXXX)。
|
||||
var sensitiveFieldsRE = regexp.MustCompile(
|
||||
`"(password|passwd|pwd|token|access_token|refresh_token|secret|api_key|apikey|credit_card|card_number)"\s*:\s*"((?:[^"\\]|\\.)*)"`,
|
||||
)
|
||||
|
||||
// filterSensitiveFields M1 修复后实现:将敏感字段的值替换为 [FILTERED],完整移除原始值。
|
||||
// 对非 JSON 输入原样返回。
|
||||
func filterSensitiveFields(body []byte) string {
|
||||
bodyStr := string(body)
|
||||
|
||||
// 过滤常见敏感字段(简单字符串替换)
|
||||
sensitiveFields := []string{
|
||||
"password", "passwd", "pwd",
|
||||
"token", "access_token", "refresh_token",
|
||||
"secret", "api_key", "apikey",
|
||||
"credit_card", "card_number",
|
||||
}
|
||||
|
||||
for _, field := range sensitiveFields {
|
||||
// 检查是否包含敏感字段
|
||||
keyPattern := `"` + field + `":`
|
||||
if strings.Contains(bodyStr, keyPattern) {
|
||||
// 简单替换:将值替换为 [FILTERED]
|
||||
// 注意:这是一个简化的实现,复杂的 JSON 可能需要更精确的处理
|
||||
bodyStr = strings.ReplaceAll(bodyStr, keyPattern, keyPattern+"\"[FILTERED]\"")
|
||||
}
|
||||
}
|
||||
|
||||
return bodyStr
|
||||
return sensitiveFieldsRE.ReplaceAllString(string(body), `"$1":"[FILTERED]"`)
|
||||
}
|
||||
|
||||
// LoggerForAPI API 专用日志中间件(更详细)
|
||||
|
||||
@@ -45,11 +45,11 @@ var (
|
||||
func Metrics() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
httpRequestsInFlight.Inc()
|
||||
defer httpRequestsInFlight.Dec() // M2 修复:defer 保证 panic 时也递减,不依赖 Recover 中间件顺序
|
||||
start := time.Now()
|
||||
|
||||
c.Next()
|
||||
|
||||
httpRequestsInFlight.Dec()
|
||||
elapsed := time.Since(start).Seconds()
|
||||
status := strconv.Itoa(c.Writer.Status())
|
||||
route := c.FullPath()
|
||||
|
||||
@@ -1188,9 +1188,8 @@ func TestLoginRedisRateLimit(t *testing.T) {
|
||||
// H4c: LoginRedisRateLimit 改 fail-closed——无 Redis 时拒绝(503),
|
||||
// 防爆破场景下 Redis 故障不能静默放行(原 fail-open 致限流失效)。
|
||||
// 测试环境无 Redis,故预期 503。
|
||||
prev := database.RedisClient
|
||||
database.RedisClient = nil
|
||||
defer func() { database.RedisClient = prev }()
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.LoginRedisRateLimit())
|
||||
@@ -1265,23 +1264,21 @@ func TestRedisRateLimiterReset(t *testing.T) {
|
||||
|
||||
// ===== H4c 回归:RedisRateLimiter fail-open/fail-closed + 裸断言 =====
|
||||
|
||||
// setupMiddlewareMiniRedis 启动 miniredis 并接到 database.RedisClient,返回 mr 与清理。
|
||||
// setupMiddlewareMiniRedis 启动 miniredis 并注入 database 内部 redisClient,返回 mr 与清理。
|
||||
func setupMiddlewareMiniRedis(t *testing.T) *miniredis.Miniredis {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
old := database.RedisClient
|
||||
database.RedisClient = client
|
||||
t.Cleanup(func() { database.RedisClient = old })
|
||||
old := database.SetTestRedisClient(client)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(old) })
|
||||
return mr
|
||||
}
|
||||
|
||||
// 回归 H4c-1a:无 Redis 时 fail-open 放行(兼容旧行为)。
|
||||
func TestRedisRateLimiterFailOpenNoRedis(t *testing.T) {
|
||||
prev := database.RedisClient
|
||||
database.RedisClient = nil
|
||||
defer func() { database.RedisClient = prev }()
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute)
|
||||
allowed, err := limiter.Allow(context.Background(), "1.2.3.4")
|
||||
@@ -1296,9 +1293,8 @@ func TestRedisRateLimiterFailOpenNoRedis(t *testing.T) {
|
||||
// 回归 H4c-1b:无 Redis 时 fail-closed 拒绝 + 返 ErrRedisRateLimiterUnavailable。
|
||||
// 修复前无此选项;fail-closed 是 H4c 新增的安全语义。
|
||||
func TestRedisRateLimiterFailClosedNoRedis(t *testing.T) {
|
||||
prev := database.RedisClient
|
||||
database.RedisClient = nil
|
||||
defer func() { database.RedisClient = prev }()
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
limiter := middleware.NewRedisRateLimiterFailClosed("test", 10, time.Minute)
|
||||
allowed, err := limiter.Allow(context.Background(), "1.2.3.4")
|
||||
@@ -1356,9 +1352,8 @@ func TestRedisRateLimiterFailClosedOnRedisError(t *testing.T) {
|
||||
|
||||
// 回归 H4c-1d:fail-closed 中间件在 Redis 故障时返 503(区别于真实超限的 429)。
|
||||
func TestRedisRateLimitFailClosedMiddlewareReturns503(t *testing.T) {
|
||||
prev := database.RedisClient
|
||||
database.RedisClient = nil // 无 Redis
|
||||
defer func() { database.RedisClient = prev }()
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.RedisRateLimitFailClosed("login_limit", 10))
|
||||
@@ -1381,9 +1376,8 @@ func TestRedisRateLimitFailClosedMiddlewareReturns503(t *testing.T) {
|
||||
|
||||
// 回归 H4c-1e:fail-open 中间件在 Redis 故障时放行(兼容旧行为)。
|
||||
func TestRedisRateLimitFailOpenMiddlewareAllowsOnError(t *testing.T) {
|
||||
prev := database.RedisClient
|
||||
database.RedisClient = nil
|
||||
defer func() { database.RedisClient = prev }()
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.RedisRateLimit("api_limit", 100))
|
||||
@@ -1434,7 +1428,7 @@ func TestRedisRateLimiterCommaOkAssertion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// miniredis 执行返回字符串的脚本——模拟 Redis 返回非 int64 结果。
|
||||
res, err := database.RedisClient.Eval(ctx, `return 'not-an-int'`, nil).Result()
|
||||
res, err := database.GetRedis().Eval(ctx, `return 'not-an-int'`, nil).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("eval err = %v", err)
|
||||
}
|
||||
@@ -1461,9 +1455,8 @@ func TestRedisRateLimiterCommaOkAssertion(t *testing.T) {
|
||||
|
||||
// 回归 H4c-2:SetFailClosed 切换策略——fail-open 限流器切 fail-closed 后无 Redis 拒绝。
|
||||
func TestRedisRateLimiterSetFailClosed(t *testing.T) {
|
||||
prev := database.RedisClient
|
||||
database.RedisClient = nil
|
||||
defer func() { database.RedisClient = prev }()
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute)
|
||||
// 初始 fail-open:放行。
|
||||
|
||||
@@ -140,7 +140,7 @@ func (rl *RateLimiter) Stop() {
|
||||
|
||||
// H4c: Redis 限流器故障相关错误。
|
||||
var (
|
||||
// ErrRedisRateLimiterUnavailable Redis 未启用(database.RedisClient == nil)。
|
||||
// ErrRedisRateLimiterUnavailable Redis 未启用(database.GetRedis() == nil)。
|
||||
// fail-closed 限流器在此情况下拒绝请求;fail-open 限流器放行。
|
||||
ErrRedisRateLimiterUnavailable = errors.New("redis rate limiter: redis client unavailable")
|
||||
// ErrRedisRateLimiterUnexpectedResult Redis 返回非预期的结果类型(非 int64)。
|
||||
@@ -216,10 +216,10 @@ func (rl *RedisRateLimiter) SetFailClosed(failClosed bool) {
|
||||
// fail-open 返 (true, err) 放行(兼容旧行为)。中间件层据此 allowed 值决定放行/拒绝,
|
||||
// 不再无条件 fail-open——登录防爆破等安全场景用 fail-closed 限流器即可在 Redis 故障时拒绝。
|
||||
//
|
||||
// Redis 未启用(database.RedisClient == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable),
|
||||
// Redis 未启用(database.GetRedis() == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable),
|
||||
// fail-open 返 (true, nil)(兼容旧行为)。安全场景必须确保 Redis 已启用。
|
||||
func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
if database.GetRedis() == nil {
|
||||
if rl.failClosed {
|
||||
return false, ErrRedisRateLimiterUnavailable
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool,
|
||||
now := float64(time.Now().UnixMilli())
|
||||
windowMs := float64(rl.window.Milliseconds())
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
|
||||
result, err := database.GetRedis().Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
|
||||
if err != nil {
|
||||
if rl.failClosed {
|
||||
return false, err
|
||||
@@ -251,7 +251,7 @@ func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool,
|
||||
|
||||
// GetCount 获取当前窗口内的请求数
|
||||
func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
if database.GetRedis() == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -260,18 +260,18 @@ func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (in
|
||||
windowStart := now - rl.window.Milliseconds()
|
||||
|
||||
// 移除旧记录并获取当前计数
|
||||
database.RedisClient.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
|
||||
return database.RedisClient.ZCard(ctx, key).Result()
|
||||
database.GetRedis().ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
|
||||
return database.GetRedis().ZCard(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Reset 重置限流计数
|
||||
func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error {
|
||||
if database.RedisClient == nil {
|
||||
if database.GetRedis() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
key := rl.keyPrefix + ":" + identifier
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
return database.GetRedis().Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ===== 全局限速器 =====
|
||||
|
||||
@@ -44,7 +44,11 @@ func NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T] {
|
||||
}
|
||||
|
||||
// readConn 返回读连接:外层 ctx 事务 > 本 repo 事务 > 读写分离路由 > r.db 回退。
|
||||
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息。
|
||||
func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB {
|
||||
if r.db == nil {
|
||||
panic("repository.BaseRepo: db is nil. Use NewBaseRepo with a valid *gorm.DB")
|
||||
}
|
||||
if tx := database.TxFromContext(ctx); tx != nil {
|
||||
return tx.WithContext(ctx)
|
||||
}
|
||||
@@ -59,7 +63,11 @@ func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB {
|
||||
|
||||
// writeConn 返回写连接:外层 ctx 事务 > 本 repo 事务 > 主库 > r.db 回退。
|
||||
// 写操作始终走主库,不路由到只读从库。
|
||||
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息。
|
||||
func (r *BaseRepo[T]) writeConn(ctx context.Context) *gorm.DB {
|
||||
if r.db == nil {
|
||||
panic("repository.BaseRepo: db is nil. Use NewBaseRepo with a valid *gorm.DB")
|
||||
}
|
||||
if tx := database.TxFromContext(ctx); tx != nil {
|
||||
return tx.WithContext(ctx)
|
||||
}
|
||||
@@ -340,20 +348,26 @@ func (r *BaseRepo[T]) ExistsWhere(ctx context.Context, query string, args ...any
|
||||
|
||||
// ===== 软删除操作 =====
|
||||
|
||||
// SoftDeleteColumn 返回软删除列名(RP2 修复:可被子类覆盖以适配自定义列名)。
|
||||
// 默认 "deleted_at",与 GORM gorm.DeletedAt 的默认列名一致。
|
||||
func (r *BaseRepo[T]) SoftDeleteColumn() string {
|
||||
return "deleted_at"
|
||||
}
|
||||
|
||||
// Restore 恢复软删除记录
|
||||
func (r *BaseRepo[T]) Restore(ctx context.Context, id uint) error {
|
||||
return r.writeConn(ctx).Model(new(T)).Unscoped().Where("id = ?", id).Update("deleted_at", nil).Error
|
||||
return r.writeConn(ctx).Model(new(T)).Unscoped().Where("id = ?", id).Update(r.SoftDeleteColumn(), nil).Error
|
||||
}
|
||||
|
||||
// RestoreBatch 批量恢复软删除记录
|
||||
func (r *BaseRepo[T]) RestoreBatch(ctx context.Context, ids []uint) error {
|
||||
return r.writeConn(ctx).Model(new(T)).Unscoped().Where("id IN ?", ids).Update("deleted_at", nil).Error
|
||||
return r.writeConn(ctx).Model(new(T)).Unscoped().Where("id IN ?", ids).Update(r.SoftDeleteColumn(), nil).Error
|
||||
}
|
||||
|
||||
// FindDeleted 查询已软删除的记录
|
||||
func (r *BaseRepo[T]) FindDeleted(ctx context.Context) ([]T, error) {
|
||||
var models []T
|
||||
err := r.readConn(ctx).Unscoped().Where("deleted_at IS NOT NULL").Find(&models).Error
|
||||
err := r.readConn(ctx).Unscoped().Where(r.SoftDeleteColumn() + " IS NOT NULL").Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -191,9 +191,10 @@ var _ = map[int]string{
|
||||
CodeBusinessRuleError: "CodeBusinessRuleError",
|
||||
}
|
||||
|
||||
// FailWithError 使用预定义错误响应
|
||||
// FailWithError 使用预定义错误响应(M7 修复:通过 ToResponse 保留 Detail)。
|
||||
func FailWithError(c *gin.Context, err *Error) {
|
||||
writeResp(c, err.Code, err.Message, nil)
|
||||
resp := err.ToResponse()
|
||||
writeResp(c, resp.Code, resp.Msg, resp.Data)
|
||||
}
|
||||
|
||||
// FailWithDetail 使用预定义错误并添加详细信息
|
||||
|
||||
@@ -495,6 +495,28 @@ func TestUUIDShort(t *testing.T) {
|
||||
if len(uuid) != 32 {
|
||||
t.Errorf("UUIDShort length = %d", len(uuid))
|
||||
}
|
||||
// U1 回归:确保无破折号
|
||||
if uuid[8] == '-' || uuid[12] == '-' || uuid[16] == '-' || uuid[20] == '-' {
|
||||
t.Errorf("UUIDShort contains dashes at unexpected positions: %s", uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUUIDShort_NoDashes(t *testing.T) {
|
||||
// U1 回归:100 次生成全部不含破折号,长度精确 32
|
||||
for range 100 {
|
||||
s := utils.UUIDShort()
|
||||
if len(s) != 32 {
|
||||
t.Fatalf("UUIDShort length = %d, want 32", len(s))
|
||||
}
|
||||
for j, c := range s {
|
||||
if c == '-' {
|
||||
t.Fatalf("UUIDShort contains dash at position %d: %s", j, s)
|
||||
}
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
t.Fatalf("UUIDShort non-hex char at position %d: %c in %s", j, c, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUUIDValid(t *testing.T) {
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -9,9 +11,9 @@ func UUID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// UUIDShort 生成短 UUID(无横线)
|
||||
// UUIDShort 生成短 UUID(无横线,32 个十六进制字符)
|
||||
func UUIDShort() string {
|
||||
return uuid.New().String()[:32]
|
||||
return strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||
}
|
||||
|
||||
// UUIDParse 解析 UUID 字符串
|
||||
|
||||
@@ -316,13 +316,16 @@ func SetCheckOrigin(fn func(r *http.Request) bool) {
|
||||
upgrader.CheckOrigin = fn
|
||||
}
|
||||
|
||||
// Hub 连接管理中心(用于广播)
|
||||
// Hub 连接管理中心(用于广播)。
|
||||
// W1 修复:添加 stop channel + Stop() 方法,支持优雅退出。
|
||||
type Hub struct {
|
||||
connections map[*Connection]bool
|
||||
register chan *Connection
|
||||
unregister chan *Connection
|
||||
broadcast chan []byte
|
||||
mu sync.RWMutex
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewHub 创建 Hub
|
||||
@@ -332,17 +335,29 @@ func NewHub() *Hub {
|
||||
register: make(chan *Connection),
|
||||
unregister: make(chan *Connection),
|
||||
broadcast: make(chan []byte, 256),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Run 运行 Hub。消费 register/unregister/broadcast 三个 channel。
|
||||
// Run 运行 Hub。消费 register/unregister/broadcast/stop 四个 channel。
|
||||
//
|
||||
// 死锁修复(C2a):广播分支不再向自身 unregister channel 回环(旧实现 broadcast 中
|
||||
// conn.Send 失败时 h.unregister <- conn,而 unregister 的唯一消费者就是本 Run goroutine,
|
||||
// 导致永久阻塞、整个 Hub 卡死)。改为持写锁单次遍历,失败的连接行内 delete + conn.Close()。
|
||||
// W1 修复:新增 stop 退出分支,Stop() 方法 close(stop) 通知退出。
|
||||
// 死锁修复(C2a):广播分支不再向自身 unregister channel 回环。
|
||||
func (h *Hub) Run() {
|
||||
h.wg.Add(1)
|
||||
defer h.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-h.stop:
|
||||
// 关闭所有连接后退出
|
||||
h.mu.Lock()
|
||||
for conn := range h.connections {
|
||||
delete(h.connections, conn)
|
||||
conn.Close()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return
|
||||
|
||||
case conn := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.connections[conn] = true
|
||||
@@ -358,8 +373,6 @@ func (h *Hub) Run() {
|
||||
|
||||
case message := <-h.broadcast:
|
||||
// 持写锁单次遍历,失败的连接行内移除并关闭,不回环 unregister channel(C2a 修复)。
|
||||
// Send 为非阻塞(C2a-residual 修复),缓冲满或已关闭即返回错误,保证持锁期间不阻塞、
|
||||
// Hub 不 stall。慢消费者缓冲满会被清理——ws 广播为 best-effort 语义,背压通过踢除实现。
|
||||
h.mu.Lock()
|
||||
for conn := range h.connections {
|
||||
if err := conn.Send(message); err != nil {
|
||||
@@ -372,19 +385,44 @@ func (h *Hub) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册连接
|
||||
// Stop 停止 Hub(W1 修复:优雅退出机制)。
|
||||
// close(stop) 通知 Run() 退出,WaitGroup 等待 Run() 完全结束。
|
||||
// 幂等:重复调用安全(close 已关闭的 channel 会 panic,
|
||||
// 由 Hub 内部 stopped 标志保护——这里的实现依赖 close(stop) 本身由唯一调用者执行)。
|
||||
func (h *Hub) Stop() {
|
||||
select {
|
||||
case <-h.stop:
|
||||
// 已关闭,幂等返回
|
||||
return
|
||||
default:
|
||||
close(h.stop)
|
||||
}
|
||||
h.wg.Wait()
|
||||
}
|
||||
|
||||
// Register 注册连接(W1 修复:Hub 已 stop 时 non-blocking 返回,避免永久阻塞)。
|
||||
func (h *Hub) Register(conn *Connection) {
|
||||
h.register <- conn
|
||||
select {
|
||||
case h.register <- conn:
|
||||
case <-h.stop:
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister 注销连接
|
||||
// Unregister 注销连接(W1 修复:Hub 已 stop 时 non-blocking 返回)。
|
||||
func (h *Hub) Unregister(conn *Connection) {
|
||||
h.unregister <- conn
|
||||
select {
|
||||
case h.unregister <- conn:
|
||||
case <-h.stop:
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast 广播消息
|
||||
// Broadcast 广播消息(W1 修复:Hub 已 stop 时 non-blocking 返回)。
|
||||
func (h *Hub) Broadcast(message []byte) {
|
||||
h.broadcast <- message
|
||||
select {
|
||||
case h.broadcast <- message:
|
||||
case <-h.stop:
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastJSON 广播 JSON 消息
|
||||
|
||||
Reference in New Issue
Block a user