deepseek修改版

This commit is contained in:
杭州明婳科技
2026-07-03 00:00:11 +08:00
parent 0f13292a46
commit a74ea0c2f4
22 changed files with 378 additions and 195 deletions
+29
View File
@@ -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 🐛
+23 -13
View File
@@ -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)
}
}
}
+17 -7
View File
@@ -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()
}
+27 -13
View File
@@ -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
}
+42 -28
View File
@@ -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()
}
+4 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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", // 驱动不支持
// PostgreSQLD5 修复)
"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
View File
@@ -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
View File
@@ -118,19 +118,10 @@ type Manager struct {
}
// defaultManager 是全局默认 JWT 管理器的真实存储,经 atomic 读写(C9c)。
// 历史上 DefaultJWT/tokenBlacklist 为裸包级指针,SetDefaultJWTManager 写与
// 请求 goroutineParseToken/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 持有的黑名单实例。
+6 -6
View File
@@ -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")
+3
View File
@@ -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
View File
@@ -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 专用日志中间件(更详细)
+1 -1
View File
@@ -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()
+16 -23
View File
@@ -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-1dfail-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-1efail-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-2SetFailClosed 切换策略——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:放行。
+9 -9
View File
@@ -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()
}
// ===== 全局限速器 =====
+17 -3
View File
@@ -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
View File
@@ -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 使用预定义错误并添加详细信息
+22
View File
@@ -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
View File
@@ -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 字符串
+51 -13
View File
@@ -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 channelC2a 修复)。
// 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 消息