From a74ea0c2f4cf80449de9ad82bac0a2a26d4b2b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=AD=E5=B7=9E=E6=98=8E=E5=A9=B3=E7=A7=91=E6=8A=80?= Date: Fri, 3 Jul 2026 00:00:11 +0800 Subject: [PATCH] =?UTF-8?q?deepseek=E4=BF=AE=E6=94=B9=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 29 ++++++++++++++ app.go | 36 ++++++++++------- cache/cache.go | 24 ++++++++---- cache/keybuilder.go | 40 ++++++++++++------- cache/lock.go | 70 ++++++++++++++++++++-------------- cache/lock_concurrency_test.go | 9 ++--- config/config.go | 16 ++++++-- cron/cron.go | 32 ++++++++++------ database/manager.go | 27 ++++++++----- database/redis.go | 46 ++++++++++++++++------ jwt/jwt.go | 22 ++++------- jwt/jwt_c9c_internal_test.go | 12 +++--- logs/app.log | 3 ++ middleware/logger.go | 31 +++++---------- middleware/metrics.go | 2 +- middleware/middleware_test.go | 39 ++++++++----------- middleware/ratelimit.go | 18 ++++----- repository/repository.go | 20 ++++++++-- response/error.go | 5 ++- utils/utils_test.go | 22 +++++++++++ utils/uuid.go | 6 ++- ws/ws.go | 64 ++++++++++++++++++++++++------- 22 files changed, 378 insertions(+), 195 deletions(-) create mode 100644 logs/app.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d5a55c..b3accbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 🐛 diff --git a/app.go b/app.go index 30629c0..8be698f 100644 --- a/app.go +++ b/app.go @@ -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) + } } } diff --git a/cache/cache.go b/cache/cache.go index f07868b..97fe775 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -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() } diff --git a/cache/keybuilder.go b/cache/keybuilder.go index b1d769f..b265452 100644 --- a/cache/keybuilder.go +++ b/cache/keybuilder.go @@ -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 } diff --git a/cache/lock.go b/cache/lock.go index a3310d9..b007439 100644 --- a/cache/lock.go +++ b/cache/lock.go @@ -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() } \ No newline at end of file diff --git a/cache/lock_concurrency_test.go b/cache/lock_concurrency_test.go index 507e4e4..466ccde 100644 --- a/cache/lock_concurrency_test.go +++ b/cache/lock_concurrency_test.go @@ -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 } diff --git a/config/config.go b/config/config.go index dbea6d7..6ce2ffb 100644 --- a/config/config.go +++ b/config/config.go @@ -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) diff --git a/cron/cron.go b/cron/cron.go index 7c9f9ea..dbb33db 100644 --- a/cron/cron.go +++ b/cron/cron.go @@ -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() -} \ No newline at end of file +} diff --git a/database/manager.go b/database/manager.go index cc8f8f2..1beec49 100644 --- a/database/manager.go +++ b/database/manager.go @@ -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) { diff --git a/database/redis.go b/database/redis.go index 3517465..0781c2a 100644 --- a/database/redis.go +++ b/database/redis.go @@ -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 } diff --git a/jwt/jwt.go b/jwt/jwt.go index f003e71..db7e1ec 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -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 持有的黑名单实例。 diff --git a/jwt/jwt_c9c_internal_test.go b/jwt/jwt_c9c_internal_test.go index a8e8651..7536f2b 100644 --- a/jwt/jwt_c9c_internal_test.go +++ b/jwt/jwt_c9c_internal_test.go @@ -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") diff --git a/logs/app.log b/logs/app.log new file mode 100644 index 0000000..f51f7ba --- /dev/null +++ b/logs/app.log @@ -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."} diff --git a/middleware/logger.go b/middleware/logger.go index 7324e00..489a03f 100644 --- a/middleware/logger.go +++ b/middleware/logger.go @@ -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 专用日志中间件(更详细) diff --git a/middleware/metrics.go b/middleware/metrics.go index f2d91de..a54045f 100644 --- a/middleware/metrics.go +++ b/middleware/metrics.go @@ -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() diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go index cb34fc8..7d08c5a 100644 --- a/middleware/middleware_test.go +++ b/middleware/middleware_test.go @@ -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:放行。 diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index a7bbf37..f1b5ed2 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -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() } // ===== 全局限速器 ===== diff --git a/repository/repository.go b/repository/repository.go index 97db770..2d67bda 100644 --- a/repository/repository.go +++ b/repository/repository.go @@ -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 } diff --git a/response/error.go b/response/error.go index 4ee4ca0..86054c3 100644 --- a/response/error.go +++ b/response/error.go @@ -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 使用预定义错误并添加详细信息 diff --git a/utils/utils_test.go b/utils/utils_test.go index 2d7638a..6b1b17b 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -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) { diff --git a/utils/uuid.go b/utils/uuid.go index 018caae..8bb0cfe 100644 --- a/utils/uuid.go +++ b/utils/uuid.go @@ -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 字符串 diff --git a/ws/ws.go b/ws/ws.go index 61cf46e..2fde502 100644 --- a/ws/ws.go +++ b/ws/ws.go @@ -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 消息