38 Commits

Author SHA1 Message Date
杭州明婳科技 5dbbce34f5 docs: 同步 GUIDE/README 至 v1.4.0
补 v1.4.0 发布时漏更的文档(代码与契约已改,文档未跟上):
- GUIDE 13 定时任务:新增 WithCronTask App 集成模式 + pre-Init cron.AddTask 迁移警示。
- GUIDE 14 链路追踪:新增 WithTrace 生命周期集成 + OTel 进程全局多 App 注意。
- GUIDE 8.4 限流:新增 app.RateLimitRegistry() 多 App 计数隔离 + WithRedisClient。
- GUIDE 5.1 缓存 / 10.3 存储:注明 App 自动初始化(per-App redis 注入 / Close 收口)。
- README:新增 v1.4.0 更新日志条目;cron/缓存/限流段补 v1.4.0 说明。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:35:47 +08:00
杭州明婳科技 96ce883d41 feat: cron/storage/cache/ratelimit 实例化 + trace 纳入 App 生命周期 (v1.4.0)
完成 instance+facade 一致性收尾,闭合三个原始问题:
- cron/storage/cache/ratelimit 实例化(App 持 per-App 实例 + SwapDefault* 提升全局默认
  + 包级 facade 代理,照 database.Manager/jwt.Manager 模式),消除单进程多 App 互相污染。
- trace 纳入 App 生命周期(WithTrace + doInit trace.Init + doShutdown trace.Close +
  config.TraceConfig + 自动装 Middleware)。不做实例隔离(OTel 进程全局)。
- 删除 WithoutWire 空函数技术债。

附带修复:
- cache/ratelimit redis 注入(jwt 模型:注入优先、database.GetRedis() 兜底)。
- commitReplacedResources 不再关闭 previousDB/previousRedis/loggerSnapshot
  (多 App 下 previous 可能是另一 App 的活跃资源,关闭致 redis: client is closed)。
- app.RateLimitRegistry() + App-bound 限流中间件(多 App per-App 计数隔离)。
- StorageManager.Close() 闭合 Init/Close 对称性(io.Closer 断言,预留扩展点),
  closeResources 与 rollbackReplacedResources 对称收口。

新增公开 API:WithTrace / WithCronTask;app.Scheduler()/Cache()/RedisClient()/
RateLimitRegistry();cron/storage/cache/middleware 各 SwapDefault*;config.TraceConfig;
middleware.RateLimitRegistry + App-bound 方法;middleware.WithRedisClient;
storage.StorageManager.Close。

Breaking:删 WithoutWire;cron.AddTask pre-Init 注册失效(改用 WithCronTask);
middleware.StopRateLimiters 语义收窄;commitReplacedResources 不再关 previous;
cache.NewRedisCacheWithRedis / CacheManager.InitWithRedis 等签名变更。
详见 CHANGELOG [1.4.0] 升级说明。

验证:go build + go vet + go test -race ./... 全绿(-buildvcs=false)。
独立对抗性复审无 CRITICAL/HIGH。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:25:37 +08:00
杭州明婳科技 399950a9f6 chore(release): v1.3.0
版本内容:
- 同义可失败 API 收敛(cache/jwt/storage/ratelimit breaking change)
- 注释/文档一致性修复(A/B/C 三档 23 项)
- 前序评审收口(M13 cron / M1 App 生命周期 / M3 logger / config 模块 / database H-db-1)

按仓库 1.x 惯例(1.1.0/1.1.1/1.2.0 均含 Breaking 未升 MAJOR),本次升 MINOR 至 1.3.0。
项目处于初级阶段,无下游用户,breaking change 在 CHANGELOG 显式声明。

验证:go build / go vet / go test -race -count=1 ./... 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:40:33 +08:00
杭州明婳科技 df78706dbb fix(jwt): 修正合并后 BlacklistFailOpen 注释与新默认语义的矛盾
合并 docs 分支(B4 给 BlacklistFailOpen/Closed 加注释,基于当时 ParseToken 默认 fail-open)
与 refactor 分支(ParseToken 默认改为 fail-closed)后,BlacklistFailOpen 注释仍写
"(默认;兼容未启用 Redis 的存量部署)",与 ParseToken 新默认 BlacklistFailClosed 矛盾。

修正:BlacklistFailOpen 标注为"非默认",BlacklistFailClosed 标注为"默认(ParseToken 即用此策略)"。

git 自动合并因两分支改在文件不同区域未报冲突,但语义冲突;经全文件核查仅此一处。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:26:57 +08:00
杭州明婳科技 905cf7db4f Merge branch 'refactor/consolidate-failable-apis' 2026-07-12 22:23:41 +08:00
杭州明婳科技 9e11f8f007 refactor: 同义可失败 API 收敛(cache/jwt/storage/ratelimit breaking change)
按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,
收敛历史双轨 API。自定义 CacheService/Storage 实现需同步更新签名(编译失败即迁移信号)。

cache:
- CacheService.Get/Exists 改为 (bool, error):命中 (true,nil)、未命中 (false,nil)、
  Redis 未就绪/命令错误/反序列化失败返回 (false,err)
- 删除 cache.GetE/cache.ExistsE/CacheGetter/CacheExistChecker/redisCache.GetE/redisCache.ExistsE
  (不保留 deprecated wrapper)
- 新增包级 cache.Get/cache.Exists(带 error);GetWithPrefix 改为 (bool,error)

jwt:
- TokenBlacklist.IsBlacklisted 改为 (bool, error),删除 IsBlacklistedE
- IsTokenRevoked 改为 (bool, error)
- ParseToken 默认从 fail-open 改为 fail-closed:黑名单后端不可检查时返回
  ErrBlacklistUnavailable 拒绝该 Token(无 Redis 部署不再支持可靠撤销)
- 删除 ParseTokenFailClosed(主 API 已 fail-closed)
- 保留 ParseTokenWithBlacklistPolicy + BlacklistPolicy/BlacklistFailOpen/BlacklistFailClosed
  供显式 fail-open(仅无 Redis 或低安全场景)

storage:
- Storage.Exists 改为 (bool, error):存在 (true,nil)、不存在 (false,nil)、
  未初始化/路径非法/穿越/后端错误返回 (false,err)
- LocalStorage.Exists 区分 os.IsNotExist 与其他 Stat 错误
- OSSStorage.Exists 区分 OSS 404/NoSuchKey 与鉴权/网络错误
- 包级 storage.Exists 同步签名,未初始化返回 ErrStorageNotInitialized
- 新增 ErrReadTooLarge:Local/OSSStorage.Get 读取超 maxReadBytes 上限原误用
  ErrInvalidPath(语义不贴切),改为 ErrReadTooLarge

ratelimit:
- NewRedisRateLimiter 加 opts ...RedisRateLimiterOption,新增 WithFailClosed(bool)
  替代原 NewRedisRateLimiterFailClosed
- RedisRateLimit/CustomRedisRateLimit/RedisRateLimitWithIdentifier 同步加 opts 参数
- 删除 NewRedisRateLimiterFailClosed/RedisRateLimitFailClosed/CustomRedisRateLimitFailClosed
- UploadRedisRateLimit 由 fail-open 改为 fail-closed(资源敏感操作,Redis 故障时拒绝)

测试与文档:
- cache_m10_internal_test.go 重写 4 个 M10 测试为 Get/Exists 契约测试,新增 mock 编译覆盖
- jwt_test.go 改 4 处;jwt_c9c_internal_test.go 改 IsTokenRevoked 2-value
- examples/full/main_test.go 加 miniredis 注入(ParseToken fail-closed 后认证需 Redis)
- middleware_test.go 改 5 处 FailClosed 调用为 WithFailClosed(true);auth_test.go 加 Redis
- storage 测试改 6 处 Exists/Get 断言为新签名/错误类型
- README/GUIDE 同步示例为新签名;CHANGELOG 新增 Breaking 升级说明与迁移示例

验证:go build / go vet / go test -race -count=1 ./... 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:22:10 +08:00
杭州明婳科技 ffcb1a77b9 docs: 注释/文档一致性审查修复(A/B/C 三档共 23 项)
对 60 个非测试源文件做文档/注释/代码一致性审查,修复 23 项。全部为注释/文档
改动 + 1 处死代码删除,无运行逻辑变更,无 breaking change。

A 档·实质错误(10 项,会误导下游):
- app.go: closeResources 三连契约注释统一修正--failAfterInit 走
  rollbackReplacedResources 不走 closeResources,closeResources 仅供 doShutdown 复用
- database/manager.go: RandomPicker 注释从过时的 rand.Intn/issue 54899 改为 crypto/rand
- model/base.go: datetime(0) 与“亚秒精度”矛盾,改为 datetime(6)
- cache/keybuilder.go: 三处示例下划线分隔符改为冒号(与默认分隔符 : 一致)
- repository/repository.go: Delete 接口注释从绝对“软删除”改为条件性;
  UpdateFields 示例移除把字段名当 WHERE 条件的误用写法
- middleware/csrf.go: CSRFToken 注释从“用于 API 模式”改为 Cookie/双重提交模式说明
- examples/full/main.go: POST /users 补上“需 Authorization”标注;删除 _ = app 死代码
- utils/random.go: RandInt/RandInt64 补 min==max 边界说明(对齐 RandIntSecure 写法)

B 档·导出符号注释缺失/不完整(6 项):
- ws/ws.go: 5 个 Type* 常量、4 个 Err* 变量补注释
- console/console.go: 5 个 Level 常量补注释
- jwt/jwt.go: 2 个 Blacklist 常量补注释
- router/router.go: 包级 GroupWithMiddlewareGroup 补注释
- middleware/ratelimit.go: NewRateLimiter 补 panic 条件与 Stop 生命周期说明

C 档·描述不完整(7 项):
- utils/strings.go Substr、convert.go CalcPageCount/CalcOffset、datetime.go
  StartOfMonth/EndOfMonth、file.go CopyFile、validator.go IsChinese、crypto.go HashFile
  补全边界/默认行为/返回值语义
- response/error.go: WithDetail 补 nil 接收者行为说明

验证:go build / go vet / go test -race 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:47:12 +08:00
杭州明婳科技 6730f12cd6 test(database): H-db-1 补齐行为闭环 + 修复 gorm.Open automatic ping
上一提交(72ea01d)修了 4 条 pingWithTimeout 路径,但行为验证有缺口:
启动 ping 路径仅代码审查、Redis HealthCheck 仅推断、无 App 级端到端。
补齐过程发现并修复了 H-db-1 的真正根因之一:

gorm.Open 的 automatic ping(gorm.go:204,!DisableAutomaticPing 且 ConnPool
为 *sql.DB 时调 pinger.Ping() 无超时)在 pingWithTimeout 之前就无限阻塞。
挂起 DB 下 initDB 的 gorm.Open 永久 hang,根本到不了 pingWithTimeout。
修复:initDB/InitDBWithReplicas 的 gormConfig 加 DisableAutomaticPing: true,
框架用 pingWithTimeout(3s)自管启动 ping。

行为闭环测试(3 缺口补齐):
- manager_hdb1_internal_test.go:新增 TestInitDBBoundsHungDB_Hdb1 /
  TestInitDBWithReplicasBoundsHungDB_Hdb1(hungDialector 注入挂起 *sql.DB,
  回归启动 master/replica ping 路径有界)。原 3 用例保留。
- redis_hdb1_internal_test.go:startHungRedisListener(accept 不响应模拟挂起
  Redis)+ TestRedisHealthCheckBoundsHungRedis_Hdb1,行为验证 HealthCheck
  受 client ReadTimeout 3s 约束(不再是推断)。提取 newRedisClient helper
  供 Init 与测试共用。
- app_hdb1_test.go:TestAppInitHungDBBounded_Hdb1 App 级端到端--App.Init
  挂起 DB 有界失败(~30s 5 次重试,非无限 hang)+ Shutdown 正常退出。

验证:database -race -short green(24.7s,6 个 H-db-1 用例)、root 包 App
-race green(33.5s,含端到端)、build/vet 干净。CHANGELOG 更新。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 00:52:17 +08:00
杭州明婳科技 5eb3879a85 fix(database): H-db-1 ping 路径统一经 pingWithTimeout 3s 约束(M11 完整覆盖)
module 04 database 评审发现 H-db-1(HIGH):M11 的 pingWithTimeout 只加到包级
HealthCheck(),未覆盖 4 条 ping 路径:
- (*Manager).HealthCheck(被后台探活 probeOnce master + /health 端点共用)
- probeOnce 从库 ping
- InitDB 启动 master ping
- InitDBWithReplicas 启动 replica ping

挂起 DB(连接活但不响应,区别于宕机的 connection-refused 快速失败)下,这些
路径的 sqlDB.PingContext(ctx) 用 rootCtx/Background(无 deadline)无限阻塞,
致:探活 goroutine 阻塞至 shutdown、#21 自愈冻结(replicaHealthy 停留旧值、
Replica() 可能路由到已死从库)、IsHealthy() 缓存失真、App.Init 启动卡死。

修复:4 路径统一改用 pingWithTimeout(sqlDB, ctx),受 healthCheckTimeout(3s)
约束(context.WithTimeout 尊重 ctx 自带更短 deadline)。/health 端点随之收紧
到 3s(更快失败,非回归);InitDB 5 次重试给足 15s+ 余量无误判。

回归测试 manager_hdb1_internal_test.go:注册 hungDriver(Conn.Ping 阻塞到 ctx
取消模拟挂起 DB),验证 pingWithTimeout / m.HealthCheck / probeOnce replica 三
路径在 Background ctx 下 ~3s 返回(修复前无限 hang)。go test -race ./database/
绿(含 3 条 ~3s 挂起用例)。

对抗复核(diff scan):4 路径均 pingWithTimeout,defer cancel 无 ctx 泄漏,
无新缺陷。CHANGELOG 记录。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:14:04 +08:00
杭州明婳科技 08ed43385d fix(config): 模块 01 评审修复 + 对抗复核
config 模块(glm_check_report_module_01_config.md)11 项 finding 修复:

- H-config-1: Set(cfg) 用 mapstructure 重建 m.v,消除 Get/GetString/viper 视图分裂(C1)
- M-config-1: 热重载回调独立 recover,panic 不杀 watcher、不阻断后续回调(C9)
- M-config-2: DB SSL/TLS -- Postgres SSLMode(默认 prefer)+ MySQL TLS/TLSRootCA;
  database.ensureMySQLTLSRegistered 注册私有 CA 命名配置,fail-fast 不回退明文。
  O-1 文档准确性修正:同名注册会覆盖 master,改为 per-host 不同名 + 自建 replica DSN 指引
- M-config-3: App.Shutdown closeResources 停 configManager watcher(C7)
- M-config-4: Clone 切片/map 字段反射守卫测试
- M-config-5: WithConfig/WithConfigPath 全局可见性文档强化
- L-config-2: watchLoop 增 ctx 逃生通道,StopWatcher cancel+Close 双重退出
- L-config-3/4/5/6: Validate 连接池交叉校验、哨兵 errors.New、去冗余 TrimSpace、nil receiver 防御

含契约级回归测试(同源一致性/生命周期闭环/扩展点防御),-race/vet/build 全绿。
附 module 01 评审报告 + 全局结构图 + 扫描收敛协议。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:54:52 +08:00
杭州明婳科技 21021d4001 fix: 收口剩余语义契约问题 2026-07-08 23:52:23 +08:00
杭州明婳科技 2529b0a074 fix database lifecycle cancellation 2026-07-08 21:25:18 +08:00
杭州明婳科技 ca822dcadd fix config driver fail closed 2026-07-08 21:02:21 +08:00
杭州明婳科技 c64a992008 fix middleware auth and log skip boundaries 2026-07-08 20:34:35 +08:00
杭州明婳科技 18eb62e2f0 fix app shutdown config and guide gaps 2026-07-08 20:22:30 +08:00
杭州明婳科技 74b65216b6 fix docs rate limit handler examples 2026-07-08 19:57:39 +08:00
杭州明婳科技 4ccbbfbf1b fix full example login password flow 2026-07-08 19:36:43 +08:00
杭州明婳科技 89721132a1 fix database default manager resource leaks 2026-07-08 19:11:37 +08:00
杭州明婳科技 1ea3d8751f fix cache lock context propagation 2026-07-08 19:02:01 +08:00
杭州明婳科技 27c14499c2 fix database nil boundary handling 2026-07-08 18:48:41 +08:00
杭州明婳科技 d73a46447c fix config watcher lifecycle and snapshots 2026-07-08 18:38:01 +08:00
杭州明婳科技 0610a159f0 fix test helpers and make validation 2026-07-08 18:21:32 +08:00
杭州明婳科技 667f0793fc docs use Chinese comments and errors 2026-07-08 18:04:33 +08:00
杭州明婳科技 7ca1d81f88 fix utils resource boundaries 2026-07-08 15:40:01 +08:00
杭州明婳科技 8ccfb162b8 fix cache error semantics 2026-07-08 15:02:52 +08:00
杭州明婳科技 9b9c5df676 fix storage safety edge cases 2026-07-08 12:21:13 +08:00
杭州明婳科技 fed3e37348 fix router registration safeguards 2026-07-08 12:05:26 +08:00
杭州明婳科技 8770e9344a fix remaining framework contract gaps 2026-07-08 11:17:54 +08:00
杭州明婳科技 232b16d65d fix trace and websocket lifecycle gaps 2026-07-08 11:16:09 +08:00
杭州明婳科技 56352fdd81 fix: harden framework lifecycle and middleware safety 2026-07-07 00:19:19 +08:00
杭州明婳科技 2d3dde99c2 fix: harden jwt locks sse and storage safety 2026-07-07 00:13:56 +08:00
杭州明婳科技 86e126c42b release: v1.2.0 版本号提升 + 脚手架对齐 + 文档核验
- 版本号 1.1.1 → 1.2.0:app.go Version 常量;脚手架 GoMod 模板经 xlgo.Version 自动跟随;GUIDE 文档版本同步
- CHANGELOG/README 新增 v1.2.0 破坏性版本条目(包级全局 atomic.Pointer 治理、jwt.DefaultJWT 删除、FindWhereOrdered 签名变更、response 去 omitempty、cache.ErrRedisNotReady、config.Clone 深拷贝等)
- 脚手架对齐:RepositoryMake 模板 FindByName 改用 FindOne(走 readConn 读写分离,避免 M-35 GetDB 不路由 footgun)
- 文档核验修复(README/GUIDE 逐行对照源码 6595e94):
  - README DB 示例 users := ...Find(&users) 自引用声明编译错误
  - GUIDE xlgo.StartServer(engine,8080) 不存在 → engine.Run(":8080")
  - GUIDE app.token_expire 死键(v1.1.0 已移除 AppConfig.TokenExpire)
  - GUIDE jwt.expire: 86400 类型错(time.Duration 需字符串 "24h")
  - GUIDE storage.Init(&cfg.Storage) 吞错误 → 显式 err 处理
  - GUIDE 登录示例 token,_:=jwt.GenerateToken 吞错误 + time.Duration(cfg.JWT.Expire)*time.Second 双重转换
  - GUIDE 验证规则表补 phone_strict / username
- .gitignore 忽略 8 个评审报告文件(仅本地保留,不入库;已从未推送提交中 filter-branch 清除)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:46:59 +08:00
杭州明婳科技 6595e94677 fix: 多模型评审终审收口 — P0安全 + 主线A并发治理 + 文档对齐
经 deepseek/GLM/Claude 三轮对抗性评审 + 交叉核验 + 终审(见
last_report.md、cross_review_assessment.md),逐条回读源码核实并修复。
go build / vet / test -race ./... 全绿。

安全(P0):
- JWT alg confusion:WithValidMethods 固定 HMAC + keyfunc 方法断言
- JWT 空密钥 / 不支持算法 fail-closed(ErrEmptySecret / ErrUnsupportedAlgorithm)
- 上传大小实测封顶(enforceUploadSize),不信任客户端 file.Size
- HTTP headers/cookies map 竞态(写锁+快照)+ SSRF 防护(opt-in dialer.Control)

并发/资源(主线A 闭合):
- 包级可变全局一律 atomic.Pointer:DefaultRedis / DefaultStorage /
  DefaultManager / Validator / DefaultCache 等,消除裸指针数据竞争
- trace.Close 去 sync.Once(防 exporter 泄漏);app OnReady 失败走 Shutdown
- ws.Hub.Stop stopOnce 防 double-close;cron 接入 App 生命周期(WithCron)
- ratelimit failClosed atomic.Bool;Recover Written() 守卫
- cron checkAndRun 锁内收集锁外 spawn(wg.Add 在锁内防 Stop 竞态)

终审剩余项收口(H-A~M-H + L 系列):
- 副本连接池 MaxOpenConns/2 截断修复(replicaMaxOpenConns)
- JWT 黑名单 1s 超时 + 显式错误;ratelimit GetRedis 取一次复用
- GetPage 深分页上限;HashFile 流式;ReadFile 去 TOCTOU
- config Clone() 深拷贝;cache 锁操作返 ErrRedisNotReady
- compress 解压残留清理;正则预编译;EqualsIgnoreCase→EqualFold
- 删 redisLimiters 死代码;CLI 输入校验 + 回滚

文档:
- README/GUIDE/CHANGELOG 对齐当前 API(含破坏性变更迁移说明)
- docs/README 索引修正;config 注释修正(RS256 已不支持)

破坏性变更详见 CHANGELOG.md [Unreleased]。项目处于研发初期、无下游用户。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:16:03 +08:00
杭州明婳科技 a74ea0c2f4 deepseek修改版 2026-07-03 00:00:11 +08:00
杭州明婳科技 0f13292a46 GLM 5.2修改版 2026-07-02 18:26:27 +08:00
杭州明婳科技 75756dd5fb release: v1.1.1 补丁发布
- ServerConfig.Host 绑定地址字段
- 面向用户文案统一中文
- 修正 README 过时/错误描述(含会致启动失败的配置示例)+ 重写首段介绍

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:19:26 +08:00
杭州明婳科技 8421e06fa8 docs: README 修正过时描述 + 重写首段介绍
修正 v1.1.0 后的过时/错误描述:
- 删除目录结构里已移除的 wire/ 段
- 快速开始配置示例:jwt.secret 补足 32 字节(否则被 Validate 拦截),
  expire 由 86400(秒,int) 改 "24h"(time.Duration),补 refresh_expire/issuer/algorithm
- server 段补 host/timeout/response_mode 字段
- v1.0.2 更新日志标注 WithWire 已于 v1.1.0 移除
- 目录结构补 v1.1.0 新文件:metrics/timeout/metrics route/validate/mode
- 框架特性段重写为三组(架构可注入/生产就绪/基础功能),补全 v1.1.0 能力
- 响应格式段补 Mode 开关(ModeBusiness/ModeREST)与 Custom API

重写首段介绍(原描述过于普通化、无辨识度):
- 加 tagline 点明核心差异:组件全部 Manager 化,简单调用与注入实例兼得
- 新增"为什么是 xlgo"段:对比一般 Gin 脚手架的包级单例痛点 + 对照代码
- 5 条差异化卖点(可注入/生产就绪内置/零 Fatal/默认轻量/可插拔方言)
- 30 秒上手极简可跑示例

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:13:11 +08:00
杭州明婳科技 18e4f7e891 fix: ServerConfig 加 Host 字段 + 面向用户文案统一中文
- ServerConfig 新增 Host 字段:空=监听所有接口(兼容),设值=绑定指定地址
  (127.0.0.1 仅本机 / 内网IP 绑定指定网卡),避免生产暴露 0.0.0.0
- app.go 启动地址改 host:port,日志区分"所有接口"/指定地址
- 面向用户/调用的文案统一中文:
  - middleware/recover.go: "Panic recovered"→"panic 已恢复","Panic: %v"→"服务器内部错误: %v"
  - middleware/logger.go: 5 处日志消息改中文
  - middleware/metrics.go: 3 个 Prometheus Help 改中文
  - app.go/database/manager.go/logger.go: 英文 error 改中文
- 保留英文:JSON 协议字段名、Prometheus metric Name、MySQL 错误串匹配、技术专有名词
- CLI 模板与 example 配置加 host 注释

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:12:40 +08:00
144 changed files with 21895 additions and 2399 deletions
+7
View File
@@ -1,9 +1,16 @@
# Claude Code 协作指引(本地,不入库)
CLAUDE.md
AGENTS.md
# 临时发版辅助文件
gitHub_release_*.md
# 构建产物
*.exe
bin/
md/
.gocache/
gpt_check_report_framework.md
gpt_check_report_review.md
gpt_check_report_module.md
+619 -3
View File
@@ -14,6 +14,620 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
---
## [Unreleased]
## [1.4.0] - 2026-07-12
> **instance+facade 一致性收尾**cron/storage/cache/ratelimit 四个"纯全局"包按 `database.Manager`/`jwt.Manager` 模式实例化(App 持实例 + `SwapDefault*` 提升全局默认 + 包级 facade 代理),消除单进程多 App 互相污染;trace 纳入 App 生命周期(不做实例隔离,OTel 本身进程全局);清理 `WithoutWire` 空函数。
>
> **修复的多 App 冲突**:① App A `Shutdown` 不再把全局 cron 调度器 / 限流器停掉(B 的任务/限流不受影响);② App B `Init` 不再覆盖 App A 的 storage driver / cache redis;③ cache/ratelimit 不再硬编码 `database.GetRedis()`,可注入 per-App redis;④ `commitReplacedResources` 不再关闭"被替换的全局默认"DB/Redis manager(多 App 下那可能是另一个 App 的活跃 manager,关闭致 "redis: client is closed");⑤ trace.Close 在 `App.Shutdown` 被调用(此前从未调用,exporter 后台 goroutine/连接泄漏)。
>
> `go vet` + `go build` + `go test -race ./...` 全绿。本机需 `-buildvcs=false`。
### Breaking ⚠️
- **`xlgo.WithoutWire()` 删除**app.go):自 v1.1.0 wire 包删除后遗留的空 no-op 函数,全仓无引用。直接删除,无替代;调用方删除该 Option 即可(无行为变化)。
- **`cron.AddTask(...)``App.Init` 之前注册不再生效**cron/cron.go + app.go):App 现持专属 `*Scheduler``Init` 时创建并 `SwapDefaultScheduler` 提升为全局默认。pre-Init 调用包级 `cron.AddTask` 会注册到 init 默认调度器、被 App swap 丢弃。迁移:改用 `xlgo.WithCronTask(name, schedule, handler)`(蕴含启用 cron),或 `Init``app.Scheduler().AddTask(...)`。post-Init 的包级 `cron.AddTask` 仍可用(代理到 App 调度器)。`cron``init()` 现预创建全局默认调度器(曾为懒初始化)。
- **`middleware.StopRateLimiters()` 语义收窄**middleware/ratelimit.go):仅停止全局默认 `RateLimitRegistry` 的限流器。App 现持专属 Registry`Shutdown``app` 自己的 `Stop`,不再调全局 `StopRateLimiters`(避免误停其他 App)。`LoginRateLimit`/`APIRateLimit`/`UploadRateLimit`/`CustomRateLimit` 改为请求时从默认 Registry 懒创建限流器(单 App 行为不变;多 App 下各自 Registry 隔离)。
- **`App.commitReplacedResources` 不再关闭被替换的全局 DB/Redis manager**app.go):多 App 下"previous"可能是另一个 App 的活跃 manager,关闭致 `redis: client is closed`。单 App 下 previous 为 init() 空默认(无 clientClose 本就是 no-op),无影响。用户若手动 `database.InitRedis`/`InitDB` 后再 `App.Init`,旧 client 的关闭由用户负责(非常规组合)。
- **`cache.redisCache` 持注入 redis client**cache/cache.go):新增 `cache.NewRedisCacheWithRedis(client)``(*CacheManager).InitWithRedis(client)``cache.SwapDefaultCacheManager(m)``cache.NewRedisCache()` 仍存在(懒取全局 Redisstandalone 用法)。App 现经 `InitWithRedis(a.redisManager.Client())` 注入 per-App client。
- **`middleware.RedisRateLimiter` 新增 `WithRedisClient(client)` 选项**middleware/ratelimit.go):`Allow`/`GetCount`/`Reset` 改用 `rl.redisClient()`(注入优先、`database.GetRedis()` 兜底,照 `jwt.TokenBlacklist` 模型)。包级 `RedisRateLimit` 系列仍用全局 Redisper-App 隔离用 `middleware.NewRedisRateLimiter(..., middleware.WithRedisClient(app.RedisClient()))`
### Added
- **`xlgo.WithTrace()`**:把 OpenTelemetry trace 纳入 App 生命周期--`Init``trace.Init`(按 `config.Trace`)、装入 `trace.Middleware``Shutdown``trace.Close`。实际导出由 `config.Trace.Enabled` 控制。**不做实例隔离**:OTel `TracerProvider`/`Propagator` 是进程级全局单例,多 App 共享(与 OTel 设计一致)。
- **`xlgo.WithCronTask(name, schedule, handler)`**:注册定时任务到 App 专属调度器(蕴含 `WithCron`),替代旧的全局 `cron.AddTask` pre-Run 注册模式。
- **`xlgo.App.Scheduler()` / `Cache()` / `RedisClient()`**:分别返回 App 专属的 cron 调度器、缓存服务、Redis 客户端,供多 App 隔离场景直接操作(绕过全局 facade)。
- **`config.TraceConfig` + `Config.Trace` 字段**:链路追踪 YAML 配置(`service_name`/`exporter_type`/`endpoint`/`insecure`/`sample_ratio`/`enabled`/`propagator` 等)。`Validate``Enabled` 时校验 `sample_ratio` 范围;导出器/传播器枚举校验委托 `trace.Init`
- **`cron.SwapDefaultScheduler(s)`**、**`storage.SwapDefaultStorageManager(m)`**、**`cache.SwapDefaultCacheManager(m)`**、**`middleware.SwapDefaultRateLimitRegistry(r)`**:返回被替换的旧实例,供 App Init 失败回滚(照 `database.SwapDefaultManager`/`SwapDefaultRedisManager` 模式)。
- **`storage.StorageManager.Close()`**:闭合 Init/Close 生命周期(与 database/redis/logger manager 对称),供 `App.closeResources`Shutdown 路径)与 `rollbackReplacedResources`(Init 失败路径)对称收口。当前 LocalStorage/OSSStorage 无 closeable 资源(`oss.Client` 未暴露 Close),故为 no-op;经 `io.Closer` 断言调用驱动,未来带连接池的驱动实现 `io.Closer` 即被自动调用,框架骨架无需改动。
- **`middleware.RateLimitRegistry`**:持 login/api/upload/custom 内存限流器,支持 per-App 隔离。`NewRateLimitRegistry()` / `Init()` / `Stop()` / `GetDefaultRateLimitRegistry()`
- **`xlgo.App.RateLimitRegistry()` + App-bound 限流中间件**middleware/ratelimit.go):`app.RateLimitRegistry().LoginRateLimit()`/`APIRateLimit()`/`UploadRateLimit()`/`CustomRateLimit(rate, window)` 返回的中间件捕获 App 自己的 Registry(请求时不查全局默认),实现多 App per-App 计数隔离。`RateLimitRegistry` 改在 `New()` 时预创建,使 getter 在 `Init` 前即可用于路由装配;`Init` 失败回滚时 `registry.Stop()` 收口 custom limiter,无泄漏。
### Fixed
- **多 App 限流器互不污染**middleware/ratelimit.go):App A `Shutdown` 不再经全局 `StopRateLimiters` 把 App B 的 loginLimiter 置 nil(致 B 下次请求懒创建新 limiter、计数清零,稳态客户端借 A 的 Shutdown 窗口绕过限流)。各 App 持自己的 `RateLimitRegistry`
- **多 App cron 调度器隔离**cron/cron.go + app.go):App A `Shutdown` 不再经 `cron.StopGlobalWithTimeout` 停掉共享全局调度器(B 的任务不受影响)。
- **多 App storage / cache 隔离**storage/cache + app.go):App B `Init` 不再覆盖 App A 的 storage driver / cache rediscache 不再硬编码 `database.GetRedis()`(致 A 的缓存读到 B 的 redis)。
- **trace exporter 泄漏**app.go):`App.Shutdown` 现调 `trace.Close` 刷出批量 span 并释放 exporter 后台 goroutine/连接(H-14 修了 `Close` 幂等,但 App 此前从未调它)。
- **多 App logger writer 跨 App 误杀**app.go `commitReplacedResources`):与 `previousDB`/`previousRedis` 对齐,`commitReplacedResources` 不再 `CloseDefaultSnapshot`(旧实现会关掉被替换的全局默认 logger 的 lumberjack writers--多 App 下那可能是另一个 App 的活跃 writer,致其后续写日志失败/丢失)。单 App 下旧默认为 Nop(无 writer,不泄漏);用户手动配置 logger 后再 `App.Init` 的旧 writer 由用户负责。
### 多 App 边界与已知缺口
本次实例化收尾覆盖 cron/storage/cache/ratelimit + trace 生命周期。多 App 进程下的边界:
- **包级限流 facade 仅绑定最后 Init 的 App**middleware/ratelimit.go):`LoginRateLimit`/`APIRateLimit`/`UploadRateLimit`/`CustomRateLimit`(包级)在请求时解析 `GetDefaultRateLimitRegistry()`(全局默认),多 App 下仅最后 Init 的 App 是全局默认。**已提供 per-App 计数隔离路径**:`app.RateLimitRegistry().LoginRateLimit()` 等 App-bound 中间件捕获 App 自己的 Registry(见 Added)。包级 facade 仍为单 App / backcompat 保留。
- **`CustomRateLimit` 首请求时机**:包级 `CustomRateLimit``sync.Once` 在首请求时登记 limiter 到全局默认 Registry,首请求若在 `App.Init` swap 前会泄漏 cleanup goroutine。**App-bound 路径 `app.RateLimitRegistry().CustomRateLimit(rate, window)` 已无此问题**(登记到 App 的 Registry,由 `registry.Stop()` 收口)。HTTP server 在 Init 后才起,常规用法不触发泄漏。
- **trace 进程全局共享**trace/trace.go,设计限制):OTel `TracerProvider`/`Propagator` 是进程级单例,任一 App `Shutdown``trace.Close` 会关闭全局 trace 导出,影响同进程其他 App。多 App 需自行管理 trace 生命周期(不在多个 App 上同时启用 `WithTrace`)。**不可修**(OTel 自身全局设计)。
- **`StorageManager``Close`**storage/storage.go,经核实:oss.Client 无 Close,当前为 no-op;已加 StorageManager.Close() 闭合生命周期(见 Added)):`LocalStorage` 无 closeable 资源;`OSSStorage``*oss.Client`,但 `oss.Client` **未暴露 `Close` 方法**aliyun-oss-go-sdk),其 HTTP 空闲连接由 OS 超时 + GC 回收。无显式资源泄漏;未来带连接池的驱动实现 io.Closer 即被 Close 自动调用。
## [1.3.0] - 2026-07-12
> 同义可失败 API 收敛 + 注释/文档一致性修复 + 前序评审收口。
>
> **API 收敛**`gpt_clear_function.md` 计划):按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,收敛 cache/jwt/storage/ratelimit 四个包的历史双轨 API。自定义 `CacheService`/`Storage` 实现需同步更新签名(编译失败即迁移信号)。
>
> **注释/文档一致性**`glm_note_report.md` 报告):A/B/C 三档共 23 项注释/文档修复,无运行逻辑变更。
>
> **前序评审收口**M13 cron panic、M1 App 生命周期状态机、M3 logger 生命周期临界区;config 模块评审(H-config-1 Set/viper 同源、M-config-1 回调 panic 隔离、M-config-2 DB SSL/TLS、M-config-3 App 关闭停 watcher、M-config-4 Clone 守卫);database 模块评审(H-db-1 后台探活/启动 ping 经 pingWithTimeout 3s 约束)。
>
> 项目处于初级阶段,无下游用户,放心引入破坏性变更。`go vet` + `go build` + `go test -race ./...` 全绿。
### Breaking ⚠️
- **PostgresDSN 默认 sslmode 由 `disable` 改为 `prefer`**M-config-2):原 `PostgresDSN` 硬编码 `sslmode=disable`,生产 DB 流量明文。新增 `DatabaseConfig.SSLMode` 字段,空值默认 `prefer`(优先加密、失败回退明文),可显式配置 `disable/allow/prefer/require/verify-ca/verify-full`(非法值在 `Validate` 阶段报错)。依赖明文 Postgres 连接的下游若因 `prefer` 回退行为受影响,请显式设置 `ssl_mode: disable`。MySQL 不受影响(用 `TLS`/`TLSRootCA`)。
- **cache 写操作与计数器/原始 Redis helper 在 Redis 未初始化时返回 `ErrRedisNotReady`**`Set` / `Delete` / `DeleteByPattern` / `Incr` / `IncrBy` / `Decr` / `GetTTL` / `SetExpire` / `GetRaw` / `SetRaw` 旧行为会静默返回成功或零值,调用方容易误判缓存写入、计数器更新或过期时间设置已经生效;现在统一显式返回错误。公共接口签名不变,但依赖“未启用 Redis 时当作成功”的下游需要改为忽略 `errors.Is(err, cache.ErrRedisNotReady)` 或显式启用 Redis。
- **`cache.WithLock` / `cache.WithLockAutoExtend` 未获取到锁时返回 `ErrLockNotAcquired`**:旧行为返回 `nil` 并跳过业务函数,调用方无法区分“业务执行成功”和“根本没有执行”。同时锁 TTL 小于 1ms、续期/重试间隔非正会返回显式错误,避免 Redis PX=0 或 `time.NewTicker(0)` 崩溃。
- **`cache.WithLock` / `cache.WithLockAutoExtend` 的业务函数签名改为 `func(context.Context) error`**:旧签名 `func() error` 无法强制业务函数接收取消信号,容易在请求取消/超时后继续访问 DB/HTTP 等下游资源;现在框架会把调用方 ctx 传入业务函数。nil 业务函数返回新增 `ErrLockFuncNil`
- **`test.Request.Execute()` 改为返回 `*test.Response`**:旧返回值是 `*httptest.ResponseRecorder`,与文档示例中的 `resp.AssertOK(t)` / `resp.ParseJSON(...)` 不一致;现在 `Execute()` 返回带断言和 JSON 解析方法的包装类型。需要原始 recorder 的调用方改用新增 `ExecuteRecorder()`
- **`config.Get()` / `(*config.Manager).Get()` 改为返回配置副本**:旧行为暴露内部 `*Config`,调用方修改返回值会污染全局配置并可能与热重载并发读写竞态;现在返回深拷贝。需要动态替换配置的测试或工具代码请改用 `config.Set(cfg)`
- **`config.Set()` / `(*config.Manager).Set()` 现在返回 `error`**:非 nil 配置会先执行 `Validate()`,非法配置不会覆盖旧配置,并返回 `ErrInvalidConfig` 包装错误。旧代码可以继续忽略返回值,但建议测试和启动路径显式检查。
- **`config.GetViper()` / `(*config.Manager).GetViper()` 改为返回 viper 快照**:旧行为暴露内部可变 `*viper.Viper`;现在修改返回对象不会影响全局配置。常规读取请使用 `GetString` / `GetInt` / `GetBool` / `GetStringMap`
- **未知数据库 driver 不再静默回退 MySQL**`config.DatabaseConfig.DSN()` 对非空但未注册的 `driver` 返回空字符串,`database.Dialector` 返回初始化即失败的 Dialector;空 `driver` 仍保持默认 MySQL。下游若使用自定义数据库驱动,需先通过 `database.RegisterDialect``config.RegisterDSNBuilder` 注册。
- **`database.InitDB` / `(*database.Manager).InitDB` / `InitDBWithReplicas` 必须显式传入 `context.Context`**:旧 API 无法取消初始化过程中的 Ping 与重试等待,shutdown 或启动失败回滚时可能长时间卡住。现在调用方必须传入生命周期 ctx;普通测试或一次性脚本可使用 `context.Background()`App 初始化会使用 App root ctx。
- **`database.SetDefaultManager` / `database.SetDefaultRedisManager` 会关闭被替换的旧 manager**:旧行为只做 atomic 替换,直接调用会遗留旧 DB/Redis 连接池。现在普通 Set 表示“接管全局默认资源并释放旧资源”;需要失败回滚或延迟释放旧资源的初始化流程请改用新增 `database.SwapDefaultManager` / `database.SwapDefaultRedisManager`
- **`jwt.ParseToken` 开始校验 issuer`RefreshToken` 使用 `jwt.refresh_expire`**:签发者与当前配置不一致的 token 会被拒绝;刷新后的 token 过期时间优先使用 `refresh_expire`,未配置时回退 `expire``GenerateTokenWithCustomExpiry` 现在拒绝非正过期时间,`InvalidateTokenByID("")` 返回 `ErrEmptyJTI`
- **`App.Init()``sync.Once` 改为生命周期状态机**(app.go,M1):5 态 `stateCreated/Initializing/Initialized/Stopping/Stopped` + `lifecycleMu`(RWMutex) + `initMu`(Mutex)。`Shutdown` 后或 `Init` 失败后再调 `Init()` 返回新增导出错误 **`xlgo.ErrAppClosed`**(原 `sync.Once` "多次调用返回首次结果"语义不再适用——已关闭的 App 不可再 Init,需新建 App)。
- **`App.Go()` 在 Shutdown 开始或 Init 失败后为 no-op**app.goM1):`state >= stateStopping` 时拒绝 `wg.Add` 直接返回,避免与 `Shutdown``wg.Wait` 竞争 `sync.WaitGroup` 契约(Add 须 happen-before Wait)。依赖"Shutdown 后仍可 Go"的下游需改用独立 goroutine。
- **生命周期 hook 不允许重入调用 `Init`/`Shutdown`/`Run`**app.goM1):`initMu` 非重入,hookOnInit/OnStart/OnReady/OnStop)内调用会自锁死锁。需在 hook 内触发关闭应改用信号通道由主流程处理。
- **`xlgo.WithConfig(cfg)` 改为快照语义并在 `Init` 时校验**(app.go,M1):传入配置会深拷贝到 App 私有快照,调用方后续修改原 `cfg` 不再影响 App;非法配置在 `Init` 返回中文校验错误。依赖“修改原 cfg 指针动态影响 App”的下游需改为重新创建 App 或使用配置管理器。
- **`logger.DefaultLogger = m` 直接赋值不再驱动包级 facade**logger/logger.goM3):为消除 `SetDefaultLogManager()` 与包级 facade 并发读取默认 manager 的裸全局指针竞态,facade 改为读取内部 atomic 快照。`logger.DefaultLogger` 仍保持 `*LogManager` 类型,旧的 `logger.DefaultLogger.Init/Close/SetLevel/GetLevel` 直接调用仍可用;替换默认 manager 请使用 `logger.SetDefaultLogManager(m)`
- **`logger.Init` 拒绝明显非法日志配置**logger/logger.goM3):空日志目录、负数 `MaxSize/MaxBackups/MaxAge` 现在直接返回错误。依赖零值日志配置启动 `WithLogger()` 的下游需显式设置 `Log.Dir` 与非负轮转参数。
- **限流器非法配置改为 fail-fast**middleware/ratelimit.goM8):`NewRateLimiter` / `NewRedisRateLimiter` / `NewRedisRateLimiterFailClosed` 现在对 `rate <= 0``window <= 0` 直接 panic,避免零值窗口/零值配额静默产生不确定限流语义。下游应在配置加载阶段校验限流参数。
- **`handler.BindJSON` 默认限制 JSON body 为 1MiB**handler/handler.goM6):防止入口层无上限读取请求体导致 OOM。需要更大 JSON 的接口请改用 `handler.BindJSONWithMaxBytes(c, req, maxBytes)` 显式声明上限。
- **cron 非法任务配置改为 fail-fast**cron/cron.goM13):`AddTask` 拒绝 nil schedule / nil handler`Every(<=0)``Daily`/`Weekly` 越界时间、非法 weekday 会 panic,避免静默生成不推进或归一化跑偏的调度。
- **`cron.ParseCron` 非法表达式改为 fail-fast panic**cron/cron.goM13):旧行为会把非法表达式静默回退为每分钟执行,容易让拼写错误变成高频任务。动态输入请用 `ParseCronStrict` 处理 error;确实需要旧回退语义时改用新增 `ParseCronOrDefault`
- **repository 查询保护默认开启**repository/repository.goM5/N5):`FindAll` 默认最多返回 `DefaultFindAllLimit=1000` 条;明确需要全表扫描时改用 `FindAllUnbounded``FindPage*` / `QueryBuilder.Page` 会归一化 `page/pageSize` 并限制 `MaxPageSize=100``MaxPage=10000``Find*Ordered` / `QueryBuilder.Order` 只接受简单字段排序(如 `created_at DESC, id ASC`),复杂表达式/raw SQL 会返回 `ErrUnsafeOrder``UpdateBatch` 字段名不合法返回 `ErrUnsafeField`
- **同义可失败 API 收敛:错误不再吞并**cache/jwt/storage/ratelimit):按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,收敛历史双轨 API。详见下述分项;自定义 `CacheService`/`Storage` 实现需同步更新签名(编译失败即迁移信号)。
- **cache**`CacheService.Get` / `Exists` 改为 `(bool, error)`--命中 `(true,nil)`、未命中 `(false,nil)`、Redis 未就绪/命令错误/反序列化失败返回 `(false,err)`。删除 `cache.GetE` / `cache.ExistsE` / `CacheGetter` / `CacheExistChecker` / `redisCache.GetE` / `redisCache.ExistsE`(不保留 deprecated wrapper)。新增包级 `cache.Get(ctx,key,dest) (bool,error)` / `cache.Exists(ctx,key) (bool,error)``GetWithPrefix` 改为 `(bool,error)`。迁移:`hit, err := cache.Get(ctx, key, &v); if err != nil { return err }; if !hit { /* miss */ }`
- **jwt**`TokenBlacklist.IsBlacklisted` 改为 `(bool, error)`,删除 `IsBlacklistedE``IsTokenRevoked` 改为 `(bool, error)``ParseToken` 默认从 **fail-open 改为 fail-closed**--黑名单后端不可检查时返回 `ErrBlacklistUnavailable` 拒绝该 Token(无 Redis 部署不再支持可靠撤销)。删除 `ParseTokenFailClosed`(主 API 已 fail-closed)。保留 `ParseTokenWithBlacklistPolicy(token, policy)` + `BlacklistPolicy`/`BlacklistFailOpen`/`BlacklistFailClosed` 供显式 fail-open(仅无 Redis 或低安全场景)。迁移:无 Redis 部署需启用 Redis,或显式 `jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen)`
- **storage**`Storage.Exists` 改为 `(bool, error)`--存在 `(true,nil)`、不存在 `(false,nil)`、未初始化/路径非法/穿越/后端错误返回 `(false,err)``LocalStorage.Exists` 区分 `os.IsNotExist`not found)与其他 `os.Stat` 错误;`OSSStorage.Exists` 区分 OSS 404/NoSuchKeynot found)与鉴权/网络错误。包级 `storage.Exists` 同步签名,未初始化返回 `ErrStorageNotInitialized`。新增 `ErrReadTooLarge``LocalStorage.Get` / `OSSStorage.Get` 读取超 `maxReadBytes` 上限原误用 `ErrInvalidPath`(路径无效语义不贴切),改为 `ErrReadTooLarge`。迁移:`ok, err := storage.Exists(p); if err != nil { return err }; if !ok { /* not found */ }`
- **ratelimit**Redis 限流器策略收敛为配置型 API。`NewRedisRateLimiter(keyPrefix, rate, window, opts ...RedisRateLimiterOption)` 新增可变参数,`WithFailClosed(true)` 替代原 `NewRedisRateLimiterFailClosed``RedisRateLimit` / `CustomRedisRateLimit` / `RedisRateLimitWithIdentifier` 同步加 `opts` 参数。删除 `NewRedisRateLimiterFailClosed` / `RedisRateLimitFailClosed` / `CustomRedisRateLimitFailClosed``UploadRedisRateLimit` 由 fail-open 改为 **fail-closed**(上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效)。迁移:`middleware.RedisRateLimit("k", 100, middleware.WithFailClosed(true))` 替代原 `RedisRateLimitFailClosed("k", 100)`
### Security 🔒
- **MySQL 连接支持 TLS**M-config-2):`DatabaseConfig.TLS` 为 true 时 `MySQLDSN` 追加 `tls=true`go-sql-driver/mysql v1.7.0 内置安全语义:系统根 CA + ServerName 自动取自 Host + 证书校验,无需注册)。配合 `DatabaseConfig.TLSRootCA`(PEM 路径)可指定私有 CA/自签证书,由 `database` 包在 `InitDB``RegisterTLSConfig` 注册命名配置(`config.MySQLTLSConfigName`);CA 不可读或非 PEM 时 fail-fast,不静默回退明文。
- **Postgres 连接支持 sslmode 配置**M-config-2):见 Breaking 项,默认 `prefer` 优先加密。
### Fixed 🐛
- **config `Set(cfg)` 与 viper 视图同源修复**H-config-1):`Set` 原只更新类型化视图 `m.cfg``m.v`viper)停留旧值,导致 `Get()``GetString/GetInt/GetBool/GetViper` 返回不同世界(违反 C1 单一配置源)。现在 `Set` 用 mapstructure 将 `*Config` 重建为不含 `AutomaticEnv` 的 viper 视图,保证 Get 与 GetString 同源。
- **config 热重载回调 panic 隔离**M-config-1):单个 `onChange` 回调 panic 原会传播致 watcher 泄漏、后续热更新静默失效。现在每个回调独立 `recover`(标准库 `log` 记录 + 堆栈),不阻断后续回调、不杀 watcher。
- **App.Shutdown 停止 configManager watcher**M-config-3):`closeResources` 末尾新增 `configManager.StopWatcher()`,用户对 App 的 configManager 调 `LoadWithWatch`/`StartWatcher` 后由 Shutdown 统一收口,避免关闭后遗留监听 goroutine(违反 C7)。
- **config `Clone` 切片字段覆盖守卫**(M-config-4):新增反射测试枚举 `Config` 所有切片/map 字段,断言 `Clone` 深拷贝;新增切片字段未同步 fixture 或 `Clone` 时测试失败,形成机械守卫。
- **config `watchLoop` 增加 ctx 逃生通道**L-config-2):原仅靠 `w.Events` 关闭退出,与"for 消费循环须 ctx.Done"红线有张力。`StopWatcher` 改为 cancel ctx + Close watcher 双重退出。
- **config `Validate` 连接池交叉校验**L-config-3):`MaxOpenConns>0``MaxIdleConns>MaxOpenConns` 视为配置错误。
- **config 哨兵错误改用 `errors.New`**L-config-4);**`DSN()` 去除冗余 TrimSpace**L-config-5);**`DSN/MySQLDSN/PostgresDSN/Addr` nil receiver 防御**L-config-6)。
- **database 后台探活/启动 ping 经 pingWithTimeout 3s 约束**H-db-1M11 修复不完整):`pingWithTimeout` 原只加到包级 `HealthCheck()`,未覆盖方法 `(*Manager).HealthCheck`(被后台探活 `probeOnce` master 与 `/health` 端点共用)、`probeOnce` 从库 ping、`InitDB`/`InitDBWithReplicas` 启动 ping。挂起 DB(连接活但不响应)下这些路径的 `PingContext` 无 ctx deadline 无限阻塞,致探活 goroutine 阻塞、#21 自愈冻结、`IsHealthy()` 缓存失真、启动卡死。现 4 路径统一经 `pingWithTimeout``healthCheckTimeout`=3s,尊重 ctx 自带更短 deadline)。**另发现并修复 gorm.Open 的 automatic ping**gorm.go:204ConnPool 为 `*sql.DB` 时调 `pinger.Ping()` 无超时)在 pingWithTimeout 之前就无限阻塞--`initDB`/`InitDBWithReplicas` 的 gormConfig 加 `DisableAutomaticPing: true`,框架用 pingWithTimeout 自管启动 ping。Redis 侧 `HealthCheck` 经 client `ReadTimeout`(3s) 约束(行为验证)。新增 `manager_hdb1_internal_test.go`hungDriver 回归 pingWithTimeout/m.HealthCheck/probeOnce replica/initDB/InitDBWithReplicas 5 路径)、`redis_hdb1_internal_test.go`(挂起 Redis 回归 HealthCheck)、`app_hdb1_test.go`App 级端到端:Init 挂起 DB 有界失败 + Shutdown 正常)。
- **M9 JWT issuer / refresh expiry 契约修复**`ParseToken``InvalidateToken``GetClaimsFromToken` 统一按当前配置校验 issuer`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键;新增 `ParseTokenFailClosed` / `ParseTokenWithBlacklistPolicy` / `TokenBlacklist.IsBlacklistedE`,默认 `ParseToken` 仍保持黑名单检查 fail-open 兼容语义,安全敏感路由可显式选择 fail-closed;解析侧配置错误现在可通过 `errors.Is` 区分 `ErrEmptySecret` / `ErrUnsupportedAlgorithm``InvalidateToken` 使用不校验时序的解析路径,允许提前吊销 `nbf` 在未来的外部 token。
- **M10 分布式锁参数与取消传播修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady``WithLock` / `WithLockAutoExtend` 现在把调用方 ctx 传入业务函数,避免取消后业务函数继续运行。
- **M10 cache 剩余错误语义收口**:新增 `cache.GetE` / `cache.ExistsE` 与可选 `CacheGetter` / `CacheExistChecker`,让调用方能区分 cache miss、Redis/backend 故障和反序列化错误;保留旧 `Get` / `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。
- **M2 config 热重载生命周期修复**:`StopWatcher` 会等待已触发的 reload/回调结束;包级 `Load` / `LoadWithWatch` 只有在新配置成功加载并启动 watcher 后才替换默认 manager,失败时保留旧 watcher`SetDefaultManager` 会停止旧 manager 的 watcher,避免全局置换后遗留 goroutine;数据库配置出现字段时会校验 driver/host/name/port,未知 driver 不再静默回退 MySQL`database.Dialector` 对未知 driver fail-closedMySQL DSN 转义用户名/库名,Postgres DSN 统一转义字符串字段。
- **M4 database nil 边界修复**`InitDB(ctx, nil)` / `InitDBWithReplicas(ctx, nil, ...)` / `InitRedis(nil)` 现在返回中文错误,不再空指针 panic;`UseMaster(nil)` / `UseReplica(nil)` / `GetDBFromContext(nil)` / `WithTx(nil, ...)` / `TxFromContext(nil)` / `TransactionWithContext(nil, ...)` / `ReadQuery(nil, ...)` / `WriteQuery(nil, ...)` / Redis health check 会把 nil context 归一化为 `context.Background()`,避免异常调用路径触发 panic`Dialector(nil)` 安全回退到 MySQL 空 DSN。
- **M4 database 全局置换资源释放修复**:`SetDefaultManager` / `SetDefaultRedisManager` 替换全局默认 manager 时会关闭旧 DB/Redis manager,避免包级默认资源反复置换后连接池泄漏;App 初始化改用 `SwapDefaultManager` / `SwapDefaultRedisManager` 暂存旧资源,保证失败回滚仍能恢复旧默认资源。
- **M4 database 初始化生命周期修复**:DB 初始化与主从库初始化统一接收 ctx,主库/从库 Ping 使用 `PingContext`,重试等待改为 `select ctx.Done()/time.After``Manager` 用生命周期锁串行化 Init/Close,避免 shutdown 与运行期重建交错;运行期重建从库后会立即重建健康标记,探活循环也能在发现健康标记缺失时自愈;包级 `HealthCheck()` 固定读取一次默认 manager 快照。
- **M11 SSE 换行注入修复**`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。
- **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost`ValidateStruct(nil)` 直接返回 nil。
- **M15 utils 转换语义收口**:新增 `utils.ToIntE` / `utils.ToInt64E` 返回解析错误;旧 `ToInt` / `ToInt64` 保留“失败返回 0”的兼容行为,调用方在 0 有业务含义时应迁移到严格变体。
- **M16 测试工具、脚手架与示例闭环修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache``MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码;`examples/full` 启动时初始化 `alice/secret`,登录校验 bcrypt 哈希,创建用户也保存哈希,避免示例首次运行无法登录或传播不验密/明文密码模式;README/GUIDE 限流示例不再引用不存在的 `handler.Login` / `handler.Upload`
- **M16 GUIDE/test API 不一致修复**:GUIDE 测试示例不再调用不存在的 `AssertCode` / `AssertJSONKeyExists`,统一改用现有 `AssertJSONContains`,避免照文档编写测试直接编译失败。
- **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。
- **M8 middleware 边界收口**`AuthRequired``Authorization` scheme 改为大小写不敏感,符合 Bearer 语义;`LoggerConfig.SkipPathPrefixes``SimpleLogger` 静态路径跳过改为路径边界匹配(`/api` 仅匹配 `/api``/api/...`,不再误跳过 `/api2`)。
- **M13 cron handler panic 未 recover 崩进程**cron/cron.go):`RunTask``checkAndRun` 调度 goroutine 统一经新增 `executeTask(t)` 边界 `recover`panic 转为 error(含 `debug.Stack` 调用栈)记入 `task.LastError` 并向上返回,不再终止进程。外侧 `defer wg.Done()`/`running` 守卫释放不受影响(recover 在边界内完成)。顺带修复 `RunTask` 手动路径此前只更 `LastRun/RunCount`、不记 `LastError` 的子问题(现与调度路径一致)。
- **M6 response/handler 入口防护**response/error.goresponse/response.gohandler/handler.go):`FailWithError(nil)` / `FailWithDetail(nil, ...)` 回退统一服务器错误响应,不再 nil deref panic;新增 `response.DownloadReader` 支持大文件/对象存储流式下载,旧 `Download` / `DownloadWithContentType` 保持兼容并复用同一响应头逻辑。
- **M14 trace 剩余记录补齐**trace/trace.go):`SampleRatio` 拒绝 NaN/越界值;`Init`/`Close` 使用操作超时并在初始化失败时回滚 provider;`RecordError` / `RecordErrorToSpan` 对 nil 输入 no-op`Middleware(serviceName)` 写入 `service.name` attribute`X-Trace-ID` 不再输出全零 TraceID。
- **M7 health/readiness 探活超时**router/router.go):`HealthCheck` 新增 `Timeout` 字段,默认每个依赖检查 2s 超时;超时项返回 `"timeout"` 并使 `/health` / `/readyz` 返回 503。单个 check 同时最多一个执行中,panic 会 recover 为错误,避免 k8s/LB/监控探活被挂死依赖无限卡住或无限堆积 goroutine。
- **M13 cron 剩余边界收口**cron/cron.go):Stop 后再次 Start 会重建调度器 context,手动和调度执行不再收到已取消 ctx;Start/Stop 生命周期串行化,避免 Stop 等待期间重新 Start 触发 WaitGroup Add/Wait 交错。
- **M5 repository 安全边界收口**repository/repository.go):nil ctx 统一按 `context.Background()` 处理;`FindByIDs(nil)` 返回非 nil 空切片;`NewQueryBuilder` 复用 nil DB 明确 panic;批量空 ids 写操作 no-op;默认 `FindAll` 加上限并新增 `FindAllUnbounded`;排序/字段名白名单避免便捷 API 误接 raw SQL。
- **M1 App 生命周期三类缺陷统一治理**(app.go):
- **Init 失败无资源回滚** → 新增 `failAfterInit`markStopping → cancel rootCtx → wg.Wait(10s) → cron 5s 显式超时停止 → `closeResources` 幂等关闭 db/redis/logger,回滚错误 `errors.Join``initErr` 不吞;先停 goroutine 再关资源,避免"关 DB 时探活 goroutine 仍在用"的竞态。
- **App.Go 与 wg.Wait race** → `lifecycleMu.RLock` 包住 `wg.Add``Shutdown` 持写锁翻 `stateStopping`,保证 Add happens-before Wait。
- **Shutdown 非幂等/非并发安全** → `shutdownOnce` 保证 `doShutdown` 单次执行,并发调用者返回同一 `shutdownErr`
- **OnStop 语义与超时** → 仅 `Init` 曾成功(`wasInitialized`)时在 `doShutdown` 开头执行;Init 失败/未 Init 时 HTTP 从未启动,OnStop 跳过。OnStop 现在受 `server.shutdown_timeout` 同一预算约束,阻塞 hook 不再无限拖住 Shutdown。
- **资源所有权** → App 只关闭自己成功初始化过的 logger/db/redis/cron,避免一个 Init 失败的新 App 关闭同进程既有全局资源。
- **复审补强:资源替换事务边界** → App 初始化 logger/db/redis 时先创建 App-owned manager 并保存旧默认 manager 快照;OnInit 或后续步骤失败时恢复旧默认 manager 并关闭新资源,完整成功后才释放旧资源,避免“新 App Init 失败”破坏同进程既有全局 logger/db/redis。
- **复审补强:health/probing 绑定 App-owned manager** → App 注册的 MySQL/Redis health check 与 DB probing 使用本 App 持有的 manager,不随后续全局默认 manager 替换漂移。
- **OnReady 早于真实监听成功** → `StartServer` 改为同步 `net.Listen` 且 TLS 证书装配成功后再启动 `Serve` 与执行 OnReady;监听/TLS 失败直接返回,不触发 ready 副作用。
- **`server.unix_socket` 不可用** → 非空 `unix_socket` 现在走 `net.Listen("unix", path)` + `http.Server.Serve`,不再把 socket path 误传给 TCP `ListenAndServe`
- `Init`/`Shutdown``initMu` 串行化 doInit/doShutdown 长段,杜绝并发改资源。
- **M3 logger 生命周期与全局 manager 并发治理**logger/logger.go):
- **`LogManager.Init()` 锁外写 `m.level`** → `Init` 在通过局部配置校验后持 `m.mu` 完成建目录、构造与发布新 logger,`m.level` 只在同一临界区更新;包级 `Logger/fileWriters` 发布另由 `globalMu` 串行化,避免多个 `LogManager` 实例用各自实例锁保护同一包级状态。
- **`DefaultLogger` 裸全局指针** → 保留导出变量兼容旧代码,新增内部 atomic 默认 manager 快照与 `GetDefaultLogManager()`;包级 `Init/Close/Sync/SetLevel/GetLevel` 全部经 atomic 读取当前 manager。
- **stale manager 关闭当前 logger** → 每次发布全局 logger 分配 generation,只有拥有当前 generation 的 `LogManager.Close()` 才能关闭当前全局 logger/writer。
- **旧 writer 关闭顺序错误** → `Init` 先 atomic 发布新 logger,再关闭旧 lumberjack writer,避免替换窗口内包级读路径拿到指向已关闭 writer 的旧 logger。
- **`Close()``Init()` 生命周期互相覆盖** → `Close` 在同一 manager 锁内先快照旧 logger/writer 并发布 Nop,再执行 Sync/Close;关闭错误通过 `errors.Join` 聚合返回,不再静默吞掉 lumberjack `Close` 错误。
- **M8 middleware/CSRF 与限流边界治理**middleware/csrf.gomiddleware/ratelimit.go):
- **CSRF JSON body 读取无上限** → 从 body 提取 `_csrf` 时使用 `http.MaxBytesReader`,默认上限 1MiB`CSRFConfig.MaxBodyBytes` 可调),超限返回 HTTP 413,避免 pre-auth OOM;仍使用 `ShouldBindBodyWith` 保留下游重复读取 body 的能力。
- **CSRF cookie SameSite 配置未真正写入** → `CSRF()``DoubleSubmitCookie()` 设置 cookie 前显式 `SetSameSite`,默认 Lax。
- **CSRF 局部配置丢默认 cookie 行为** → `CSRFConfig` 归一化补齐 `Path``SameSite``MaxAge``FormField``MaxBodyBytes` 等默认值,只覆盖单个字段时不再意外丢失默认 cookie 约束。
- **CSRF session cookie 显式配置** → 新增 `CSRFConfig.SessionCookie`,需要会话 Cookie 时设置为 `true`;为保持 `CSRFWithConfig(CSRFConfig{})` 默认 1 小时语义,`MaxAge=0` 且未设置 `SessionCookie` 时仍回退默认值。
- **CSRF TokenLength 负数 panic/退化** → `TokenLength <= 0` 统一回退默认长度,避免配置错误导致 `make([]byte, negative)` panic。
- **CSRF skip path 前缀误跳过** → `CSRFWithSkip([]string{"/api"})` 仅跳过 `/api``/api/...`,不再误跳过 `/apix`
- **API CSRF token map 只校验时清理过期 token** → `GenerateAPIToken` 颁发新 token 前同步清理过期项,避免长期只发不验场景内存增长。
- **`RateLimit(nil)` panic** → 改为 fail-closed 返回 HTTP 503 + `CodeServiceUnavailable`,避免未初始化限流器导致请求路径 nil deref。
- **`RedisRateLimitWithIdentifier` nil 回调 panic** → `identifierFunc == nil` 时回退 `ClientIP()`,并保留空字符串回退逻辑。
- **gosec database 告警收口**database/manager.godatabase/redis.go):`RoundRobinPicker` 改为 mutex 保护的有界 `int` 计数器,消除 G115 整数转换告警;`RandomPicker` 改用 `crypto/rand` 选择从库,消除 G404 弱随机源告警,随机源失败时安全回退到首个从库;Redis 初始化 ping 失败时不再静默吞掉 `client.Close()` 错误。
- **复审补强:DB/Redis 重建路径资源释放**database/manager.godatabase/redis.go):Redis 重复初始化会关闭旧 client;DB 重建/重试路径的旧连接池关闭失败不再静默丢弃,会记录 warning 供排查。
- **gosec Close 错误收口**utils/http.gocompress/compress.go):HTTP multipart 上传循环中的本地文件 `Close()` 错误不再静默吞掉;gzip/zip 解压输出文件关闭失败会通过 `errors.Join` 返回,并触发残留目标文件清理。
- **gosec 兼容性/误报标注收口**cmd/xlgoutils/http.goutils/crypto.goutils/file.gocompress/compress.go):为已存在输入校验或明确调用方契约的路径、客户端请求 cookie、非安全用途 checksum、压缩源路径遍历、兼容模式 HTTP 请求添加精确 `#nosec` 理由;`NewSSRFSafeHTTPClient` / `BlockPrivateNetworks` 仍是处理不可信 URL 的推荐入口。
- **示例参数解析错误处理**examples/full/main.go):示例用户详情接口现在检查 `fmt.Sscanf` 错误,非法用户 ID 返回失败响应,不再静默使用零值。
## [1.2.0] - 2026-07-04
> 框架整体评估后的结构性修复:4 轮对抗性评审(deepseek / GLM / Claude / 终审)收口的全部 CRITICAL/HIGH/MEDIUM 问题 + 主线A 包级可变全局并发治理统一。
> 项目处于初级阶段,无下游用户,放心引入破坏性变更。`go vet` + `go build` + `go test -race ./...` 全绿。
### Breaking ⚠️
- **`database.RedisClient` 不再可外部访问**:包级变量改为 unexported。所有消费者必须通过 `database.GetRedis()` 获取客户端。测试注入使用 `database.SetTestRedisClient(c)` 替代直接赋值。
- **`xlgo.WithConfig(cfg)` 不再调用 `config.Set(cfg)`**:配置不再写入全局状态。依赖 `config.Get()` 获取注入配置的下游代码需改用 `WithConfigPath`
- **`App.Init()` 内部改为 `sync.Once`**:多次调用返回首次执行的结果(含错误),不再是之前的"第二次直接返回 nil"。
- **`database.DefaultRedis` 类型变更**database/redis.go):由 `*RedisManager` 改为 `atomic.Pointer[RedisManager]`,消除裸指针无锁置换的数据竞争(C-1)。下游若直接调用 `DefaultRedis.Init(...)` 等方法需改用 `InitRedis(...)` facade,或 `DefaultRedis.Load().Init(...)`
- **`storage.DefaultStorage` 类型变更**storage/storage.go):由 `*StorageManager` 改为 `atomic.Pointer[StorageManager]`,与 `config.defaultManager`/`database.DefaultRedis` 并发保护对齐。下游直接调用方法需改用 facade 或 `.Load()`
- **`validation.Validator` 类型变更**validation/validator.go):由 `*validator.Validate` 改为 `atomic.Pointer[validator.Validate]`,消除无锁读写竞态(H-13)。下游直接读 `validation.Validator.Struct(...)` 需改用 `ValidateStruct(...)`,或 `validation.Validator.Load().Struct(...)`
- **`repository.FindWhereOrdered` / `FindPageWhereOrdered` 签名变更**repository/repository.go):`args []any` 改为 `args ...any`,与同类方法统一;因 Go 变长参数须为末尾参数,`order` 前置于 `query`。新签名:`FindWhereOrdered(ctx, order, query string, args ...any)``FindPageWhereOrdered(ctx, page, pageSize int, order, query string, args ...any)`H-15)。
- **`response.Response``Data`/`RequestID` 去掉 `omitempty`**response/response.go):`data``request_id` 字段在所有响应中恒存在(失败时 `data:null`、未装 RequestID 中间件时 `request_id:""`),下游严格按 schema 解析不再缺字段(M-38/M-39)。
- **`cache.IsLocked`/`GetLockTTL`/`ForceUnlock` Redis 不可用时改返 `ErrRedisNotReady`**cache/lock.goM-E):原返 `(false/0, nil)` 与"锁确实未占用/已过期"不可区分,调用方可能误以为可获取锁而进入临界区。现统一为:锁操作(正确性相关)Redis 不可用返 `ErrRedisNotReady`,调用方 `errors.Is(err, ErrRedisNotReady)` 区分;cache 数据操作(Get/Set/Incr,性能层)保持 best-effort 静默。
- **`config.Load()` 返回深拷贝**config/config.goM-G):新增 `(*Config).Clone()` 深拷贝所有切片字段(CORS/Upload/Storage 白名单),`Load()` 与 reload 回调改返 Clone。原"防御性拷贝"为浅拷贝,切片字段与内部 `m.cfg` 共享底层数组,调用方 append/sort/改元素会污染全局。`Get()` 仍返回内部只读指针(热路径零分配),需可变副本用 `Clone()`
- **`handler.GetPage` 加 page 上限 10000**handler/handler.goM-D):防 `?page=999999999` 产生超大 OFFSET 拖垮 DB(深分页 DoS)。超过上限钳制到 `MaxPage`,需更深遍历应改游标/keyset 分页。
- **`utils.EqualsIgnoreCase``strings.EqualFold`**utils/strings.goL-C):原仅 ASCII 字节折叠,非 ASCII(如 `É`/`é`)误判为不等。现 Unicode 大小写折叠,行为更正确。
- **`utils.ReadFile``FileExists` 前置检查**utils/file.goM-F):消除 TOCTOU 竞态,直接 `os.ReadFile`。文件不存在返 `*os.PathError`,调用方改用 `errors.Is(err, os.ErrNotExist)` 判断(原字符串 `"file not found"` 不再返回)。
- **`database.DefaultManager` 类型变更**database/manager.go,主线A 收口):由 `*Manager` 改为 `atomic.Pointer[Manager]`,消除原裸指针(外部直接 `database.DefaultManager = ...` 赋值与请求 goroutine 经 facade 读取)的数据竞争,与 `database.DefaultRedis`/`storage.DefaultStorage`/`cache.defaultCachePtr`/`jwt.defaultManager`/`config.defaultManager` 对齐——框架内包级可变全局一律 atomic.Pointer。新增 `database.SetDefaultManager(m)`(并发安全,替换后关闭旧 manager)、`database.SwapDefaultManager(m)`(并发安全,返回旧 manager 且不关闭)与 `database.GetDefaultManager()`(atomic 读取)。下游若直接调用 `DefaultManager.Init/Master` 等方法需改用 `InitDB`/`GetDB` 等 facade,或 `DefaultManager.Load().Init(...)`,或经 `GetDefaultManager()` 取实例;原 `database.DefaultManager = myDB` 直接赋值改为 `database.SetDefaultManager(myDB)`
### 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)`
#### 第二轮:并发纪律红线 + 生命周期/泄漏修复
> 来源:glm_check_report_framework.md 结构性评估。聚焦"包级可变全局统一治理"与"重建路径资源释放"两条主线。
- **C-1/H-4 `database.DefaultRedis` 并发竞态 + `redisClient` 双源**database/redis.go):`DefaultRedis``atomic.Pointer[RedisManager]`init Store 兜底),所有 facade 经 `Load()`;废弃 `redisClient` 包级变量,`GetRedis` 单源化;`SetTestRedisClient` 改为在当前 manager 上持锁 `setClientForTest` 替换 client,消除双源真相与无锁写竞态。
- **H-13 `validation.Validator` 无锁读写**validation/validator.go):改 `atomic.Pointer[validator.Validate]``InitValidator` 先注册全部自定义规则再 Store,确保 Load 得到的 validator 永远完整。
- **H-11 + 主线A `storage` 死代码全局 + `DefaultStorage` 裸指针**storage/storage.go):删除从未被读取的死代码 `var storage Storage``DefaultStorage``atomic.Pointer[StorageManager]`
- **H-10 `response.Error.WithDetail` 并发不安全**response/error.go):原实现 `e.Detail = detail` mutate 共享的预定义 `Err*`(并发写竞争 + 全局污染)。改为返回新 `*Error` 拷贝。
- **H-6 `middleware.RedisRateLimiter.failClosed` 数据竞争**middleware/ratelimit.go):改 `atomic.Bool``SetFailClosed`/`Allow` 全用 atomic,支持运行期并发切换策略。
- **H-7 `middleware.GetCSRFToken` 裸断言 + 非恒定时间比较**middleware/csrf.go):`token.(string)` 改 comma-okCSRF token 比较改 `subtle.ConstantTimeCompare`(两处),防时序侧信道。
- **H-14/M-64/M-65 `trace.Close` sync.Once 泄漏 + Init 回滚不全**trace/trace.go):`Close``sync.Once` 改 Swap+Shutdown,支持 Close→Init→Close 多轮生命周期(原第二次 Close no-op 致 exporter 泄漏);`Init` `!Enabled` 分支 Swap+Shutdown 旧 provider;传播器失败时不切 otel 全局(移到成功路径末尾),避免指向已 Shutdown provider。
- **H-8/H-9 `ws.Hub.Stop` double-close panic + WaitGroup Add/Wait 竞态**ws/ws.go):`Stop``sync.Once``close(stop)`(删除"stopped 标志"虚假注释);用 `runDone chan` + `runStarted atomic.Bool` 替代 WaitGroup,消除 `wg.Add(0→1)``wg.Wait` 的 happens-before 竞态,Stop 先于 Run 调用也不泄漏。
- **H-1 `app.OnReady` 失败资源泄漏**(app.go):失败时走 `Shutdown()` 释放 HTTP 端口/后台 goroutine/资源,不再直接 return 致监听 goroutine 永久阻塞。
- **H-2 `app.Go` + `Init` 失败 goroutine 泄漏**app.go):`Init` 失败时 cancel rootCtx + 等 wg10s 超时兜底),通知已通过 `App.Go` 启动的后台 goroutine 退出。
- **H-12 `utils.HTTPClient.SetSkipTLS` 数据竞争**utils/http.go):加 `sync.RWMutex``SetSkipTLS`/`SetTimeout` 写锁下重建 client/transport 并释放旧 transport 空闲连接;`do`/`DoWithResponse`/`Close` 读锁快照后无锁调 `Do`。修复注释自承"需重建 Transport"却未重建的不一致。
#### 第三轮:P0 安全阻断 + P1 并发/资源收口(Claude 终审)
> 来源:claude_check_report_framework.md / claude_check_report_module.md。在 deepseek/GLM 修复后的版本上补审,闭合两者共同漏掉的 JWT 算法混淆等安全默认值问题。
- **#1 JWT 算法混淆(alg confusion**jwt/jwt.go):`ParseWithClaims` 统一传 `jwt.WithValidMethods(["HS256","HS384","HS512"])`keyfunc 内断言 `*jwt.SigningMethodHMAC`,拒绝 alg=none 及非 HMAC 算法(防公钥当 HMAC 密钥伪造 token)。回归 `TestParseTokenRejectsAlgNone`
- **#2 JWT 空密钥 fail-closed**jwt/jwt.go):新增 `secretKey()``GenerateToken*`/`ParseToken*` 空 secret 返 `ErrEmptySecret`。回归 `TestEmptySecretFailsClosed`
- **#3 JWT 不支持算法拒绝**jwt/jwt.go):`signingMethod` 改返 `(SigningMethod, error)`RS256 等返 `ErrUnsupportedAlgorithm`,不再静默回退 HS256。回归 `TestUnsupportedAlgorithmFailsClosed`
- **#4 上传大小实测封顶**storage/storage.go):新增 `enforceUploadSize`/`enforceMaxReader`,本地与 OSS 上传拷贝阶段按实际字节封顶,超限返 `ErrUploadTooLarge` 并清理部分落盘文件。修复原信任客户端 `file.Size` 的绕过。
- **#5 HTTP header/cookie map 竞态**utils/http.go):`SetHeader/SetHeaders/SetCookie` 加写锁,`do/DoWithResponse``snapshotHeadersCookies()` 读锁快照。
- **#6 HTTP SSRF 防护**utils/http.go):新增 `BlockPrivateNetworks` + `NewSSRFSafeHTTPClient()` + `SetBlockPrivateNetworks()`,经 `net.Dialer.Control` 在连接建立时拦截回环/私有/链路本地/元数据 IP,覆盖重定向每一跳。
- **#7#21 P1 并发/资源/泄露收口**console 写锁(`writeMu`);config 包级 `Load/LoadWithWatch` `pkgLoadMu` 串行化 + debounce timer StopCSRF `ShouldBindBodyWith`body 可重放);cron `StopWithTimeout`/`WithCron()` 接入 App 生命周期;database `m.cfg``getCfg/setCfg` 锁内读写;`isTransientDBError` 移除过宽 `"database"` 子串;router `applyOnce sync.Once` + 版本 map 排序;logger `filterSensitiveQuery` 脱敏;response `SetExposeDetail`(生产隐藏 Detail);validation 密码 MaxLength=72 + phone 全数字 + `RegisterValidation` `must` 检查;trace noop 包 + `defer span.End()` + `[unmatched]` 低基数 span 名;storage `NewLocalStorage` Abs fail-closedcmd/xlgo 项目名/模块路径校验 + `os.Exit(1)` + 失败回滚。
#### 第四轮:终审剩余项收口(last_report.md
> 来源:last_report.md 终审。在第三轮基础上逐项回读核实后修复,均经 `go test -race` 验证。
- **H-A 从库连接池上限截断**database/manager.go):`MaxOpenConns/2` 在配置为 1 时得 0`database/sql` 视 0 为无限制),与"从库减少"意图相反、资源失控。新增 `replicaMaxOpenConns()``>0``max(1, /2)``<=0` 与主库一致无限。
- **H-B `router.GroupWithMiddlewareGroup` nil panic**router/router.go):改走 `ensureRegistry()`(与其他全局 helper 一致),把未初始化 nil 解引用 panic 转成可定位错误。
- **M-A JWT 黑名单无超时 + 吞错**jwt/jwt.go):`Add`/`IsBlacklisted` 改用 `context.WithTimeout(1s)``blacklistCtx`),把鉴权路径阻塞上限收敛到 1s`IsBlacklisted``.Result()` 替代 `.Val()` 显式处理错误(fail-open 保留但记录告警)。
- **M-B `Recover` 响应已写出时无效写 500**middleware/recover.go):加 `c.Writer.Written()` 守卫,已写出时仅 Abort + 记录,避免流式/部分写场景下 `response.Custom` 沦为 no-op 致客户端收到截断响应。
- **M-C `RedisRateLimiter` 多次 `GetRedis()` nil-deref 窗口**middleware/ratelimit.go):`Allow`/`GetCount`/`Reset` 改为取一次 `rdb` 复用,消除两次调用间 `CloseRedis` 致第二次 nil-deref 的窗口。
- **M-D `handler.GetPage` 深分页 DoS**handler/handler.go):加 `page` 上限 `MaxPage=10000`(见 Breaking)。
- **M-E Redis 不可用失败语义统一**cache/lock.go,见 Breaking):锁操作返 `ErrRedisNotReady``IsLocked``.Result()` 返错(合并 L-G)。
- **M-F `HashFile`/`ReadFile` OOM 与 TOCTOU**utils/crypto.go, utils/file.go):`HashFile` 改流式 `io.Copy(h, f)`(恒定内存,可哈希任意大文件);`ReadFile``FileExists` 前置检查(见 Breaking)。
- **M-G `config.Get()` 返回内部指针切片别名**config/config.go,见 Breaking):新增 `Clone()``Load()` 与 reload 回调返深拷贝;`Get()` 保持热路径零分配返只读指针。
- **M-H `cron.checkAndRun` 持锁 spawn 阻塞管理 API**cron/cron.go):锁内只收集到期任务(CAS + 推进 NextRun + `wg.Add`),锁外 spawn。`wg.Add` 必须在锁内以保证 `Stop``wg.Wait` 等到本批(否则 Stop 可能在任务间提前返回、后启动 goroutine `wg.Done` 下溢 panic)。
- **L-A compress 解压残留文件**compress/compress.go):`GzipDecompressFileWithOptions`/`unzipFile` 失败时(超限/拷贝错误)`os.Remove` 部分落盘文件,避免被拒炸弹遗留残片。
- **L-B utils 正则重编译**utils/validator.go):`IsPhone`/`IsEmail`/`IsIPv4`/`IsIDCard` 正则提为包级 `MustCompile`,避免每次调用重编译。
- **L-C `EqualsIgnoreCase` 非 ASCII 误判**utils/strings.go,见 Breaking):改 `strings.EqualFold`
- **L-D `redisLimiters` 死代码**middleware/ratelimit.go):删除从未被读写的包级 `redisLimiters` map 与其 `init()`
- **L-E `logger.Logger` 导出变量**logger/logger.go):标 `Deprecated:`,引导用包级 `Info/Debug/...` 函数(atomic 读取,并发安全)。
- **L-H `ws.SetCheckOrigin` 无锁写**ws/ws.go):文档明确"仅启动前调用",运行期切换 Origin 用自有 upgrader 实例。
- **L-J `cron.RunTask``s.ctx`**cron/cron.go):文档说明 Stop 后 handler 收到 canceled ctx 的预期行为。
> **未修(评估后决定)**L-K `model` 时间戳 `omitempty`——`encoding/json` 对 `time.Time`(结构体)的 `omitempty` 是 no-op,报告建议的修复无效;真正的修复需改 `*time.Time`(破坏性 + nil 安全隐患),收益与成本不匹配,暂不做。L-I `response.writeResp` nil-c 守卫——nil `*gin.Context` 是程序员错误,panic 是恰当反馈,加静默守卫反而掩盖 bug。L-F/L-M/L-N/L-O 为文档/命名类,已就地注释说明或属设计取舍。
### 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 🐛
#### P3 清理第三批(MEDIUM/MINOR 收尾)
> 续前两批。本批为校验收紧、全局状态并发、Windows 控制台、trace 遗留、logger 级别、测试质量。`go test -race` + `go vet` + `gosec` 通过。
- **M5 身份证无校验位 + 用户名首字节**validation/validator.go):18 位身份证补 GB 11643-1999 校验位验证(`validateIDCardChecksum`),原仅查长度+格式可被任意构造通过;`username` 验证首字符改按 `[]rune` 取,避免非 ASCII 首字节误判(原 `rune(username[0])` 取字节)。
- **M13 cache globalKeyBuilder 无锁 + 无 sync.Once**cache/keybuilder.go):`globalKeyBuilder``sync.RWMutex` 读写保护,`GetKeyBuilder``sync.Once` 保证自动初始化只执行一次,消除 check-then-init 竞态。`SetPrefix` 补实例非并发安全注释。
- **M12 NewRedisCache 构造时快照 client**cache/cache.go):`redisCache` 不再构造时 `database.GetRedis()` 快照(Init Redis 之前构造则永久 nil no-op),改每次操作实时取 client,使"先构造后 Init Redis"顺序也能正确工作。
- **M18 trace 显式设 codes.Ok + Close 无 double-close 守卫**trace/trace.go):成功路径不再 `SetStatus(codes.Ok, "")`OTel 规范默认 UNSET,显式 Ok 会掩盖子 Span 错误状态);`Close``sync.Once` 保证只 Shutdown 一次,重复调用安全。
- **M19 logger 无法显式设级别**logger/logger.go):三个 coreapp/api/db/console)改共享 `zap.AtomicLevel`,新增 `LogManager.SetLevel`/`GetLevel` + 包级 `SetLevel`/`GetLevel`,支持运行期热切换日志级别。
- **M17 console_windows 死代码 + 着色句柄分裂**console/console_windows.go):移除从未被调用的 `EnableVirtualTerminal``printColor` 原对 `syscall.Stdout` 设置颜色、文本却写 `c.output`(非 stdout 时二者分裂),改为按 `c.output` 实际类型取句柄(`*os.File` 用 Fd,否则退化为纯文本)。
- **N2 repository_test 空壳**repository/repository_test.go):原全为注释空壳、CRUD 零覆盖,改为编译期断言 `var _ BaseRepository[T] = (*BaseRepo[T])(nil)` 锁定接口契约(实现与接口漂移即编译失败)。
- **N3 test MockStorage 签名不符 + SetupRouter 文档**test/test.go):`MockStorage.Upload` 签名对齐真实 `storage.Upload(file *multipart.FileHeader, subdir)`,新增 `UploadFromBytes` 对齐 `storage.UploadFromBytes``SetupRouter` 补文档说明刻意返回裸 `gin.New()`(不含框架中间件,由测试方控制)。
- **Added**`logger.LogManager.SetLevel`/`GetLevel``logger.SetLevel`/`GetLevel``validation.validateIDCardChecksum`(未导出)。无 breaking(新 API;身份证校验位收紧可能拒绝先前"格式正确但校验位错"的输入——这正是修复目的)。
#### P3 清理第二批(MEDIUM/MINOR 文档/正确性/校验)
> 续上一批。本批以正确性修复 + 文档/命名澄清为主,风险分级处理,`go test -race` + `go vet` + `gosec` 通过。
- **M4 datetime StartOfWeek DST 落错日**utils/datetime.go):原用 `t.Add(-N*24h)` 回退到周一,DST 切换日 24h ≠ 1 个日历日会落错日。改为按日历日 `time.Date(..., Day-(weekday-1), ...)` 计算,保留原时区。`ParseDateInt` 补注释说明非法输入会被 time.Date 静默规范化、调用方须校验。
- **M11 HealthCheck 同步 ping 无超时 + WriteQuery 命名误导**database/manager.go):包级 `HealthCheck()``sqlDB.Ping()``pingWithTimeout`(3s 超时,尊重调用方 ctx deadline),避免探针被慢/挂起的 DB 长期阻塞。`WriteQuery` 补注释说明其命名沿用历史、实际为读取语义(强制主库 read-your-writes)。
- **M9 DSN 密码不转义 + 时区硬编**config/config.go):MySQL DSN 密码改 `url.QueryEscape`Postgres DSN 密码改单引号包裹+内嵌单引号翻倍,避免含 `@`/`:`/空格/引号 的密码破坏 DSN。新增 `DatabaseConfig.Timezone` 字段(MySQL loc / Postgres TimeZone 可配,空则保持原默认 `Local`/`Asia/Shanghai` 向后兼容)。
- **M7 ToResponse 丢 Detail**response/error.go):`Error.ToResponse()``Detail` 放入 `data.detail`(非空时),不再丢失细节信息。
- **M14 timeout 软超时文档化**middleware/timeout.go):注释明确软超时语义——仅注入带 deadline 的 ctx,不主动中断 handler;纯 CPU/不查 ctx 的 handler 不生效,硬中断需配合 `http.Server.WriteTimeout` 或 handler 内 `select ctx.Done`
- **C3 收尾:生产者取消信号契约**(sse/sse.go):包文档化断连契约——框架消费循环已监听 `c.Request.Context()` 断连即退,但生产者(LLM 流)必须自行监听同一 ctx 在取消时停止,否则上游持续运行浪费算力。`StreamText` 注释已有,补包级 doc。
- **M20 生成器非法标识符 + fileExists 权限误判**cmd/xlgo):`make handler my-thing``cases.Title``My-ThingHandler`(非法标识符);新增 `sanitizeIdent` 把非字母数字转下划线再 Title,得 `MyThingHandler``fileExists` 注释澄清权限错误的判定语义。
- **N1 BaseModelWithTime 命名误导**model/base.go):补注释说明与 BaseModel 唯一区别是 `type:datetime`(部分 MySQL 丢毫秒),名字 "WithTime" 易误导,保留仅为兼容。
- **N4 Nl2br 死分支 + IsEmpty 文档不符**utils/):`Nl2br``case '\n'``r == '\r'` 半恒假(r 恒为 '\n')已清理;`IsEmpty` 文档原称支持 slice/map 但实现仅支持 string/[]byte/nil,文档修正为实际行为。
- **N6 sse KeepAlive 触发 onmessage**sse/sse.go):心跳由 `data: \n\n`(触发客户端 onmessage)改 SSE 注释行 `: ping\n\n`(不产生消息事件,更符合心跳语义)。
- **Added**`config.DatabaseConfig.Timezone`。无 breakingTimezone 零值保持原默认时区);DSN 密码转义对合法密码无影响(无特殊字符的密码转义后不变)。Postgres DSN 格式变更(password 加单引号)对下游 GORM 透明。
#### P3 安全/正确性轻量清理(一批 MEDIUM/MINOR
> 风险分级处理:安全与逻辑正确性项实际修复 + 针对性用例;纯文档/感知项仅加注释。全部经 `go test -race` + `go vet` + `gosec` 验证。
- **M15 requestid 头注入**middleware/requestid.go):原无条件信任客户端 `X-Request-ID`,可注入 CRLF 伪造响应头/日志。新增 `sanitizeRequestID`:仅接受可见 ASCII(0x20-0x7e)、无换行、长度 ≤128,非法则忽略并重新生成。合法 ASCII ID 仍沿用客户端值(向后兼容)。
- **C7/N7 ws CheckOrigin 默认 trueCSWSH**ws/ws.go):默认 `CheckOrigin` 由恒 `true` 改为同源校验(空 Origin 放行非浏览器客户端、否则要求 Origin host 与请求 Host 一致),防 Cross-Site WebSocket Hijacking。新增 `AllowOrigins(origins...)` 辅助多可信域名场景。**Breaking ⚠️**:原默认放行所有跨域 WS 连接,现拒绝跨域;依赖跨域 WS 的下游需用 `ws.SetCheckOrigin``ws.AllowOrigins(...)` 显式放行。
- **C5/N5 HTTPClient Upload FD 累积 + 响应体无上限**utils/http.go):`Upload` 循环内 `defer file.Close` 改为显式关闭,避免大批量上传累积 FD;`do``io.ReadAll(resp.Body)``io.LimitReader` 封顶(默认 32MB,可经 `HTTPClientConfig.MaxResponseBodySize` 配置,-1 不限),防异常服务端返回超大响应打爆内存。
- **M16/B18 压缩写侧 defer Close 吞错**compress/compress.go):`GzipCompressFile`/`Zip``defer gz.Close()`/`defer zipWriter.Close()`/`defer archive.Close()` 改为显式关闭并向上传播错误——flush 失败(归档损坏)不再被吞成成功返回。
- **M6 Download 中文文件名乱码**response/response.go):`Content-Disposition` 由直接拼接 `filename=` 改为 RFC 5987:同时给 ASCII 回退 `filename="..."` 与 UTF-8 百分号编码 `filename*=UTF-8''...`,中文等非 ASCII 文件名不再乱码。
- **M8 CodeDataAlreadyExists 状态不一致**response/mode.go):`CodeDataAlreadyExists` 在 ModeREST 下原落 200,与同语义的 `CodeDataConflict`(409) 不一致。映射到 409 Conflict。
- **M10 driver 拼写错误静默回退 MySQL**database/dialect.go):未注册驱动回退 MySQL 时新增 `logger.Warnf` 告警(含已注册驱动列表),避免拼错 driver 名(如 `postgrs`)静默回退导致连接错误难排查。
- **M2 AddQueries 实为 Set**utils/url.go):`AddQueries` 原用 `query.Set`(覆盖同名),与 `AddQuery`(追加)语义不一致。改为 `query.Add`,同 key 多值共存。
- **M3 file.go 路径穿越感知**utils/file.go):文件工具函数加包级文档警告——直接操作调用方路径不做穿越校验,不可信输入须调用方自行净化(框架 storage 包已做防护)。
- **Added**`ws.AllowOrigins``utils.HTTPClientConfig.MaxResponseBodySize`。**Breaking**`ws` 默认 CheckOrigin 收紧为同源(见 C7)。无配置/migration 变更(MaxResponseBodySize 零值默认 32MB)。
#### H6`repository/repository.go` BaseRepo 不接 GetDBFromContext + 读写分离失效 + 事务无法 join + 分页不一致(repository/repository.go, database/manager.go
`BaseRepo` 构造时捕获 `r.db`,所有方法 `r.db.WithContext(ctx)` 从不调 `database.GetDBFromContext` → 读写分离形同虚设(读全走主库)、外层 ctx 事务无法 join、`WithTransaction` 内方法走 `r.db` 拿不到事务。叠加 `Update``Save` 全列覆写(H6a)、`FindPage` 的 count+list 为两条独立语句高并发下 total/items 不一致(H6d)、`QueryBuilder` 终结方法未克隆且 `Count` 受残留 Limit/Offset 截断(H6e)。修复:
- **H6c 连接路由**:新增 `readConn(ctx)`/`writeConn(ctx)`,优先级为「外层 ctx 事务(`database.TxFromContext`> 本 repo 事务(`r.tx`> 路由 db > `r.db` 回退」。读走 `database.GetDBFromContext`(默认从库,支持 `UseMaster`/`UseReplica`),写走 `database.GetWriteDB()`(主库,不路由到只读从库)。`DefaultManager` 未初始化(如单测注入 sqlite)时回退 `r.db`,兼容下游 `NewBaseRepo[T](database.GetDB())`
- **H6c 事务 join**`BaseRepo` 新增未导出 `tx` 字段;`WithTransaction` 创建 `txRepo` 时注入 `tx`,其方法自动 join 事务。新增 `database.WithTx(ctx, tx)`/`TxFromContext(ctx)` 支持跨层/跨 repo join(外层 `database.TransactionWithContext` 拿到的 tx 经 `WithTx` 注入 ctx 后传给 repo 方法即可参与同一事务)。`WithTransaction` 签名**不变**。
- **H6a 局部更新**:新增 `UpdateFields(ctx, model, conds...)` 基于 `gorm.Updates`(struct 仅更新非零字段、map 可显式置零),避免 `Save` 全列覆写丢失更新/零值不可辨。`Update`(Save)保留并文档化其全列覆写语义。
- **H6b 软删除契约**`Delete` 文档化行为契约——`T` 内嵌 `gorm.DeletedAt`/`gorm.Model` 时软删除,否则硬删除(泛型类型约束无法编译期强制)。
- **H6d 分页一致性**`FindPage`/`FindPageOrdered`/`FindPageWhere`/`FindPageWhereOrdered` 的 count+list 包进单事务(同一快照),消除高并发下 total/items 不一致。
- **H6e QueryBuilder 克隆**:终结方法(`Find`/`First`/`Count`/`Page`)基于 `Session(&gorm.Session{})` 克隆,不污染 `qb.db``Count`/`Page` 的 count 额外 `Limit(-1).Offset(-1)` 剥离残留分页条件。文档标注 QueryBuilder 单次使用、非并发安全。
- **Added**`database.WithTx`/`database.TxFromContext``repository.BaseRepo.UpdateFields`。无既有 API 签名/配置/migration 变更(非 breaking)。行为变更:读操作默认路由到从库(原全走主库)、写操作显式走主库、分页查询包单事务(每页一次 BEGIN/COMMIT,见下)。
#### C10`config/config.go` 全局 Manager 无锁置换 + 热重载绕过 Validate + StopWatcher 空函数(config/config.go
- **C10a 全局 Manager 无锁置换**:包级 `defaultManager` 原为裸 `*Manager` 指针,`Load`/`LoadWithWatch`/`SetDefaultManager` 直接赋值,与 `Get`/`GetViper`/`GetString` 等请求 goroutine 的无锁读存在数据竞争。改为 `atomic.Pointer[Manager]`,所有包级便捷函数经 `Load()`/`Store()` 原子读写。
- **C10b 热重载绕过 Validate**`OnConfigChange``Reload` 原均不调 `Validate()`(仅 `Load` 调用),非法配置(坏端口、负超时、短密钥)直接发布。`Reload` 与文件监听路径统一走 `reload()`:读取/解析/校验任一步失败均保留旧配置并返回错误,仅新配置通过 `Validate` 后才替换 `m.cfg` 并触发回调。
- **C10c Load 返回可变指针**`Load` 原返回 `&cfg``m.cfg` 同一指针,调用方可变并竞争。改为返回防御性浅拷贝,调用方修改返回值不污染全局读取路径。
- **C10d StopWatcher 空函数**:原 `StopWatcher()` 为空,viper 内部 watcher goroutine + fd 永不释放。改为自管 `fsnotify.Watcher`(监听配置文件所在目录以兼容编辑器改写/k8s ConfigMap 原子替换,按文件名过滤 + 200ms 去抖),`StopWatcher` 关闭 watcher 并等待监听 goroutine 退出(`watchDone`),幂等。废弃 viper `WatchConfig`/`OnConfigChange`
- 无 API 签名/配置结构变更;行为变更(热重载非法配置保留旧配置而非发布、StopWatcher 真正释放监听资源)。
#### C11`database/manager.go` 池泄漏 + Master/Replicas 无锁读 + 健康状态陈旧(database/manager.go
- **C11b InitDB 重试泄漏**`InitDB` 原直接 `m.master = gorm.Open(...)``gorm.Open` 成功但 `Ping` 失败时旧池不关、下轮覆盖 `m.master`,每次重试泄漏一池。改为先打开到局部变量,仅 `Ping` 通过后才在锁内安装为 `m.master` 并关闭旧主库池;`Ping`/`DB()` 失败时关闭刚打开的池。
- **C11c InitDBWithReplicas 泄漏**:原 `m.replicas = nil` 前不关旧从库池,且从库 `DB()`/`Ping` 失败时 `continue` 不关刚打开的池。改为重建前在锁内取出旧从库、重置健康状态后逐个 `closeDB`;从库构建失败时关闭刚打开的池;新从库先构建到局部切片再原子安装。
- **C11a 健康状态陈旧**`initReplicaHealth``replicaHealthSet` 早返回使重新 `InitDBWithReplicas` 后健康切片与新 replicas 长度错位。新增 `resetReplicaHealth``InitDBWithReplicas`/`Close` 重建/关闭前调用,使下次 `initReplicaHealth` 按新 replicas 长度重建。
- **C11d Master/Replicas 无锁读**`Master()`/`Replicas()` 原裸读 `m.master`/`m.replicas`,与 `Close`/`InitDB` 写竞争。改为全程持 `m.mu` 锁;`Replicas()` 返回拷贝;`Replica()` 的空从库判断移入锁内;`FromContext`/`HealthCheck`/`Transaction`/`TransactionWithContext`/`WriteQuery`/包级 `HealthCheck` 改经 `Master()`/`Replicas()` 读取;`probeOnce` 快照 `replicaHealthy` 切片头避免与重置竞争。
- **C11f 包级 Close 仅关主库**:包级 `Close()` 原仅关 master 且无锁,命名误导致从库泄漏。改为委托 `CloseAll()`(关主+从并重置健康状态)。
- **C11e(非缺陷)**`RoundRobinPicker` `int(n-1)%len` 取模后仍在 `[0,len)` 内,无 panic/正确性问题;`RandomPicker` 全局 `math/rand` 仅锁竞争。属微优化,非功能 bug,未改。
- 无 API 签名/配置结构变更;行为变更(包级 `Close` 现关闭从库、`InitDB` 重试/重建不再泄漏旧池、`Master`/`Replicas` 加锁读取)。gosec G115/G404 为 `RoundRobinPicker`/`RandomPicker` 既有项(C11e 范围外)。
#### C9c`jwt/jwt.go` 包级 `DefaultJWT`/`tokenBlacklist` 无锁置换(jwt/jwt.go
`SetDefaultJWTManager` 原裸写包级 `DefaultJWT`/`tokenBlacklist`,与请求 goroutine`ParseToken`/`RefreshToken`/`InvalidateToken`/`InvalidateTokenByID`/`IsTokenRevoked``tokenBlacklist`)存在数据竞争(C9cC9a/b 已在 C9b 修复 fail-closed,此项是遗留并发隐患)。修复:
- 新增内部 `defaultManager atomic.Pointer[Manager]` 作真实存储,`init()` Store;包级函数经 `currentManager()`/`currentBlacklist()`(atomic 读取)访问,消除裸指针读写竞争。
- `SetDefaultJWTManager` 改用 `defaultManager.Store(m)` 原子置换;移除裸写的包级 `tokenBlacklist` 变量。
- `DefaultJWT` 保留为导出 `*Manager` 兼容别名(类型不变,非 breaking),由 `SetDefaultJWTManager` 同步维护;注释标注直接读 `DefaultJWT` 非并发安全,并发访问应用包级函数或 `SetDefaultJWTManager`
- 无 API 签名变更;`DefaultJWT` 类型不变(非 breaking)。行为变更:包级黑名单读写改经 atomic,`SetDefaultJWTManager` 可安全在请求期调用。
#### H3`middleware/logger.go` 请求/响应 body 无上限读 → OOMmiddleware/logger.go
`LoggerWithConfig``LogRequestBody:true` 时用 `io.ReadAll(c.Request.Body)` 无封顶读入内存,`MaxBodyLength` 仅在读完后截断**日志副本**,全 body 已驻留并二次 buffer——多 GB POST 可 OOM;响应侧 `bodyLogWriter.body` 同样无上限累积。默认 `LogRequestBody:false` 使默认安全,但 `LoggerForAPI`/`LoggerForDebug` 显式开启即暴露。修复:
- 请求体新增 `readBodyBounded(c, maxLen)``io.LimitReader(body, maxLen+1)` 仅向内存读入最多 `maxLen+1` 字节(+1 检测截断),通过 `io.MultiReader(已读前缀, 原始 body 剩余)` 复原 `c.Request.Body`——**下游处理器仍得完整请求体**;日志副本截断到 `maxLen`
- 响应体 `bodyLogWriter``maxLen` 字段,捕获缓冲区封顶;`Write`/`WriteString` 仍把完整响应写入下游 `ResponseWriter`,仅捕获缓冲区封顶。
- `LoggerWithConfig` 入口归一化 `MaxBodyLength``<=0` 时回退默认值(1024),确保请求/响应两侧捕获均有上限,消除手配 `MaxBodyLength:0` 时响应侧无上限的 OOM 残留路径。
- 无 API 签名/配置结构变更;行为变更:`MaxBodyLength` 现同时门控响应体捕获(此前响应侧无视该值无上限累积,属 bug 修正);`MaxBodyLength<=0` 不再意味"无上限",统一回退默认上限。
#### H7`logger/logger.go` 全局指针写有锁读无锁 + `Field.Duration` 签名与实现矛盾(logger/logger.go, logger/field.go
`Init`/`Close``m.mu`(实例锁)写包级 `Logger`/`sugar`/`apiLog`/`dbLog`,但 `Info`/`Error`/`APILog()`/`DBLog()`/`Sync` 等请求期函数无锁裸读——锁与被保护对象作用域错配(实例锁保护包级全局变量),热重载 re-Init/Close 与请求日志存在数据竞争(H7a)。另 `Field.Duration` 签名为 `func(key string, value interface{})``case zap.Field` 分支 `return v` 丢弃 `key`,签名与实现矛盾(H7b)。修复:
- **H7a**:新增内部 `loggerPtr`/`sugarPtr`/`apiLogPtr`/`dbLogPtr atomic.Pointer[...]` 作真实存储,`init()` Store 为 Nop`Info`/`Debug`/`Warn`/`Error`/`Fatal`/`Debugf`-`Fatalf`/`APILog`/`DBLog`/`Sync` 读路径统一经 `currentLogger()`/`currentSugar()`/`currentAPILog()`/`currentDBLog()`atomic Loadnil 防御回退 Nop),消除请求期裸读竞争。`Init`/`Close``m.mu` 下 Store atomic。
- **H7a 兼容别名**`Logger` 保留为导出 `*zap.Logger` 兼容别名(类型不变,非 breaking),由 `Init`/`Close``m.mu` 下同步维护;注释标注直接读 `Logger` 变量在 re-Init/Close 期间非并发安全,并发访问应用包级函数。
- **H7b**`Field.Duration` 签名改为 `func(key string, value time.Duration) zap.Field`,直接委托 `zap.Duration(key, value)`,类型安全、key 不再可能被丢弃。
- 顺带收紧 `os.MkdirAll` 日志目录权限 `0o755``0o750`(与 storage 目录权限一致,gosec G301)。
- 无 API 签名/配置结构变更(`Logger` 类型不变);行为变更:包级日志读路径改经 atomic,re-Init/Close 可安全与请求日志并发。`Field.Duration` 签名变更为**类型收紧**`interface{}``time.Duration`),旧调用方传 `time.Duration` 不受影响,传 `zap.Field` 等非 Duration 类型将编译失败(属修复目的)。
#### C12`cron/cron.go` 数据竞争 + 重叠执行 + 漂移 + Weekly 跳周 + cron 解析缺陷(cron/cron.go
`cron/cron.go` 存在 5 子项缺陷(C12a–C12e)。修复:
- **C12a 数据竞争**`runTask` 原无锁写 `LastRun`/`RunCount``GetTask`/`ListTasks` 返回 live 指针并发读 → data race。改为 `LastRun`/`RunCount`/`NextRun` 写入一律在 `s.mu` 写锁内;`GetTask`/`ListTasks` 返回拷贝快照(`cp := *task`)。
- **C12b 无重叠守卫**`checkAndRun` 每秒 tick,长任务跨 tick 被反复 spawn 同一任务并发执行。新增 per-task `running *atomic.Bool` 守卫,`checkAndRun``RunTask` 均经 `CompareAndSwap(false,true)` 占用,正在执行则跳过/返错。
- **C12c Interval 漂移**`NextRun` 原在 handler 完成后以 `time.Now()` 锚定,每周期累积 handler 时长。改为 `checkAndRun` spawn 前 `task.NextRun = task.Schedule.Next(task.NextRun)`(以上次 `NextRun` 锚定),`runTask`/`RunTask` 不再更新 `NextRun`
- **C12d Weekly 跳周**:原 `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻,当天目标未到点被跳一周。重写为 `((day-now)+7)%7` 加天数后 `!next.After(now)` 才 +7,当天未到点返回本周、已过返回下周。
- **C12e cron 解析缺陷**`parseInt` 忽略非数字逐位累积,`1-5,8` 因先判 `-` 被当范围(`parseInt("5,8")=58`)、`garbage`→0 误触发、`*/garbage`→step=0 匹配全部、周日 `7` 不匹配。重写 `matchField`:列表分支独立于范围分支(先按逗号拆,每项判 `*/n`/`a-b/n`/`a-b`/单值),全用 `strconv.Atoi` 返错;weekday `7→0`,范围 `lo>hi` 环绕;歧义范围 `0-7`/`7-0` 拒绝。新增 `ParseCronStrict(expr) (*FullCronSchedule, error)` 严格校验;当前 Unreleased 已进一步将 `ParseCron` 非法输入改为 fail-fast panic,旧版回退默认全 `*` 的兼容语义迁移到 `ParseCronOrDefault`
- 无 API 签名变更(`ParseCron` 仍返 `*FullCronSchedule``AddTask`/`RunTask`/`GetTask`/`ListTasks` 签名不变);新增 `ParseCronStrict`(非 breaking)。`Task` 新增未导出 `running` 字段(外部不可构造)。行为变更:`GetTask`/`ListTasks` 返回拷贝(修改返回值不影响内部状态);`RunTask` 占用守卫期间再次调用返"任务正在执行中"错误;长任务不再重叠;调度不漂移;Weekly 当天未到点不再跳周;cron 解析拒绝非法表达式。
#### C13`trace/trace.go` opt-in 即崩 + 未实现导出器/传播器 + Middleware 不更新 c.Requesttrace/trace.go
`trace/trace.go` 存在 5 子项缺陷(C13a–C13e)。修复:
- **C13a nil tracer panic**:包级 `tracer`/`tracerProvider` 原为裸指针,未 `Init` 即 nil`Middleware`/`StartSpan`/`StartSpanFromContext`/`GetTracer` 裸用 → 首个请求 panic。改为 `atomic.Pointer` + `init()` Store Noop 兜底,`getTracer()` 永不 nil`Init` 原子替换,`Close` Shutdown 后 Store 回 Noop(防 Close 后再用 panic)。`GetContext` 裸断言改 comma-ok。
- **C13b 未知导出器 + stdout 缺失**`createExporter` `default` 原返 `nil, nil``WithBatcher(nil)`,文档承诺的 `stdout` 未实现。新增 `case "stdout"`(官方 `stdouttrace` 包);`default``fmt.Errorf`(不再喂 nil)。
- **C13c OTLP 默认 HTTPS 无 WithInsecure**`Config``Insecure bool`(零值 false=TLSopt-in 明文,安全默认);`Insecure` 时 otlp-http/otlp-grpc 追加 `WithInsecure()`,对 `localhost:4318` 等明文 collector 不再握手失败。
- **C13d Middleware 不更新 c.Request**:原仅 `c.Set("otel_ctx", ctx)`,下游 `c.Request.Context()` 拿不到 span。补 `c.Request = c.Request.WithContext(ctx)`(保留 `c.Set` 兼容)。
- **C13e b3/jaeger 未实现**`createPropagator` 原仅 `w3c` + default 静默回落 W3C。新增 `case "b3"`contrib b3 propagator,单头+多头);`case "jaeger"` 映射 W3C TraceContext(现代 Jaeger agent 透传 W3C,不引入不稳定的 jaegerremix 模块);`default` 返错(不再静默回落);`Init` 在非法 propagator 时返错并回滚已创建 provider。
- 顺带修复 `resource.Merge` SchemaURL 冲突(`resource.Default()``semconv v1.24.0` schema 不一致致 `Init` 报错)——改用空 schema URL 合并属性。
- 新增依赖:`go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0``go.opentelemetry.io/contrib/propagators/b3 v1.43.0`(均与 OTel core v1.43.0 同版本族)。
- 无 API 签名变更(`Init`/`Middleware`/`StartSpan`/`GetTracer`/`Close` 签名不变);`Config` 新增 `Insecure` 字段(零值兼容,非 breaking)。行为变更:未 Init 不 panic;未知导出器/传播器返错;`Insecure` opt-in 明文;Middleware 更新 c.Requestb3 实现、jaeger 映射 W3C。`Propagator` 空字符串现按 `w3c` 处理(兼容)。
#### H5`handler` 业务码与 HTTP 状态混乱 + 丢失 RequestIDhandler/handler.go
`handler.BadRequest`/`handler.InternalError` 原直接 `c.JSON(http.StatusBadRequest/StatusInternalServerError, response.Response{...})`:硬编 HTTP 400/500 **绕过响应模式系统**ModeBusiness 下所有失败响应本应 HTTP 200,错误经 body code 表达),且不写 `RequestID`(对比 `response.writeResp` 在 mode.go:73 写入)——与 `response` 体系不一致、丢失链路追踪。这正是"handler 绕过 response 模式系统"反模式。修复:
- `BadRequest` 委托 `response.FailWithCode(c, CodeFail, msg)``InternalError` 委托 `response.ServerError(c, msg)`——复用 `writeResp` 路径,遵循当前 `Mode` 并写入 `RequestID`
- 无 API 签名变更;`net/http` 导入随之移除。行为变更见下方升级说明。
#### H4b`middleware/ratelimit.go` CustomRateLimit goroutine 泄漏(middleware/ratelimit.go
`CustomRateLimit` 每次调用 `NewRateLimiter`(启动一个 cleanup goroutine)但创建的 limiter 无任何句柄,`StopRateLimiters` 仅停止 `loginLimiter`/`apiLimiter`/`uploadLimiter` 不感知自定义限流器 → cleanup goroutine 永久泄漏。修复:
- 新增包级 `customLimiters []*RateLimiter` 登记表(受 `limitersMu` 保护);`CustomRateLimit` 创建后登记入表。
- `StopRateLimiters` / `InitRateLimiters``drainCustomLimiters()` 取出并停止已登记的自定义限流器,释放 cleanup goroutine。
- 无 API 签名变更;无行为变更(仅修复 goroutine 泄漏,限流语义不变)。`StopRateLimiters` 现可正确停止所有自定义限流器(与 H4a 报告建议一致)。
#### H4c`middleware/ratelimit.go` RedisRateLimiter fail-open + 裸断言(middleware/ratelimit.go
`RedisRateLimiter.Allow` 原有两个缺陷(H4c):
- **H4c-1 fail-open**Redis 错误时 `return true, err`(放行),中间件层 `err != nil → c.Next()` 同样放行——**含登录防爆破场景静默失效**(Redis 抖动窗口限流失效,攻击者可借机爆破)。无 fail-closed 选项。
- **H4c-2 裸断言**`result.(int64)` 无 comma-okRedis 返回非 int64 时 panic(当前 Lua 脚本恒返整数不会触发,属脆弱性)。
修复:
- **H4c-1**`RedisRateLimiter` 新增 `failClosed` 字段(零值 false=兼容默认 fail-open)。`Allow` 在 Redis 未启用/错误/断言失败时按策略决定:fail-closed 返 `(false, err)` 拒绝,fail-open 返 `(true, err)` 放行(兼容旧行为)。中间件层抽取 `redisLimitDecision`——不再无条件 fail-open,按 `allowed` 值决定:fail-closed 故障拒绝返 **503**`CodeServiceUnavailable`,区别于真实超限的 429),fail-open 故障放行。
- **H4c-2**`result.(int64)` 改 comma-ok,断言失败返 `ErrRedisRateLimiterUnexpectedResult`(按 failClosed 策略拒绝/放行)而非 panic。
- 新增构造函数 `NewRedisRateLimiterFailClosed`、切换方法 `SetFailClosed`、中间件 `RedisRateLimitFailClosed`/`CustomRedisRateLimitFailClosed`、导出错误 `ErrRedisRateLimiterUnavailable`/`ErrRedisRateLimiterUnexpectedResult`
- **`LoginRedisRateLimit` 改 fail-closed**(行为变更,见升级说明):登录防爆破场景 Redis 故障时拒绝,防限流静默失效。
- 无 API 签名变更(既有函数签名不变);`RedisRateLimiter` 新增未导出 `failClosed` 字段。
`Allow` 每次放行都 `v.lastSeen = time.Now()`,重置分支 `time.Since(lastSeen) > window` 对持续客户端永不成立 → count 单调累加,稳态客户端(低于 rate)被误限流,须静默满 window 才解锁(算例:rate=10/min、9 req/min 客户端也会被误限)。修复:
- `visitor.lastSeen` 改名 `windowStart`,语义改为"当前固定窗口起点",仅在新窗口开始时设置,放行时不变更。
- `Allow` 窗口过期时重置 count + 新 windowStart;放行 count++ 不更新 windowStart;超限拒绝。
- 新增 `nowFunc` 字段 + `SetNowFunc` 导出方法(默认 time.Now,测试可注入可控时钟,避免真实 Sleep flaky)。
- 注:固定窗口允许窗口边界突发(2×rate),如需平滑用 Redis 版滑动窗口。H4bCustomRateLimit goroutine 泄漏)/H4cRedis fail-open + 裸断言)属独立缺陷,后续跟进。
#### C9b`jwt/jwt.go` 刷新令牌撤销失败仍签发(jwt/jwt.go)
`RefreshToken` 原丢弃 `tokenBlacklist.Add` 错误仍 `return GenerateToken(...)`Redis 抖动时旧 token 未拉黑、新旧 token 双有效,形成会话固定窗口。叠加 C9a:`Add`/`IsBlacklisted``client==nil` 时静默 `return nil`/`false`,黑名单失效无信号。修复:
- **C9b**`RefreshToken``Add` 错误 `return "", fmt.Errorf(...)`fail-closed 不签发新 token。
- **C9a**`Add` 无 Redis 时返 `ErrBlacklistUnavailable`(新增导出错误),让 `RefreshToken`/`InvalidateToken`/`InvalidateTokenByID` 感知黑名单不可用并 fail-closed`IsBlacklisted` 无 Redis 仍返 false(验证侧 fail-open 是无 Redis 部署固有局限,文档约束安全场景必须启用 Redis)。
#### H1`utils/random.go` 不安全 RNG 且文档反向推荐(utils/random.go
`randPool``math/rand` + `time.Now().UnixNano()` 播种,`RandString`/`RandDigit`/`RandInt`/`RandInt64` 取自该池,非密码学安全、可预测(`-race` 下并发同纳秒取池实例甚至生成相同序列)。GUIDE.md 原主动推荐 `RandString(16)` 用于 token、`RandDigit(6)` 用于 OTP 验证码,使可预测性可被实际利用。修复:
- 新增 `RandStringSecure(n) (string, error)` / `RandDigitSecure(n) (string, error)`,基于 `crypto/rand` + `big.Int` 索引(拒绝采样无偏),不可预测;`n>1<<20``ErrRandInvalidLength` 保护熵池。
- `RandString`/`RandDigit` 加安全警示注释(禁止用于 token/OTP/重置码/会话 ID);保留用于非安全场景(测试数据、随机展示等)。
- GUIDE.md token/OTP 示例改用 Secure 版本并标注错误处理;高分函数表区分"随机(安全/非安全)";移除"sync.Pool 性能"误导宣传,改为并列说明。
- gosec G404randPool 的 math/rand)加 `#nosec` 留痕(非安全函数,安全场景用 Secure 版本)。
#### H2`utils/http.go` 默认关闭 TLS 校验(utils/http.go
`DefaultHTTPClientConfig.SkipTLSVerify` 原为 `true``NewHTTPClient()``HTTPGet`/`HTTPPost`/`HTTPPostJSON``DefaultHTTPClient()` 全部默认 `InsecureSkipVerify: true`,可被中间人攻击(MITM)。改为默认 `false`(校验 TLS);自签证书场景需显式 `SetSkipTLS(true)` 或配置 `SkipTLSVerify: true``SetSkipTLS` 注释补充安全警示。gosec G402 加 `#nosec` 留痕(默认 falseopt-in 跳过)。
#### H8:路由/注册中心全局单例无锁 + Apply 不幂等 + metrics 依赖调用顺序 + 三个 `/health` 行为不一(router/router.go, router/metrics.go, handler/handler.go, app.go
- **H8a 全局注册中心无锁 + 无 nil 守卫**:包级 `globalRegistry` 原为裸 `*Registry``Init` 写、`Use`/`RegisterModule`/`RegisterVersion`/`Apply` 等全局 helper 读存在数据竞争;且 `Init` 之前调用任意全局 helper 触发晦涩的 nil 解引用 panic。改为 `atomic.Pointer[Registry]`,读写均经 `Load()`/`Store()`;新增 `ensureRegistry()`,未初始化时以明确信息 panic`router: 全局注册中心未初始化,请先调用 router.Init(engine)`),把 nil 解引用转成可定位的初始化顺序错误。
- **H8b Apply 不幂等**`Registry.Apply` 原无幂等位,二次调用重复 `engine.Use` 装入全局中间件并触发 Gin 重复路由 panic。新增 `applied` 标记,二次及以后 `Apply` 直接返回,中间件与路由仅装入一次。
- **H8c metrics 依赖调用顺序**`RegisterMetricsRoute` 原用 `r.Use(middleware.Metrics())`Gin `engine.Use` 仅对其后注册的路由生效,先于其注册的路由不被采集(依赖调用顺序)。改为:`RegisterMetricsRoute` 仅注册 `/metrics` 暴露端点;采集中间件经新增 `Registry.SetMetricsMiddleware``Apply` 内作首个全局中间件装入,覆盖所有经注册中心注册的业务路由,不依赖注册顺序。`/metrics` 自身与 `/health` 等基础路由直接挂 engine、不经采集中间件,不被自采集(保留原意图)。
- **H8d 三个 `/health` 行为/响应体不一**`RegisterHealthRoute`(可 503)、`defaultModule`(恒 200 `{"status":"ok"}`)、`handler.HealthCheck`(恒 200 经 `response.Success` 包成 `{code,msg,data}` 信封)三处 schema/行为各异。抽取统一 `healthHandler(checks)``RegisterHealthRoute`/`RegisterReadinessRoute`/`defaultModule` 均委托之;`handler.HealthCheck` 响应体收敛为 `{"status":"ok"}`(不再走 response 业务信封),便于 K8s 探针直读。
- **H8d 收尾:defaultModule 与 Register* 并存重复路由 panicfootgun**`defaultModule`(经 `WithModules` 注册 `/health`+`/swagger/*any`)与 `RegisterHealthRoute`/`RegisterSwaggerRoutes`(经 `WithDefaultRoutes` 注册同名路由)并存时触发 Gin `handlers are already registered` panic。新增 `registerGETOnce(r, path, h)` 幂等注册辅助,`RegisterHealthRoute`/`RegisterLivenessRoute`/`RegisterReadinessRoute`/`RegisterSwaggerRoutes`/`RegisterMetricsRoute`/`defaultModule` 全部经之:(GET, path) 已存在则静默跳过,首次注册胜出。`*gin.Engine``Routes()` 精确预检(不吞 panic,真正不同的路由冲突仍按 gin 原语义 panic);`*gin.RouterGroup`(gin 未暴露 engine,无法预检)用 recover 兜底,仅吞 gin 重复路由 panic(`already registered` / `conflicts with existing wildcard`),最坏情况退化为原行为。
- **Breaking ⚠️**`handler.HealthCheck` 响应体由 `{code,msg,data:{status:"ok"}}` 改为 `{"status":"ok"}`,与 `router.RegisterHealthRoute` 同 schema。直接断言旧信封字段的下游需改断言 `status` 字段。需依赖探活(mysql/redis 失败 503)时改用 `router.RegisterHealthRoute(checks...)`
- **Changed**:框架基础路由注册(health/livez/readyz/swagger/metrics/defaultModule)改为幂等,重复注册静默跳过(首次胜出)——消除并存组合的 panic footgun,非破坏性(原本重复注册即 panic,现安全跳过)。
- **Added**`router.Registry.SetMetricsMiddleware``router.registerGETOnce`/`ensureRegistry`/`healthHandler`(未导出)。无配置/migration 变更。
#### C3`sse/sse.go` 断连泄漏 goroutine + 算力(AI 主场景,sse/sse.go
- **C3b 写/Flush 错误被吞**`WriteEvent`/`WriteMessage` 原丢弃 `fmt.Fprintf` 错误且恒 `return nil`,导致 `StreamText` 等的 `if err := WriteJSON(...); err != nil` 守卫只对 marshal 失败生效、对客户端断连永不触发 → 消费循环不退出 + 上游 LLM 流持续运行直到进程结束。改为传播 `fmt.Fprintf` 写错误。
- **C3a 循环无 ctx.Done**`Stream`/`StreamText`/`StreamChunks`/`StreamWithID``for range ch``for { select { case <-ctx.Done(): return ctx.Err(); case v,ok:=<-ch: ... } }`,客户端断连即退出。`SSEWriter``ctx` 字段(NewSSEWriter 存 `c.Request.Context()`),并对 nil ctx 回退 `context.Background()` 防御。
- **C3c 手设 chunked 头**:删除 `Transfer-Encoding: chunked`HTTP/1.1 冗余、HTTP/2 非法),交由 server 自动分帧。
- 生产者契约文档化:`StreamText` 注释说明生产者应监听 `c.Request.Context()`,取消时停止上游 LLM 流(框架无法单方面停止生产者)。
#### C7`middleware/cors.go` 通配符后缀绕过 + 开发态任意 Origin 回显(middleware/cors.go
- **C7a 通配后缀绕过**`*.example.com` 原用 `strings.HasSuffix(origin, domain)` 未锚定 host 边界,`https://notexample.com``https://evil-example.com` 被接受。改用 `net/url` 解析 origin 的 host,要求 host 以 `.domain` 结尾(真实子域边界)且不等于 apex 自身。抽取 `matchOrigin` 函数(精确匹配 + 通配子域,大小写不敏感、支持端口与 FQDN 尾点)。
- **C7b 开发态任意 Origin 回显**:开发态原无条件回显任意 Origin,若同时 `AllowCredentials=true` 构成凭据型反射。改仅对 localhost/127.0.0.1/::1 回显(`isLocalhostOrigin`),杜绝任意站点携凭证访问。
- **C7 收尾(信息泄露收敛)**:未匹配 origin 时不再发送 `Access-Control-Allow-Methods`/`Allow-Headers`/`Expose-Headers`/`Max-Age`,避免向未授权 origin 暴露 API 允许的方法/头清单。这些头现仅在 origin 匹配时随 `Allow-Origin` 一并发送。
#### C1`cache/lock.go` 分布式锁 panic/泄漏/裸断言(cache/lock.go
- **C1a `WithLockAutoExtend` send-on-closed panic + 锁泄漏**:续期改"父关停 + 子 ack"双 channel`close(stop)` + `<-finished`),消除旧 `done <- struct{}{}` 向已关闭 channel send 的 panic。`Unlock``context.WithTimeout(context.Background(), 5s)` 派生超时,避免原 ctx 已取消致解锁失败再泄漏。`fn()` panic 路径加 `defer` 兜底,保证 panic 时也停止续期 goroutine 并释放锁(独立复审发现 CRITICAL)。
- **C1a 一致性(HIGH**`WithLock``defer Unlock` 同改 Background 超时 ctx + defer 兜底,与 `WithLockAutoExtend` 一致。
- **C1b 裸类型断言**:新增 `toInt64(v)` 辅助函数(comma-ok),`NewLock`/`Unlock`/`ExtendLock` 三处 `result.(int64)` 改用之,断言失败返 `ErrLockUnexpectedResult` 而非 panic。新增导出错误 `ErrLockUnexpectedResult`
- **C1c `TryLock` 忽略 ctx**`time.Sleep``select { ctx.Done()/time.After }`,响应取消。
- **C1d 无 fencing token**`LockToken` 文档化设计局限(需 Redis INCR + 下游校验,框架无法单方面保证),不引入破坏性数据结构变更。
#### C2`ws` Hub 死锁 + send-on-closed panic + 半开连接泄漏(ws/ws.go
- **C2a 广播死锁**`Hub.Run` 的 broadcast 分支原在 `conn.Send` 失败时 `h.unregister <- conn`(向自身消费的 channel 发送,无接收者)→ 永久阻塞、整个 Hub 卡死。改持写锁单次遍历,失败连接行内 `delete + conn.Close()`,去掉 channel 回环。`Send` 改非阻塞投递(缓冲满返回 `ErrSendBufferFull`),避免持写锁期间因慢消费者/已死连接阻塞最长 `pongWait` 导致 Hub stallC2a-residual)。
- **C2b send-on-closed panic**`Close()` 不再 `close(c.send)`,仅 `close(c.closeChan)` + `c.conn.Close()``Send` 前置 `IsClosed()` 快速失败 + select 兜底。消除 `Close` 与并发 `Send` 的 send-on-closed panic。
- **C2c 半开连接泄漏**`Handle` 读循环前置 `SetReadDeadline(pongWait)` + `SetPongHandler`(重置读 deadline);`writePump` 每次写前 `SetWriteDeadline(writeWait)`、ping 周期 `pingPeriod = pongWait*9/10`;写失败主动 `Close` 触发读循环退出,加速半开连接回收。新增常量 `pongWait=60s`/`pingPeriod=54s`/`writeWait=10s` 与导出错误 `ErrSendBufferFull`
#### C5`compress` Zip-Slip + 解压炸弹(compress/compress.go
- **C5a Zip-Slip**`unzipFile` 改用 `filepath.Join` + 前缀锚定(`absDst+sep`),拒绝条目名含 `..` 逃逸、绝对路径、以分隔符开头;拒绝符号链接条目(`ModeSymlink`)防经软链二次穿越。修复前 `file.Name` 可含 `../``os.Create` 覆盖任意文件。
- **C5b 解压炸弹**`GzipDecompress``io.ReadAll``io.LimitReader``GzipDecompressFile`/`Unzip``io.Copy``io.CopyN` 单条目封顶 + Unzip 累计封顶。新增 `DecompressOptions{MaxBytes, MaxTotalBytes}`0=默认,-1=不限)与 `*WithOptions` 变体;默认单流/单条目 100MB、Unzip 累计 1GB。
#### C4`storage` 路径穿越 + 无上传校验 + Get OOMstorage/storage.go
- **C4a 路径穿越**Local 的 `Delete/Get/Exists/Upload/UploadFromBytes` 的相对路径全经新增 `safeJoin` 前缀锚定(`rootAbs+sep`),拒绝绝对路径/NUL/`..` 逃逸,杜绝任意文件删/读/探测与任意目录写。OSS 的 `Delete/Get/Exists/Upload/UploadFromBytes` 全经新增 `sanitizeObjectKey` 拒绝含 `..`/绝对路径/空/NUL 的 key。
- **C4b 上传校验**:新增可配置 `UploadPolicy{MaxSizeBytes, AllowedExts, AllowedMIMEs}`(嵌入 `local`/`oss` 配置)。`AllowedMIMEs` 非空时用 `http.DetectContentType` 嗅探前 512B(取主类型比较)并拼回头部。零值不限(兼容下游)。
- **C4c Get 读封顶**Local `Get``os.ReadFile` 改为 `io.LimitReader` 封顶,OSS `Get` 同理;默认上限 100MB`max_read_bytes`:0=默认,-1=不限,正数=该值),防全量读入内存 OOM。
- **HIGH(跨平台)**OSS object key 拼接由 `filepath.Join`Windows 产 `\`)改 `path.Join` + `sanitizeObjectKey` 归一化 `\``/`,保证 Windows/Linux 部署 key 一致。
- 附:`MkdirAll` 权限 0755→0750gosec G301)。
### 升级说明 🛠️
- **H6 行为变更(非破坏性,正向修复)**:
- `BaseRepo` 读操作(`FindByID`/`FindAll`/`FindPage`/`FindWhere`/`Count`/`Exists`/...)默认路由到**从库**(原全部走构造时捕获的主库),支持 `database.UseMaster(ctx)`/`UseReplica(ctx)` 显式路由。未配置从库时仍走主库(`Replica()` 无从库回退主库)。
- `BaseRepo` 写操作(`Create`/`Update`/`Delete`/`*Batch`/`Restore`/...)显式走**主库**,即便 ctx 标记 `UseReplica` 也不写到从库。
- `FindPage*` 的 count+list 现包进单事务(每页一次 BEGIN/COMMIT)以保证 total/items 快照一致。高频分页接口会有极小额外往返开销;若不可接受可自行用 `QueryBuilder.Page`(轻量、不包事务)。
- 跨层/跨 repo 事务 join:外层 `database.TransactionWithContext`(或任意 `*gorm.DB` 事务)中,用 `database.WithTx(ctx, tx)` 注入 ctx 后传给 `BaseRepo` 方法即可参与同一事务。
- 新增 `BaseRepo.UpdateFields`(局部更新,推荐替代 `Update` 的全列覆写);`Update``Save`)行为不变但文档化其全列覆写语义。
- `QueryBuilder` 标注为单次使用、非并发安全;终结方法现克隆不污染构建器,`Count`/`Page` 的 count 剥离残留 Limit/Offset。
- 无 API 签名/配置/migration 变更;下游 `NewBaseRepo[T](database.GetDB())` 用法完全兼容。
- **C8 行为变更(非破坏性)**:panic 响应的 HTTP 状态由 200ModeBusiness)改为 500body 不变(`code:500` + msg + `request_id`)。ModeREST 行为不变。下游若按"panic 返 200"做适配(极罕见)需注意。已知局限:若 handler 在 panic 前已 flush 部分响应,HTTP 状态无法再改写(HTTP 固有局限,非本次引入)。
- **C6 行为变更**API 模式 CSRF token 改为单次消费(每次成功 POST 后需重新 `GenerateAPIToken`+ 30min TTL`DoubleSubmitCookie` 的 cookie `HttpOnly` 由 true 改 false。原 API 模式整体不可用,故无真实回归。
- **C4 行为变更(非破坏性)**:
-`..`/绝对路径的 `Delete/Get/Exists` 路径现被拒绝(`ErrPathTraversal`),合法相对路径不受影响。
- `Get` 默认读取上限 100MB,超限返回错误;需读大文件请配置 `storage.local.max_read_bytes: -1`(不限)或具体值。OSS 同理(`storage.oss.max_read_bytes`)。
- 上传目录权限 0755→0750(仅 owner/group 可访问)。
- 新增可选配置 `storage.local.upload` / `storage.oss.upload``max_size_bytes`/`allowed_exts`/`allowed_mime_types`),零值不限制以兼容现有下游;生产环境建议显式配置。
- 安全约束:本地存储根目录应为框架独占,不与用户可控内容混用(防符号链接二次穿越)。
- **C5 行为变更(非破坏性)**:
- `Unzip` 现默认拒绝含 `..`/绝对路径的条目(`ErrPathTraversal`)与符号链接条目(`ErrSymlinkEntry`),合法归档不受影响。
- `GzipDecompress`/`GzipDecompressFile`/`Unzip` 默认解压上限:单流/单条目 100MB、Unzip 累计 1GB,超限返回 `ErrDecompressLimit`。需解压更大文件用 `*WithOptions` 变体设 `MaxBytes: -1`(不限)或具体值。
- 原函数签名保留;新增 `GzipDecompressWithOptions`/`GzipDecompressFileWithOptions`/`UnzipWithOptions`
- **C2 行为变更(非破坏性)**:
- `Connection.Send` 改为非阻塞投递:发送缓冲满时返回 `ErrSendBufferFull`(新导出错误)而非阻塞等待。原阻塞语义的下游需改为重试或关闭连接。
- `Hub` 广播对发送失败(含缓冲满/已关闭)的连接行内移除并关闭——慢消费者会被踢除(ws 广播 best-effort 语义)。
- WebSocket 连接现启用读写超时与 ping/pong 心跳:半开连接在 `pongWait`(60s)内退出,不再永久阻塞 goroutine。
- 公共 API 签名不变;`Connection.send` 不再被 close(内部行为)。
- **C1 行为变更(非破坏性)**:
- `WithLockAutoExtend`/`WithLock` 的解锁改用独立 `context.Background()` 超时(5s),原 ctx 已取消也能解锁(语义:取消业务 ≠ 继续独占锁)。
- `WithLockAutoExtend` fn panic 时仍释放锁(defer 兜底)。
- `TryLock` 重试等待响应 ctx 取消。
- 新增导出错误 `ErrLockUnexpectedResult`Lua 返回非 int64)。
- **C7 行为变更(非破坏性,收紧)**:
- `*.example.com` 通配不再匹配 `notexample.com`/`evil-example.com` 等后缀相同但非真实子域的域名;apex `example.com` 不由通配覆盖,需显式配置。
- 开发态 CORS 兜底仅对 localhost/127.0.0.1/::1 回显 Origin,不再回显任意 Origin。原本依赖开发态回显任意域名的下游需改用显式白名单。
- 未匹配 origin 的响应不再携带 `Access-Control-Allow-Methods`/`Allow-Headers`/`Expose-Headers`/`Max-Age`(信息泄露收敛);匹配时正常发送。
- **C3 行为变更(非破坏性)**:
- `StreamText`/`StreamChunks`/`StreamWithID`/`Stream` 在客户端断连(`c.Request.Context()` 取消)时返回 `context.Canceled` 而非永久阻塞。
- `WriteEvent`/`WriteMessage` 现可能返回写错误(旧实现恒 nil);下游若忽略返回值不受影响。
- 响应不再手设 `Transfer-Encoding: chunked`
- 公共 API 签名不变;`SSEWriter` 新增私有 `ctx` 字段(外部字面量构造需走 `NewSSEWriter`)。
- **H2 行为变更(可能影响下游)**:`utils` HTTP 客户端默认**校验 TLS**`DefaultHTTPClientConfig.SkipTLSVerify``true``false`)。`HTTPGet`/`HTTPPost`/`HTTPPostJSON`/`NewHTTPClient()` 不再默认跳过证书校验。下游访问**自签证书**的内网/开发服务会因证书校验失败报错,需显式 `client.SetSkipTLS(true)``NewHTTPClientWithConfig(HTTPClientConfig{SkipTLSVerify: true})`。生产环境应保持默认校验。
- **H1 行为变更(Breaking,删除函数)**:
- **删除** `utils.RandString` / `RandDigit`(math/rand 版本)。字符串随机的用途几乎都是安全场景(token/OTP/验证码/会话 ID),保留 math/rand 版本会诱导误用(H1 的根因正是 GUIDE 推荐 RandString 做 token)。下游迁移:
- token/OTP/验证码/会话 ID → `RandStringSecure` / `RandDigitSecure`crypto/rand)。
- 非安全场景需高性能随机串 → 直接用标准库 `math/rand`
- **保留** `RandInt` / `RandInt64`(范围随机,有明确非安全场景:负载均衡/游戏/A-B 分桶),加非安全警示注释。
- 新增 `RandStringSecure` / `RandDigitSecure`(基于 `crypto/rand`,返 `(string, error)`)与 `ErrRandInvalidLength`
- 新增 `RandIntSecure` / `RandInt64Secure`(基于 `crypto/rand` + `big.Int` 拒绝采样无偏,返 `(T, error)`),用于安全 nonce 范围、防猜抽奖、密钥分桶等。
- GUIDE 示例改用 Secure 版本。
- **C9b 行为变更(可能影响下游)**:
- `jwt.RefreshToken` 在旧 token 撤销失败(Redis 不可用/抖动)时**不再签发新 token**fail-closed),返回错误。原行为是丢弃错误仍签发,致新旧 token 双有效。
- `jwt.InvalidateToken`/`InvalidateTokenByID`/`TokenBlacklist.Add` 在无 Redis 时返回 `ErrBlacklistUnavailable`(新增导出错误),不再静默成功。
- `IsBlacklisted` 无 Redis 仍返 false(验证侧 fail-open,无 Redis 部署固有局限——安全敏感场景必须启用 Redis)。
- 签名不变;新增 `ErrBlacklistUnavailable`
- **H4a 行为变更(非破坏性)**:内存限流器 `RateLimiter` 改固定窗口语义,稳态客户端(低于 rate 的持续请求)不再被误限流。`Allow`/`NewRateLimiter`/`Stop` 签名不变;新增 `SetNowFunc`(测试用可控时钟)。
- **H5 行为变更(非破坏性)**`handler.BadRequest`/`handler.InternalError` 不再硬编 HTTP 400/500,改为遵循当前响应模式(委托 `response` 体系):默认 `ModeBusiness` 下两者均返回 HTTP 200(错误经 body `code` 表达,与 `response.Fail*` 一致);`ModeREST``InternalError` 返回 500`CodeServerError` 映射),`BadRequest` 返回 200`CodeFail` 属业务失败不映射 HTTP 错误,与 `response.Fail` 一致)。两者现写入 `RequestID`。下游若依赖 `handler.BadRequest` 恒返 400 需改用 `response.Custom(c, 400, code, msg, nil)` 或业务自定义 4xxxx 错误码。
- **H4c 行为变更(可能影响下游)**:
- **`LoginRedisRateLimit` 改 fail-closed**Redis 故障/未启用时由放行改为拒绝(HTTP 503,`CodeServiceUnavailable`)。登录防爆破场景下 Redis 故障不再静默放行(原 fail-open 致限流失效)。下游登录接口须确保 Redis 可用,否则登录会在 Redis 故障时不可用(安全语义:宁拒勿放)。
- 其余 Redis 限流中间件(`RedisRateLimit`/`APIRedisRateLimit`/`UploadRedisRateLimit`/`CustomRedisRateLimit`/`RedisRateLimitWithIdentifier`)保持 fail-open(兼容默认)。
- 新增 fail-closed 变体:`RedisRateLimitFailClosed`/`CustomRedisRateLimitFailClosed`/`NewRedisRateLimiterFailClosed`/`SetFailClosed`,供安全敏感场景选用。
- 新增导出错误 `ErrRedisRateLimiterUnavailable`/`ErrRedisRateLimiterUnexpectedResult`
- 无既有 API 签名变更。
- 无 API 签名变更、无 migration。新增测试依赖 `github.com/alicebob/miniredis/v2`
---
## [1.1.1] - 2026-06-23
> 本版本为 v1.1.0 的补丁发布:补 ServerConfig.Host 字段、统一面向用户文案为中文、修正 README 过时/错误描述。
### Added ✨
#### ServerConfig.Host(绑定地址)
`server` 新增 `host` 字段,控制监听地址:
- `host: ""`(默认)→ `:8080`,监听所有接口(0.0.0.0),向后兼容
- `host: "127.0.0.1"``127.0.0.1:8080`,仅本机(前面有 nginx 时常用)
- `host: "10.0.0.5"` → 绑定内网网卡
避免生产环境无意暴露在 0.0.0.0。启动日志相应区分"所有接口"/指定地址。
### Changed 🔄
#### 面向用户文案统一中文
v1.1.0 前部分面向用户/调用的文案为英文,与其余中文文案不一致。本次统一为中文:
- `middleware/recover.go``"Panic recovered"``"panic 已恢复"``"Panic: %v"``"服务器内部错误: %v"`(消除同文件内中英矛盾)
- `middleware/logger.go`:5 处日志消息(慢请求/请求错误/客户端请求错误/API 请求/请求)改中文
- `middleware/metrics.go`3 个 Prometheus `Help` 文本改中文
- `app.go` / `database/manager.go` / `logger/logger.go`:英文 error 改中文
**保留英文**(非文案,属协议/约定/技术必需):JSON 字段名(`code`/`msg`/`data`)、health 探针状态枚举(`ok`/`error`/`disabled`)、Prometheus metric `Name`(命名规则限制)、`database/manager.go` 中匹配 MySQL 驱动错误串的英文(`"Access denied"` 等,改了会失效)、Redis/CSRF Token/JWT/OSS 等技术专有名词。
### Fixed 🐛
#### README 错误描述修正
v1.1.0 后 README 存在过时/错误描述,照抄会导致新用户启动失败,本次修正:
- **删除目录结构里已移除的 `wire/` 段**wire 包 v1.1.0 已删)
- **快速开始配置示例**`jwt.secret` 补足 ≥32 字节(否则被 v1.1.0 `Validate` 拦截启动失败);`expire: 86400`int 秒)改为 `expire: "24h"``time.Duration`),补 `refresh_expire`/`issuer`/`algorithm`
- `server` 段补 `host`/`read_timeout`/`write_timeout`/`idle_timeout`/`shutdown_timeout`/`response_mode` 字段
- v1.0.2 更新日志标注 `WithWire` 已于 v1.1.0 移除
- 目录结构补 v1.1.0 新文件:`middleware/metrics.go``middleware/timeout.go``router/metrics.go``config/validate.go``response/mode.go`
- 框架特性段重写为三组(架构可注入 / 生产就绪 / 基础功能),补全 v1.1.0 能力
- 响应格式段补 `Mode` 开关(`ModeBusiness`/`ModeREST`)与 `Custom` API
#### README 首段重写
原首段描述("轻量级 Web 开发框架,提供完整后端基础设施")过于普通化,适用于多数 Gin 脚手架,无辨识度。重写为:
- tagline 点明核心差异:组件全部 Manager 化,简单调用与注入实例兼得
- "为什么是 xlgo"段:对比一般 Gin 脚手架的包级单例痛点 + 对照代码
- 5 条差异化卖点(可注入 / 生产就绪内置 / 零 Fatal / 默认轻量 / 可插拔方言)
- 30 秒上手极简可跑示例
### 升级说明 🛠️
从 v1.1.0 升级无破坏性变更,`go get github.com/EthanCodeCraft/xlgo-core@v1.1.1` 即可。`host` 字段默认空,行为与 v1.1.0 一致。
---
## [1.1.0] - 2026-06-23
> 本版本定位为 **HA & Manager 化 release**:高可用与生产就绪改进 + 组件 Manager 化。对应体检报告 #10-#24。
@@ -215,8 +829,8 @@ database.InitMySQL(cfg)
database.InitMySQLWithReplicas(cfg, replicas)
// ✅ 新(驱动由 cfg.Database.Driver 决定,可以是 mysql / postgres / 自定义注册的方言)
database.InitDB(cfg)
database.InitDBWithReplicas(cfg, replicas)
database.InitDB(ctx, cfg)
database.InitDBWithReplicas(ctx, cfg, replicas)
```
**为什么现在动手**
@@ -516,7 +1130,9 @@ v1.0.2 引入可插拔方言注册表后,`gorm.io/driver/postgres` 成为直
- 基础框架功能
- 完整示例代码
[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.1.0...HEAD
[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.2.0...HEAD
[1.2.0]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.1.1...v1.2.0
[1.1.1]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.1.1
[1.1.0]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.1.0
[1.0.4]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.4
[1.0.3]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.3
+170 -48
View File
@@ -111,7 +111,6 @@ app:
env: "dev" # dev/test/prod
debug: true
base_url: "http://localhost:8080"
token_expire: 86400 # Token过期时间(秒)
server:
port: 8080
@@ -134,7 +133,7 @@ redis:
jwt:
secret: your_jwt_secret_key
expire: 86400
expire: "24h" # time.Duration,如 "24h"/"30m"
log:
dir: ./logs
@@ -288,7 +287,7 @@ xlgo 基于 GORM,驱动由配置 `database.driver` 决定。v1.0.2 起 GORM
```go
import "github.com/EthanCodeCraft/xlgo-core/database"
// 初始化数据库(驱动由配置决定,等价于 database.DefaultManager.InitDB(cfg)
// 初始化数据库(驱动由配置决定,等价于 database.DefaultManager.Load().InitDB(cfg)
database.InitDB(cfg)
// 关闭全部连接(含从库)
@@ -335,6 +334,8 @@ mgr.SetPicker(&database.RoundRobinPicker{})
db := mgr.FromContext(ctx)
```
`database.SetDefaultManager(m)` 会把新 manager 提升为全局默认,并关闭被替换的旧 manager,避免连接池泄漏。需要失败回滚或延迟释放旧资源时,请使用 `database.SwapDefaultManager(m)` 并自行关闭其返回的旧 manager。
### 4.1.3 注册自定义 GORM 驱动
`database.RegisterDialect` 一次注册即让 `database.driver: <name>` 生效,DSN 构建器同步登记到 `config` 包:
@@ -393,13 +394,23 @@ import "github.com/EthanCodeCraft/xlgo-core/repository"
// 创建仓库
userRepo := repository.NewBaseRepo[model.User](database.GetDB())
// 连接路由(v1.1.1+ 修复 H6c):
// - 读操作(FindByID/FindAll/FindPage/FindWhere/Count/Exists/...)默认路由到从库,
// 支持 database.UseMaster(ctx)/UseReplica(ctx) 显式路由;未配置从库时回退主库。
// - 写操作(Create/Update/Delete/*Batch/Restore/...)始终走主库,不路由到只读从库。
// - DefaultManager 未初始化(如单测注入 sqlite)时回退到 NewBaseRepo 传入的 db。
// 基础 CRUD
user, err := userRepo.FindByID(ctx, 1)
users, err := userRepo.FindAll(ctx)
users, err := userRepo.FindByIDs(ctx, []uint{1, 2, 3})
err := userRepo.Create(ctx, &user)
err := userRepo.Update(ctx, &user)
err := userRepo.Delete(ctx, 1)
err := userRepo.Update(ctx, &user) // Save 全列覆写(含零值),见下方 UpdateFields
err := userRepo.Delete(ctx, 1) // T 含 gorm.Model 时软删除,否则硬删除
// 局部更新(推荐,H6a):避免 Save 全列覆写丢失更新/零值不可辨
err := userRepo.UpdateFields(ctx, &model.User{Name: "new"}, "id = ?", id) // struct 仅更新非零字段
err := userRepo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map 可显式置零
// 统计数量
count, err := userRepo.Count(ctx)
@@ -421,7 +432,7 @@ result, err := userRepo.FindPageOrdered(ctx, 1, 20, "created_at DESC")
// 条件查询
user, err := userRepo.FindOne(ctx, "email = ?", "test@example.com")
users, err := userRepo.FindWhere(ctx, "status = ?", 1)
users, err := userRepo.FindWhereOrdered(ctx, "status = ?", 1, []any{}, "created_at DESC")
users, err := userRepo.FindWhereOrdered(ctx, "created_at DESC", "status = ?", 1)
// 排序查询
users, err := userRepo.FindOrdered(ctx, "created_at DESC", 10)
@@ -442,7 +453,7 @@ err := userRepo.RestoreBatch(ctx, []uint{1, 2})
// 查询已删除的记录
deletedUsers, err := userRepo.FindDeleted(ctx)
// 链式查询(灵活构建)
// 链式查询(灵活构建QueryBuilder 为单次使用、非并发安全
users, err := userRepo.NewQueryBuilder().
Where("status = ?", 1).
Where("role = ?", "admin").
@@ -465,6 +476,15 @@ err := userRepo.WithTransaction(ctx, func(txRepo *repository.BaseRepo[model.User
// 更新关联数据
return txRepo.Update(ctx, &profile)
})
// 跨层/跨 repo 事务 join(H6c):把外层事务注入 ctx 后传给任意 repo 方法
err := database.TransactionWithContext(ctx, func(tx *gorm.DB) error {
ctx2 := database.WithTx(ctx, tx)
if err := userRepo.Create(ctx2, &user); err != nil {
return err
}
return otherRepo.Update(ctx2, &other) // 同一事务
})
```
### 4.4 Model 基础模型
@@ -493,6 +513,9 @@ type User struct {
### 5.1 初始化缓存
> **v1.4.0**App 用户经 `WithRedis()` 启用时,`Init` 自动用 `app.redisManager.Client()` 初始化缓存
> (走 App 自己的 Redis,多 App 隔离,不串越)。下方 `cache.Init()`(包级)用于 standalone 或回退全局 Redis。
```go
import "github.com/EthanCodeCraft/xlgo-core/cache"
@@ -509,9 +532,11 @@ c := cache.GetCache()
// 设置缓存
c.Set(ctx, "user:1", userData, 30*time.Minute)
// 获取缓存
// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err
var user User
if c.Get(ctx, "user:1", &user) {
if hit, err := c.Get(ctx, "user:1", &user); err != nil {
return err
} else if hit {
// 缓存命中
}
@@ -521,8 +546,11 @@ c.Delete(ctx, "user:1")
// 批量删除(按模式)
c.DeleteByPattern(ctx, "user:*")
// 检查是否存在
exists := c.Exists(ctx, "user:1")
// 检查是否存在(未命中 false,nil;后端错误返回 err
exists, err := c.Exists(ctx, "user:1")
if err != nil {
return err
}
```
### 5.3 键名前缀管理(多站点共用 Redis)
@@ -542,7 +570,10 @@ cache.KSession("sid") // → "session:my_app:sid"
// 使用带前缀的缓存
c.Set(ctx, cache.K("user:1"), userData, ttl)
c.Get(ctx, cache.K("user:1"), &user)
hit, err := c.Get(ctx, cache.K("user:1"), &user)
if err != nil {
return err
}
```
### 5.4 分布式锁(安全增强版)
@@ -564,12 +595,12 @@ if token != nil {
}
// 自动管理锁(简单场景)
err := cache.WithLock(ctx, key, ttl, func() error {
err := cache.WithLock(ctx, key, ttl, func(ctx context.Context) error {
return nil
})
// 自动续期锁(长任务场景)
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func() error {
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func(ctx context.Context) error {
// 执行时间超过 TTL 时自动续期
return nil
})
@@ -577,11 +608,14 @@ err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func()
// 续期锁(手动续期)
cache.ExtendLock(ctx, token, 30*time.Second)
// 检查锁是否被占用
// 检查锁是否被占用Redis 不可用时返回 ErrRedisNotReady,可经
// errors.Is(err, cache.ErrRedisNotReady) 与"锁未占用"区分)
locked, err := cache.IsLocked(ctx, key)
// 强制释放锁(管理场景)
cache.ForceUnlock(ctx, key)
// 强制释放锁(管理场景Redis 不可用时返回 ErrRedisNotReady
if err := cache.ForceUnlock(ctx, key); err != nil {
// 处理 Redis 不可用
}
```
**安全特性:**
@@ -679,7 +713,9 @@ if !ok {
| `max=20` | 最大长度 |
| `email` | 邮箱格式 |
| `phone` | 手机号格式 |
| `password` | 密码强度(至少 8 位,包含字母和数字) |
| `phone_strict` | 手机号严格校验(验证运营商号段) |
| `password` | 密码强度(8-72 字节,需含大写+小写+数字) |
| `username` | 字母开头,3-20 位字母/数字/下划线 |
| `url` | URL 格式 |
| `ip` | IP 地址格式 |
@@ -749,9 +785,11 @@ response.FailWithDetail(c, response.ErrPasswordWrong, "连续错误3次将锁定
```go
// 文件下载
response.Download(c, "report.xlsx", fileData)
// 大文件/对象存储流式下载优先使用 DownloadReader
// HTML响应
response.HTML(c, "<html>...</html>")
// HTML 会原样输出;只传入可信或已清洗的 markup
// 页面跳转
response.Redirect(c, 302, "https://example.com")
@@ -871,30 +909,41 @@ r.Use(middleware.CSRFForAPI())
token := middleware.GetCSRFToken(c)
```
### 8.3 限流(支持 Redis 分布式限流)
### 8.4 限流(支持 Redis 分布式限流)
> **v1.4.0**App 持专属 `RateLimitRegistry``Shutdown` 调 `registry.Stop()`(不再调全局
> `StopRateLimiters`,避免误停其他 App 的限流器)。下方包级 `LoginRateLimit`/`APIRateLimit`/等仍可用
> (请求时解析全局默认 Registry,单 App 场景无变化)。**多 App per-App 计数隔离**用
> `app.RateLimitRegistry().LoginRateLimit()` 等 App-bound 中间件(捕获 App 自己的 Registry,不查全局)。
> **多 App 走各自 Redis** 用 `middleware.NewRedisRateLimiter(..., middleware.WithRedisClient(app.RedisClient()))`。
```go
// 初始化限流器
middleware.InitRateLimiters()
// 内存限流(单实例)
r.POST("/login", middleware.LoginRateLimit(), handler.Login) // 每分钟10次
r.POST("/upload", middleware.UploadRateLimit(), handler.Upload) // 每分钟20次
r.Use(middleware.APIRateLimit()) // 每分钟100
r.POST("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
response.Success(c, gin.H{"token": "..."})
}) // 每分钟10次
r.POST("/upload", middleware.UploadRateLimit(), func(c *gin.Context) {
response.Success(c, gin.H{"file": "..."})
}) // 每分钟20次
r.Use(middleware.APIRateLimit()) // 每分钟100次
// 自定义内存限流
r.Use(middleware.CustomRateLimit(50, time.Minute)) // 每分钟50次
// Redis 分布式限流(多实例共享)
r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次
r.Use(middleware.LoginRedisRateLimit()) // 登录限流
r.Use(middleware.APIRedisRateLimit()) // API限流
r.Use(middleware.UploadRedisRateLimit()) // 上传限流
r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次fail-openRedis 故障时放行)
r.Use(middleware.LoginRedisRateLimit()) // 登录限流fail-closedRedis 故障时拒绝,防爆破)
r.Use(middleware.APIRedisRateLimit()) // API限流fail-open
r.Use(middleware.UploadRedisRateLimit()) // 上传限流fail-closed:资源敏感,Redis 故障时拒绝)
// 自定义 Redis 限流
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute))
// 自定义 Redis 限流(默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open
r.Use(middleware.CustomRedisRateLimit("sensitive", 50, time.Minute, middleware.WithFailClosed(true))) // fail-closed(安全场景)
// 自定义标识限流(如按用户ID
// 自定义标识限流(如按用户ID,同样支持 WithFailClosed
r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Context) string {
return fmt.Sprintf("user:%d", middleware.GetUserID(c))
}))
@@ -903,11 +952,16 @@ r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Con
defer middleware.StopRateLimiters()
```
> **fail-open vs fail-closedH4c**Redis 限流器在 Redis 故障时有两种策略——
> - **fail-open**(默认):Redis 故障时放行,避免影响业务,但限流静默失效。
> - **fail-closed**`WithFailClosed(true)`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、上传、敏感操作)必须用 fail-closed**。
> `RedisRateLimiter` 可经 `NewRedisRateLimiter(..., WithFailClosed(true))` 构造或 `SetFailClosed(true)` 切换策略。
**内存限流 vs Redis 限流:**
- 内存限流:单实例使用,简单高效
- Redis 限流:多实例共享,滑动窗口算法,分布式场景必需
### 8.4 路由架构
### 8.5 路由架构
框架提供灵活的路由系统,支持模块化、版本化 API 和中间件分组。
@@ -1042,7 +1096,8 @@ router.RegisterVersion(router.NewVersion("v1", "/api/v1"))
// 应用路由
router.Apply()
xlgo.StartServer(engine, 8080)
// 启动服务(如需优雅关闭与生命周期管理,改用 app := xlgo.New(...); app.Run()
engine.Run(":8080")
```
#### 8.4.7 默认路由
@@ -1077,7 +1132,7 @@ import "github.com/EthanCodeCraft/xlgo-core/jwt"
// 生成Token(自动包含唯一 JTI)
token, err := jwt.GenerateToken(userID, username, "admin", "admin")
// 解析Token
// 解析Token(默认 fail-closed:黑名单后端不可检查时拒绝 Token,需 Redis
claims, err := jwt.ParseToken(tokenString)
// 使Token失效(使用 JTI,内存占用约 30 字节)
@@ -1167,11 +1222,17 @@ storage:
### 10.3 使用存储
> **v1.4.0**App 用户经 `WithStorage()` 启用时,`Init` 自动初始化存储并提升为全局默认;`Shutdown`
> 经 `StorageManager.Close()` 收口(当前 LocalStorage/OSSStorage 无 closeable 资源为 no-op,为未来
> 驱动预留)。下方 `storage.Init()`(包级)用于 standalone。
```go
import "github.com/EthanCodeCraft/xlgo-core/storage"
// 初始化
storage.Init(&cfg.Storage)
if err := storage.Init(&cfg.Storage); err != nil {
return err
}
// 上传文件(从请求中获取)
file, _ := c.FormFile("file")
@@ -1189,8 +1250,12 @@ err := storage.Delete(path)
// 获取文件内容
data, err := storage.Get(path)
// 检查文件是否存在
if storage.Exists(path) {
// 检查文件是否存在(不存在 false,nil;后端错误返回 err
ok, err := storage.Exists(path)
if err != nil {
return err
}
if ok {
// 文件存在
}
```
@@ -1201,19 +1266,32 @@ if storage.Exists(path) {
### 11.1 随机数生成
**安全选型**:字符串随机(token/OTP/验证码/会话 ID/nonce)用 `RandStringSecure`/`RandDigitSecure`
(基于 `crypto/rand`,不可预测)。`RandString`/`RandDigit`math/rand 版本)已移除——
字符串随机的用途几乎都是安全场景,保留 math/rand 版本会诱导误用。
```go
import "github.com/EthanCodeCraft/xlgo-core/utils"
// 随机字符串(16位
token := utils.RandString(16)
// 安全场景:token、会话 ID、API keycrypto/rand,不可预测
token, err := utils.RandStringSecure(32)
if err != nil {
// 处理 crypto/rand 失败(极罕见,通常仅系统熵池耗尽)
}
// 随机数字(6位验证码
code := utils.RandDigit(6)
// 安全场景:6 位 OTP 验证码、密码重置码(crypto/rand
code, err := utils.RandDigitSecure(6)
// 范围随机数
// 安全场景:安全 nonce 范围、防猜抽奖、密钥分桶(crypto/rand 无偏)
idx, err := utils.RandIntSecure(0, 1000)
// 范围随机数(非密码学安全,仅用于负载均衡/游戏/A-B 分桶等非安全场景)
n := utils.RandInt(1, 100)
```
> 非安全场景需要高性能随机串时,直接用标准库 `math/rand` 即可,框架不再提供
> 易误用的 `RandString`/`RandDigit`。
### 11.2 字符串处理
```go
@@ -1394,7 +1472,8 @@ if utils.UUIDValid(uuid) { }
| 分类 | 高分函数(⭐⭐⭐⭐⭐) | 说明 |
|------|---------------------|------|
| **随机** | `RandString/RandDigit` | sync.Pool 优化性能 |
| **随机(安全)** | `RandStringSecure/RandDigitSecure/RandIntSecure/RandInt64Secure` | crypto/randtoken/OTP/nonce 用 |
| **随机(范围)** | `RandInt/RandInt64` | math/rand,负载均衡/游戏等非安全场景 |
| **字符串** | `IsBlank/DefaultIfBlank/StrLen` | 空值处理、Unicode支持 |
| **时间** | `FormatDateTime/StartOfDay/EndOfMonth` | 标准格式、边界计算 |
| **转换** | `ToIntDefault/CalcPageCount/CalcOffset` | 安全转换、分页计算 |
@@ -1409,7 +1488,7 @@ if utils.UUIDValid(uuid) { }
| 改进 | 说明 |
|------|------|
| 性能优化 | `RandString/RandDigit` 使用 sync.Pool 复用随机源 |
| 性能优化 | 非安全随机用 `RandInt/RandInt64`math/randsync.Pool 复用源);安全场景用 `RandStringSecure/RandDigitSecure/RandIntSecure`crypto/rand + big.Int 拒绝采样无偏) |
| 类型安全 | 移除使用反射的函数,保持类型安全 |
| 式调用 | `HTTPClient``URLBuilder` 支持链式调用 |
| 零依赖 | 仅依赖 `google/uuid`,其余使用标准库 |
@@ -1470,6 +1549,24 @@ count := hub.Count()
## 13. 定时任务
> **v1.4.0**:App 持专属调度器(实例隔离,多 App 互不污染)。**App 管理的任务用
> `xlgo.WithCronTask(name, schedule, handler)` 注册**(蕴含启用 cron`Init` 时注册+启动,
> `Shutdown` 时停止并等待在跑任务退出)。下方包级 `cron.AddTask`/`cron.Start`/`cron.Stop` 仍可用于
> standalone(无 App)或 `Init` 之后;**`Init` 之前**调包级 `cron.AddTask` 会注册到 init 默认
> 调度器、被 App swap 丢弃。`app.Scheduler()` 返回 App 调度器,供动态注册与管理
> `AddTask`/`RemoveTask`/`RunTask`/`ListTasks`)。
>
> ```go
> app := xlgo.New(xlgo.WithConfig(cfg),
> xlgo.WithCronTask("cleanup", cron.Every(5*time.Minute), func(ctx context.Context) error {
> return cleanupOldData()
> }),
> xlgo.WithCronTask("daily_report", cron.Daily(2, 0), generateReport),
> )
> // 动态注册(Init 后):app.Scheduler().AddTask(...)
> if err := app.Run(); err != nil { /* ... */ }
> ```
### 13.1 添加任务
```go
@@ -1493,6 +1590,9 @@ cron.AddTask("noon", cron.Cron("0", "12"), doSomething) // 每天12:00
cron.AddTask("complex", cron.ParseCron("*/15 * * * *"), doSomething) // 每15分钟
cron.AddTask("monthly", cron.ParseCron("0 0 1 * *"), doSomething) // 每月1号凌晨
cron.AddTask("workday", cron.ParseCron("0 9-17 * * 1-5"), doSomething) // 工作日9-17点
// ParseCron 对非法表达式 fail-fast panic;动态输入请用 ParseCronStrict 处理 error。
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
```
### 13.2 启动与停止
@@ -1517,6 +1617,17 @@ scheduler.EnableTask("cleanup") // 启用任务
## 14. 链路追踪
> **v1.4.0**`xlgo.WithTrace()` 把 trace 纳入 App 生命周期--`Init` 按 `config.Trace` 调
> `trace.Init`、装入 `trace.Middleware`、`Shutdown` 调 `trace.Close` 刷出 span 并释放 exporter
> 后台 goroutine。下方手动 `trace.Init`/`trace.Close`/`trace.Middleware` 仍可用于 standalone
> (无 App)。**trace 不做实例隔离**--OTel `TracerProvider` 是进程级全局单例,多 App 进程共享,
> 任一 `Shutdown` 会关全局导出;多 App 不要同时开 `WithTrace`。
>
> ```go
> app := xlgo.New(xlgo.WithConfigPath("config.yaml"), xlgo.WithTrace())
> // config.yaml 配置 trace.enabled / trace.endpoint / trace.exporter_type / trace.sample_ratio ...
> ```
### 14.1 初始化
```go
@@ -1527,11 +1638,18 @@ trace.Init(trace.Config{
ServiceName: "my-service",
Endpoint: "localhost:4318",
ExporterType: "otlp-http",
// Insecure: true, // 明文 collectorlocalhost:4318 等本地 collector 默认无 TLS,需显式开启)
SampleRatio: 1.0,
// Propagator: "w3c", // 可选 "w3c"(默认) / "b3" / "jaeger"(映射 W3C)
})
defer trace.Close(ctx)
```
> **导出器类型**`otlp-http` / `otlp-grpc` / `stdout`(写标准输出,便于调试)。未知类型 `Init` 返错。
> **Insecure**:默认 `false`TLS);对无 TLS 的本地 collector(如 `localhost:4318`)需显式置 `true`,否则握手失败。
> **传播器**`w3c`(默认,W3C TraceContext + Baggage/ `b3`(同时支持单头与多头,兼容旧 B3 客户端)/ `jaeger`(映射为 W3C TraceContext——现代 Jaeger agent 透传 W3C;纯 Jaeger thrift 头协议请用 `b3`)。未知类型 `Init` 返错。
> **未 Init 也安全**:未调用 `Init` 或 `Init(Enabled:false)` 时为 Noop tracer`Middleware`/`StartSpan` 等不 panic。
### 14.2 使用中间件
```go
@@ -1607,12 +1725,12 @@ func TestUserAPI(t *testing.T) {
WithJSON(map[string]any{"name": "test"}).
Execute()
resp.AssertOK(t)
resp.AssertCode(t, 1)
resp.AssertJSONContains(t, "code", float64(1))
// GET请求
resp = test.GET(router, "/api/users/1").Execute()
resp.AssertOK(t)
resp.AssertJSONKeyExists(t, "data.id")
resp.AssertJSONContains(t, "data.id", float64(1))
}
```
@@ -1750,13 +1868,17 @@ func Login(c *gin.Context) {
}
// 生成Token
token, _ := jwt.GenerateToken(user.ID, user.Username, "admin", "admin")
token, err := jwt.GenerateToken(user.ID, user.Username, "admin", "admin")
if err != nil {
response.FailWithError(c, response.ErrServerError)
return
}
// 缓存用户信息
cache.GetCache().Set(c.Request.Context(),
cache.KSession(token),
user,
time.Duration(cfg.JWT.Expire)*time.Second)
cfg.JWT.Expire)
response.Success(c, gin.H{
"token": token,
@@ -1768,5 +1890,5 @@ func Login(c *gin.Context) {
---
_文档版本: v1.0.2_
_最后更新: 2026-06-20_
_文档版本: v1.2.0_
_最后更新: 2026-07-04_
+193 -32
View File
@@ -6,22 +6,84 @@
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
</p>
xlgo 是一个基于 Go + Gin 的轻量级 Web 开发框架,提供了完整的后端开发基础设施,包括配置管理、数据库访问、缓存、认证、日志、文件存储等常用功能
> 基于 Go + Gin 的后端开发框架。**组件全部 Manager 化**——既能像脚本一样用包级函数简单调用,又能像正经框架一样注入实例、跑多套、塞 mock 做单测
## 为什么是 xlgo
多数 Gin 脚手架把数据库、Redis、缓存、JWT、日志做成**包级全局单例**:写小项目顺手,但一旦要做单元测试、同进程跑多套实例、或替换实现,就动弹不得。xlgo 把这些组件全部抽象成 `XxxManager`,同时保留包级便捷函数做 facade——**简单用法零成本,复杂场景不封顶**:
```go
// 简单用法:包级函数,跟单例脚手架一样直接
database.InitDB(cfg)
db := database.GetDB()
// 同一份代码,也能注入实例 / 跑多套 / 塞 mock
myDB := database.NewManager(cfg)
myDB.Open(ctx) // 独立实例,不受全局影响
database.SetDefaultManager(myDB) // 提升为全局默认,并关闭旧默认 manager
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入自定义 CacheManager
```
### 区别于一般 Gin 脚手架的几点
- **组件可注入** — database / redis / cache / jwt / storage / logger 均为 `Manager` + 全局 facade 双轨,支持多实例与 mock 注入,而非写死的包级单例
- **生产就绪内置** — `/livez` `/readyz`、Prometheus `/metrics`、主库探活自愈、请求级超时、优雅关闭(等待 in-flight goroutine)开箱即用,不用自己搭
- **框架内零 Fatal** — 错误一律返回由调用方处理,框架代码不 `panic` 杀进程
- **默认轻量、按需启用** — `New()` 什么都不开,`NewFullStack()` 一键全开,中间任意组合;`Without*` 精细排除
- **可插拔数据库方言** — GORM 方言注册表,内置 MySQL / PostgreSQL,可扩展 SQLite / ClickHouse 等
### 30 秒上手
```go
package main
import (
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
)
func main() {
app := xlgo.New(
xlgo.WithConfigPath("./config.yaml"),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
)
app.GetRouter().GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"hello": "xlgo"})
})
_ = app.Run()
}
```
## 框架特性
- **配置管理** - 支持 YAML 配置文件,环境变量覆盖,配置热更新
- **数据库** - 基于 GORM 的可插拔方言注册表,内置 MySQL / PostgreSQL,可注册任意 GORM 驱动;支持自动迁移、重试、连接池、读写分离
- **缓存** - Redis 缓存,支持分布式缓存、键前缀、TTL,SCAN 优化
- **认证** - JWT 认证,支持 Token 黑名单、刷新机制
- **日志** - 分级日志(API、数据库),日志轮转
- **中间件** - CORS、限流、日志、认证、CSRF 防护
- **文件存储** - 本地存储 + 阿里云 OSS 支持
- **实时通信** - SSE 流式响应 + WebSocket 支持
- **定时任务** - 内置任务调度器
- **验证器** - 请求参数验证,支持自定义错误消息
- **错误处理** - 统一错误码体系
- **CLI 工具** - 脚手架工具,快速创建项目和代码
**架构与可注入性**
- **组件 Manager 化** - database / redis / cache / jwt / storage / logger 均为 `XxxManager` + 全局 facade 双轨,支持多实例与测试注入 mock
- **可插拔数据库方言** - GORM 方言注册表,内置 MySQL / PostgreSQL,可注册 SQLite / ClickHouse 等任意驱动
- **读写分离** - 主库 + 多副本,`ReplicaPicker` 策略可插拔(轮询 / 随机 / 自定义),context 路由强制主库
- **Lifecycle Hooks** - `OnInit / OnStart / OnReady / OnStop` 生命周期钩子,可插拔启动/关闭逻辑
**生产就绪**
- **健康探针** - `/livez`(存活)+ `/readyz`(就绪)+ `/health`(兼容),K8s probe 友好
- **Prometheus 指标** - `/metrics` 端点 + 请求计数 / 耗时 / 在飞数采集中间件
- **依赖健康自愈** - 主库后台探活,连续失败标记不健康联动 503;从库失败临时剔除、恢复自动纳入
- **请求级超时** - `middleware.Timeout` 级联取消下游 GORM/Redis
- **优雅关闭** - `App.Go` 管理后台 goroutineShutdown 时 cancel + 等待退出(带超时)
- **配置校验** - 启动期 `Validate` 拦截非法配置(端口/密钥/必填字段),错误前置
**基础功能**
- **配置管理** - YAML + 环境变量覆盖 + 热更新,`time.Duration` 字符串解析(`"24h"`
- **缓存** - Redis 缓存,键前缀、TTL、SCAN 优化、分布式锁
- **认证** - JWT 认证,Token 黑名单、刷新机制,HMAC 算法可选
- **日志** - 分级日志(通用 / API / DB 三分流),lumberjack 轮转
- **中间件** - CORS、限流(内存 + Redis 版)、请求日志、认证、CSRF、RequestID、Recover
- **文件存储** - 本地 + 阿里云 OSS
- **实时通信** - SSE 流式响应 + WebSocket
- **定时任务** - 内置调度器(interval / daily / weekly / cron
- **验证器** - 请求参数验证,自定义错误消息
- **响应模式** - `ModeBusiness`(业务码,默认)/ `ModeREST`(按错误码映射 HTTP status)一键切换
- **CLI 工具** - 脚手架 + 代码生成(handler/repository/model/service
## 快速开始
@@ -47,8 +109,14 @@ go mod tidy
```yaml
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
port: 8080
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business # business(默认,全200+业务码) 或 rest(按错误码映射HTTP status)
database:
driver: mysql # mysql(默认)或 postgres
@@ -68,8 +136,11 @@ redis:
db: 0
jwt:
secret: your_jwt_secret_key
expire: 86400
secret: your_jwt_secret_key_at_least_32_bytes # ≥32 字节,否则启动期被 Validate 拦截
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
refresh_expire: "168h" # 刷新 token 过期时间
issuer: xlgo
algorithm: HS256 # HS256(默认)/HS384/HS512
storage:
driver: local
@@ -174,7 +245,8 @@ database.InitDBWithReplicas(cfg, []string{
})
// 读操作自动路由到从库
users := database.GetReadDB().Find(&users)
var users []model.User
database.GetReadDB().Find(&users)
// 强制使用主库
ctx := database.UseMaster(context.Background())
@@ -203,6 +275,8 @@ defer mgr.Close()
database.SetReplicaPicker(&database.RoundRobinPicker{})
```
`database.SetDefaultManager(m)` 表示把新 manager 接管为全局默认,并关闭被替换的旧 manager,避免连接池泄漏。需要失败回滚或延迟释放旧资源的初始化流程,请使用 `database.SwapDefaultManager(m)` 并自行决定何时关闭返回的旧 manager。
#### 注册自定义 GORM 方言
通过 `database.RegisterDialect` 一次注册即可让 `database.driver: <name>` 生效,DSN 构建器会同步登记到 `config` 包,因此 `cfg.Database.DSN()` 也会识别新驱动:
@@ -265,6 +339,8 @@ err := userRepo.WithTransaction(ctx, func(txRepo *repository.BaseRepo[model.User
### Redis 缓存
> **v1.4.0**App 经 `WithRedis()` 启用时自动用 App 的 Redis 客户端初始化缓存(多 App 隔离,不串越)。下方 `cache.Init()` 用于 standalone。
```go
// 初始化缓存
cache.Init()
@@ -276,11 +352,18 @@ cacheService := cache.GetCache()
// 设置缓存
cacheService.Set(ctx, "user:1", user, 10*time.Minute)
// 获取缓存
// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err
var user User
if cacheService.Get(ctx, "user:1", &user) {
if hit, err := cacheService.Get(ctx, "user:1", &user); err != nil {
return err
} else if hit {
// 缓存命中
}
if exists, err := cacheService.Exists(ctx, "user:1"); err != nil {
return err
} else if exists {
// key 存在
}
// 删除缓存
cacheService.Delete(ctx, "user:1")
@@ -296,8 +379,12 @@ cacheService.DeleteByPattern(ctx, "user:*")
token, err := jwt.GenerateToken(userID, username, "admin", "admin")
// 解析 Token
// ParseToken 默认 fail-closed:黑名单后端不可检查时拒绝 Token(需 Redis
claims, err := jwt.ParseToken(tokenString)
// 需显式 fail-open(仅无 Redis 或低安全场景)用:
claims, err = jwt.ParseTokenWithBlacklistPolicy(tokenString, jwt.BlacklistFailOpen)
// 使 Token 失效(使用 JTI,高效)
jwt.InvalidateToken(tokenString)
@@ -321,7 +408,7 @@ if token != nil {
cache.ExtendLock(ctx, token, 30*time.Second)
// 自动续期执行
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func() error {
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func(ctx context.Context) error {
// 长任务自动续期
return nil
})
@@ -331,6 +418,8 @@ err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func()
### Redis 分布式限流
> **v1.4.0**:多 App 走各自 Redis 用 `middleware.NewRedisRateLimiter(..., middleware.WithRedisClient(app.RedisClient()))`。
```go
// 内存限流(单实例)
r.Use(middleware.CustomRateLimit(100, time.Minute))
@@ -401,8 +490,11 @@ err := storage.Delete(path)
// 获取文件内容
data, err := storage.Get(path)
// 检查文件是否存在
exists := storage.Exists(path)
// 检查文件是否存在(不存在 false,nil;后端错误返回 err
ok, err := storage.Exists(path)
if err != nil {
return err
}
```
### SSE 流式响应
@@ -440,6 +532,11 @@ hub.Broadcast([]byte("广播消息"))
### 定时任务
> **v1.4.0**App 持专属调度器(实例隔离)。**App 管理的任务用 `xlgo.WithCronTask(name, schedule, handler)`
> 注册**(蕴含启用 cron`Init` 时注册+启动,`Shutdown` 时停止)。下方包级 `cron.AddTask`/`cron.Start`
> 仅用于 standalone(无 App)或 `Init` 之后;**`Init` 之前**调包级 `cron.AddTask` 会注册到 init 默认
> 调度器、被 App swap 丢弃。`app.Scheduler()` 供动态注册与管理。
```go
// 每隔 5 分钟执行
cron.AddTask("cleanup", cron.Every(5*time.Minute), func(ctx context.Context) error {
@@ -456,6 +553,9 @@ cron.AddTask("weekly", cron.Weekly(time.Monday, 9, 0), weeklyTask)
cron.AddTask("every15min", cron.ParseCron("*/15 * * * *"), doSomething)
cron.AddTask("monthly", cron.ParseCron("0 0 1 * *"), doSomething) // 每月1号
// ParseCron 对非法表达式 fail-fast panic;动态输入请用 ParseCronStrict 处理 error。
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
// 启动调度器
cron.Start()
defer cron.Stop()
@@ -529,12 +629,18 @@ userType := middleware.GetUserType(c)
### 限流中间件
> **v1.4.0**App `Shutdown` 自动 `registry.Stop()`(不再调全局 `StopRateLimiters`,避免误停其他 App)。多 App per-App 计数隔离用 `app.RateLimitRegistry().LoginRateLimit()` 等 App-bound 中间件。
```go
// 登录限流(每分钟 10 次)
r.POST("/login", middleware.LoginRateLimit(), handler.Login)
r.POST("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
response.Success(c, gin.H{"token": "..."})
})
// 上传限流(每分钟 20 次)
r.POST("/upload", middleware.UploadRateLimit(), handler.Upload)
r.POST("/upload", middleware.UploadRateLimit(), func(c *gin.Context) {
response.Success(c, gin.H{"file": "..."})
})
// 自定义限流
r.Use(middleware.CustomRateLimit(50, time.Minute))
@@ -574,6 +680,16 @@ response.Unauthorized(c, "请先登录")
response.NotFound(c, "资源不存在")
response.ServerError(c, "服务器错误")
response.RateLimit(c)
// 显式指定 HTTP status(不受 Mode 影响)
response.Custom(c, http.StatusBadRequest, response.CodeFail, "参数错误", nil)
```
**响应模式**v1.1.0):默认 `ModeBusiness`,所有响应 HTTP 200 + 业务码(兼容存量)。切换 `ModeREST` 后,失败响应按错误码映射对应 HTTP status401/404/429/500...),body 仍带业务码,便于 APM / Prometheus / 网关按 status 区分异常:
```go
response.SetMode(response.ModeREST) // 代码切换
// 或在 config.yaml 配置:server.response_mode: rest
```
---
@@ -638,15 +754,16 @@ xlgo/
├── compress/
│ └── compress.go # Gzip/Zip 压缩解压
├── config/
── config.go # 配置管理(支持热更新)
── config.go # 配置管理(支持热更新、time.Duration 解析
│ └── validate.go # 配置校验(启动期拦截非法配置)
├── console/
│ └── console.go # 彩色控制台输出
├── cron/
│ └── cron.go # 定时任务调度
├── database/
│ ├── manager.go # 数据库管理器(主从、Picker、Init/Close/HealthCheck
│ ├── manager.go # 数据库管理器(主从、Picker、探活自愈、Init/Close/HealthCheck
│ ├── dialect.go # GORM 方言注册表(mysql / postgres,可扩展)
│ └── redis.go # Redis 连接
│ └── redis.go # Redis 连接管理器(RedisManager
├── handler/
│ └── handler.go # 基础处理器(类型安全参数获取)
├── jwt/
@@ -659,18 +776,22 @@ xlgo/
│ ├── cors.go # CORS 跨域中间件
│ ├── csrf.go # CSRF 防护中间件
│ ├── logger.go # 请求日志中间件
│ ├── metrics.go # Prometheus 指标中间件
│ ├── ratelimit.go # 限流中间件
│ ├── requestid.go # 请求ID中间件
── recover.go # Panic恢复中间件
── recover.go # Panic恢复中间件
│ └── timeout.go # 请求级超时中间件
├── model/
│ └── base.go # 基础模型
├── repository/
│ └── repository.go # 基础仓库(泛型CRUD
├── response/
│ ├── response.go # 统一响应格式
│ ├── mode.go # 响应模式开关(business / REST
│ └── error.go # 统一错误码
├── router/
── router.go # 路由注册中心(模块化/版本化)
── router.go # 路由注册中心(模块化/版本化、livez/readyz/health
│ └── metrics.go # Prometheus /metrics 端点
├── sse/
│ └── sse.go # SSE 流式响应
├── storage/
@@ -694,8 +815,6 @@ xlgo/
│ ├── validator.go # 请求验证器
│ ├── password.go # 密码强度验证
│ └── hash.go # 密码加密
├── wire/
│ └── wire.go # 依赖注入
└── ws/
└── ws.go # WebSocket 支持
```
@@ -707,7 +826,7 @@ xlgo/
### Docker 部署
```dockerfile
FROM golang:1.22-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY . .
@@ -734,6 +853,48 @@ docker run -d -p 8080:8080 xlgo-app:latest
> 完整变更历史见 [CHANGELOG.md](./CHANGELOG.md)。
### v1.4.0 (2026-07-12)
> **instance+facade 一致性收尾**cron/storage/cache/ratelimit 实例化(App 持 per-App 实例 + `SwapDefault*` + 包级 facade 代理,照 `database.Manager`/`jwt.Manager` 模式),消除单进程多 App 互相污染;trace 纳入 App 生命周期(`WithTrace`);删除 `WithoutWire` 空函数。`go vet` + `go build` + `go test -race ./...` 全绿。完整变更见 [CHANGELOG.md](./CHANGELOG.md)。
主要破坏性变更(升级前必读):
- **`xlgo.WithoutWire()` 删除**v1.1.0 wire 包删除后遗留的空 no-op,直接删除调用即可(无行为变化)。
- **`cron.AddTask``App.Init` 之前注册失效**:App 持专属调度器,pre-Init 包级 `cron.AddTask` 注册到 init 默认调度器、被 App swap 丢弃。改用 `xlgo.WithCronTask(name, schedule, handler)`(或 Init 后 `app.Scheduler().AddTask`)。
- **`middleware.StopRateLimiters` 语义收窄**:仅停默认 RegistryApp `Shutdown``app` 自己的 `registry.Stop()`,不再误停其他 App 限流器。
- **`commitReplacedResources` 不再关闭 `previousDB`/`previousRedis`/`loggerSnapshot`**:多 App 下 previous 可能是另一 App 的活跃资源(致 `redis: client is closed` / logger writer 误杀)。单 App 无影响(init 默认无资源)。
- **cache/ratelimit redis 注入**jwt 模型):`cache.NewRedisCacheWithRedis`/`CacheManager.InitWithRedis``middleware.WithRedisClient`
新增:`WithTrace`/`WithCronTask``app.Scheduler()`/`Cache()`/`RedisClient()`/`RateLimitRegistry()``cron/storage/cache/middleware``SwapDefault*``config.TraceConfig``middleware.RateLimitRegistry` + App-bound 限流中间件;`storage.StorageManager.Close()`
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.4.0`(⚠️ 含破坏性变更,详见 CHANGELOG 迁移说明)
### v1.2.0 (2026-07-04)
> **破坏性版本**4 轮对抗性评审(deepseek / GLM / Claude / 终审)收口的全部 CRITICAL/HIGH/MEDIUM 修复 + 主线A 包级可变全局并发治理统一。`go vet` + `go build` + `go test -race ./...` 全绿。完整变更见 [CHANGELOG.md](./CHANGELOG.md)。
主要破坏性变更(升级前必读):
- **包级可变全局一律 `atomic.Pointer`**(主线A):`database.DefaultRedis`/`database.DefaultManager`/`storage.DefaultStorage`/`validation.Validator` 由裸指针改为 `atomic.Pointer[T]`。直接读字段调方法需改用 facade(`InitRedis`/`GetDB`/`ValidateStruct` 等)或 `.Load()``database.DefaultManager = x``database.SetDefaultManager(x)`
- **`jwt.DefaultJWT` 包级变量删除** → `GetDefaultJWT()` / `SetDefaultJWTManager()`
- **`repository.FindWhereOrdered`/`FindPageWhereOrdered` 签名**`args []any``args ...any``order` 前置于 `query`
- **`response.Response``Data`/`RequestID``omitempty`**:两字段在所有响应中恒存在。
- **`cache.IsLocked`/`GetLockTTL`/`ForceUnlock`** Redis 不可用改返 `ErrRedisNotReady`(锁操作正确性相关)。
- **`config.Load()` 返回深拷贝**:新增 `(*Config).Clone()``Get()` 仍返只读指针(热路径零分配)。
- **`handler.GetPage``page` 上限 10000**`utils.EqualsIgnoreCase``strings.EqualFold``utils.ReadFile` 去 TOCTOU 前置检查(错误改 `errors.Is(err, os.ErrNotExist)`)。
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.2.0`(⚠️ 含破坏性变更,详见 CHANGELOG 迁移说明)
### v1.1.1 (2026-06-23)
> v1.1.0 的补丁发布:补 `ServerConfig.Host` 绑定地址、面向用户文案统一中文、修正 README 过时/错误描述(含会致启动失败的配置示例)。
- **ServerConfig.Host** - `server.host` 控制监听地址,空=所有接口(兼容),`127.0.0.1`=仅本机,内网 IP=绑定指定网卡
- **文案统一中文** - recover/logger/metrics 等面向用户文案统一中文(保留协议字段名、Prometheus metric Name、MySQL 错误串匹配等英文)
- **README 修正** - 删 wire 段、修配置示例(secret≥32 字节 + expire 改 Duration)、补 v1.1.0 新文件、重写首段介绍
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.1.1`(无破坏性变更)
### v1.1.0 (2026-06-23)
> 本版本定位为 **HA & Manager 化 release**:高可用与生产就绪改进 + 组件 Manager 化。含少量破坏性变更,升级前请阅读 [CHANGELOG 升级说明](./CHANGELOG.md#升级说明)。
@@ -907,7 +1068,7 @@ response.Fail(c, "用户名格式错误")
#### App 启动流程
- **轻量默认** - `xlgo.New()` 默认不再初始化 MySQL / Redis / Storage,也不注册 `/health``/swagger/*`;通过 Option 显式启用
- **组件 Option** - 新增 `WithLogger / WithMySQL / WithRedis / WithStorage / WithWire / WithHealthRoutes / WithSwaggerRoutes / WithDefaultRoutes / WithAutoMigrate` 及对应 `WithoutXxx` 关闭项
- **组件 Option** - 新增 `WithLogger / WithMySQL / WithRedis / WithStorage / WithHealthRoutes / WithSwaggerRoutes / WithDefaultRoutes / WithAutoMigrate` 及对应 `WithoutXxx` 关闭项(注:`WithWire` 曾在此版本新增,已于 v1.1.0 随 wire 包删除而移除)
- **迁移控制** - 新增 `Migrator` 类型与 `WithMigrator / WithModels`,注册时自动开启 `WithAutoMigrate``WithoutAutoMigrate` 可显式关闭;不再强制调用空的 `database.AutoMigrate()`
- **batteries-included** - 新增 `WithFullStack` / `NewFullStack` / `RunFullStack`,一键启用全部默认组件
- **错误传播** - 框架初始化失败一律 `return error`,不再在框架内部直接 `Fatalf` 退出进程
+752 -82
View File
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
package xlgo_test
import (
"context"
"errors"
"net"
"strconv"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/cache"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/alicebob/miniredis/v2"
)
func splitAddr(t *testing.T, addr string) (string, int) {
t.Helper()
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("SplitHostPort %q: %v", addr, err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi port %q: %v", portStr, err)
}
if host == "" {
host = "127.0.0.1"
}
return host, port
}
// TestAppCacheMultiAppRedisIsolation 固化 Phase 4 核心契约:单进程多 App 时,每个 App 的
// 缓存走自己的 redisManager.Client(),互不串越。修复前 cache 硬编码 database.GetRedis()
// (全局),App B swap 后 App A 的缓存会读到 App B 的 redis。
//
// 用两个 miniredis 实例分别作 App A/B 的 Redis 后端,断言各自 cache 只写自己的实例、
// 互读对方的 key 为 miss。appA.Cache()/appB.Cache() 绕过全局 facade 直取 App 实例。
func TestAppCacheMultiAppRedisIsolation(t *testing.T) {
mrA := miniredis.RunT(t)
mrB := miniredis.RunT(t)
hostA, portA := splitAddr(t, mrA.Addr())
hostB, portB := splitAddr(t, mrB.Addr())
cfgA := testConfig(18120)
cfgA.Redis = config.RedisConfig{Host: hostA, Port: portA}
cfgB := testConfig(18121)
cfgB.Redis = config.RedisConfig{Host: hostB, Port: portB}
appA := xlgo.New(xlgo.WithConfig(cfgA), xlgo.WithRedis())
if err := appA.Init(); err != nil {
t.Fatalf("appA Init: %v", err)
}
defer appA.Shutdown()
appB := xlgo.New(xlgo.WithConfig(cfgB), xlgo.WithRedis())
if err := appB.Init(); err != nil {
t.Fatalf("appB Init: %v", err)
}
defer appB.Shutdown()
if appA.Cache() == nil || appB.Cache() == nil {
t.Fatal("app.Cache() = nil after Init with WithRedis")
}
ctx := context.Background()
// A 写 keyA -> 仅落 miniredisA
if err := appA.Cache().Set(ctx, "keyA", "valA", time.Minute); err != nil {
t.Fatalf("appA cache Set: %v", err)
}
if !mrA.Exists("keyA") {
t.Error("keyA not in miniredisA (appA redis)")
}
if mrB.Exists("keyA") {
t.Error("keyA leaked into miniredisB (appB redis) -- cache not isolated")
}
// B 写 keyB -> 仅落 miniredisB
if err := appB.Cache().Set(ctx, "keyB", "valB", time.Minute); err != nil {
t.Fatalf("appB cache Set: %v", err)
}
if !mrB.Exists("keyB") {
t.Error("keyB not in miniredisB (appB redis)")
}
if mrA.Exists("keyB") {
t.Error("keyB leaked into miniredisA (appA redis) -- cache not isolated")
}
// 互读对方 key -> miss(各自走自己的 redis
var got string
if ok, err := appA.Cache().Get(ctx, "keyB", &got); ok || err != nil {
t.Errorf("appA cache should miss keyB (different redis): ok=%v err=%v", ok, err)
}
if ok, err := appB.Cache().Get(ctx, "keyA", &got); ok || err != nil {
t.Errorf("appB cache should miss keyA (different redis): ok=%v err=%v", ok, err)
}
// 各自读自己的 key -> hit
if ok, err := appA.Cache().Get(ctx, "keyA", &got); err != nil || !ok || got != "valA" {
t.Errorf("appA cache Get keyA = %v %q err=%v (want true valA)", ok, got, err)
}
if ok, err := appB.Cache().Get(ctx, "keyB", &got); err != nil || !ok || got != "valB" {
t.Errorf("appB cache Get keyB = %v %q err=%v (want true valB)", ok, got, err)
}
}
// TestAppCacheRollbackOnInitFailure 固化 Phase 4 回滚:Init 失败后全局默认 CacheManager
// 必须恢复为 Init 前的实例。
func TestAppCacheRollbackOnInitFailure(t *testing.T) {
preInit := cache.NewCacheManager()
saved := cache.SwapDefaultCacheManager(preInit) // saved = init 默认;preInit 现为全局
defer cache.SwapDefaultCacheManager(saved)
mr := miniredis.RunT(t)
host, port := splitAddr(t, mr.Addr())
cfg := testConfig(18122)
cfg.Redis = config.RedisConfig{Host: host, Port: port}
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithRedis(),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
)
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
if cache.GetDefaultCache() != preInit {
t.Fatal("rollback did not restore preInit cache manager as global default")
}
}
+60
View File
@@ -0,0 +1,60 @@
package xlgo_test
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
)
// TestAppShutdownStopsConfigWatcher_Mconfig3 固化 M-config-3App.Shutdown 须停止其
// configManager 的热重载 watcher,避免关闭后遗留监听 goroutine(违反 C7 生命周期契约)。
//
// Init 后 resolveConfig 把 a.configManager 提升为全局默认,故经包级 config.StartWatcher
// 在其上启动 watcher(等价于用户对 configManager 调 LoadWithWatch/StartWatcher);
// Shutdown 后断言 watchLoop goroutine 已退出。
func TestAppShutdownStopsConfigWatcher_Mconfig3(t *testing.T) {
dir := filepath.Join(os.TempDir(), "xlgo_app_mconfig3")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
defer os.RemoveAll(dir)
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte("app:\n name: m3\n env: dev\nserver:\n port: 18080\n"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
app := xlgo.New(xlgo.WithConfigPath(cfgPath))
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
// 经全局默认(=a.configManager)启动 watcher,模拟用户开启热重载
if err := config.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
defer config.SetDefaultManager(nil)
time.Sleep(120 * time.Millisecond) // 等 watchLoop 就绪
before := runtime.NumGoroutine()
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
// watchLoop goroutine 应随 Shutdown 退出
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() < before {
break
}
time.Sleep(20 * time.Millisecond)
}
if after := runtime.NumGoroutine(); after >= before {
t.Errorf("App.Shutdown 未停止 config watcherM-config-3: before=%d after=%d", before, after)
}
}
+135
View File
@@ -0,0 +1,135 @@
package xlgo_test
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/cron"
)
// TestAppCronSchedulerInstance 固化 Phase 3WithCronTask 把任务注册到 App 专属调度器,
// app.Scheduler() 返回该实例且含注册的任务。
func TestAppCronSchedulerInstance(t *testing.T) {
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
defer cron.SwapDefaultScheduler(saved)
app := xlgo.New(
xlgo.WithConfig(testConfig(18112)),
xlgo.WithCronTask("my-task", cron.Every(time.Hour), func(context.Context) error { return nil }),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
defer app.Shutdown()
s := app.Scheduler()
if s == nil {
t.Fatal("app.Scheduler() = nil after Init with WithCronTask")
}
found := false
for _, tk := range s.ListTasks() {
if tk.Name == "my-task" {
found = true
break
}
}
if !found {
t.Fatal("WithCronTask task not registered on App scheduler")
}
}
// TestAppCronRollbackRestoresDefault 固化 Phase 3 回滚:Init 失败后全局默认调度器
// 必须恢复为 Init 前的实例(不含 App 注册的任务),而非残留 App 的调度器。
func TestAppCronRollbackRestoresDefault(t *testing.T) {
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
defer cron.SwapDefaultScheduler(saved)
app := xlgo.New(
xlgo.WithConfig(testConfig(18113)),
xlgo.WithCronTask("rollback-task", cron.Every(time.Hour), func(context.Context) error { return nil }),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
)
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
// 回滚后全局默认不应含 App 的 "rollback-task"
for _, tk := range cron.GetScheduler().ListTasks() {
if tk.Name == "rollback-task" {
t.Fatal("rollback did not restore global default scheduler (App task still on global)")
}
}
}
// TestAppCronMultiAppIsolation 固化 Phase 3 核心契约:单进程多 App 时,App A 的 Shutdown
// 只停 A 自己的调度器,不影响 App B 的调度器继续运行(修复前 cron 是全局单例,A.Shutdown
// 会经 cron.StopGlobalWithTimeout 把 B 的调度器也停了)。
//
// 调度器内部 ticker 为 1s,故任务每 1s tick 触发一次(schedule=Every(1ms) 仅决定 due 判定,
// 实际 spawn 受 1s ticker 节拍)。测试因此需要跨 1s tick 观测。
func TestAppCronMultiAppIsolation(t *testing.T) {
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
defer cron.SwapDefaultScheduler(saved)
var ranA, ranB atomic.Int32
appA := xlgo.New(
xlgo.WithConfig(testConfig(18110)),
xlgo.WithCronTask("taskA", cron.Every(time.Millisecond), func(context.Context) error {
ranA.Add(1)
return nil
}),
)
appB := xlgo.New(
xlgo.WithConfig(testConfig(18111)),
xlgo.WithCronTask("taskB", cron.Every(time.Millisecond), func(context.Context) error {
ranB.Add(1)
return nil
}),
)
if err := appA.Init(); err != nil {
t.Fatalf("appA Init: %v", err)
}
if err := appB.Init(); err != nil {
t.Fatalf("appB Init: %v", err)
}
defer appB.Shutdown()
// 轮询等第一个 1s tick:两个调度器都应各跑至少一次(deadline 3s,容忍慢机/CI 漂移)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if ranA.Load() > 0 && ranB.Load() > 0 {
break
}
time.Sleep(50 * time.Millisecond)
}
aAtTick := ranA.Load()
bAtTick := ranB.Load()
if aAtTick == 0 || bAtTick == 0 {
t.Fatalf("expected both schedulers ran at least once: A=%d B=%d", aAtTick, bAtTick)
}
// 停 App A
if err := appA.Shutdown(); err != nil {
t.Fatalf("appA Shutdown: %v", err)
}
// 轮询等 B 再跑一次(deadline 3s),证明 B 调度器未被 A.Shutdown 停掉
deadline = time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if ranB.Load() > bAtTick {
break
}
time.Sleep(50 * time.Millisecond)
}
if ranA.Load() != aAtTick {
t.Errorf("App A scheduler ran after its Shutdown (not isolated): before=%d after=%d", aAtTick, ranA.Load())
}
if ranB.Load() <= bAtTick {
t.Errorf("App B scheduler did not continue after App A Shutdown (not isolated): before=%d after=%d", bAtTick, ranB.Load())
}
}
+116
View File
@@ -0,0 +1,116 @@
package xlgo_test
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"sync"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// appHungDriver 是测试用 database/sql 驱动,Conn.Ping 阻塞到 ctx 取消,模拟挂起 DB。
// 与 database 包内部的 hungDriver 同构(app_test 为外部包,需自带一份)。
type appHungDriver struct{}
func (appHungDriver) Open(string) (driver.Conn, error) { return appHungConn{}, nil }
type appHungConn struct{}
func (appHungConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("hung: not implemented") }
func (appHungConn) Close() error { return nil }
func (appHungConn) Begin() (driver.Tx, error) { return nil, errors.New("hung: not implemented") }
// Ping 阻塞到 ctx 取消(实现 driver.Pinger,方法名 Ping 而非 PingContext)。
func (appHungConn) Ping(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
var appHungRegisterOnce sync.Once
func registerAppHungDialect() {
appHungRegisterOnce.Do(func() {
sql.Register("xlgo_app_hung_hdb1", appHungDriver{})
database.RegisterDialect(database.DialectSpec{
Name: "app_hung_hdb1",
Dialector: func(string) gorm.Dialector {
db, _ := sql.Open("xlgo_app_hung_hdb1", "")
return appHungDialector{sqlDB: db}
},
DSN: func(*config.DatabaseConfig) string { return "app_hung://" },
})
})
}
// appHungDialector 让 gorm.Open 成功并注入挂起 *sql.DB,使 initDB 的 pingWithTimeout 触发。
type appHungDialector struct{ sqlDB *sql.DB }
func (d appHungDialector) Name() string { return "app_hung_hdb1" }
func (d appHungDialector) Initialize(db *gorm.DB) error { db.Config.ConnPool = d.sqlDB; return nil }
func (d appHungDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
func (d appHungDialector) DataTypeOf(*schema.Field) string { return "" }
func (d appHungDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
func (d appHungDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
func (d appHungDialector) QuoteTo(w clause.Writer, s string) { _, _ = w.WriteString(s) }
func (d appHungDialector) Explain(s string, _ ...any) string { return s }
// TestAppInitHungDBBounded_Hdb1 端到端回归 H-db-1App 级闭环):
// App.Init 启用 MySQL 且 DB 挂起时,initDB 的 pingWithTimeout 3s 失败 -> InitDB 返回错误
// -> App.Init 失败(failAfterInit 回滚)。全程有界,不因挂起 DB 无限卡死 App.Init。
// 修复前 gorm.Open 的 automatic Ping() 在 pingWithTimeout 之前就无限阻塞 -> App.Init 永久 hang。
// 同时验证 App.Init 失败后无 goroutine 泄漏(closeResources 正常)。
func TestAppInitHungDBBounded_Hdb1(t *testing.T) {
registerAppHungDialect()
cfg := &config.Config{
App: config.AppConfig{Env: "test"},
Server: config.ServerConfig{Port: 18091},
Database: config.DatabaseConfig{
Driver: "app_hung_hdb1",
Host: "localhost", Port: 3306, User: "u", Password: "p", Name: "n",
},
}
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithMySQL())
done := make(chan error, 1)
start := time.Now()
go func() { done <- app.Init() }()
select {
case err := <-done:
elapsed := time.Since(start)
if err == nil {
t.Fatalf("挂起 DB 下 App.Init 应失败,got nil")
}
// initDB pingWithTimeout 3s 失败后重试 5 次(每次 ping 3s + retryDelay 1s 起步翻倍),
// 全跑约 5*4s=20s+。放宽到 35s 上限。关键是不触发下方 45s watchdog 的"无限阻塞"。
if elapsed > 35*time.Second {
t.Fatalf("App.Init 耗时 %v 超过 35s 上限(H-db-1initDB ping 应被 3s 约束,重试 5 次应 ~20s", elapsed)
}
case <-time.After(45 * time.Second):
t.Fatalf("App.Init 在挂起 DB 上无限阻塞(H-db-1gorm.Open automatic ping 或 initDB ping 未被超时约束)")
}
// App.Init 失败后应能正常 ShutdownfailAfterInit 已回滚资源,Shutdown 幂等)。
shutdownDone := make(chan error, 1)
go func() { shutdownDone <- app.Shutdown() }()
select {
case err := <-shutdownDone:
if err != nil {
t.Logf("App.Shutdown after failed Init returned err: %v(可接受,资源已回滚)", err)
}
case <-time.After(10 * time.Second):
t.Fatalf("App.Shutdown 在 Init 失败后无限阻塞(closeResources 未正常收口)")
}
// rootCtx 已被 failAfterInit cancel,无残留 goroutineStartProbing 未启动因 Init 失败)。
// 此处不直接断言 goroutine 数(受测试 runtime 噪声影响),靠 Shutdown 有界退出间接证明。
_ = context.Background()
}
+463
View File
@@ -0,0 +1,463 @@
package xlgo_test
import (
"context"
"errors"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/cron"
"github.com/EthanCodeCraft/xlgo-core/logger"
"gorm.io/gorm"
)
// TestAppGoAddWaitRace_M1 回归(须 -race):App.Go 的 wg.Add 与 Shutdown 的 wg.Wait 并发,
// 必须由 lifecycleMu 串行化(Add happens-before Wait),否则违反 WaitGroup 契约。
// 修复前:-race 报 wg.Add/wg.Wait data race 或 "negative WaitGroup counter" panic。
func TestAppGoAddWaitRace_M1(t *testing.T) {
const M = 20
const iters = 20
for iter := 0; iter < iters; iter++ {
app := xlgo.New()
var spawned, exited atomic.Int32
gate := make(chan struct{})
var goDone sync.WaitGroup
goDone.Add(M)
for i := 0; i < M; i++ {
go func() {
<-gate
app.Go(func(ctx context.Context) {
spawned.Add(1)
<-ctx.Done()
exited.Add(1)
})
goDone.Done()
}()
}
shutCh := make(chan error, 1)
go func() {
<-gate
shutCh <- app.Shutdown()
}()
close(gate)
goDone.Wait()
err := <-shutCh
if err != nil {
t.Fatalf("iter %d: Shutdown error: %v", iter, err)
}
sp, ex := spawned.Load(), exited.Load()
if sp != ex {
t.Fatalf("iter %d: spawned %d != exited %d (goroutine leak / Add-after-Wait)", iter, sp, ex)
}
}
}
// TestAppGoRefusedAfterShutdown_M1Shutdown 后 Go() 必须拒绝(state>=Stopping),不 spawn。
func TestAppGoRefusedAfterShutdown_M1(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18102)))
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
ran := make(chan struct{}, 1)
app.Go(func(context.Context) { ran <- struct{}{} })
time.Sleep(50 * time.Millisecond) // 给可能被 spawn 的 goroutine 时间发送
select {
case <-ran:
t.Fatal("Go ran after Shutdown — should be refused (state=Stopping)")
default:
}
}
// TestAppInitAfterShutdownRejected_M1Edge 1):Shutdown 后再 Init 必须返 ErrAppClosed。
func TestAppInitAfterShutdownRejected_M1(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18101)))
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
if err := app.Init(); !errors.Is(err, xlgo.ErrAppClosed) {
t.Fatalf("Init after Shutdown err = %v, want ErrAppClosed", err)
}
}
// TestAppShutdownIdempotent_M1:并发 Shutdown 幂等,OnStop 仅执行一次。
func TestAppShutdownIdempotent_M1(t *testing.T) {
var stopCount atomic.Int32
app := xlgo.New(
xlgo.WithConfig(testConfig(18100)),
xlgo.WithHook(xlgo.Hook{Name: "count-stop", OnStop: func(*xlgo.App) error { stopCount.Add(1); return nil }}),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); _ = app.Shutdown() }()
go func() { defer wg.Done(); _ = app.Shutdown() }()
wg.Wait()
if stopCount.Load() != 1 {
t.Fatalf("OnStop called %d times, want 1 (idempotent)", stopCount.Load())
}
if err := app.Shutdown(); err != nil {
t.Fatalf("third Shutdown error: %v", err)
}
if stopCount.Load() != 1 {
t.Fatalf("OnStop called %d times after 3rd, want 1", stopCount.Load())
}
}
// TestAppInitFailureRollsBackCron_M1Edge 3 + 资源回滚):Init 失败(OnInit hook 报错)时,
// 已 cron.Start() 的全局调度器必须被 failAfterInit 停止,canary 任务不再触发。
// 修复前:Init 失败不停 cron → canary 在下个 1s tick 跑 → ran > 0。
// TestAppShutdownTimeoutCoversOnStop_M1 回归:OnStop 必须受 server.shutdown_timeout 约束。
// 修复前:OnStop 同步执行且不看超时,阻塞 hook 会拖死 Shutdown。
func TestAppShutdownTimeoutCoversOnStop_M1(t *testing.T) {
cfg := testConfig(18105)
cfg.Server.ShutdownTimeout = 30 * time.Millisecond
release := make(chan struct{})
defer close(release)
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{Name: "slow-stop", OnStop: func(*xlgo.App) error {
<-release
return nil
}}),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
start := time.Now()
err := app.Shutdown()
if err == nil || !strings.Contains(err.Error(), "OnStop hook") {
t.Fatalf("Shutdown err = %v, want OnStop timeout error", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("Shutdown took %s, OnStop timeout did not bound shutdown", elapsed)
}
}
// TestWithConfigClonesAndValidates_M1 回归:WithConfig 捕获私有快照,并在 Init 时校验。
func TestWithConfigClonesAndValidates_M1(t *testing.T) {
cfg := testConfig(18106)
app := xlgo.New(xlgo.WithConfig(cfg))
cfg.Server.Port = -1 // 不应污染 App 内部快照
if err := app.Init(); err != nil {
t.Fatalf("Init with cloned config failed after caller mutation: %v", err)
}
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
bad := testConfig(-1)
err := xlgo.New(xlgo.WithConfig(bad)).Init()
if err == nil || !strings.Contains(err.Error(), "配置校验失败") {
t.Fatalf("Init with invalid WithConfig err = %v, want validation error", err)
}
}
func TestAppInitFailureRollsBackCron_M1(t *testing.T) {
cron.StopGlobalWithTimeout(time.Second) // 清前序残留
t.Cleanup(func() {
cron.GetScheduler().RemoveTask("canary-m1")
cron.StopGlobalWithTimeout(time.Second)
})
var ran atomic.Int32
// Phase 3canary 注册到 App 专属调度器(WithCronTask),Init 失败时 stopCron 必须停掉它。
app := xlgo.New(
xlgo.WithConfig(testConfig(18104)),
xlgo.WithCronTask("canary-m1", cron.Every(time.Millisecond), func(context.Context) error {
ran.Add(1)
return nil
}),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom-init") }}),
)
err := app.Init()
if err == nil || !strings.Contains(err.Error(), "boom-init") {
t.Fatalf("Init err = %v, want boom-init", err)
}
time.Sleep(1300 * time.Millisecond) // 跨一个 1s ticker
if ran.Load() != 0 {
t.Fatalf("canary ran %d times after Init failure — cron not stopped by rollback", ran.Load())
}
}
// TestAppGoRefusedAfterInitFailure_M1Init 失败后 failAfterInit 置 state=Stopping
// 后续 Go() 拒绝;先前的 Go goroutine 因 rootCtx cancel 退出(不泄漏)。
func TestAppGoRefusedAfterInitFailure_M1(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18103)),
xlgo.WithMigrator(func(*gorm.DB) error { return nil }), // 无 WithMySQL → Init 确定性失败
)
firstDone := make(chan struct{})
app.Go(func(ctx context.Context) { <-ctx.Done(); close(firstDone) })
if err := app.Init(); err == nil {
t.Fatal("expected Init error")
}
select {
case <-firstDone:
case <-time.After(5 * time.Second):
t.Fatal("first Go goroutine did not exit after Init failure (rootCtx not cancelled)")
}
ran := make(chan struct{}, 1)
app.Go(func(context.Context) { ran <- struct{}{} })
time.Sleep(50 * time.Millisecond)
select {
case <-ran:
t.Fatal("Go ran after Init failure — should be refused (state=Stopping)")
default:
}
}
// TestAppInitFailureDoesNotCloseUnownedGlobals_M1 回归:一个尚未成功初始化任何资源的
// App Init 失败时,不得关闭进程里已有的全局 logger/db/redis 等资源。
func TestAppInitFailureDoesNotCloseUnownedGlobals_M1(t *testing.T) {
dir := t.TempDir()
if err := logger.Init(&config.Config{
App: config.AppConfig{Env: "test"},
Server: config.ServerConfig{Mode: "development"},
Log: config.LogConfig{Dir: dir, MaxSize: 1, MaxBackups: 1, MaxAge: 1},
}); err != nil {
t.Fatalf("logger.Init: %v", err)
}
t.Cleanup(func() { _ = logger.Close() })
missingConfig := filepath.Join(t.TempDir(), "missing.yaml")
if err := xlgo.New(xlgo.WithConfigPath(missingConfig)).Init(); err == nil {
t.Fatal("expected Init error without config")
}
const mark = "M1_UNOWNED_LOGGER_STILL_ACTIVE"
logger.Info(mark)
_ = logger.Sync()
data, err := os.ReadFile(filepath.Join(dir, "app.log"))
if err != nil {
t.Fatalf("read app.log: %v", err)
}
if !strings.Contains(string(data), mark) {
t.Fatal("unowned global logger was closed by failed App.Init")
}
}
// TestAppInitShutdownConcurrentSerialization_M1 直接覆盖 initMu 串行化 doInit/doShutdown
// Edge 2):Init 持 initMu 期间(OnInit hook 阻塞),并发 Shutdown 必须阻塞等 Init 释放,
// 不能与 doInit 并发改资源。确定性逻辑测试,不依赖 -race 概率。
func TestAppInitFailureRestoresReplacedLogger_M1(t *testing.T) {
oldMgr := logger.NewLogManager()
oldDir := t.TempDir()
if err := oldMgr.Init(&config.Config{
App: config.AppConfig{Env: "test"},
Server: config.ServerConfig{Mode: "development"},
Log: config.LogConfig{Dir: oldDir, MaxSize: 1, MaxBackups: 1, MaxAge: 1},
}); err != nil {
t.Fatalf("old logger Init: %v", err)
}
logger.SetDefaultLogManager(oldMgr)
t.Cleanup(func() {
_ = logger.Close()
logger.SetDefaultLogManager(logger.NewLogManager())
})
cfg := testConfig(18105)
cfg.Log.Dir = t.TempDir()
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithLogger(),
xlgo.WithHook(xlgo.Hook{Name: "fail-after-logger", OnInit: func(*xlgo.App) error {
return errors.New("boom-after-logger")
}}),
)
err := app.Init()
if err == nil || !strings.Contains(err.Error(), "boom-after-logger") {
t.Fatalf("Init err = %v, want boom-after-logger", err)
}
if got := logger.GetDefaultLogManager(); got != oldMgr {
t.Fatalf("default logger manager was not restored after Init failure: got %p want %p", got, oldMgr)
}
const mark = "M1_RESTORED_LOGGER_STILL_ACTIVE"
logger.Info(mark)
_ = logger.Sync()
data, err := os.ReadFile(filepath.Join(oldDir, "app.log"))
if err != nil {
t.Fatalf("read old app.log: %v", err)
}
if !strings.Contains(string(data), mark) {
t.Fatal("restored logger did not write to old app.log")
}
}
func TestAppInitShutdownConcurrentSerialization_M1(t *testing.T) {
initStarted := make(chan struct{})
releaseInit := make(chan struct{})
app := xlgo.New(
xlgo.WithConfig(testConfig(18200)),
xlgo.WithHook(xlgo.Hook{
Name: "block-init",
OnInit: func(*xlgo.App) error {
close(initStarted)
<-releaseInit
return nil
},
}),
)
initDone := make(chan error, 1)
go func() { initDone <- app.Init() }()
<-initStarted // OnInit 在 doInit 末尾运行 → 此时 Init 持 initMu
// 并发 Shutdown:必须阻塞等 initMu,不能在 Init 完成前返回。
shutDone := make(chan error, 1)
go func() { shutDone <- app.Shutdown() }()
select {
case <-shutDone:
t.Fatal("Shutdown returned before Init finished — initMu not serializing doInit/doShutdown")
case <-time.After(150 * time.Millisecond):
// 期望:Shutdown 仍阻塞等 initMu
}
close(releaseInit) // 让 OnInit 返回 → doInit 完成 → Init 释放 initMu
select {
case err := <-initDone:
if err != nil {
t.Fatalf("Init: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Init did not return after release")
}
select {
case <-shutDone:
// Shutdown 在 Init 后获得 initMu 并完成
case <-time.After(5 * time.Second):
t.Fatal("Shutdown did not return after Init finished — initMu not released?")
}
}
// TestAppStartServerListenFailureSkipsOnReady_M1 回归:监听失败时不得先执行 OnReady。
// 修复前 StartServer 在 goroutine 内 ListenAndServe,主 goroutine 立即跑 OnReady
// 端口被占用时仍可能先触发 ready 副作用,再返回启动失败。
func TestAppStartServerListenFailureSkipsOnReady_M1(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("occupy port: %v", err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
cfg := testConfig(port)
cfg.Server.Host = "127.0.0.1"
var ready atomic.Int32
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{
Name: "ready-must-not-run",
OnReady: func(*xlgo.App) error {
ready.Add(1)
return nil
},
}),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
err = app.StartServer()
if err == nil || !strings.Contains(err.Error(), "服务器监听失败") {
t.Fatalf("StartServer err = %v, want listen failure", err)
}
if ready.Load() != 0 {
t.Fatalf("OnReady ran %d times despite listen failure", ready.Load())
}
_ = app.Shutdown()
}
// TestAppStartServerTLSFailureSkipsOnReady_M1 回归:TLS 证书装配失败也不得触发 OnReady。
func TestAppStartServerTLSFailureSkipsOnReady_M1(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve port: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
if err := ln.Close(); err != nil {
t.Fatalf("release port: %v", err)
}
cfg := testConfig(port)
cfg.Server.Host = "127.0.0.1"
cfg.Server.TLS.Enabled = true
cfg.Server.TLS.CertFile = filepath.Join(t.TempDir(), "missing.crt")
cfg.Server.TLS.KeyFile = filepath.Join(t.TempDir(), "missing.key")
var ready atomic.Int32
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{
Name: "tls-ready-must-not-run",
OnReady: func(*xlgo.App) error {
ready.Add(1)
return nil
},
}),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
err = app.StartServer()
if err == nil || !strings.Contains(err.Error(), "服务器 TLS 配置无效") {
t.Fatalf("StartServer err = %v, want TLS configuration failure", err)
}
if ready.Load() != 0 {
t.Fatalf("OnReady ran %d times despite TLS failure", ready.Load())
}
_ = app.Shutdown()
}
// TestAppStartServerUnixSocketListens_M1 回归:server.unix_socket 必须真的通过
// net.Listen("unix", path) 监听,而不是把 path 塞给 TCP ListenAndServe 的 Addr。
func TestAppStartServerUnixSocketListens_M1(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows unix socket support varies by environment")
}
sock := filepath.Join(t.TempDir(), "xlgo.sock")
cfg := testConfig(0)
cfg.Server.UnixSocket = sock
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{
Name: "dial-unix-ready",
OnReady: func(*xlgo.App) error {
conn, err := net.DialTimeout("unix", sock, time.Second)
if err != nil {
return err
}
_ = conn.Close()
return errors.New("stop after unix socket ready")
},
}),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
err := app.StartServer()
if err == nil || !strings.Contains(err.Error(), "stop after unix socket ready") {
t.Fatalf("StartServer err = %v, want OnReady sentinel after unix dial", err)
}
}
+101
View File
@@ -0,0 +1,101 @@
package xlgo_test
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/gin-gonic/gin"
)
// TestAppRateLimitPerAppCountingIsolation 固化 #1 修复:app.RateLimitRegistry().LoginRateLimit()
// 返回的中间件捕获 App 自己的 Registry,多 App 下 per-App 计数隔离。修复前包级 LoginRateLimit()
// 解析全局默认(多 App 仅最后 Init 的 App),App A 用满额度后 App B 首请求即被拒。
//
// 验证:App A 用满 10/10 后第 11 次 429App B 同 IP 仍可 10 次 200、第 11 次 429(独立计数)。
func TestAppRateLimitPerAppCountingIsolation(t *testing.T) {
// REST 模式:限流映射 HTTP 429
cfgA := testConfig(18140)
cfgA.Server.ResponseMode = "rest"
cfgB := testConfig(18141)
cfgB.Server.ResponseMode = "rest"
appA := xlgo.New(xlgo.WithConfig(cfgA))
if appA.RateLimitRegistry() == nil {
t.Fatal("appA.RateLimitRegistry() = nil before Init (should be pre-created in New)")
}
if err := appA.Init(); err != nil {
t.Fatalf("appA Init: %v", err)
}
defer appA.Shutdown()
// app-bound 中间件:捕获 appA 的 Registry,不查全局默认
appA.GetRouter().GET("/login", appA.RateLimitRegistry().LoginRateLimit(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
appB := xlgo.New(xlgo.WithConfig(cfgB))
if err := appB.Init(); err != nil {
t.Fatalf("appB Init: %v", err)
}
defer appB.Shutdown()
appB.GetRouter().GET("/login", appB.RateLimitRegistry().LoginRateLimit(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := func(h http.Handler) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/login", nil)
r.RemoteAddr = "192.0.2.7:1234" // 两 App 用同一 IP
h.ServeHTTP(w, r)
return w
}
// App A 用满 10 次(200),第 11 次 429
for i := 0; i < 10; i++ {
if w := req(appA.GetRouter()); w.Code != http.StatusOK {
t.Fatalf("appA request %d: got %d, want 200", i+1, w.Code)
}
}
if w := req(appA.GetRouter()); w.Code != http.StatusTooManyRequests {
t.Fatalf("appA 11th: got %d, want 429 (limit reached)", w.Code)
}
// App B 同 IP:应独立计数,10 次 200、第 11 次 429。
// 包级 facade 下 B 会共享 A 的 loginLimiterA 已用满)-> 首请求即 429,本测试即暴露该回归。
for i := 0; i < 10; i++ {
if w := req(appB.GetRouter()); w.Code != http.StatusOK {
t.Fatalf("appB request %d: got %d, want 200 (per-App isolation: B should not share A's count)", i+1, w.Code)
}
}
if w := req(appB.GetRouter()); w.Code != http.StatusTooManyRequests {
t.Fatalf("appB 11th: got %d, want 429 (B independent limit reached)", w.Code)
}
}
// TestAppRateLimitRegistryCustomNoLeakOnRollback 固化 #2 修复路径:app-bound CustomRateLimit
// 的 limiter 登记在 App 的 Registry 上,Init 失败回滚时由 registry.Stop() 收口,不泄漏。
// 验证:注册 CustomRateLimit 任务后 Init 失败,rollback 调 registry.StopcustomLimiters 被清)。
func TestAppRateLimitRegistryCustomNoLeakOnRollback(t *testing.T) {
cfg := testConfig(18142)
app := xlgo.New(xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
)
// app-bound CustomRateLimit(首请求才创建 limiter,但 Init 会失败故不会触发请求;
// 此用例固化 app.RateLimitRegistry() 在 pre-Init 可用、Init 失败回滚不 panic)
app.GetRouter().GET("/c", app.RateLimitRegistry().CustomRateLimit(5, time.Minute), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
// Init 失败后 rollbackReplacedResources 已 Stop App 的 Registry 并把 a.rateLimitRegistry 置 nil
// 故 app.RateLimitRegistry() 返回 nilrollback 已收口,无泄漏、无 panic)。
if app.RateLimitRegistry() != nil {
t.Fatal("a.rateLimitRegistry should be nil after Init-failure rollback")
}
}
+107
View File
@@ -0,0 +1,107 @@
package xlgo_test
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/gin-gonic/gin"
)
// TestAppRateLimitMultiAppShutdownIsolation 固化 Phase 5 核心契约:单进程多 App 时,
// App A 的 Shutdown 不得重置/停止 App B 的限流器。修复前 App.Shutdown 调全局
// middleware.StopRateLimiters,把全局 loginLimiter 置 nilApp B 下次请求会懒创建
// 新 limiter(计数清零)--稳态客户端借 App A 的 Shutdown 窗口绕过限流。
//
// 验证:App B 的 /login 已用 10/10 次后,App A.Shutdown,第 11 次仍应 429(计数保留)。
// 修复前第 11 次会 200(计数被重置)。
func TestAppRateLimitMultiAppShutdownIsolation(t *testing.T) {
saved := middleware.SwapDefaultRateLimitRegistry(middleware.NewRateLimitRegistry())
defer middleware.SwapDefaultRateLimitRegistry(saved)
// 用 REST 响应模式:限流映射 HTTP 429business 模式下是 200+业务码 429,不便断言)
cfgA := testConfig(18130)
cfgA.Server.ResponseMode = "rest"
cfgB := testConfig(18131)
cfgB.Server.ResponseMode = "rest"
appA := xlgo.New(xlgo.WithConfig(cfgA))
if err := appA.Init(); err != nil {
t.Fatalf("appA Init: %v", err)
}
appA.GetRouter().GET("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
appB := xlgo.New(xlgo.WithConfig(cfgB))
if err := appB.Init(); err != nil {
t.Fatalf("appB Init: %v", err)
}
defer appB.Shutdown()
appB.GetRouter().GET("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// 10 次请求 App B 的 /login(限流 10/min,同一 IP-> 全 200
for i := 0; i < 10; i++ {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.RemoteAddr = "192.0.2.1:1234"
appB.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d: got %d, want 200 (under limit)", i+1, w.Code)
}
}
// 第 11 次(不经 A.Shutdown)应 429 -- 先确认限流器确实计数到上限(排除 IP/计数 bug)
{
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.RemoteAddr = "192.0.2.1:1234"
appB.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("sanity: 11th without shutdown got %d, want 429 (limiter not counting?)", w.Code)
}
}
// App A Shutdown -- 不得重置/停止 App B 的 login limiter
if err := appA.Shutdown(); err != nil {
t.Fatalf("appA Shutdown: %v", err)
}
// 第 12 次请求 App B 的 /login -> 应仍 429(计数保留在 10registryB 未被 A.Shutdown 重置)。
// 修复前 A.Shutdown 调 StopRateLimiters 把全局 loginLimiter 置 nil,下次请求懒创建新 limiter(计数清零)-> 200。
{
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.RemoteAddr = "192.0.2.1:1234"
appB.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Errorf("12th request after appA Shutdown: got %d, want 429 (limiter count should be preserved, not reset by appA.Shutdown)", w.Code)
}
}
}
// TestAppRateLimitRollbackOnInitFailure 固化 Phase 5 回滚:Init 失败后全局默认 Registry
// 必须恢复为 Init 前的实例。
func TestAppRateLimitRollbackOnInitFailure(t *testing.T) {
preInit := middleware.NewRateLimitRegistry()
saved := middleware.SwapDefaultRateLimitRegistry(preInit)
defer middleware.SwapDefaultRateLimitRegistry(saved)
app := xlgo.New(
xlgo.WithConfig(testConfig(18132)),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
)
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
if middleware.GetDefaultRateLimitRegistry() != preInit {
t.Fatal("rollback did not restore preInit rate limit registry as global default")
}
}
+71
View File
@@ -0,0 +1,71 @@
package xlgo_test
import (
"errors"
"testing"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/storage"
)
// localStorageCfg 构造一个本地存储配置(tmpdir 由调用方提供)。
func localStorageCfg(port int, dir string) *config.Config {
cfg := testConfig(port)
cfg.Storage = config.StorageConfig{
Driver: "local",
Local: config.LocalStorageConfig{Path: dir},
}
return cfg
}
// TestAppWithStorageSwapsDefault 固化 Phase 2 契约:App.Init(WithStorage) 必须把 App 的
// StorageManager 提升为全局默认。证据:Init 前 GetStorage()=nil(空 manager),Init 后非 nil。
func TestAppWithStorageSwapsDefault(t *testing.T) {
// 用空 manager 作"Init 前"默认,确保 GetStorage() 起点 nil
saved := storage.SwapDefaultStorageManager(storage.NewStorageManager())
defer storage.SwapDefaultStorageManager(saved)
if s := storage.GetStorage(); s != nil {
t.Fatalf("precondition: GetStorage() should be nil, got %T", s)
}
dir := t.TempDir()
app := xlgo.New(xlgo.WithConfig(localStorageCfg(18094, dir)), xlgo.WithStorage())
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
defer app.Shutdown()
if s := storage.GetStorage(); s == nil {
t.Fatal("storage.GetStorage() = nil after App.Init with WithStorage(未把 App manager 提升为全局默认)")
}
}
// TestAppStorageRollbackOnInitFailure 固化 Phase 2 回滚契约:Init 在 storage 之后失败时,
// rollbackReplacedResources 必须把全局默认 swap 回 Init 前的旧 manager。
// 用 OnInit hook 触发失败(OnInit 在 storage init 之后、commitReplacedResources 之前)。
func TestAppStorageRollbackOnInitFailure(t *testing.T) {
saved := storage.SwapDefaultStorageManager(storage.NewStorageManager())
defer storage.SwapDefaultStorageManager(saved)
dir := t.TempDir()
app := xlgo.New(
xlgo.WithConfig(localStorageCfg(18095, dir)),
xlgo.WithStorage(),
xlgo.WithHook(xlgo.Hook{
Name: "force-fail",
OnInit: func(*xlgo.App) error { return errors.New("boom") },
}),
)
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
// 回滚后全局默认应恢复为 saved(空 manager-> GetStorage() 返回 nil
if s := storage.GetStorage(); s != nil {
t.Errorf("Init 失败回滚未恢复 storage 全局默认: GetStorage()=%T", s)
}
}
+118
View File
@@ -3,6 +3,7 @@ package xlgo_test
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -10,6 +11,7 @@ import (
"strconv"
"strings"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -241,3 +243,119 @@ func TestAppWithConfigPathDrivesManager(t *testing.T) {
t.Fatalf("expected GetInt(server.port)=18092, got %d", config.GetInt("server.port"))
}
}
// TestAppMetricsInstrumentsRegistryRoutes_H8c 端到端验证 H8c:经注册中心注册的业务路由
// 被指标中间件采集,不依赖 RegisterMetricsRoute 的调用顺序。修复前 RegisterMetricsRoute
// 用 r.Use 仅采集其后注册的路由;修复后采集中间件在 Apply 内作首个全局中间件装入。
func TestAppMetricsInstrumentsRegistryRoutes_H8c(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18093)),
xlgo.WithMetricsRoute(),
xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) {
r.GET("/h8c-biz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
})),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
r := app.GetRouter()
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8c-biz", nil))
if w.Code != http.StatusOK {
t.Fatalf("/h8c-biz status = %d, want 200", w.Code)
}
}
// 拉取 /metrics,断言 /h8c-biz 路由被采集(route 标签存在)。
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if w.Code != http.StatusOK {
t.Fatalf("/metrics status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `route="/h8c-biz"`) {
t.Fatalf("metrics output should contain route=\"/h8c-biz\" series, got:\n%s", body)
}
}
// freePort 返回一个当前空闲的 TCP 端口。
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freePort: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// TestAppOnReadyFailureReleasesPort H-1 回归:OnReady 失败时 Run 应返回错误,
// 且 HTTP 端口被释放(监听 goroutine 不泄漏)。
// 修复前 OnReady 失败直接 return,监听 goroutine 仍阻塞在 ListenAndServe
// 端口不释放、goroutine 泄漏。
func TestAppOnReadyFailureReleasesPort(t *testing.T) {
port := freePort(t)
app := xlgo.New(
xlgo.WithConfig(testConfig(port)),
xlgo.WithHook(xlgo.Hook{
Name: "fail-on-ready",
OnReady: func(*xlgo.App) error { return errors.New("boom") },
}),
)
errCh := make(chan error, 1)
go func() { errCh <- app.Run() }()
select {
case err := <-errCh:
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected OnReady failure error, got %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("Run did not return after OnReady failure (server goroutine leaked?)")
}
// 端口应已释放:能重新 bind。容忍 TIME_WAIT 短暂残留。
deadline := time.Now().Add(3 * time.Second)
for {
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
if err == nil {
l.Close()
break
}
if time.Now().After(deadline) {
t.Fatalf("port %d not released 3s after Run returned: %v (server goroutine leaked)", port, err)
}
time.Sleep(50 * time.Millisecond)
}
}
// TestAppInitFailureCancelsGoGoroutine H-2 回归:App.Go 启动的 goroutine 在 Init 失败后
// 应因 rootCtx cancel 而退出,不泄漏。
// 修复前 Init 失败不 cancel rootCtxgoroutine 永久阻塞在 ctx.Done()。
func TestAppInitFailureCancelsGoGoroutine(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18087)),
// WithMigrator 无 WithMySQL → Init 确定性失败("未启用 MySQL"
xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }),
)
ctxDone := make(chan struct{})
app.Go(func(ctx context.Context) {
<-ctx.Done()
close(ctxDone)
})
if err := app.Init(); err == nil {
t.Fatal("expected Init error")
}
select {
case <-ctxDone:
// 好:goroutine 在 Init 失败后退出
case <-time.After(5 * time.Second):
t.Fatal("App.Go goroutine did not exit after Init failure (rootCtx not cancelled, leaked)")
}
}
+120
View File
@@ -0,0 +1,120 @@
package xlgo_test
import (
"context"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/trace"
"github.com/gin-gonic/gin"
)
// TestWithTraceInitErrorPropagated 固化 Phase 1 契约:WithTrace 启用后 doInit 必须调
// trace.Init 并把错误向上传播。用非法 ExporterType 触发 trace.Init 失败(config.Validate
// 只校验 SampleRatio,故 bogus exporter 能过 Validate、在 trace.Init 失败,证明是 App 主动调 Init)。
func TestWithTraceInitErrorPropagated(t *testing.T) {
cfg := testConfig(18090)
cfg.Trace.Enabled = true
cfg.Trace.ExporterType = "bogus-exporter"
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
err := app.Init()
if err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail with bogus trace exporter, got nil")
}
// "初始化 trace 失败" 包裹 trace.Init 的 "不支持的导出器类型"
msg := err.Error()
if !strings.Contains(msg, "trace") && !strings.Contains(msg, "导出器") {
t.Fatalf("expected trace init error, got %v", err)
}
}
// TestWithTraceNoopMiddlewareDoesNotBreakRequest 固化:WithTrace + Enabled=false(默认)
// 时 trace.Init 安装 Noop tracertrace.Middleware 装入链后不应破坏正常请求。
func TestWithTraceNoopMiddlewareDoesNotBreakRequest(t *testing.T) {
cfg := testConfig(18091)
// Trace.Enabled 保持默认 false -> noop
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
defer app.Shutdown()
// Init 后(middleware 链已装入)注册路由,确保该路由经过 trace 中间件。
app.GetRouter().GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("trace noop middleware broke request: got status %d, body %s", w.Code, w.Body.String())
}
}
// TestWithTraceShutdownReleasesExporterGoroutine 固化 Phase 1 生命周期闭环:
// WithTrace + Enabled=true + stdout exporterInit 后 BatchSpanProcessor 后台 goroutine
// 在运行;Shutdown 必须调 trace.Close 让其退出,否则 goroutine 泄漏(H-14 修了 Close 幂等,
// 但 App 此前从未调 Close -- 本测试断言 App.Shutdown 确实调了)。
//
// 不发任何请求 -> stdout exporter 不输出 -> 无测试输出污染;仅观测 goroutine 计数。
func TestWithTraceShutdownReleasesExporterGoroutine(t *testing.T) {
cfg := testConfig(18092)
cfg.Trace.Enabled = true
cfg.Trace.ExporterType = "stdout"
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
baseline := runtime.NumGoroutine()
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
afterInit := runtime.NumGoroutine()
if afterInit <= baseline {
t.Logf("note: afterInit=%d baseline=%d (batch goroutine delta not detected; test may be noisy)", afterInit, baseline)
}
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
// trace.Close 的 provider.Shutdown 异步停止 batch goroutine,轮询等待回归 baseline。
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= baseline {
break
}
time.Sleep(20 * time.Millisecond)
}
if after := runtime.NumGoroutine(); after > baseline {
t.Errorf("trace.Close 未释放 exporter goroutineApp.Shutdown 疑未调 trace.Close: baseline=%d after=%d", baseline, after)
}
}
// TestWithTraceEnabledStdoutInitOK 固化:WithTrace + Enabled=true + stdout 是合法组合,
// trace.Init 成功,App.Init 成功;二次 trace.Close 幂等(H-14)。
func TestWithTraceEnabledStdoutInitOK(t *testing.T) {
cfg := testConfig(18093)
cfg.Trace.Enabled = true
cfg.Trace.ExporterType = "stdout"
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
// Shutdown 已调 trace.Close;再调一次必须幂等不报错(H-14 契约)
if err := trace.Close(context.Background()); err != nil {
t.Fatalf("trace.Close after Shutdown should be idempotent: %v", err)
}
}
+121 -46
View File
@@ -4,68 +4,89 @@ import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// CacheService 缓存服务接口
type CacheService interface {
// Get 获取缓存值,如果存在则反序列化到 dest 并返回 true
Get(ctx context.Context, key string, dest any) bool
// Get 获取缓存值,命中则反序列化到 dest 并返回 (true, nil);未命中返回 (false, nil)
// Redis 未就绪、命令错误或反序列化失败返回 (false, err)。调用方须显式处理错误。
Get(ctx context.Context, key string, dest any) (bool, error)
// Set 设置缓存值
Set(ctx context.Context, key string, value any, ttl time.Duration) error
// Delete 删除缓存
Delete(ctx context.Context, key string) error
// DeleteByPattern 按模式删除缓存
DeleteByPattern(ctx context.Context, pattern string) error
// Exists 检查缓存是否存在
Exists(ctx context.Context, key string) bool
// Exists 检查缓存是否存在;未命中返回 (false, nil),后端错误返回 (false, err)。
Exists(ctx context.Context, key string) (bool, error)
}
// redisCache Redis 缓存实现
// redisCache Redis 缓存实现
//
// 不在构造时快照 redis.ClientM12 修复:原 NewRedisCache 构造时取 database.GetRedis()
// 若在 database.InitRedis 之前构造则永久 nil、即使后续 Redis 就绪也是 no-op)。
// 改为每次操作实时取 redis 客户端,使"先构造后 Init Redis"的顺序也能正确工作。
//
// Phase 4:持有可选的注入 clientNewRedisCacheWithRedis),注入优先、否则回退全局
// database.GetRedis()(照 jwt.TokenBlacklist.redisClient 模型)。App 经 InitWithRedis
// 注入 per-App redisManager.Client(),使缓存走 App 自己的 Redis 而非全局(修复跨 App 串越)。
type redisCache struct {
client *redis.Client
injectedClient *redis.Client
}
// NewRedisCache 创建 Redis 缓存实例
// client 返回缓存使用的 Redis 客户端:注入优先,否则回退全局 database.GetRedis()。
func (c *redisCache) client() *redis.Client {
if c != nil && c.injectedClient != nil {
return c.injectedClient
}
return database.GetRedis()
}
// NewRedisCache 创建 Redis 缓存实例(懒取全局 Redis,兼容 standalone 用法)。
func NewRedisCache() CacheService {
return &redisCache{
client: database.GetRedis(),
}
return &redisCache{}
}
// Get 获取缓存值
func (c *redisCache) Get(ctx context.Context, key string, dest any) bool {
if c.client == nil {
return false
// NewRedisCacheWithRedis 创建使用指定 Redis 客户端的缓存实例(多 Redis/测试隔离)。
func NewRedisCacheWithRedis(client *redis.Client) CacheService {
return &redisCache{injectedClient: client}
}
// Get 获取缓存值。命中返回 (true, nil);未命中返回 (false, nil)
// Redis 未就绪返回 (false, ErrRedisNotReady)Redis 命令错误或反序列化失败返回 (false, err)。
func (c *redisCache) Get(ctx context.Context, key string, dest any) (bool, error) {
cli := c.client()
if cli == nil {
return false, ErrRedisNotReady
}
val, err := c.client.Get(ctx, key).Result()
val, err := cli.Get(ctx, key).Result()
if err != nil {
if err != redis.Nil {
logger.Warn("缓存获取失败", zap.String("key", key), zap.Error(err))
if err == redis.Nil {
return false, nil
}
return false
return false, err
}
if err := json.Unmarshal([]byte(val), dest); err != nil {
logger.Warn("缓存反序列化失败", zap.String("key", key), zap.Error(err))
return false
return false, err
}
return true
return true, nil
}
// Set 设置缓存值
func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
if c.client == nil {
return nil // Redis 未启用,跳过缓存
cli := c.client()
if cli == nil {
return ErrRedisNotReady
}
data, err := json.Marshal(value)
@@ -74,7 +95,7 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du
return err
}
if err := c.client.Set(ctx, key, data, ttl).Err(); err != nil {
if err := cli.Set(ctx, key, data, ttl).Err(); err != nil {
logger.Warn("缓存设置失败", zap.String("key", key), zap.Error(err))
return err
}
@@ -84,11 +105,12 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du
// Delete 删除缓存
func (c *redisCache) Delete(ctx context.Context, key string) error {
if c.client == nil {
return nil
cli := c.client()
if cli == nil {
return ErrRedisNotReady
}
if err := c.client.Del(ctx, key).Err(); err != nil {
if err := cli.Del(ctx, key).Err(); err != nil {
logger.Warn("缓存删除失败", zap.String("key", key), zap.Error(err))
return err
}
@@ -98,8 +120,9 @@ func (c *redisCache) Delete(ctx context.Context, key string) error {
// DeleteByPattern 按模式删除缓存(使用 SCAN 避免阻塞 Redis)
func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error {
if c.client == nil {
return nil
cli := c.client()
if cli == nil {
return ErrRedisNotReady
}
var cursor uint64
@@ -107,7 +130,7 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
for {
// 使用 SCAN 命令迭代查找匹配的键
keys, nextCursor, err := c.client.Scan(ctx, cursor, pattern, 100).Result()
keys, nextCursor, err := cli.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
logger.Warn("缓存键扫描失败", zap.String("pattern", pattern), zap.Error(err))
return err
@@ -115,7 +138,7 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
// 删除找到的键
if len(keys) > 0 {
if err := c.client.Del(ctx, keys...).Err(); err != nil {
if err := cli.Del(ctx, keys...).Err(); err != nil {
logger.Warn("缓存批量删除失败", zap.Strings("keys", keys), zap.Error(err))
return err
}
@@ -138,13 +161,19 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
return nil
}
// Exists 检查缓存是否存在
func (c *redisCache) Exists(ctx context.Context, key string) bool {
if c.client == nil {
return false
// Exists 检查缓存是否存在。命中返回 (true, nil);未命中返回 (false, nil)
// Redis 未就绪返回 (false, ErrRedisNotReady);命令错误返回 (false, err)。
func (c *redisCache) Exists(ctx context.Context, key string) (bool, error) {
cli := c.client()
if cli == nil {
return false, ErrRedisNotReady
}
return c.client.Exists(ctx, key).Val() > 0
n, err := cli.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
// CacheManager 缓存管理器(#10)。照 database.Manager 模式:
@@ -154,19 +183,38 @@ 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)
}
}
// SwapDefaultCacheManager 将指定 CacheManager 置为全局默认,返回被替换的旧 Manager。
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存(照
// SwapDefaultRedisManager / SwapDefaultStorageManager 模式)。nil 被忽略,返回当前默认。
func SwapDefaultCacheManager(m *CacheManager) *CacheManager {
if m == nil {
return defaultCachePtr.Load()
}
return defaultCachePtr.Swap(m)
}
// Init 初始化缓存服务(基于 DefaultRedis 的客户端)。
func (m *CacheManager) Init() {
m.mu.Lock()
@@ -174,6 +222,13 @@ func (m *CacheManager) Init() {
m.svc = NewRedisCache()
}
// InitWithRedis 用指定 Redis 客户端初始化缓存服务(多 Redis/测试隔离)。
func (m *CacheManager) InitWithRedis(client *redis.Client) {
m.mu.Lock()
defer m.mu.Unlock()
m.svc = NewRedisCacheWithRedis(client)
}
// Set 设置缓存服务实现(用于注入 mock 或自定义实现)。
func (m *CacheManager) Set(svc CacheService) {
m.mu.Lock()
@@ -191,14 +246,34 @@ 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()
}
// Get 获取全局缓存值。命中返回 (true, nil);未命中返回 (false, nil)
// 缓存未初始化或 Redis 错误返回 (false, err)。
func Get(ctx context.Context, key string, dest any) (bool, error) {
svc := GetCache()
if svc == nil {
return false, ErrRedisNotReady
}
return svc.Get(ctx, key, dest)
}
// Exists 检查全局缓存是否存在。未命中返回 (false, nil);缓存未初始化或
// Redis 错误返回 (false, err)。
func Exists(ctx context.Context, key string) (bool, error) {
svc := GetCache()
if svc == nil {
return false, ErrRedisNotReady
}
return svc.Exists(ctx, key)
}
+199
View File
@@ -0,0 +1,199 @@
package cache
import (
"context"
"errors"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func setupM10MiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
database.SetTestRedisClient(client)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
return mr
}
func TestM10CacheWritesReturnRedisNotReady(t *testing.T) {
database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
c := &redisCache{}
ctx := context.Background()
if err := c.Set(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Set without Redis err = %v, want ErrRedisNotReady", err)
}
if err := c.Delete(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Delete without Redis err = %v, want ErrRedisNotReady", err)
}
if err := c.DeleteByPattern(ctx, "k:*"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("DeleteByPattern without Redis err = %v, want ErrRedisNotReady", err)
}
}
func TestM10RawHelpersReturnRedisNotReady(t *testing.T) {
database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
ctx := context.Background()
if _, err := Incr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Incr without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := IncrBy(ctx, "k", 2); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("IncrBy without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := Decr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Decr without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := GetTTL(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("GetTTL without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := SetExpire(ctx, "k", time.Minute); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("SetExpire without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := GetRaw(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("GetRaw without Redis err = %v, want ErrRedisNotReady", err)
}
if err := SetRaw(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("SetRaw without Redis err = %v, want ErrRedisNotReady", err)
}
}
func TestM10ExistsReturnsBackendErrors(t *testing.T) {
setupM10MiniRedis(t)
c := &redisCache{}
ctx := context.Background()
exists, err := c.Exists(ctx, "missing")
if err != nil {
t.Fatalf("Exists missing err = %v", err)
}
if exists {
t.Fatal("Exists missing = true, want false")
}
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set present: %v", err)
}
exists, err = c.Exists(ctx, "present")
if err != nil || !exists {
t.Fatalf("Exists present = %v, err=%v; want true,nil", exists, err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if exists, err = c.Exists(canceled, "present"); err == nil || exists {
t.Fatalf("Exists canceled = %v, err=%v; want false,error", exists, err)
}
}
func TestM10GetReturnsBackendAndDecodeErrors(t *testing.T) {
setupM10MiniRedis(t)
c := &redisCache{}
ctx := context.Background()
var got string
ok, err := c.Get(ctx, "missing", &got)
if err != nil || ok {
t.Fatalf("Get missing = %v, err=%v; want false,nil", ok, err)
}
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set present: %v", err)
}
ok, err = c.Get(ctx, "present", &got)
if err != nil || !ok || got != "value" {
t.Fatalf("Get present = %v, %q, err=%v; want true,value,nil", ok, got, err)
}
if err := c.client().Set(ctx, "bad-json", "{", time.Minute).Err(); err != nil {
t.Fatalf("Set bad-json: %v", err)
}
ok, err = c.Get(ctx, "bad-json", &got)
if err == nil || ok {
t.Fatalf("Get bad-json = %v, err=%v; want false,error", ok, err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
ok, err = c.Get(canceled, "present", &got)
if err == nil || ok {
t.Fatalf("Get canceled = %v, err=%v; want false,error", ok, err)
}
}
func TestM10PackageExistsReturnsBackendErrors(t *testing.T) {
setupM10MiniRedis(t)
orig := GetDefaultCache()
t.Cleanup(func() { SetDefaultCacheManager(orig) })
SetDefaultCacheManager(NewCacheManager())
Init()
ctx := context.Background()
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set through facade: %v", err)
}
exists, err := Exists(ctx, "present")
if err != nil || !exists {
t.Fatalf("facade Exists = %v, err=%v; want true,nil", exists, err)
}
}
func TestM10PackageGetReturnsBackendErrors(t *testing.T) {
setupM10MiniRedis(t)
orig := GetDefaultCache()
t.Cleanup(func() { SetDefaultCacheManager(orig) })
SetDefaultCacheManager(NewCacheManager())
Init()
ctx := context.Background()
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set through facade: %v", err)
}
var got string
ok, err := Get(ctx, "present", &got)
if err != nil || !ok || got != "value" {
t.Fatalf("facade Get = %v, %q, err=%v; want true,value,nil", ok, got, err)
}
}
// TestM10CustomCacheServiceCompile asserts a user-provided CacheService
// implementation compiles against the (bool, error) contract. This is a
// compile-time guard: if the interface changes again, this stops building.
type customCacheSvc struct{}
func (customCacheSvc) Get(ctx context.Context, key string, dest any) (bool, error) { return false, nil }
func (customCacheSvc) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
return nil
}
func (customCacheSvc) Delete(ctx context.Context, key string) error { return nil }
func (customCacheSvc) DeleteByPattern(ctx context.Context, p string) error { return nil }
func (customCacheSvc) Exists(ctx context.Context, key string) (bool, error) { return false, nil }
func TestM10CustomCacheServiceCompiles(t *testing.T) {
var _ CacheService = customCacheSvc{}
}
// TestM10RedisNotReadyOnGetExists asserts the Redis-not-ready error is
// surfaced (not swallowed) when no backend is configured.
func TestM10RedisNotReadyOnGetExists(t *testing.T) {
database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
c := &redisCache{}
ctx := context.Background()
if _, err := c.Get(ctx, "k", new(string)); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Get without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := c.Exists(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Exists without Redis err = %v, want ErrRedisNotReady", err)
}
}
+69
View File
@@ -0,0 +1,69 @@
package cache
import (
"testing"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// TestRedisCacheInjectedClientPriority 固化 Phase 4 核心契约(jwt 模型):
// redisCache.client() 注入优先、全局兜底。注入的 client 即使与 database.GetRedis()
// 不同也必须用注入的--这是多 App 缓存隔离的基础。
func TestRedisCacheInjectedClientPriority(t *testing.T) {
// 全局 redis = clientG
mrG := miniredis.RunT(t)
clientG := redis.NewClient(&redis.Options{Addr: mrG.Addr()})
t.Cleanup(func() { _ = clientG.Close() })
origGlobal := database.SetTestRedisClient(clientG)
t.Cleanup(func() { database.SetTestRedisClient(origGlobal) })
// 注入 clientA(与全局 clientG 不同实例)
mrA := miniredis.RunT(t)
clientA := redis.NewClient(&redis.Options{Addr: mrA.Addr()})
t.Cleanup(func() { _ = clientA.Close() })
// 注入优先
injected := &redisCache{injectedClient: clientA}
if got := injected.client(); got != clientA {
t.Fatalf("injected redisCache.client() = %v, want clientA (注入优先)", got)
}
// 未注入 -> 回退全局 clientG
fallback := &redisCache{}
if got := fallback.client(); got != clientG {
t.Fatalf("fallback redisCache.client() = %v, want clientG (全局兜底)", got)
}
// NewRedisCacheWithRedis 构造的实例持注入 client
svc := NewRedisCacheWithRedis(clientA)
rc, ok := svc.(*redisCache)
if !ok {
t.Fatalf("NewRedisCacheWithRedis returned %T, want *redisCache", svc)
}
if rc.client() != clientA {
t.Fatalf("NewRedisCacheWithRedis client = %v, want clientA", rc.client())
}
}
// TestSwapDefaultCacheManagerReturnsOld 固化 Phase 4Swap 返回被替换的旧 Manager 且不关闭。
func TestSwapDefaultCacheManagerReturnsOld(t *testing.T) {
orig := defaultCachePtr.Load()
defer SwapDefaultCacheManager(orig)
first := NewCacheManager()
if prev := SwapDefaultCacheManager(first); prev != orig {
t.Fatalf("Swap returned %v, want orig", prev)
}
second := NewCacheManager()
if returned := SwapDefaultCacheManager(second); returned != first {
t.Fatalf("Swap returned %v, want first", returned)
}
if defaultCachePtr.Load() != second {
t.Fatal("Swap did not install second as default")
}
if got := SwapDefaultCacheManager(nil); got != second {
t.Fatalf("Swap(nil) = %v, want second (current default)", got)
}
}
+68 -22
View File
@@ -3,14 +3,16 @@ package cache
import (
"context"
"strings"
"sync"
"time"
"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"
@@ -23,6 +25,9 @@ type KeyBuilderOption func(*KeyBuilder)
// 示例: WithPrefix("site_a") -> 所有 key 自动添加 "site_a" 前缀
func WithPrefix(prefix string) KeyBuilderOption {
return func(kb *KeyBuilder) {
if kb == nil {
return
}
kb.prefix = prefix
}
}
@@ -31,14 +36,20 @@ func WithPrefix(prefix string) KeyBuilderOption {
// 示例: WithSeparator(":") -> "site_a:user:1"
func WithSeparator(separator string) KeyBuilderOption {
return func(kb *KeyBuilder) {
if kb == nil {
return
}
kb._separator = separator
}
}
// WithCacheType 设置缓存类型标识
// 示例: WithCacheType("session") -> "session_site_a_user:1"
// 示例: WithCacheType("session") -> "session:site_a:user:1"
func WithCacheType(cacheType string) KeyBuilderOption {
return func(kb *KeyBuilder) {
if kb == nil {
return
}
kb._cacheType = cacheType
}
}
@@ -51,15 +62,20 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
_cacheType: "cache",
}
for _, opt := range opts {
if opt == nil {
continue
}
opt(kb)
}
return kb
}
// Build 构建完整键名
// Build 构建完整键名CK2 修复:mu 读保护)。
// 格式: {cacheType}{separator}{prefix}{separator}{key}
// 示例: kb.Build("user:1") -> "cache_site_a_user:1"
// 示例: 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)
@@ -71,9 +87,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)
@@ -82,9 +100,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)
@@ -93,9 +113,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)
@@ -104,9 +126,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)
@@ -115,9 +139,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)
@@ -126,26 +152,35 @@ func (kb *KeyBuilder) BuildSession(key string) string {
return strings.Join(parts, kb._separator)
}
// BuildPattern 构建匹配模式(用于 SCAN/Keys)
// 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*"
// 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 动态设置前缀
// SetPrefix 动态设置前缀(CK2 修复:mu 写保护,并发安全)。
func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder {
kb.mu.Lock()
defer kb.mu.Unlock()
kb.prefix = prefix
return kb
}
// ===== 全局键名构建器 =====
var globalKeyBuilder *KeyBuilder
// globalKeyBuilder 全局构建器,受 globalKBMu 保护;globalKBOnce 保证自动初始化只执行一次(M13)。
var (
globalKeyBuilder *KeyBuilder
globalKBMu sync.RWMutex
globalKBOnce sync.Once
)
// InitKeyBuilder 初始化全局键名构建器
// 参数: prefix 站点别名,如果为空则自动从配置读取
@@ -164,7 +199,10 @@ func InitKeyBuilder(prefix string, opts ...KeyBuilderOption) {
}
opts = append([]KeyBuilderOption{WithPrefix(prefix)}, opts...)
globalKeyBuilder = NewKeyBuilder(opts...)
kb := NewKeyBuilder(opts...)
globalKBMu.Lock()
globalKeyBuilder = kb
globalKBMu.Unlock()
}
// AutoInitKeyBuilder 自动从配置初始化键名构建器
@@ -177,12 +215,19 @@ func AutoInitKeyBuilder(opts ...KeyBuilderOption) {
InitKeyBuilder("", opts...)
}
// GetKeyBuilder 获取全局键名构建器
// GetKeyBuilder 获取全局键名构建器,未初始化时用 sync.Once 自动初始化一次(M13)。
func GetKeyBuilder() *KeyBuilder {
if globalKeyBuilder == nil {
// 自动从配置初始化
AutoInitKeyBuilder()
}
globalKBOnce.Do(func() {
// 仅在仍为 nil 时自动初始化(已由 InitKeyBuilder 设置则跳过)。
globalKBMu.RLock()
kb := globalKeyBuilder
globalKBMu.RUnlock()
if kb == nil {
AutoInitKeyBuilder()
}
})
globalKBMu.RLock()
defer globalKBMu.RUnlock()
return globalKeyBuilder
}
@@ -225,8 +270,9 @@ func SetWithPrefix(ctx context.Context, key string, value any, ttl time.Duration
return GetCache().Set(ctx, kb.Build(key), value, ttl)
}
// GetWithPrefix 带前缀的缓存获取
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) bool {
// GetWithPrefix 带前缀的缓存获取。命中返回 (true, nil);未命中返回 (false, nil)
// Redis 错误或反序列化失败返回 (false, err)。
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) (bool, error) {
kb := NewKeyBuilder(WithPrefix(prefix))
return GetCache().Get(ctx, kb.Build(key), dest)
}
+31
View File
@@ -0,0 +1,31 @@
package cache
import (
"sync"
"testing"
)
// TestGlobalKeyBuilderConcurrentInitGet_M13:并发 InitKeyBuilder/GetKeyBuilder/K 不触发 data race
// 且不 nil-panicM13sync.Once + RWMutex 保护全局构建器)。须配合 -race 运行。
func TestGlobalKeyBuilderConcurrentInitGet_M13(t *testing.T) {
// 预置一个已知前缀,避免依赖 config.Get。
InitKeyBuilder("race_site")
t.Cleanup(func() { globalKBMu.Lock(); globalKeyBuilder = nil; globalKBMu.Unlock() })
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(3)
go func() { defer wg.Done(); InitKeyBuilder("race_site") }()
go func() { defer wg.Done(); _ = GetKeyBuilder() }()
go func() { defer wg.Done(); _ = K("user:1") }()
}
wg.Wait()
// 最终 GetKeyBuilder 非 nilK 返回带前缀键。
if GetKeyBuilder() == nil {
t.Fatal("GetKeyBuilder nil after concurrent init")
}
if got := K("user:1"); got == "user:1" {
t.Errorf("K returned unprefixed key %q (prefix not applied)", got)
}
}
+12 -1
View File
@@ -62,6 +62,17 @@ func TestKeyBuilderNoPrefix(t *testing.T) {
}
}
func TestKeyBuilderNilOptionIgnored(t *testing.T) {
kb := cache.NewKeyBuilder(nil, cache.WithPrefix("site"))
if got := kb.Build("user:1"); got != "cache:site:user:1" {
t.Fatalf("Build with nil option = %q, want cache:site:user:1", got)
}
cache.WithPrefix("ignored")(nil)
cache.WithSeparator("_")(nil)
cache.WithCacheType("ignored")(nil)
}
func TestKeyBuilderSetPrefix(t *testing.T) {
kb := cache.NewKeyBuilder(cache.WithPrefix("site_a"))
@@ -211,4 +222,4 @@ func contains(s, sub string) bool {
}
}
return false
}
}
+200 -76
View File
@@ -3,6 +3,7 @@ package cache
import (
"context"
"errors"
"fmt"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
@@ -11,12 +12,47 @@ import (
// 分布式锁错误
var (
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
ErrLockExpired = errors.New("锁已过期")
ErrRedisNotReady = errors.New("Redis 未初始化")
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
ErrLockExpired = errors.New("锁已过期")
ErrRedisNotReady = errors.New("Redis 未初始化")
// ErrLockNotAcquired 表示锁被其它客户端持有,业务函数未执行。
ErrLockNotAcquired = errors.New("未获取到锁")
// ErrInvalidLockTTL 表示锁 TTL 小于 Redis PX 支持的 1ms 粒度或非正。
ErrInvalidLockTTL = errors.New("锁 TTL 必须大于等于 1ms")
// ErrInvalidLockRetryInterval 表示重试或续期间隔非法。
ErrInvalidLockRetryInterval = errors.New("锁重试/续期间隔必须大于 0")
// ErrLockUnexpectedResult Lua 脚本返回了非预期的结果类型(C1b:裸类型断言防护)。
ErrLockUnexpectedResult = errors.New("锁脚本返回非预期结果")
// ErrLockFuncNil 表示分布式锁业务函数为空。
ErrLockFuncNil = errors.New("锁业务函数不能为空")
)
// LockToken 锁令牌(用于安全释放锁)
// toInt64 将 Lua 脚本返回值安全断言为 int64(C1b:禁止裸断言 panic)。
// go-redis 对整数返回 int64,但 nil/错误响应下可能为其他类型。
func toInt64(v any) (int64, error) {
n, ok := v.(int64)
if !ok {
return 0, fmt.Errorf("脚本返回类型 %T: %w", v, ErrLockUnexpectedResult)
}
return n, nil
}
func ttlMillis(ttl time.Duration) (int64, error) {
if ttl < time.Millisecond {
return 0, ErrInvalidLockTTL
}
return int64(ttl / time.Millisecond), nil
}
// LockToken 锁令牌(用于安全释放锁)。
//
// 安全说明(C1d 设计局限):Token 是随机 UUID(非单调递增的 fencing token)。
// 本实现基于 Redis SET PX + Lua CAS,保证"持有者才能解锁/续期",但**无法防 TTL 到期后的
// 双 worker 并发**:若 worker A 因 GC/网络停滞超过 TTL,锁过期后 worker B 获得锁,
// A 恢复后仍可能写过期数据。完整的 fencing token 防护需:① 用 Redis INCR 生成单调 token
// ② 下游存储层(DB/外部服务)记录已见最大 token 并拒绝旧 token 写入。
// 框架无法单方面保证②,需下游配合,故本类型仅提供 UUID token。对 TTL 到期敏感的场景,
// 请确保 ttl >> 业务最长执行时间,或下游实现 fencing token 校验。
type LockToken struct {
Key string // 锁的键名
Token string // 锁的唯一标识(UUID
@@ -61,19 +97,27 @@ 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()
ttlMs, err := ttlMillis(ttl)
if err != nil {
return nil, err
}
if result.(int64) == 1 {
result, err := rdb.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
if err != nil {
return nil, err
}
n, err := toInt64(result)
if err != nil {
return nil, err
}
if n == 1 {
return &LockToken{Key: key, Token: token}, nil
}
@@ -92,7 +136,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
}
@@ -100,12 +145,16 @@ 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
}
if result.(int64) == 0 {
n, err := toInt64(result)
if err != nil {
return err
}
if n == 0 {
return ErrLockNotHeld
}
@@ -115,16 +164,18 @@ func Unlock(ctx context.Context, token *LockToken) error {
// UnlockByKey 按键名释放锁(不安全,仅用于旧代码兼容)
// 注意: 此函数不检查 Token,任何客户端都能释放锁
func UnlockByKey(ctx context.Context, key string) error {
if database.RedisClient == nil {
return nil
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
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
}
@@ -132,22 +183,32 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
return ErrLockNotHeld
}
ttlMs := int64(ttl / time.Millisecond)
result, err := database.RedisClient.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
ttlMs, err := ttlMillis(ttl)
if err != nil {
return err
}
if result.(int64) == 0 {
result, err := rdb.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
if err != nil {
return err
}
n, err := toInt64(result)
if err != nil {
return err
}
if n == 0 {
return ErrLockNotHeld
}
return nil
}
// TryLock 尝试获取锁,失败时等待重试
// TryLock 尝试获取锁,失败时等待重试。重试等待响应 ctx 取消(C1c 修复)。
func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval time.Duration, maxRetry int) (*LockToken, error) {
if retryInterval <= 0 {
return nil, ErrInvalidLockRetryInterval
}
for i := 0; i < maxRetry; i++ {
token, err := NewLock(ctx, key, ttl)
if err != nil {
@@ -156,42 +217,71 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t
if token != nil {
return token, nil
}
time.Sleep(retryInterval)
// 响应 ctx 取消,避免最长阻塞 maxRetry*retryIntervalC1c:禁止 time.Sleep 无视 ctx)。
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(retryInterval):
}
}
return nil, nil
return nil, ErrLockNotAcquired
}
// WithLock 使用分布式锁执行函数(自动管理锁)
// WithLock 使用分布式锁执行函数(自动管理锁)
// 参数: key 锁名称,ttl 锁定时长,fn 业务函数
// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() error) error {
// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend
//
// 解锁用独立 Background ctxC1a 一致性修复):fn 返回或 panic 后,原 ctx 可能已被
// 调用方取消,用其解锁会失败导致锁泄漏到 TTL。fn panic 时 defer 也保证解锁执行。
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) error) error {
if fn == nil {
return ErrLockFuncNil
}
token, err := NewLock(ctx, key, ttl)
if err != nil {
return err
}
if token == nil {
return nil // 未获取到锁,跳过执行
return ErrLockNotAcquired
}
defer Unlock(ctx, token)
defer func() {
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = Unlock(unlockCtx, token)
}()
return fn()
return fn(ctx)
}
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)
// 参数: key 锁名称,initialTTL 初始锁定时长,extendInterval 续期间隔,fn 业务函数
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func() error) error {
//
// 并发安全说明(C1a 修复):续期 goroutine 与父用"父关停 + 子 ack"双 channel 协调——
// 父用 close(stop) 通知子退出(close 由唯一所有者执行,安全),子用 close(finished) ack。
// 避免旧实现 done 无缓冲 + 子 defer close(done) + 父 done<-struct{}{} 的 send-on-closed panic
// ctx 取消或 ExtendLock 失败时 done 已 closed,父再 send 即 panicUnlock 不执行、锁泄漏到 TTL)。
// Unlock 用 context.Background() 派生超时,避免原 ctx 已取消致 Unlock 失败再泄漏。
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func(context.Context) error) error {
if fn == nil {
return ErrLockFuncNil
}
if extendInterval <= 0 {
return ErrInvalidLockRetryInterval
}
token, err := NewLock(ctx, key, initialTTL)
if err != nil {
return err
}
if token == nil {
return nil // 未获取到锁,跳过执行
return ErrLockNotAcquired
}
// 启动续期协程
done := make(chan struct{})
// 父关停信号(仅父 close)与子 ack 信号(仅子 close)。
stop := make(chan struct{})
finished := make(chan struct{})
go func() {
defer close(done)
defer close(finished) // 子退出时 ack,父等待 finished
ticker := time.NewTicker(extendInterval)
defer ticker.Stop()
@@ -199,106 +289,140 @@ func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duratio
select {
case <-ctx.Done():
return
case <-done:
case <-stop:
return
case <-ticker.C:
// 续期锁(每次续期为 initialTTL)
// 续期锁(每次续期为 initialTTL)。续期失败则停止续期,fn 应尽快结束。
if err := ExtendLock(ctx, token, initialTTL); err != nil {
return // 续期失败,停止续期
return
}
}
}
}()
// 执行业务函数
err = fn()
// defer 兜底:fn panic 时也要停止续期 goroutine 并释放锁(C1a panic 路径修复)。
// 无 defer 时 fn panic 会导致 close(stop) 不执行 → 续期 goroutine 永久泄漏,且 Unlock 不执行 → 锁泄漏到 TTL。
defer func() {
close(stop)
<-finished
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = Unlock(unlockCtx, token)
}()
// 停止续期并释放锁
done <- struct{}{}
Unlock(ctx, token)
// 执行业务函数。fn 必须接收 ctx,避免请求取消后业务 loader/DB/HTTP 调用继续运行。
err = fn(ctx)
return err
}
// IsLocked 检查锁是否被占用(不获取锁)
// IsLocked 检查锁是否被占用(不获取锁)
//
// M-E 修复(失败语义统一):Redis 未初始化时返回 (false, ErrRedisNotReady) 而非 (false, nil)——
// 后者与"锁确实未被占用"不可区分,调用方可能误以为可获取锁而进入临界区(正确性 bug)。
// 调用方应 errors.Is(err, ErrRedisNotReady) 区分"Redis 不可用"与"锁未占用"。
// L-G 修复:用 .Result() 显式返回 Redis 错误(原 .Val() 吞错致故障被当"未占用")。
//
// 契约说明:锁操作有正确性影响(无锁进入临界区 = bug),故 Redis 不可用时显式返错;
// 与 cache.Get/Set 等数据操作(性能层、best-effort 静默 no-op)区分。
func IsLocked(ctx context.Context, key string) (bool, error) {
if database.RedisClient == nil {
return false, nil
rdb := database.GetRedis()
if rdb == nil {
return false, ErrRedisNotReady
}
return database.RedisClient.Exists(ctx, key).Val() > 0, nil
n, err := rdb.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
// GetLockTTL 获取锁的剩余过期时间
// GetLockTTL 获取锁的剩余过期时间
//
// M-E 修复:Redis 未初始化时返回 (0, ErrRedisNotReady) 而非 (0, nil),调用方可区分
// "Redis 不可用"与"锁不存在/已过期"(后者 TTL=-1/-2)。
func GetLockTTL(ctx context.Context, key string) (time.Duration, error) {
if database.RedisClient == nil {
return 0, nil
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return database.RedisClient.TTL(ctx, key).Result()
return rdb.TTL(ctx, key).Result()
}
// ForceUnlock 强制释放锁(危险操作,仅用于管理场景)
// 注意: 此函数不检查 Token,强制删除锁
//
// M-E 修复:Redis 未初始化时返回 ErrRedisNotReady 而非 nil——原 nil 让调用方误以为
// 已解锁成功、实则从未操作。管理脚本应据此重试或告警,而非假设成功。
func ForceUnlock(ctx context.Context, key string) error {
if database.RedisClient == nil {
return nil
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
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 {
return 0, nil
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
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 {
return 0, nil
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
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 {
return 0, nil
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
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 {
return 0, nil
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
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 {
return false, nil
rdb := database.GetRedis()
if rdb == nil {
return false, ErrRedisNotReady
}
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 {
return "", nil
rdb := database.GetRedis()
if rdb == nil {
return "", ErrRedisNotReady
}
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 {
return nil
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
return database.RedisClient.Set(ctx, key, value, ttl).Err()
}
return rdb.Set(ctx, key, value, ttl).Err()
}
+413
View File
@@ -0,0 +1,413 @@
package cache_test
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/cache"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// 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() })
// 通过 SetTestRedisClient 注入 miniredis 客户端到 database 包的内部 redisClient
database.SetTestRedisClient(client)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
return mr
}
// ===== C1aWithLockAutoExtend 不 panic / 不泄漏锁 =====
// 回归 C1actx 取消时 WithLockAutoExtend 不 send-on-closed panic,且锁被释放。
// 旧实现:ctx 取消时子 goroutine defer close(done),父 fn() 后 done<-struct{}{} 即 panic
// Unlock 不执行,锁泄漏到 TTL。
func TestWithLockAutoExtendCtxCancelNoPanic(t *testing.T) {
mr := setupMiniRedis(t)
_ = mr
ctx, cancel := context.WithCancel(context.Background())
key := "c1a:cancel"
ran := make(chan struct{})
var panicked any
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() { panicked = recover() }()
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(runCtx context.Context) error {
close(ran)
// 阻塞直到 ctx 被取消,模拟长任务。
<-runCtx.Done()
return runCtx.Err()
})
// WithLockAutoExtend 返回 fn 的错误(ctx.Err),不应 panic。
if err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("unexpected err: %v", err)
}
}()
// 等 fn 启动 + 至少一次续期 ticker,再取消 ctx,制造最大竞态窗口。
<-ran
time.Sleep(250 * time.Millisecond)
cancel()
wg.Wait()
if panicked != nil {
t.Fatalf("WithLockAutoExtend panicked on ctx cancel (C1a send-on-closed): %v", panicked)
}
// 锁必须被释放(Unlock 用 Background ctx 执行)。
locked, err := cache.IsLocked(context.Background(), key)
if err != nil {
t.Fatalf("IsLocked: %v", err)
}
if locked {
t.Error("lock leaked after ctx cancel (Unlock not executed)")
}
}
// 回归 C1a:fn 正常返回时锁被释放,无 panic。
func TestWithLockAutoExtendNormalRelease(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1a:normal"
called := false
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
called = true
return nil
})
if err != nil {
t.Fatalf("WithLockAutoExtend: %v", err)
}
if !called {
t.Error("fn not called")
}
locked, _ := cache.IsLocked(ctx, key)
if locked {
t.Error("lock not released after normal fn return")
}
}
// 回归 C1a:fn 执行超过续期间隔,锁被续期不丢失(续期 goroutine 工作)。
func TestWithLockAutoExtendExtendsLock(t *testing.T) {
mr := setupMiniRedis(t)
ctx := context.Background()
key := "c1a:extend"
// initialTTL=500msextendInterval=100ms。fn 执行 800ms,期间应多次续期。
// 若续期失效,锁会在 500ms 过期,另一 worker 可获取。
err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func(context.Context) error {
time.Sleep(800 * time.Millisecond)
return nil
})
if err != nil {
t.Fatalf("WithLockAutoExtend: %v", err)
}
// 期间另一 worker 应无法获取锁(续期生效)。
// 此断言在 fn 执行中验证更准;这里用 fn 返回后锁已释放验证基础闭环。
locked, _ := cache.IsLocked(ctx, key)
if locked {
t.Error("lock not released after fn")
}
_ = mr
}
// 回归 C1afn 执行中另一 worker 拿不到锁(续期保活)。
func TestWithLockAutoExtendBlocksContender(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1a:block"
fnStarted := make(chan struct{})
fnDone := make(chan struct{})
lockDone := make(chan struct{})
var contenderGotLock atomic.Bool
go func() {
defer close(lockDone)
cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func(context.Context) error {
close(fnStarted)
time.Sleep(600 * time.Millisecond)
close(fnDone)
return nil
})
}()
<-fnStarted
// 期间尝试获取锁,应失败(nil)。
token, err := cache.NewLock(ctx, key, 2*time.Second)
if err != nil {
t.Fatalf("NewLock contender: %v", err)
}
if token != nil {
contenderGotLock.Store(true)
_ = cache.Unlock(ctx, token)
}
<-fnDone
<-lockDone
if contenderGotLock.Load() {
t.Error("contender acquired lock while auto-extend active (extend failed)")
}
}
// 回归 C1a:fn panic 时锁仍被释放、续期 goroutine 不泄漏(defer 兜底)。
// 旧实现(无 deferfn panic → close(stop) 不执行 → 续期 goroutine 永久泄漏 + Unlock 不执行 → 锁泄漏。
func TestWithLockAutoExtendFnPanicReleasesLock(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1a:panic"
var panicked any
func() {
defer func() { panicked = recover() }()
_ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
time.Sleep(150 * time.Millisecond) // 让续期 ticker 至少触发一次
panic("boom")
})
}()
if panicked == nil {
t.Fatal("expected fn panic to propagate")
}
// 锁必须被释放(defer Unlock 执行)。
locked, _ := cache.IsLocked(ctx, key)
if locked {
t.Error("lock leaked after fn panic (defer Unlock not executed)")
}
}
// 回归 C1a/HIGHWithLock 在 ctx 取消后仍能解锁(Unlock 用 Background ctx)。
// 旧实现 defer Unlock(ctx, token) 用原 ctxctx 取消致 Unlock 失败、锁泄漏到 TTL。
func TestWithLockCtxCancelReleasesLock(t *testing.T) {
setupMiniRedis(t)
ctx, cancel := context.WithCancel(context.Background())
key := "c1a:withlock"
fnStarted := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
_ = cache.WithLock(ctx, key, 10*time.Second, func(runCtx context.Context) error {
close(fnStarted)
<-runCtx.Done()
return runCtx.Err()
})
}()
<-fnStarted
cancel()
<-done
locked, _ := cache.IsLocked(context.Background(), key)
if locked {
t.Error("lock leaked after ctx cancel (WithLock Unlock should use Background ctx)")
}
}
func TestWithLockPassesContextToFunction(t *testing.T) {
setupMiniRedis(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
key := "m10:ctx-aware"
var seen context.Context
err := cache.WithLock(ctx, key, time.Second, func(runCtx context.Context) error {
seen = runCtx
cancel()
<-runCtx.Done()
return runCtx.Err()
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("WithLock ctx-aware err = %v, want context.Canceled", err)
}
if seen != ctx {
t.Fatal("WithLock 应把调用方 ctx 传入业务函数")
}
}
func TestWithLockRejectsNilFunction(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
if err := cache.WithLock(ctx, "m10:nil-fn", time.Second, nil); !errors.Is(err, cache.ErrLockFuncNil) {
t.Fatalf("WithLock nil fn err = %v, want ErrLockFuncNil", err)
}
if err := cache.WithLockAutoExtend(ctx, "m10:nil-fn-auto", time.Second, 100*time.Millisecond, nil); !errors.Is(err, cache.ErrLockFuncNil) {
t.Fatalf("WithLockAutoExtend nil fn err = %v, want ErrLockFuncNil", err)
}
}
// ===== C1b:类型断言不 panic =====
// 回归 C1bNewLock/Unlock/ExtendLock 在正常路径返回正确结果(Lua 脚本返 int64)。
// 此用例锁定正常路径不被 toInt64 改坏;裸断言 panic 路径需构造非 int64 返回,
// miniredis Lua 恒返整数,故 panic 路径由 toInt64 的 comma-ok 防护(代码审查保证)。
func TestLockUnlockExtendCycle(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1b:cycle"
// 加锁
token, err := cache.NewLock(ctx, key, 5*time.Second)
if err != nil || token == nil {
t.Fatalf("NewLock: err=%v token=%v", err, token)
}
// 重复加锁应失败(返回 nil token)
t2, err := cache.NewLock(ctx, key, 5*time.Second)
if err != nil {
t.Fatalf("second NewLock err: %v", err)
}
if t2 != nil {
t.Error("second NewLock should return nil token (lock held)")
_ = cache.Unlock(ctx, t2)
}
// 续期
if err := cache.ExtendLock(ctx, token, 5*time.Second); err != nil {
t.Errorf("ExtendLock: %v", err)
}
// 用错误 token 解锁应失败
wrong := &cache.LockToken{Key: key, Token: "wrong-token"}
if err := cache.Unlock(ctx, wrong); !errors.Is(err, cache.ErrLockNotHeld) {
t.Errorf("Unlock wrong token err = %v, want ErrLockNotHeld", err)
}
// 正确解锁
if err := cache.Unlock(ctx, token); err != nil {
t.Errorf("Unlock: %v", err)
}
// 解锁后另一方可获取
t3, err := cache.NewLock(ctx, key, 5*time.Second)
if err != nil || t3 == nil {
t.Fatalf("NewLock after unlock: err=%v token=%v", err, t3)
}
_ = cache.Unlock(ctx, t3)
}
// ===== C1cTryLock 响应 ctx 取消 =====
// 回归 C1cTryLock 在 ctx 取消时立即返回,不阻塞 maxRetry*retryInterval。
// 旧实现 time.Sleep 不响应 ctx,取消后仍要等满所有重试。
func TestTryLockRespectsCtxCancel(t *testing.T) {
setupMiniRedis(t)
// 先占用锁,使 TryLock 必然重试。
holder, err := cache.NewLock(context.Background(), "c1c:try", 10*time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder: err=%v token=%v", err, holder)
}
defer cache.Unlock(context.Background(), holder)
ctx, cancel := context.WithCancel(context.Background())
// retryInterval=200msmaxRetry=10 → 旧实现取消后最长阻塞 2s。
start := time.Now()
go func() {
time.Sleep(150 * time.Millisecond)
cancel()
}()
token, err := cache.TryLock(ctx, "c1c:try", 5*time.Second, 200*time.Millisecond, 10)
elapsed := time.Since(start)
if !errors.Is(err, context.Canceled) {
t.Errorf("TryLock err = %v, want context.Canceled", err)
}
if token != nil {
t.Error("TryLock should return nil token (held by other)")
_ = cache.Unlock(context.Background(), token)
}
// 取消应在 ~150ms 后返回,远小于 2s。
if elapsed > 1*time.Second {
t.Errorf("TryLock took %v, should return shortly after ctx cancel (C1c: time.Sleep ignored ctx)", elapsed)
}
}
func TestLockRejectsSubMillisecondTTL(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
if token, err := cache.NewLock(ctx, "m10:ttl", time.Nanosecond); !errors.Is(err, cache.ErrInvalidLockTTL) || token != nil {
t.Fatalf("NewLock sub-ms ttl token=%v err=%v, want ErrInvalidLockTTL", token, err)
}
token, err := cache.NewLock(ctx, "m10:ttl-valid", time.Second)
if err != nil || token == nil {
t.Fatalf("NewLock valid setup token=%v err=%v", token, err)
}
defer cache.Unlock(ctx, token)
if err := cache.ExtendLock(ctx, token, 0); !errors.Is(err, cache.ErrInvalidLockTTL) {
t.Errorf("ExtendLock zero ttl err = %v, want ErrInvalidLockTTL", err)
}
}
func TestWithLockAutoExtendRejectsNonPositiveInterval(t *testing.T) {
setupMiniRedis(t)
called := false
err := cache.WithLockAutoExtend(context.Background(), "m10:interval", time.Second, 0, func(context.Context) error {
called = true
return nil
})
if !errors.Is(err, cache.ErrInvalidLockRetryInterval) {
t.Fatalf("WithLockAutoExtend zero interval err = %v, want ErrInvalidLockRetryInterval", err)
}
if called {
t.Fatal("WithLockAutoExtend should not run fn with invalid interval")
}
}
func TestWithLockHeldReturnsErrLockNotAcquired(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "m10:not-acquired"
holder, err := cache.NewLock(ctx, key, time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder token=%v err=%v", holder, err)
}
defer cache.Unlock(ctx, holder)
called := false
err = cache.WithLock(ctx, key, time.Second, func(context.Context) error {
called = true
return nil
})
if !errors.Is(err, cache.ErrLockNotAcquired) {
t.Fatalf("WithLock held err = %v, want ErrLockNotAcquired", err)
}
if called {
t.Fatal("WithLock should not run fn when lock is held")
}
}
func TestTryLockExhaustedReturnsErrLockNotAcquired(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "m10:try-exhausted"
holder, err := cache.NewLock(ctx, key, time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder token=%v err=%v", holder, err)
}
defer cache.Unlock(ctx, holder)
token, err := cache.TryLock(ctx, key, time.Second, time.Millisecond, 2)
if !errors.Is(err, cache.ErrLockNotAcquired) {
t.Fatalf("TryLock exhausted err = %v, want ErrLockNotAcquired", err)
}
if token != nil {
t.Fatalf("TryLock exhausted token = %v, want nil", token)
}
}
+26 -22
View File
@@ -2,6 +2,7 @@ package cache_test
import (
"context"
"errors"
"testing"
"time"
@@ -32,50 +33,53 @@ func TestLockErrors(t *testing.T) {
t.Errorf("Unlock with nil token should return ErrLockNotHeld or ErrRedisNotReady, got %v", err)
}
// Test IsLocked without Redis (should return false)
// Test IsLocked without Redis — M-E:应返回 (false, ErrRedisNotReady)
// 让调用方可区分"Redis 不可用"与"锁未占用"(原 (false,nil) 与"未占用"不可区分)。
locked, err := cache.IsLocked(ctx, "test_key")
if err != nil {
t.Errorf("IsLocked error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("IsLocked without Redis should return ErrRedisNotReady, got %v", err)
}
if locked {
t.Error("IsLocked should return false without Redis")
}
// Test GetLockTTL without Redis
// Test GetLockTTL without Redis — M-E:应返回 (0, ErrRedisNotReady)。
ttl, err := cache.GetLockTTL(ctx, "test_key")
if err != nil {
t.Errorf("GetLockTTL error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("GetLockTTL without Redis should return ErrRedisNotReady, got %v", err)
}
if ttl != 0 {
t.Error("GetLockTTL should return 0 without Redis")
}
if err := cache.UnlockByKey(ctx, "test_key"); !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("UnlockByKey without Redis should return ErrRedisNotReady, got %v", err)
}
}
func TestIncrDecr(t *testing.T) {
ctx := context.Background()
// Test Incr without Redis
// Redis 未初始化时应显式返回 ErrRedisNotReady,避免调用方误判为计数器值为 0。
n, err := cache.Incr(ctx, "counter")
if err != nil {
t.Errorf("Incr error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("Incr without Redis should return ErrRedisNotReady, got %v", err)
}
if n != 0 {
t.Error("Incr should return 0 without Redis")
}
// Test IncrBy
n, err = cache.IncrBy(ctx, "counter", 10)
if err != nil {
t.Errorf("IncrBy error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("IncrBy without Redis should return ErrRedisNotReady, got %v", err)
}
if n != 0 {
t.Error("IncrBy should return 0 without Redis")
}
// Test Decr
n, err = cache.Decr(ctx, "counter")
if err != nil {
t.Errorf("Decr error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("Decr without Redis should return ErrRedisNotReady, got %v", err)
}
if n != 0 {
t.Error("Decr should return 0 without Redis")
@@ -86,8 +90,8 @@ func TestSetExpire(t *testing.T) {
ctx := context.Background()
ok, err := cache.SetExpire(ctx, "test_key", time.Minute)
if err != nil {
t.Errorf("SetExpire error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("SetExpire without Redis should return ErrRedisNotReady, got %v", err)
}
if ok {
t.Error("SetExpire should return false without Redis")
@@ -99,14 +103,14 @@ func TestGetRawSetRaw(t *testing.T) {
// Test SetRaw
err := cache.SetRaw(ctx, "test_key", "test_value", time.Minute)
if err != nil {
t.Errorf("SetRaw error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("SetRaw without Redis should return ErrRedisNotReady, got %v", err)
}
// Test GetRaw
val, err := cache.GetRaw(ctx, "test_key")
if err != nil {
t.Errorf("GetRaw error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("GetRaw without Redis should return ErrRedisNotReady, got %v", err)
}
if val != "" {
t.Error("GetRaw should return empty string without Redis")
@@ -144,4 +148,4 @@ func TestKFunctions(t *testing.T) {
if sessionKey == "" {
t.Error("KSession should not return empty string")
}
}
}
+166 -55
View File
@@ -13,10 +13,14 @@ import (
xlgo "github.com/EthanCodeCraft/xlgo-core"
)
func createProject(name string) {
func createProject(name string) error {
// P1 #21:校验项目名,拒绝路径穿越与非法 Go 包名。
if err := validateProjectName(name); err != nil {
return err
}
// #nosec G703 -- name was validated by validateProjectName: no path separators, no "..", no leading dot, Go identifier only.
if _, err := os.Stat(name); !os.IsNotExist(err) {
fmt.Printf("目录 %s 已存在\n", name)
return
return fmt.Errorf("目录 %s 已存在", name)
}
// 解析 --template 与 --module 参数(默认 template=api
@@ -26,25 +30,34 @@ func createProject(name string) {
for i := 0; i < len(args); i++ {
switch args[i] {
case "--template", "-t":
if i+1 < len(args) {
tmplName = args[i+1]
i++
if i+1 >= len(args) {
return fmt.Errorf("%s 缺少参数值", args[i])
}
tmplName = args[i+1]
i++
case "--module", "-m":
if i+1 < len(args) {
module = args[i+1]
i++
if i+1 >= len(args) {
return fmt.Errorf("%s 缺少参数值", args[i])
}
module = args[i+1]
i++
default:
// P1 #21:未知参数显式报错,不再静默忽略。
return fmt.Errorf("未知参数: %s", args[i])
}
}
// P1 #21:校验 module 路径,避免模板元字符 {{ }} 经 Sprintf 进 go.mod 后触发 Parse 报错。
if err := validateModulePath(module); err != nil {
return err
}
// 校验模板名
switch tmplName {
case "minimal", "api", "fullstack":
// ok
default:
fmt.Printf("未知模板: %s(可选: minimal / api / fullstack\n", tmplName)
return
return fmt.Errorf("未知模板: %s(可选: minimal / api / fullstack", tmplName)
}
// minimal 模板目录结构最小化;api/fullstack 含完整分层目录
@@ -62,9 +75,11 @@ func createProject(name string) {
}
for _, dir := range dirs {
// #nosec G301,G703 -- dir is built from validated project name plus fixed scaffold subdirectories; 0755 is intentional for generated project dirs.
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Printf("创建目录失败: %s\n", err)
return
// #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back.
_ = os.RemoveAll(name) // P1 #21:清理半成品
return fmt.Errorf("创建目录失败: %w", err)
}
}
@@ -102,22 +117,10 @@ func createProject(name string) {
}
for path, content := range files {
tmpl, err := template.New(path).Parse(content)
if err != nil {
fmt.Printf("解析模板失败: %s\n", err)
return
}
file, err := os.Create(path)
if err != nil {
fmt.Printf("创建文件失败: %s\n", err)
return
}
defer file.Close()
if err := tmpl.Execute(file, data); err != nil {
fmt.Printf("写入文件失败: %s\n", err)
return
if err := renderTemplateFile(path, content, data); err != nil {
// #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back.
_ = os.RemoveAll(name) // P1 #21:部分失败回滚,避免留下半成品项目
return err
}
}
@@ -126,33 +129,132 @@ func createProject(name string) {
fmt.Printf(" cd %s\n", name)
fmt.Println(" go mod tidy")
fmt.Println(" go run main.go")
return nil
}
func makeFile(fileType, name string) {
// renderTemplateFile 解析并渲染单个模板文件,每次调用显式关闭句柄
// P1 #21:不在循环内 defer 累积句柄,且 Close 错误纳入返回)。
func renderTemplateFile(path, content string, data TemplateData) (retErr error) {
tmpl, err := template.New(path).Parse(content)
if err != nil {
return fmt.Errorf("解析模板 %s 失败: %w", path, err)
}
// #nosec G304 -- path is from createProject's files map built from validated project name plus fixed filenames.
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("创建文件 %s 失败: %w", path, err)
}
defer func() {
if cerr := file.Close(); cerr != nil && retErr == nil {
retErr = fmt.Errorf("关闭文件 %s 失败: %w", path, cerr)
}
}()
if err := tmpl.Execute(file, data); err != nil {
return fmt.Errorf("写入文件 %s 失败: %w", path, err)
}
return nil
}
// validateProjectName 校验项目名(P1 #21):非空、无路径分隔符/.. /前导点,
// 且可派生为合法 Go 包名(须以字母开头,仅含字母/数字/下划线)。
func validateProjectName(name string) error {
if name == "" {
return fmt.Errorf("项目名不能为空")
}
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return fmt.Errorf("项目名不能包含路径分隔符或 ..(防止在预期目录外创建文件): %q", name)
}
if strings.HasPrefix(name, ".") {
return fmt.Errorf("项目名不能以 . 开头: %q", name)
}
if !isValidGoIdentifier(name) {
return fmt.Errorf("项目名 %q 无法生成合法 Go 包名:须以字母开头,仅含字母、数字、下划线", name)
}
return nil
}
// isValidGoIdentifier 判断 s 是否为脚手架接受的 ASCII 标识符:字母开头,后续允许字母、数字、下划线。
func isValidGoIdentifier(s string) bool {
if s == "" {
return false
}
for i, r := range s {
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
isNum := r >= '0' && r <= '9'
if i == 0 {
if !isLetter {
return false
}
} else if !isLetter && !isNum && r != '_' {
return false
}
}
return true
}
// validateModulePath 校验 --moduleP1 #21):非空、不含模板元字符与空白。
func validateModulePath(module string) error {
if module == "" {
return fmt.Errorf("模块路径不能为空")
}
if strings.Contains(module, "{{") || strings.Contains(module, "}}") {
return fmt.Errorf("模块路径不能包含模板元字符 {{ }}: %q", module)
}
if strings.ContainsAny(module, " \t\r\n") {
return fmt.Errorf("模块路径不能包含空白字符: %q", module)
}
return nil
}
func makeFile(fileType, name string) error {
if err := validateMakeName(name); err != nil {
return err
}
name = strings.ToLower(name)
caser := cases.Title(language.English)
nameTitle := caser.String(name)
nameTitle := makeNameTitle(name)
switch fileType {
case "handler":
createHandler(name, nameTitle)
return createHandler(name, nameTitle)
case "repository":
createRepository(name, nameTitle)
return createRepository(name, nameTitle)
case "model":
createModel(name, nameTitle)
return createModel(name, nameTitle)
case "service":
createService(name, nameTitle)
return createService(name, nameTitle)
default:
fmt.Printf("未知类型: %s\n", fileType)
fmt.Println("可用类型: handler, repository, model, service")
return fmt.Errorf("未知类型: %s(可用类型: handler, repository, model, service", fileType)
}
}
func createHandler(name, nameTitle string) {
// validateMakeName 校验 xlgo make 的资源名,避免路径穿越或生成不可编译的 Go 代码。
func validateMakeName(name string) error {
if name == "" {
return fmt.Errorf("名称不能为空")
}
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name)
}
if strings.HasPrefix(name, ".") {
return fmt.Errorf("名称不能以 . 开头: %q", name)
}
if !isValidGoIdentifier(name) {
return fmt.Errorf("名称 %q 必须是合法标识符:须以字母开头,仅含字母、数字、下划线", name)
}
return nil
}
// makeNameTitle 把 snake_case 名称转为导出的 CamelCase 类型名。
func makeNameTitle(name string) string {
caser := cases.Title(language.English)
nameTitle := caser.String(strings.ReplaceAll(name, "_", " "))
return strings.ReplaceAll(nameTitle, " ", "")
}
func createHandler(name, nameTitle string) error {
path := fmt.Sprintf("handler/%s.go", name)
if fileExists(path) {
fmt.Printf("文件 %s 已存在\n", path)
return
return fmt.Errorf("文件 %s 已存在", path)
}
content := fmt.Sprintf(templates.HandlerMake,
@@ -166,48 +268,54 @@ func createHandler(name, nameTitle string) {
)
content = replaceModuleImports(content)
writeFile(path, content)
if err := writeFile(path, content); err != nil {
return err
}
fmt.Printf("✓ 创建处理器: %s\n", path)
return nil
}
func createRepository(name, nameTitle string) {
func createRepository(name, nameTitle string) error {
path := fmt.Sprintf("repository/%s_repository.go", name)
if fileExists(path) {
fmt.Printf("文件 %s 已存在\n", path)
return
return fmt.Errorf("文件 %s 已存在", path)
}
content := fmt.Sprintf(templates.RepositoryMake,
nameTitle, name, nameTitle, nameTitle,
nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle,
nameTitle, nameTitle, nameTitle,
nameTitle, nameTitle,
)
content = replaceModuleImports(content)
writeFile(path, content)
if err := writeFile(path, content); err != nil {
return err
}
fmt.Printf("✓ 创建仓库: %s\n", path)
return nil
}
func createModel(name, nameTitle string) {
func createModel(name, nameTitle string) error {
path := fmt.Sprintf("model/%s.go", name)
if fileExists(path) {
fmt.Printf("文件 %s 已存在\n", path)
return
return fmt.Errorf("文件 %s 已存在", path)
}
content := fmt.Sprintf(templates.ModelMake,
nameTitle, name, nameTitle, nameTitle, name,
)
writeFile(path, content)
if err := writeFile(path, content); err != nil {
return err
}
fmt.Printf("✓ 创建模型: %s\n", path)
return nil
}
func createService(name, nameTitle string) {
func createService(name, nameTitle string) error {
path := fmt.Sprintf("service/%s_service.go", name)
if fileExists(path) {
fmt.Printf("文件 %s 已存在\n", path)
return
return fmt.Errorf("文件 %s 已存在", path)
}
content := fmt.Sprintf(templates.ServiceMake,
@@ -220,6 +328,9 @@ func createService(name, nameTitle string) {
)
content = replaceModuleImports(content)
writeFile(path, content)
if err := writeFile(path, content); err != nil {
return err
}
fmt.Printf("✓ 创建服务: %s\n", path)
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateMakeNameRejectsUnsafeOrInvalidNames(t *testing.T) {
tests := []string{
"",
"../user",
`..\user`,
".user",
"my-thing",
"123user",
}
for _, tt := range tests {
t.Run(tt, func(t *testing.T) {
if err := validateMakeName(tt); err == nil {
t.Fatalf("名称 %q 应返回错误", tt)
}
})
}
}
func TestMakeFileAcceptsSnakeCaseIdentifier(t *testing.T) {
oldWd, err := os.Getwd()
if err != nil {
t.Fatalf("获取当前目录失败: %v", err)
}
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("切换测试目录失败: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(oldWd); err != nil {
t.Fatalf("恢复测试目录失败: %v", err)
}
})
if err := makeFile("model", "user_profile"); err != nil {
t.Fatalf("生成模型失败: %v", err)
}
path := filepath.Join(tmp, "model", "user_profile.go")
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取生成文件失败: %v", err)
}
if !strings.Contains(string(content), "type UserProfile struct") {
t.Fatalf("生成类型名不符合预期:\n%s", content)
}
}
+14 -7
View File
@@ -39,21 +39,28 @@ func main() {
command := os.Args[1]
// P1 #21:命令执行失败时以非零码退出,便于 `xlgo new x && cd x` 等脚本/CI 正确中断。
switch command {
case "new":
if len(os.Args) < 3 {
fmt.Println("用法: xlgo new <项目名>")
return
fmt.Fprintln(os.Stderr, "用法: xlgo new <项目名>")
os.Exit(2)
}
if err := createProject(os.Args[2]); err != nil {
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
os.Exit(1)
}
createProject(os.Args[2])
case "make":
if len(os.Args) < 4 {
fmt.Println("用法: xlgo make <类型> <名称>")
fmt.Println("类型: handler, repository, model, service")
return
fmt.Fprintln(os.Stderr, "用法: xlgo make <类型> <名称>")
fmt.Fprintln(os.Stderr, "类型: handler, repository, model, service")
os.Exit(2)
}
if err := makeFile(os.Args[2], os.Args[3]); err != nil {
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
os.Exit(1)
}
makeFile(os.Args[2], os.Args[3])
case "version":
fmt.Printf("xlgo v%s\n", xlgo.Version)
+8 -7
View File
@@ -77,6 +77,7 @@ func registerRoutes(r *gin.RouterGroup) {
base_url: "http://localhost:8080"
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机;内网IP=绑定指定网卡
port: 8080
mode: development # development 或 production
read_timeout: 15s # 读超时
@@ -247,6 +248,7 @@ func registerRoutes(r *gin.RouterGroup) {
base_url: "http://localhost:8080"
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
port: 8080
mode: development
read_timeout: 15s
@@ -489,14 +491,13 @@ func New%sRepository() *%sRepository {
}
}
// FindByName 根据名称查询
// FindByName 根据名称查询
//
// 走 BaseRepo.FindOne → readConn 读连接:默认路由到从库(读写分离,H6c),
// 需读主库用 database.UseMaster(ctx),事务内自动 join 外层事务。
// 不要用 r.GetDB() 自行查询——它返回注入的主库且不参与路由(M-35 footgun)。
func (r *%sRepository) FindByName(ctx context.Context, name string) (*model.%s, error) {
var m model.%s
err := r.GetDB().WithContext(ctx).Where("name = ?", name).First(&m).Error
if err != nil {
return nil, err
}
return &m, nil
return r.FindOne(ctx, "name = ?", name)
}
`,
+13 -4
View File
@@ -7,20 +7,29 @@ import (
"strings"
)
func writeFile(path, content string) {
func writeFile(path, content string) error {
dir := filepath.Dir(path)
// #nosec G301,G703 -- path is built by makeFile from fixed scaffold directories and a name that rejects separators/".."; 0755 is intentional for generated dirs.
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Printf("创建目录失败: %s\n", err)
return
return fmt.Errorf("创建目录失败: %w", err)
}
// #nosec G306,G703 -- path is built by makeFile from fixed scaffold directories and a validated name; 0644 is intentional for generated source files.
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
fmt.Printf("写入文件失败: %s\n", err)
return fmt.Errorf("写入文件失败: %w", err)
}
return nil
}
func fileExists(path string) bool {
// #nosec G703 -- path is built by makeFile from fixed scaffold directories and a name that rejects separators/"..".
_, err := os.Stat(path)
if err == nil {
return true
}
// 仅当"不存在"才返回 false;权限错误等其它错误视为"可能存在/不可覆写"
// 避免把无权限访问的路径误判为可创建(M20:原 !os.IsNotExist 把权限错误当存在,
// 反而安全;但语义模糊,显式区分更清晰)。
return !os.IsNotExist(err)
}
+269 -69
View File
@@ -4,14 +4,67 @@ import (
"archive/zip"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
// 解压安全相关错误。
var (
// ErrPathTraversal zip 条目名逃逸目标目录(C5a Zip-Slip)。
ErrPathTraversal = errors.New("zip entry escapes destination directory")
// ErrSymlinkEntry zip 条目为符号链接,已拒绝(防经软链二次穿越)。
ErrSymlinkEntry = errors.New("zip entry is a symlink, rejected")
// ErrDecompressLimit 解压大小超过上限(C5b 解压炸弹)。
ErrDecompressLimit = errors.New("decompress size limit exceeded")
)
const (
// defaultDecompressLimit 单流/单条目默认解压上限(100MB),防 OOM / 磁盘耗尽(C5b)。
defaultDecompressLimit int64 = 100 * 1024 * 1024
// defaultDecompressTotalLimit Unzip 默认累计解压上限(1GB)。
defaultDecompressTotalLimit int64 = 1 * 1024 * 1024 * 1024
)
// DecompressOptions 解压安全选项。Zip-Slip 防护(前缀锚定 + 拒绝符号链接)始终启用,无需配置;
// 本选项仅控制解压大小上限以防解压炸弹(C5b)。
type DecompressOptions struct {
// MaxBytes 单流 / 单条目解压大小上限(字节)。0 = 默认 100MB,-1 = 不限制。
MaxBytes int64
// MaxTotalBytes Unzip 累计解压大小上限(字节)。0 = 默认 1GB,-1 = 不限制。
// 仅 Unzip 生效。
MaxTotalBytes int64
}
// resolveLimit 解析大小上限:n<0 不限,n==0 用 defn>0 用 n。
func resolveLimit(n, def int64) int64 {
if n < 0 {
return -1
}
if n == 0 {
return def
}
return n
}
// minLimit 返回两个上限中较小者;-1 视为无限。
func minLimit(a, b int64) int64 {
if a < 0 {
return b
}
if b < 0 {
return a
}
if a < b {
return a
}
return b
}
// GzipCompress 压缩数据
func GzipCompress(data []byte) ([]byte, error) {
var buf bytes.Buffer
@@ -26,33 +79,53 @@ func GzipCompress(data []byte) ([]byte, error) {
return buf.Bytes(), nil
}
// GzipDecompress 解压缩数据
// GzipDecompress 解压缩数据。默认上限 100MB 防解压炸弹 OOM(C5b);
// 需解压更大文件请用 GzipDecompressWithOptions。
func GzipDecompress(data []byte) ([]byte, error) {
return GzipDecompressWithOptions(data, DecompressOptions{})
}
// GzipDecompressWithOptions 解压缩数据,可配置大小上限(C5b)。
func GzipDecompressWithOptions(data []byte, opts DecompressOptions) ([]byte, error) {
buf := bytes.NewReader(data)
gz, err := gzip.NewReader(buf)
if err != nil {
return nil, err
}
defer gz.Close()
return io.ReadAll(gz)
limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
var reader io.Reader = gz
if limit > 0 {
// 多读 1 字节用于判断是否超限。
reader = io.LimitReader(gz, limit+1)
}
out, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if limit > 0 && int64(len(out)) > limit {
return nil, fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit)
}
return out, nil
}
// GzipCompressFile 压缩文件
func GzipCompressFile(src, dst string) error {
// #nosec G304 -- src/dst 为调用方提供的本地文件路径,压缩 API 固有语义,非不可信输入
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
// #nosec G304 -- 同上,dst 为调用方指定输出路径
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
gz := gzip.NewWriter(dstFile)
defer gz.Close()
// 保留原文件名和时间戳
if info, err := srcFile.Stat(); err == nil {
@@ -60,12 +133,29 @@ func GzipCompressFile(src, dst string) error {
gz.ModTime = info.ModTime()
}
_, err = io.Copy(gz, srcFile)
return err
_, copyErr := io.Copy(gz, srcFile)
// gz.Close 刷出 gzip 尾部(含 CRC/大小校验),失败说明归档损坏,必须向上传播(M16/B18)。
closeErr := gz.Close()
// dstFile.Close 失败(如延迟写盘)同样意味着归档可能不完整。
dstErr := dstFile.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
return dstErr
}
// GzipDecompressFile 解压文件
// GzipDecompressFile 解压文件。默认上限 100MB 防磁盘耗尽(C5b);
// 需解压更大文件请用 GzipDecompressFileWithOptions。
func GzipDecompressFile(src, dst string) error {
return GzipDecompressFileWithOptions(src, dst, DecompressOptions{})
}
// GzipDecompressFileWithOptions 解压文件,可配置大小上限(C5b)。
func GzipDecompressFileWithOptions(src, dst string, opts DecompressOptions) (err error) {
// #nosec G304 -- src 为调用方提供的本地文件路径,解压 API 固有语义,非不可信输入
srcFile, err := os.Open(src)
if err != nil {
return err
@@ -78,126 +168,236 @@ func GzipDecompressFile(src, dst string) error {
}
defer gz.Close()
// #nosec G304 -- dst 为调用方指定输出路径,解压 API 固有语义
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 dst,避免被拒炸弹遗留残片;
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
defer func() {
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
_ = os.Remove(dst)
}
}()
_, err = io.Copy(dstFile, gz)
return err
limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
var written int64
if limit > 0 {
// CopyN 最多读 limit+1 字节,超限即判定为炸弹。
written, err = io.CopyN(dstFile, gz, limit+1)
} else {
// #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹
written, err = io.Copy(dstFile, gz)
}
if err != nil && err != io.EOF {
return err
}
if limit > 0 && written > limit {
return fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit)
}
return nil
}
// Zip 压缩文件或目录
// 参数: zipPath 目标zip文件路径,paths 要压缩的文件或目录列表
func Zip(zipPath string, paths []string) error {
// 创建目标目录
if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
if err := os.MkdirAll(filepath.Dir(zipPath), 0750); err != nil {
return err
}
// 创建 zip 文件
// #nosec G304 -- zipPath 为调用方指定输出路径,压缩 API 固有语义
archive, err := os.Create(zipPath)
if err != nil {
return err
}
defer archive.Close()
zipWriter := zip.NewWriter(archive)
defer zipWriter.Close()
for _, srcPath := range paths {
srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator))
walkErr := func() error {
for _, srcPath := range paths {
srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator))
err = filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error {
// #nosec G122 -- 压缩调用方提供的源路径,非解压不可信输入;symlink 跟随是压缩场景的可接受行为
err := filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// 创建文件头
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Method = zip.Deflate
// 设置相对路径
header.Name, err = filepath.Rel(filepath.Dir(srcPath), path)
if err != nil {
return err
}
if info.IsDir() {
header.Name += string(os.PathSeparator)
}
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// #nosec G304,G122 -- path 为 Walk 遍历调用方源路径产生,非不可信输入;压缩场景接受 symlink 跟随语义。
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
})
if err != nil {
return err
}
// 创建文件头
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Method = zip.Deflate
// 设置相对路径
header.Name, err = filepath.Rel(filepath.Dir(srcPath), path)
if err != nil {
return err
}
if info.IsDir() {
header.Name += string(os.PathSeparator)
}
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
if info.IsDir() {
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
})
if err != nil {
return err
}
return nil
}()
// zipWriter.Close 刷出中央目录记录,失败说明归档损坏,必须向上传播(M16/B18)。
zipCloseErr := zipWriter.Close()
archiveCloseErr := archive.Close()
if walkErr != nil {
return walkErr
}
return nil
if zipCloseErr != nil {
return zipCloseErr
}
return archiveCloseErr
}
// Unzip 解压 zip 文件
// 参数: zipPath zip文件路径,dstDir 目标目录
// Unzip 解压 zip 文件。默认启用 Zip-Slip 防护(前缀锚定 + 拒绝符号链接),
// 单条目上限 100MB、累计上限 1GB 防解压炸弹(C5b);需自定义上限请用 UnzipWithOptions。
func Unzip(zipPath, dstDir string) error {
return UnzipWithOptions(zipPath, dstDir, DecompressOptions{})
}
// UnzipWithOptions 解压 zip 文件,可配置大小上限(C5b)。Zip-Slip 防护始终启用。
func UnzipWithOptions(zipPath, dstDir string, opts DecompressOptions) error {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer reader.Close()
// 用绝对路径作目标锚定根,避免相对路径 + `..` 组合绕过前缀校验(C5a)。
absDst, err := filepath.Abs(dstDir)
if err != nil {
return fmt.Errorf("resolve unzip destination failed: %w", err)
}
absDst = filepath.Clean(absDst)
entryLimit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
totalLimit := resolveLimit(opts.MaxTotalBytes, defaultDecompressTotalLimit)
var total int64
for _, file := range reader.File {
if err := unzipFile(file, dstDir); err != nil {
written, err := unzipFile(file, absDst, entryLimit, totalLimit, total)
if err != nil {
return err
}
total += written
}
return nil
}
func unzipFile(file *zip.File, dstDir string) error {
filePath := path.Join(dstDir, file.Name)
// unzipFile 解压单个 zip 条目到 absDst 下。
// entryLimit: 单条目上限(-1 不限);totalLimit: 累计上限(-1 不限);accrued: 已解压累计字节。
// 返回本条目写入字节数。
func unzipFile(file *zip.File, absDst string, entryLimit, totalLimit, accrued int64) (written int64, err error) {
// 拒绝符号链接条目,防经软链二次穿越(C5a)。
if file.Mode()&os.ModeSymlink != 0 {
return 0, fmt.Errorf("条目 %s 为符号链接: %w", file.Name, ErrSymlinkEntry)
}
// zip 条目名规范用正斜杠;转成当前平台分隔符后再 Join,并以前缀锚定拒绝 `..` 逃逸(C5a)。
name := filepath.FromSlash(file.Name)
// 拒绝绝对路径与以分隔符开头的条目(非标准、可疑,避免平台语义差异)。
// filepath.IsAbs 在 Windows 不认 "/x"(无盘符)为绝对路径,故补充分隔符前缀检查。
if filepath.IsAbs(name) || strings.HasPrefix(name, string(os.PathSeparator)) || strings.HasPrefix(file.Name, "/") {
return 0, fmt.Errorf("条目 %s 为绝对路径: %w", file.Name, ErrPathTraversal)
}
target := filepath.Join(absDst, name)
if target == absDst || !strings.HasPrefix(target, absDst+string(os.PathSeparator)) {
return 0, fmt.Errorf("条目 %s 逃逸目标目录: %w", file.Name, ErrPathTraversal)
}
if file.FileInfo().IsDir() {
return os.MkdirAll(filePath, 0755)
if err := os.MkdirAll(target, 0750); err != nil {
return 0, err
}
return 0, nil
}
// 创建父目录
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return err
if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil {
return 0, err
}
// 打开 zip 中的文件
rc, err := file.Open()
if err != nil {
return err
return 0, err
}
defer rc.Close()
// 创建目标文件
dstFile, err := os.Create(filePath)
// #nosec G304 -- target 经前缀锚定校验(absDst+sep),已防 Zip-Slip 逃逸
dstFile, err := os.Create(target)
if err != nil {
return err
return 0, err
}
defer dstFile.Close()
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 target,避免被拒炸弹遗留残片;
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
defer func() {
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
_ = os.Remove(target)
}
}()
_, err = io.Copy(dstFile, rc)
return err
// 计算本次拷贝上限:单条目上限与累计剩余上限中较小者(-1 视为无限)。
// remaining: 累计剩余(-1 表示累计不限);totalLimit>0 时若已无剩余,直接判超限。
remaining := int64(-1)
if totalLimit > 0 {
remaining = totalLimit - accrued
if remaining <= 0 {
return 0, fmt.Errorf("累计解压超过上限 %d 字节: %w", totalLimit, ErrDecompressLimit)
}
}
cap := minLimit(entryLimit, remaining)
// cap 为 -1(两者皆不限)或 >0(有限上限,剩余已保证 >0),不会是 0。
if cap > 0 {
written, err = io.CopyN(dstFile, rc, cap+1)
} else {
// #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹
written, err = io.Copy(dstFile, rc)
}
if err != nil && err != io.EOF {
return written, err
}
if cap > 0 && written > cap {
return written, fmt.Errorf("条目 %s 超过解压上限 %d 字节: %w", file.Name, cap, ErrDecompressLimit)
}
return written, nil
}
+347
View File
@@ -0,0 +1,347 @@
package compress_test
import (
"archive/zip"
"bytes"
"compress/gzip"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/compress"
)
// makeZipAt 构造一个 zip 文件,entries 为 name -> contentdir 条目用空 content 且 IsDir=true。
func makeZipAt(t *testing.T, zipPath string, entries []struct {
name string
content string
isDir bool
}) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(zipPath), 0750); err != nil {
t.Fatalf("mkdir: %v", err)
}
f, err := os.Create(zipPath)
if err != nil {
t.Fatalf("create zip: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
for _, e := range entries {
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
if e.isDir {
hdr.SetMode(os.ModeDir | 0750)
}
w, err := zw.CreateHeader(hdr)
if err != nil {
t.Fatalf("CreateHeader %s: %v", e.name, err)
}
if !e.isDir {
if _, err := w.Write([]byte(e.content)); err != nil {
t.Fatalf("write %s: %v", e.name, err)
}
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip writer: %v", err)
}
}
// ===== C5aZip-Slip =====
// 回归 C5a:zip 条目名含 `..` 逃逸目标目录必须被拒绝,且不在 dst 外创建/覆盖文件。
func TestUnzipZipSlipRejected(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "evil.zip")
// 在 dst 的父目录放一个蜜罐文件,确保穿越不会覆盖它。
canary := filepath.Join(dir, "canary.txt")
if err := os.WriteFile(canary, []byte("original"), 0644); err != nil {
t.Fatalf("write canary: %v", err)
}
dst := filepath.Join(dir, "out")
makeZipAt(t, zipPath, []struct {
name string
content string
isDir bool
}{
{name: "../canary.txt", content: "pwned"},
})
err := compress.Unzip(zipPath, dst)
if !errors.Is(err, compress.ErrPathTraversal) {
t.Errorf("Unzip with ../ entry err = %v, want ErrPathTraversal", err)
}
// 蜜罐文件必须未被覆盖。
data, err := os.ReadFile(canary)
if err != nil {
t.Fatalf("read canary: %v", err)
}
if string(data) != "original" {
t.Errorf("canary overwritten by Zip-Slip: got %q", string(data))
}
}
// 回归 C5a:绝对路径条目必须被拒绝。
func TestUnzipAbsolutePathRejected(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "abs.zip")
dst := filepath.Join(dir, "out")
makeZipAt(t, zipPath, []struct {
name string
content string
isDir bool
}{
{name: "/etc/evil.txt", content: "x"},
})
if err := compress.Unzip(zipPath, dst); !errors.Is(err, compress.ErrPathTraversal) {
t.Errorf("Unzip absolute path err = %v, want ErrPathTraversal", err)
}
}
// 回归 C5a:合法相对路径条目不误伤(含子目录)。
func TestUnzipNormalEntriesWork(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "ok.zip")
dst := filepath.Join(dir, "out")
makeZipAt(t, zipPath, []struct {
name string
content string
isDir bool
}{
{name: "sub/", isDir: true},
{name: "sub/a.txt", content: "hello"},
{name: "top.txt", content: "world"},
})
if err := compress.Unzip(zipPath, dst); err != nil {
t.Fatalf("Unzip normal: %v", err)
}
if b, err := os.ReadFile(filepath.Join(dst, "sub", "a.txt")); err != nil || string(b) != "hello" {
t.Errorf("sub/a.txt = %q, err=%v, want 'hello'", string(b), err)
}
if b, err := os.ReadFile(filepath.Join(dst, "top.txt")); err != nil || string(b) != "world" {
t.Errorf("top.txt = %q, err=%v, want 'world'", string(b), err)
}
}
// 回归 C5a:符号链接条目必须被拒绝(防经软链二次穿越)。
func TestUnzipSymlinkRejected(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "symlink.zip")
dst := filepath.Join(dir, "out")
makeZipAt(t, zipPath, []struct {
name string
content string
isDir bool
}{
{name: "lnk", content: "/etc/passwd"}, // content 为链接目标,mode 设为 symlink
})
// 把条目 mode 改成符号链接:重建 zip 时设置 ModeSymlink。
// makeZipAt 不支持 symlink mode,这里直接用底层 API 重建。
zipPath2 := filepath.Join(dir, "symlink2.zip")
f, err := os.Create(zipPath2)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
hdr := &zip.FileHeader{Name: "lnk", Method: zip.Deflate}
hdr.SetMode(os.ModeSymlink | 0777)
w, err := zw.CreateHeader(hdr)
if err != nil {
t.Fatalf("CreateHeader: %v", err)
}
if _, err := w.Write([]byte("/etc/passwd")); err != nil {
t.Fatalf("write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if err := compress.Unzip(zipPath2, dst); !errors.Is(err, compress.ErrSymlinkEntry) {
t.Errorf("Unzip symlink err = %v, want ErrSymlinkEntry", err)
}
}
// ===== C5b:解压炸弹 =====
// 回归 C5bGzipDecompress 超限返回错误而非 OOM。
// 用显式小上限验证限流代码路径(与默认 100MB 走同一 io.LimitReader + 超限判定逻辑)。
func TestGzipDecompressBombLimit(t *testing.T) {
// 2MB 解压后数据。
big := bytes.Repeat([]byte("A"), 2*1024*1024)
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(big); err != nil {
t.Fatalf("gzip write: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("gzip close: %v", err)
}
compressed := buf.Bytes()
// 显式上限 1MB → 必须拒绝(2MB 超限)。
if _, err := compress.GzipDecompressWithOptions(compressed, compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}); !errors.Is(err, compress.ErrDecompressLimit) {
t.Errorf("GzipDecompress over-limit err = %v, want ErrDecompressLimit", err)
}
// 显式 -1 不限 → 成功解压完整 2MB。
out, err := compress.GzipDecompressWithOptions(compressed, compress.DecompressOptions{MaxBytes: -1})
if err != nil {
t.Errorf("GzipDecompress unlimited err = %v", err)
}
if len(out) != len(big) {
t.Errorf("unlimited decompressed len = %d, want %d", len(out), len(big))
}
// 默认上限(100MB)放行 2MB 正常数据。
if _, err := compress.GzipDecompress(compressed); err != nil {
t.Errorf("GzipDecompress default err = %v (2MB should pass default 100MB limit)", err)
}
}
// 回归 C5b:Unzip 单条目上限——超大条目被拒。
func TestUnzipEntryBombLimit(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "bomb.zip")
dst := filepath.Join(dir, "out")
// 构造一个含 2MB(解压后)条目的 zip。
f, err := os.Create(zipPath)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
w, err := zw.Create("big.txt")
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := w.Write(bytes.Repeat([]byte("A"), 2 * 1024 * 1024)); err != nil {
t.Fatalf("write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close: %v", err)
}
// 显式单条目上限 1MB → 必须拒绝。
opts := compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}
if err := compress.UnzipWithOptions(zipPath, dst, opts); !errors.Is(err, compress.ErrDecompressLimit) {
t.Errorf("Unzip entry bomb err = %v, want ErrDecompressLimit", err)
}
}
// 回归 C5b:Unzip 累计上限——多个条目累计超限被拒。
func TestUnzipTotalBombLimit(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "many.zip")
dst := filepath.Join(dir, "out")
// 5 个 1MB 条目 = 累计 5MB;单条目上限 2MB(不超),累计上限 3MB → 第 4 个累计 4MB 超限。
f, err := os.Create(zipPath)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
chunk := bytes.Repeat([]byte("B"), 1*1024*1024)
for i := 0; i < 5; i++ {
w, err := zw.Create("file" + string(rune('0'+i)) + ".dat")
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := w.Write(chunk); err != nil {
t.Fatalf("write: %v", err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close: %v", err)
}
opts := compress.DecompressOptions{MaxBytes: 2 * 1024 * 1024, MaxTotalBytes: 3 * 1024 * 1024}
if err := compress.UnzipWithOptions(zipPath, dst, opts); !errors.Is(err, compress.ErrDecompressLimit) {
t.Errorf("Unzip total bomb err = %v, want ErrDecompressLimit", err)
}
}
// 回归 C5bGzipDecompressFile 单流封顶——超限返回错误而非磁盘耗尽。
func TestGzipDecompressFileBombLimit(t *testing.T) {
dir := t.TempDir()
// 2MB 解压后数据写入 gzip 文件。
big := bytes.Repeat([]byte("A"), 2*1024*1024)
srcGz := filepath.Join(dir, "src.gz")
{
f, err := os.Create(srcGz)
if err != nil {
t.Fatalf("create: %v", err)
}
gz := gzip.NewWriter(f)
if _, err := gz.Write(big); err != nil {
t.Fatalf("write: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gz: %v", err)
}
f.Close()
}
// 显式上限 1MB → 必须拒绝(2MB 超限)。
dst1 := filepath.Join(dir, "out1.txt")
if err := compress.GzipDecompressFileWithOptions(srcGz, dst1, compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}); !errors.Is(err, compress.ErrDecompressLimit) {
t.Errorf("GzipDecompressFile over-limit err = %v, want ErrDecompressLimit", err)
}
// 显式 -1 不限 → 成功解压完整 2MB。
dst2 := filepath.Join(dir, "out2.txt")
if err := compress.GzipDecompressFileWithOptions(srcGz, dst2, compress.DecompressOptions{MaxBytes: -1}); err != nil {
t.Errorf("GzipDecompressFile unlimited err = %v", err)
}
b, err := os.ReadFile(dst2)
if err != nil {
t.Fatalf("read out: %v", err)
}
if len(b) != len(big) {
t.Errorf("unlimited out len = %d, want %d", len(b), len(big))
}
// 默认上限(100MB)放行 2MB。
dst3 := filepath.Join(dir, "out3.txt")
if err := compress.GzipDecompressFile(srcGz, dst3); err != nil {
t.Errorf("GzipDecompressFile default err = %v (2MB should pass default 100MB limit)", err)
}
}
// 兼容性回归:原有 Zip/Unzip 闭环(合法归档)在默认防护下仍正常。
func TestZipUnzipRoundTripStillWorks(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.txt")
content := "round trip content"
if err := os.WriteFile(src, []byte(content), 0644); err != nil {
t.Fatalf("write: %v", err)
}
zipPath := filepath.Join(dir, "rt.zip")
if err := compress.Zip(zipPath, []string{src}); err != nil {
t.Fatalf("Zip: %v", err)
}
dst := filepath.Join(dir, "out")
if err := compress.Unzip(zipPath, dst); err != nil {
t.Fatalf("Unzip: %v", err)
}
// Zip 用 filepath.Rel(src 的父目录, src) = "src.txt"。
b, err := os.ReadFile(filepath.Join(dst, "src.txt"))
if err != nil {
// 目录结构可能因平台分隔符略有差异,尝试找 src.txt。
_ = filepath.Walk(dst, func(p string, _ os.FileInfo, _ error) error {
if strings.HasSuffix(p, "src.txt") {
b, err = os.ReadFile(p)
return filepath.SkipDir
}
return nil
})
}
if string(b) != content {
t.Errorf("round trip = %q, want %q", string(b), content)
}
}
+558 -106
View File
@@ -1,9 +1,16 @@
package config
import (
"context"
"errors"
"fmt"
"log"
"net/url"
"path/filepath"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/fsnotify/fsnotify"
@@ -13,21 +20,90 @@ import (
// 配置错误
var (
ErrConfigNotLoaded = fmt.Errorf("配置未加载")
// L-config-4:无格式化的哨兵错误用 errors.Newfmt.Errorf 无动词等价但语义上后者暗示格式化)。
ErrConfigNotLoaded = errors.New("配置未加载")
ErrInvalidConfig = errors.New("配置非法")
)
// Config 全局配置结构体
type Config struct {
App AppConfig `mapstructure:"app"`
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
SMS SMSConfig `mapstructure:"sms"`
Storage StorageConfig `mapstructure:"storage"`
Upload UploadConfig `mapstructure:"upload"`
Log LogConfig `mapstructure:"log"`
CORS CORSConfig `mapstructure:"cors"`
App AppConfig `mapstructure:"app"`
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
SMS SMSConfig `mapstructure:"sms"`
Storage StorageConfig `mapstructure:"storage"`
Upload UploadConfig `mapstructure:"upload"`
Log LogConfig `mapstructure:"log"`
CORS CORSConfig `mapstructure:"cors"`
Trace TraceConfig `mapstructure:"trace"`
}
// Clone 返回 Config 的深拷贝(M-G 修复)。
//
// 标量字段与子结构体经结构体值拷贝独立;所有切片字段(CORS 的 4 个列表、Upload 的 2 个
// 类型白名单、Storage.Local/OSS 上传策略的扩展名/MIME 白名单)深拷贝底层数组,使返回值
// 可被调用方安全修改(含 append/sort/改元素)而不污染框架内部配置、不与其他读者竞态。
//
// 用于需要可变配置副本的场景。Load/Get/回调均返回 Clone,避免调用方误改全局配置。
func (c *Config) Clone() *Config {
if c == nil {
return nil
}
cp := *c // 浅拷贝:标量与子结构体独立,切片字段仍共享底层数组(下方逐个深拷贝)
// CORS
cp.CORS.AllowedOrigins = cloneStrings(c.CORS.AllowedOrigins)
cp.CORS.AllowedMethods = cloneStrings(c.CORS.AllowedMethods)
cp.CORS.AllowedHeaders = cloneStrings(c.CORS.AllowedHeaders)
cp.CORS.ExposedHeaders = cloneStrings(c.CORS.ExposedHeaders)
// Upload
cp.Upload.AllowedImageTypes = cloneStrings(c.Upload.AllowedImageTypes)
cp.Upload.AllowedVideoTypes = cloneStrings(c.Upload.AllowedVideoTypes)
// Storage.Local.Upload / Storage.OSS.Upload
cp.Storage.Local.Upload.AllowedExts = cloneStrings(c.Storage.Local.Upload.AllowedExts)
cp.Storage.Local.Upload.AllowedMIMEs = cloneStrings(c.Storage.Local.Upload.AllowedMIMEs)
cp.Storage.OSS.Upload.AllowedExts = cloneStrings(c.Storage.OSS.Upload.AllowedExts)
cp.Storage.OSS.Upload.AllowedMIMEs = cloneStrings(c.Storage.OSS.Upload.AllowedMIMEs)
return &cp
}
// cloneStrings 返回字符串切片的深拷贝(nil 保持 nil 语义,避免把 nil 变成空切片)。
func cloneStrings(s []string) []string {
if s == nil {
return nil
}
out := make([]string, len(s))
copy(out, s)
return out
}
func cloneStringAnyMap(in map[string]any) map[string]any {
if in == nil {
return nil
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = cloneAny(v)
}
return out
}
func cloneAny(v any) any {
switch x := v.(type) {
case map[string]any:
return cloneStringAnyMap(x)
case []any:
out := make([]any, len(x))
for i, item := range x {
out[i] = cloneAny(item)
}
return out
case []string:
return cloneStrings(x)
default:
return x
}
}
// AppConfig 应用配置
@@ -91,6 +167,7 @@ type TLSConfig struct {
// ServerConfig 服务配置
type ServerConfig struct {
Host string `mapstructure:"host"` // 绑定地址,空=监听所有接口(0.0.0.0)"127.0.0.1"=仅本机;内网IP=绑定指定网卡
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // development 或 production
ReadTimeout time.Duration `mapstructure:"read_timeout"` // 读超时,如 "15s"
@@ -99,7 +176,7 @@ type ServerConfig struct {
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` // 优雅关闭超时,如 "30s"
MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 最大请求头字节数
TLS TLSConfig `mapstructure:"tls"`
UnixSocket string `mapstructure:"unix_socket"` // 非空时优先于 Port,监听 unix socket
UnixSocket string `mapstructure:"unix_socket"` // 非空时优先于 Port,监听 unix socket
ResponseMode string `mapstructure:"response_mode"` // business(默认) 或 rest,见 response.SetMode
}
@@ -158,6 +235,12 @@ const (
DriverPostgres = "postgres"
)
// MySQLTLSConfigName 是 database 包为 MySQL 私有 CA TLS 注册的命名配置名(M-config-2)。
// 当 DatabaseConfig.TLS=true 且 TLSRootCA 非空时,MySQLDSN 追加 tls=<本常量>
// 由 database 包在 InitDB 时通过 go-sql-driver/mysql.RegisterTLSConfig 注册自定义 *tls.Config。
// TLS=true 但 TLSRootCA 为空时则用内置 tls=true(系统根 CA),无需注册。
const MySQLTLSConfigName = "xlgo-mysql"
// DSNBuilder 根据 DatabaseConfig 生成连接字符串
type DSNBuilder func(*DatabaseConfig) string
@@ -224,6 +307,9 @@ type DatabaseConfig struct {
Password string `mapstructure:"password"`
// Name 数据库名
Name string `mapstructure:"name"`
// Timezone 连接时区。MySQL 用作 loc 参数、Postgres 用作 TimeZone 参数。
// 空时 MySQL 默认 "Local"、Postgres 默认 "Asia/Shanghai"(向后兼容,M9)。
Timezone string `mapstructure:"timezone"`
// CustomDSN 自定义连接字符串,设置后优先于由 Host/Port 等字段生成的 DSN
CustomDSN string `mapstructure:"dsn"`
// MaxIdleConns 最大空闲连接数
@@ -236,32 +322,102 @@ type DatabaseConfig struct {
HealthCheckInterval time.Duration `mapstructure:"health_check_interval"`
// HealthCheckFailureThreshold 连续探活失败多少次标记不健康(#21)。0 表示用默认 3
HealthCheckFailureThreshold int `mapstructure:"health_check_failure_threshold"`
// SSLMode PostgreSQL sslmodedisable/allow/prefer/require/verify-ca/verify-full)。
// 空时默认 "prefer"M-config-2:原硬编码 disable 已改为默认 prefer,优先加密、失败回退明文)。
// 该字段仅对 PostgreSQL 生效;MySQL 用 TLS/TLSRootCA。
SSLMode string `mapstructure:"ssl_mode"`
// TLS 是否对 MySQL 启用 TLS。true 时 MySQLDSN 追加 tls=true(内置:系统根 CA + 证书校验,
// ServerName 自动取自 Host)。配合 TLSRootCA 可指定私有 CAM-config-2)。
TLS bool `mapstructure:"tls"`
// TLSRootCA MySQL TLS 自定义 CA 证书 PEM 路径(用于私有 CA/自签证书)。
// 非空时 MySQLDSN 改用 tls=MySQLTLSConfigName,由 database 包在 InitDB 时注册命名 TLS 配置
// ServerName 取自 Host)。该命名配置仅覆盖「replica DSN host 与主库 Host 相同」的单 host 集群;
// 多 host replicas + 私有 CA 时须为每个 replica host 注册不同名 TLS 配置并自建 replica DSN(框架
// MySQLDSN 硬编码 MySQLTLSConfigName,不适用),详见 database.ensureMySQLTLSRegistered 注释。
// 空时用内置 tls=true(系统根 CA)。仅 MySQL 生效。
TLSRootCA string `mapstructure:"tls_root_ca"`
}
// DSN 根据驱动返回连接字符串。设置了 CustomDSN 时优先返回 CustomDSN
// 未指定 Driver 时按 MySQL 处理(向后兼容)。
// 若驱动通过 RegisterDSNBuilder 注册过自定义构建器,则使用注册的构建器
// DSN 根据驱动返回连接字符串。设置了 CustomDSN 时优先返回 CustomDSN
// 未指定 Driver 时按 MySQL 处理;非空但未注册的 Driver 返回空字符串,
// 避免把拼写错误静默当作 MySQL 连接,正常加载路径会由 Validate 提前报错
func (c *DatabaseConfig) DSN() string {
if c == nil { // L-config-6:防御 nil receiver
return ""
}
if c.CustomDSN != "" {
return c.CustomDSN
}
if builder, ok := LookupDSNBuilder(c.Driver); ok {
driver := strings.TrimSpace(c.Driver)
if driver == "" { // L-config-5:去除原重复的 TrimSpace(driver)
driver = DriverMySQL
}
if builder, ok := LookupDSNBuilder(driver); ok {
return builder(c)
}
// 未注册时回退到 MySQL(保持向后兼容)
return c.MySQLDSN()
return ""
}
// MySQLDSN 返回 MySQL 连接字符串
func (c DatabaseConfig) isConfigured() bool {
return strings.TrimSpace(c.Driver) != "" ||
strings.TrimSpace(c.Host) != "" ||
c.Port != 0 ||
strings.TrimSpace(c.User) != "" ||
strings.TrimSpace(c.Password) != "" ||
strings.TrimSpace(c.Name) != "" ||
strings.TrimSpace(c.CustomDSN) != ""
}
// MySQLDSN 返回 MySQL 连接字符串。
// 密码经 url.QueryEscape 转义,避免含 @/:/空格 等特殊字符破坏 DSN(M9)。
// loc 由 Timezone 配置,空则默认 "Local"(向后兼容)。
// TLS=true 时追加 tls 参数(M-config-2):TLSRootCA 为空用内置 tls=true(系统根 CA + 证书校验);
// TLSRootCA 非空用 tls=MySQLTLSConfigNamedatabase 包注册的私有 CA 命名配置)。
func (c *DatabaseConfig) MySQLDSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
c.User, c.Password, c.Host, c.Port, c.Name)
if c == nil { // L-config-6
return ""
}
loc := c.Timezone
if loc == "" {
loc = "Local"
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s",
url.QueryEscape(c.User), url.QueryEscape(c.Password), c.Host, c.Port, url.PathEscape(c.Name), url.QueryEscape(loc))
if c.TLS {
if strings.TrimSpace(c.TLSRootCA) == "" {
dsn += "&tls=true"
} else {
dsn += "&tls=" + MySQLTLSConfigName
}
}
return dsn
}
// PostgresDSN 返回 PostgreSQL 连接字符串
// PostgresDSN 返回 PostgreSQL 连接字符串
// 字符串字段统一用单引号包裹并转义,避免含空格/引号/反斜杠破坏 key=value DSN。
// TimeZone 由 Timezone 配置,空则默认 "Asia/Shanghai"(向后兼容)。
// sslmode 由 SSLMode 配置,空则默认 "prefer"M-config-2:原硬编码 disable 改为 prefer,优先加密)。
func (c *DatabaseConfig) PostgresDSN() string {
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai",
c.Host, c.Port, c.User, c.Password, c.Name)
if c == nil { // L-config-6
return ""
}
tz := c.Timezone
if tz == "" {
tz = "Asia/Shanghai"
}
sslmode := strings.TrimSpace(c.SSLMode)
if sslmode == "" {
sslmode = "prefer"
}
// sslmode 不加引号(与原 sslmode=disable 格式一致);SSLMode 已被 Validate 限定为固定枚举,无注入风险。
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s",
postgresQuote(c.Host), c.Port, postgresQuote(c.User), postgresQuote(c.Password), postgresQuote(c.Name), sslmode, postgresQuote(tz))
}
func postgresQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `'`, `\'`)
return "'" + s + "'"
}
// RedisConfig Redis 配置
@@ -274,6 +430,9 @@ type RedisConfig struct {
// Addr 返回 Redis 地址
func (c *RedisConfig) Addr() string {
if c == nil { // L-config-6
return ""
}
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
@@ -283,7 +442,7 @@ type JWTConfig struct {
Expire time.Duration `mapstructure:"expire"` // 过期时间,如 "24h"time.Duration
RefreshExpire time.Duration `mapstructure:"refresh_expire"` // 刷新 token 过期时间,如 "168h"
Issuer string `mapstructure:"issuer"` // 签发者
Algorithm string `mapstructure:"algorithm"` // 签名算法:HS256(默认)/RS256
Algorithm string `mapstructure:"algorithm"` // 签名算法:HS256(默认)/HS384/HS512;非 HMAC 算法(如 RS256)会被 jwt.signingMethod 拒绝(ErrUnsupportedAlgorithm
}
// SMSConfig 短信配置
@@ -303,10 +462,26 @@ type StorageConfig struct {
OSS OSSStorageConfig `mapstructure:"oss"`
}
// UploadPolicy 上传安全策略(C4b)。零值表示不限制,向后兼容;
// 生产环境强烈建议显式配置 MaxSizeBytes 与 AllowedExts / AllowedMIMEs。
type UploadPolicy struct {
// MaxSizeBytes 单文件大小上限(字节)。0 = 不限制。
MaxSizeBytes int64 `mapstructure:"max_size_bytes"`
// AllowedExts 允许的扩展名白名单(小写、含点,如 ".jpg")。空 = 不限制。
AllowedExts []string `mapstructure:"allowed_exts"`
// AllowedMIMEs 允许的 MIME 类型白名单(小写,如 "image/jpeg")。
// 非空时用 http.DetectContentType 嗅探文件前 512 字节校验。空 = 不嗅探。
AllowedMIMEs []string `mapstructure:"allowed_mime_types"`
}
// LocalStorageConfig 本地存储配置
type LocalStorageConfig struct {
Path string `mapstructure:"path"`
BaseURL string `mapstructure:"base_url"`
// Upload 上传安全策略(可选,零值不限制)。
Upload UploadPolicy `mapstructure:"upload"`
// MaxReadBytes Get 读取单文件上限(字节)。0 = 默认 100MB,-1 = 不限制。
MaxReadBytes int64 `mapstructure:"max_read_bytes"`
}
// OSSStorageConfig OSS 存储配置
@@ -316,6 +491,10 @@ type OSSStorageConfig struct {
AccessKeyID string `mapstructure:"access_key_id"`
AccessKeySecret string `mapstructure:"access_key_secret"`
BaseURL string `mapstructure:"base_url"`
// Upload 上传安全策略(可选,零值不限制)。
Upload UploadPolicy `mapstructure:"upload"`
// MaxReadBytes Get 读取单文件上限(字节)。0 = 默认 100MB,-1 = 不限制。
MaxReadBytes int64 `mapstructure:"max_read_bytes"`
}
// LogConfig 日志配置
@@ -346,6 +525,26 @@ type CORSConfig struct {
MaxAge int `mapstructure:"max_age"` // 预检请求缓存时间(秒)
}
// TraceConfig 链路追踪配置(OpenTelemetry)。由 App 在 WithTrace 时读取并调 trace.Init。
//
// 语义说明:OTel 的 TracerProvider / TextMapPropagator 本身是进程级全局单例
// otel.SetTracerProvider 全局生效),故 trace 不做 per-App 实例隔离--多 App 进程
// 共享同一 OTel 全局状态,这与 OTel 自身设计一致。App 仅负责把 trace 纳入生命周期
// Init/Close)与装入 Middleware,不提供实例隔离。
//
// Enabled=false(默认)时 trace.Init 安装 Noop tracerMiddleware 不 panic、不导出。
type TraceConfig struct {
ServiceName string `mapstructure:"service_name"` // 服务名(空则回退 cfg.App.Name
ServiceVersion string `mapstructure:"service_version"` // 服务版本
Environment string `mapstructure:"environment"` // 运行环境
ExporterType string `mapstructure:"exporter_type"` // otlp-http(默认) / otlp-grpc / stdout
Endpoint string `mapstructure:"endpoint"` // OTLP collector 地址
Insecure bool `mapstructure:"insecure"` // 明文(无 TLS)连接 collector
SampleRatio float64 `mapstructure:"sample_ratio"` // 采样比例 0.0-1.0
Enabled bool `mapstructure:"enabled"` // 是否启用导出
Propagator string `mapstructure:"propagator"` // w3c(默认) / b3 / jaeger
}
// GetAllowedOrigins 获取允许的域名列表
func (c *CORSConfig) GetAllowedOrigins() []string {
if c == nil || len(c.AllowedOrigins) == 0 {
@@ -393,9 +592,24 @@ type Manager struct {
v *viper.Viper
cfg *Config
callbacks []func(*Config)
// watcher 是自管的 fsnotify 监听器(C10d)。nil 表示未启用文件监听。
// 由 StartWatcher 创建、StopWatcher 关闭,避免依赖 viper 内部无法停止的 watcher。
watcher *fsnotify.Watcher
// watchDone 在监听 goroutine 退出时被 close,供 StopWatcher 等待退出确认。
watchDone chan struct{}
// watchCancel 取消 watchLoop 的 ctxL-config-2)。watchLoop 同时监听 ctx.Done 与 w.Events
// 提供 fsnotify 致命错误且 Events 未关闭时的逃生通道,避免监听 goroutine 永驻。
// StopWatcher 时 cancel + Close(w) 双重退出保障。
watchCancel context.CancelFunc
}
var defaultManager = NewManager("")
// defaultManager 是包级默认管理器(C10a)。改用 atomic.Pointer 保护读写,
// 消除原裸指针置换与请求 goroutine 无锁读之间的数据竞争。
var defaultManager atomic.Pointer[Manager]
func init() {
defaultManager.Store(NewManager(""))
}
// NewManager 创建配置管理器
func NewManager(configPath string) *Manager {
@@ -410,6 +624,49 @@ func newViper(configPath string) *viper.Viper {
return v
}
func cloneViper(src *viper.Viper, configPath string) *viper.Viper {
if src == nil {
return nil
}
cp := newViper(configPath)
if err := cp.MergeConfigMap(src.AllSettings()); err != nil {
return nil
}
return cp
}
// configToMap 将 *Config 按 mapstructure tag 转为嵌套 map[string]anyH-config-1)。
// mapstructure struct->map 会递归嵌套结构体为 map,切片/map 字段原样保留,
// 用于在 Set(cfg) 后重建 m.v,使 GetString/GetInt/GetBool/GetViper 与 Get() 读到同一配置。
func configToMap(cfg *Config) map[string]any {
if cfg == nil {
return nil
}
out := map[string]any{}
if err := mapstructure.Decode(cfg, &out); err != nil {
return nil
}
return out
}
// viperFromConfig 由 *Config 重建一个不含 AutomaticEnv 的 viperH-config-1)。
// 用于 Set(cfg) 后同步 m.v:不启用 AutomaticEnv 是为了确保 GetString 等只读取 cfg 派生的值,
// 与 Get()(返回 m.cfg 即调用方传入的 cfg)严格同源,避免 env 覆盖造成二者的二次分裂。
// 保留 SetConfigFile(m.path) 以便后续 Reload 从文件重读。
//
// 已知差异(Duration 字段):mapstructure struct->map 将 time.Duration 原样保留为 time.Duration
// 故 GetString("jwt.expire") 在 Set 后返回 Duration.String() 格式(如 "24h0m0s"),与文件加载路径
// 返回的原始字符串(如 "24h")字面不同。二者语义一致(均可 ParseDuration),typed viewGet().JWT.Expire
// 与 GetDuration 在两条路径下完全一致。Duration 字段应经 typed view 或 GetDuration 读取,勿用 GetString 字面比较。
func viperFromConfig(configPath string, cfg *Config) *viper.Viper {
v := viper.New()
v.SetConfigFile(configPath)
if m := configToMap(cfg); m != nil {
_ = v.MergeConfigMap(m)
}
return v
}
// unmarshalConfig 将 viper 解析到 Config,启用 string→time.Duration decode hook
// 使 ServerConfig/JWTConfig 的 Duration 字段可写 "24h"/"15s" 等字符串。
func unmarshalConfig(v *viper.Viper, cfg *Config) error {
@@ -440,7 +697,11 @@ func (m *Manager) Load() (*Config, error) {
m.cfg = &cfg
m.mu.Unlock()
return &cfg, nil
// M-G 修复:返回深拷贝(Clone),标量与切片字段均独立,调用方可安全修改。
// 原"防御性拷贝"为浅拷贝(out := cfg)——切片字段(CORS.AllowedOrigins 等)仍与
// 内部 m.cfg 共享底层数组,调用方 append/sort/改元素会污染全局并与其他读者竞态。
// 内部 m.cfg 保留独立的 &cfg,不受返回值修改影响。
return cfg.Clone(), nil
}
// LoadWithWatch 加载配置文件并启用热更新
@@ -468,204 +729,395 @@ func (m *Manager) RegisterCallback(cb func(*Config)) {
m.callbacks = append(m.callbacks, cb)
}
// StartWatcher 启动配置文件监听
// StartWatcher 启动配置文件监听。使用自管的 fsnotify.Watcher(监听配置文件
// 所在目录以兼容编辑器改写/k8s ConfigMap 原子替换),文件变更时去抖后重新加载。
// 幂等:重复调用不会创建多个监听 goroutine。
func (m *Manager) StartWatcher() error {
if m == nil {
return ErrConfigNotLoaded
}
m.mu.RLock()
v := m.v
m.mu.RUnlock()
if v == nil {
m.mu.Lock()
if m.watcher != nil {
// 已在监听,幂等返回
m.mu.Unlock()
return nil
}
if m.v == nil || m.path == "" {
m.mu.Unlock()
return ErrConfigNotLoaded
}
v.WatchConfig()
v.OnConfigChange(func(e fsnotify.Event) {
var newCfg Config
if err := unmarshalConfig(v, &newCfg); err != nil {
return
}
m.mu.Lock()
m.cfg = &newCfg
cbs := make([]func(*Config), len(m.callbacks))
copy(cbs, m.callbacks)
w, err := fsnotify.NewWatcher()
if err != nil {
m.mu.Unlock()
return fmt.Errorf("创建文件监听失败: %w", err)
}
// 监听父目录而非文件本身:vim/k8s 等通过"写临时文件 + rename"替换配置,
// 直接监听文件会在 rename 后丢失。监听目录并按文件名过滤更稳健。
dir := filepath.Dir(m.path)
if err := w.Add(dir); err != nil {
m.mu.Unlock()
_ = w.Close()
return fmt.Errorf("监听配置目录失败: %w", err)
}
m.watcher = w
m.watchDone = make(chan struct{})
// L-config-2:为 watchLoop 创建可取消 ctx,作为 w.Events 关闭之外的逃生通道。
ctx, cancel := context.WithCancel(context.Background())
m.watchCancel = cancel
target := filepath.Base(m.path)
done := m.watchDone
m.mu.Unlock()
for _, cb := range cbs {
cb(&newCfg)
}
})
go m.watchLoop(ctx, w, target, done)
return nil
}
// StopWatcher 停止配置文件监听
func (m *Manager) StopWatcher() {}
// watchLoop 是文件监听 goroutine 主体。文件变更经去抖后调用 reload;
// watcher 被 CloseEvents 通道关闭)或 ctx 被 cancelL-config-2)时退出并 close done。
// done 与 ctx 均由 StartWatcher 在锁内捕获传入,避免本 goroutine 读取 m.watchDone /
// m.watchCancel 字段与 StopWatcher 写入竞争。ctx 提供 fsnotify 致命错误下的逃生通道。
func (m *Manager) watchLoop(ctx context.Context, w *fsnotify.Watcher, target string, done chan struct{}) {
defer close(done)
const debounce = 200 * time.Millisecond
timer := time.NewTimer(time.Hour)
if !timer.Stop() {
<-timer.C
}
defer timer.Stop()
var timerC <-chan time.Time
for {
select {
case <-ctx.Done(): // L-config-2StopWatcher cancel 时退出,不单靠 w.Events 关闭
return
case ev, ok := <-w.Events:
if !ok {
return
}
if filepath.Base(ev.Name) != target {
continue
}
if !ev.Has(fsnotify.Create) && !ev.Has(fsnotify.Write) &&
!ev.Has(fsnotify.Remove) && !ev.Has(fsnotify.Rename) {
continue
}
// 去抖:合并编辑器/工具的连续写事件,仅最后一次触发重载。
if !timer.Stop() && timerC != nil {
select {
case <-timer.C:
default:
}
}
timer.Reset(debounce)
timerC = timer.C
case _, ok := <-w.Errors:
if !ok {
return
}
// 非致命错误:继续监听。
case <-timerC:
timerC = nil
// reload 内部对非法配置保留旧配置(C10b),错误被忽略——
// 监听路径无法向上传播错误,保留旧配置即正确语义。
_ = m.reload()
}
}
}
// Get 获取配置
// StopWatcher 停止配置文件监听并释放 watcher(C10d)。幂等。
// cancel ctx 与关闭 fsnotify watcher 双重退出(L-config-2),等待 watchDone 确认 goroutine 退出。
func (m *Manager) StopWatcher() {
if m == nil {
return
}
m.mu.Lock()
w := m.watcher
done := m.watchDone
cancel := m.watchCancel
m.watcher = nil
m.watchDone = nil
m.watchCancel = nil
m.mu.Unlock()
if cancel != nil {
cancel() // L-config-2:先 cancel 让 watchLoop 经 ctx.Done 退出
}
if w == nil {
return
}
_ = w.Close() // 再 Close watcher 让 w.Events 关闭,双重保障
if done != nil {
<-done
}
}
// Get 获取配置副本。返回值可由调用方自由修改,不会污染 Manager 内部配置。
func (m *Manager) Get() *Config {
if m == nil {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
return m.cfg
return m.cfg.Clone()
}
// GetViper 获取 viper 实例
// GetViper 获取 viper 的只读快照。
//
// 返回值不是 Manager 内部 viper 指针;调用方修改该快照不会影响全局配置。
// 需要扩展配置读取时优先使用 GetString/GetInt/GetBool 等包级 helper。
func (m *Manager) GetViper() *viper.Viper {
if m == nil {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
return m.v
return cloneViper(m.v, m.path)
}
// Set 手动设置配置
func (m *Manager) Set(cfg *Config) {
// GetString 获取字符串配置
func (m *Manager) GetString(key string) string {
if m == nil {
return
return ""
}
m.mu.RLock()
defer m.mu.RUnlock()
if m.v == nil {
return ""
}
return m.v.GetString(key)
}
// GetInt 获取整数配置。
func (m *Manager) GetInt(key string) int {
if m == nil {
return 0
}
m.mu.RLock()
defer m.mu.RUnlock()
if m.v == nil {
return 0
}
return m.v.GetInt(key)
}
// GetBool 获取布尔配置。
func (m *Manager) GetBool(key string) bool {
if m == nil {
return false
}
m.mu.RLock()
defer m.mu.RUnlock()
if m.v == nil {
return false
}
return m.v.GetBool(key)
}
// GetStringMap 获取字符串映射配置副本。
func (m *Manager) GetStringMap(key string) map[string]any {
if m == nil {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
if m.v == nil {
return nil
}
return cloneStringAnyMap(m.v.GetStringMap(key))
}
// Set 手动设置配置。非 nil 配置会先 Validate,并以深拷贝形式保存。
// H-config-1:非 nil 配置同时用其重建 m.v(不含 AutomaticEnv),使 Get() 与
// GetString/GetInt/GetBool/GetViper 读到同一配置世界,消除原"Set 只更新类型化视图、
// viper 视图停留在旧值"的静默分裂(违反 C1 单一配置源)。
func (m *Manager) Set(cfg *Config) error {
if m == nil {
return ErrConfigNotLoaded
}
if cfg != nil {
if err := cfg.Validate(); err != nil {
return errors.Join(ErrInvalidConfig, err)
}
}
m.mu.Lock()
defer m.mu.Unlock()
m.cfg = cfg
m.cfg = cfg.Clone()
if cfg == nil {
m.v = nil
} else {
m.v = viperFromConfig(m.path, cfg)
}
return nil
}
// Reload 重新加载配置文件
// Reload 重新加载配置文件。读取、解析、校验(C10b)任一步失败均保留旧配置并返回错误;
// 仅当新配置通过 Validate 后才替换 m.cfg 并触发回调。
func (m *Manager) Reload() error {
if m == nil {
return ErrConfigNotLoaded
}
return m.reload()
}
m.mu.RLock()
// reload 是 Reload 与文件监听共享的重载实现。全程持写锁以串行化对 viper 的
// ReadInConfig 访问(viper 非完全并发安全),并在替换前强制 Validate(C10b)。
func (m *Manager) reload() error {
m.mu.Lock()
v := m.v
m.mu.RUnlock()
if v == nil {
m.mu.Unlock()
return ErrConfigNotLoaded
}
if err := v.ReadInConfig(); err != nil {
m.mu.Unlock()
return fmt.Errorf("读取配置文件失败: %w", err)
}
var newCfg Config
if err := unmarshalConfig(v, &newCfg); err != nil {
m.mu.Unlock()
return fmt.Errorf("解析配置文件失败: %w", err)
}
m.mu.Lock()
if err := newCfg.Validate(); err != nil {
// 非法配置保留旧配置,不得静默发布(C10b)
m.mu.Unlock()
return err
}
m.cfg = &newCfg
cbs := make([]func(*Config), len(m.callbacks))
copy(cbs, m.callbacks)
m.mu.Unlock()
// M-G:回调传入 newCfg 的深拷贝,避免回调修改切片字段与 Get() 读者(持有 &newCfg)竞态。
// 回调为 onChange 通知语义,应观察而非改写配置;改写副本不影响内部 m.cfg。
// M-config-1:每个回调独立 recover,单个回调 panic 不得杀掉 watcher 或阻断后续回调。
// config 是叶子包(logger 依赖 config),不能用框架 zap,故用标准库 log 记录 + 堆栈。
for _, cb := range cbs {
cb(&newCfg)
func(cb func(*Config)) {
defer func() {
if r := recover(); r != nil {
log.Printf("config: 配置变更回调 panic(已隔离,继续后续回调): %v\n%s", r, debug.Stack())
}
}()
cb(newCfg.Clone())
}(cb)
}
return nil
}
// Load 加载配置文件
// pkgLoadMu 串行化包级 Load/LoadWithWatch 对 defaultManager 的"停旧 watcher → 建新 → 置换"
// 序列(P1 #8)。原实现该序列非原子:两个 goroutine 并发调用可能都读到 old、都 StopWatcher、
// 都 Store,遗留一个已 StartWatcher 的 manager 无引用可停(goroutine 泄漏)。
var pkgLoadMu sync.Mutex
// Load 加载配置文件。
// P1 #8:全程持 pkgLoadMu 串行化。新配置加载成功后才替换默认 Manager 并停止旧 watcher
// 加载失败会保留旧 Manager 与旧 watcher,避免一次错误配置导致热更新链路断掉。
func Load(configPath string) (*Config, error) {
defaultManager = NewManager(configPath)
return defaultManager.Load()
pkgLoadMu.Lock()
defer pkgLoadMu.Unlock()
m := NewManager(configPath)
cfg, err := m.Load()
if err != nil {
return nil, err
}
old := defaultManager.Load()
defaultManager.Store(m)
if old != nil && old != m {
old.StopWatcher()
}
return cfg, nil
}
// LoadWithWatch 加载配置文件并启用热更新
// LoadWithWatch 加载配置文件并启用热更新
// P1 #8:全程持 pkgLoadMu 串行化,且新 manager 在其 watcher 成功启动后才置换为默认;
// 启动失败则保留旧 Manager 与旧 watcher,并停掉新 manager 可能半启动的 watcher。
func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) {
defaultManager = NewManager(configPath)
return defaultManager.LoadWithWatch(onChange)
pkgLoadMu.Lock()
defer pkgLoadMu.Unlock()
m := NewManager(configPath)
cfg, err := m.LoadWithWatch(onChange)
if err != nil {
m.StopWatcher() // 清理可能已半启动的 watcher,避免孤儿 goroutine
return nil, err
}
old := defaultManager.Load()
defaultManager.Store(m)
if old != nil && old != m {
old.StopWatcher()
}
return cfg, nil
}
// RegisterCallback 注册配置变更回调
func RegisterCallback(cb func(*Config)) {
defaultManager.RegisterCallback(cb)
defaultManager.Load().RegisterCallback(cb)
}
// StartWatcher 启动配置文件监听
func StartWatcher() error {
return defaultManager.StartWatcher()
return defaultManager.Load().StartWatcher()
}
// StopWatcher 停止配置文件监听
func StopWatcher() {
defaultManager.StopWatcher()
defaultManager.Load().StopWatcher()
}
// Get 获取全局配置
func Get() *Config {
return defaultManager.Get()
return defaultManager.Load().Get()
}
// GetViper 获取 viper 实例(用于扩展配置)
func GetViper() *viper.Viper {
return defaultManager.GetViper()
return defaultManager.Load().GetViper()
}
// Set 手动设置配置(用于测试或动态修改)
func Set(cfg *Config) {
defaultManager.Set(cfg)
// Set 手动设置配置(用于测试或动态修改)。非 nil 配置会先校验并复制。
func Set(cfg *Config) error {
return defaultManager.Load().Set(cfg)
}
// Reload 重新加载配置文件
func Reload() error {
return defaultManager.Reload()
return defaultManager.Load().Reload()
}
// SetDefaultManager 替换全局默认配置管理器。
// 主要供应用层(如 App)在持有自己的 Manager 时使用,
// 使 config.Get / config.GetString 等便捷函数仍然能取到正确的配置。
// 传入 nil 表示重置为空管理器。
//
// C10a:经 atomic.Pointer.Store 原子置换,消除与并发读取(Get 等)的数据竞争。
// 置换后会停止旧 Manager 的 watcher,避免全局默认 manager 切换后遗留热更新 goroutine。
func SetDefaultManager(m *Manager) {
old := defaultManager.Load()
if m == nil {
defaultManager = NewManager("")
return
m = NewManager("")
}
defaultManager.Store(m)
if old != nil && old != m {
old.StopWatcher()
}
defaultManager = m
}
// GetString 获取字符串配置
func GetString(key string) string {
v := GetViper()
if v == nil {
return ""
}
return v.GetString(key)
return defaultManager.Load().GetString(key)
}
// GetInt 获取整数配置
func GetInt(key string) int {
v := GetViper()
if v == nil {
return 0
}
return v.GetInt(key)
return defaultManager.Load().GetInt(key)
}
// GetBool 获取布尔配置
func GetBool(key string) bool {
v := GetViper()
if v == nil {
return false
}
return v.GetBool(key)
return defaultManager.Load().GetBool(key)
}
// GetStringMap 获取字符串映射配置
func GetStringMap(key string) map[string]any {
v := GetViper()
if v == nil {
return nil
}
return v.GetStringMap(key)
return defaultManager.Load().GetStringMap(key)
}
// IsDevelopment 是否开发环境
+499
View File
@@ -0,0 +1,499 @@
package config_test
import (
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
)
// writeConfig 写入临时配置文件并返回路径。
func writeConfig(t *testing.T, name, content string) string {
t.Helper()
dir := filepath.Join(os.TempDir(), "xlgo_c10_test")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return p
}
func validConfigYAML(port int) string {
return "app:\n name: c10\n env: dev\nserver:\n port: " + itoa(port) + "\n"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
// TestSetDefaultManagerConcurrent 并发置换默认 Manager 与并发读取,
// 必须经 -race 无竞争(C10a)。
func TestSetDefaultManagerConcurrent(t *testing.T) {
// 准备若干可加载的 Manager
paths := make([]string, 4)
for i := range paths {
paths[i] = writeConfig(t, "c10_concurrent_"+itoa(i)+".yaml", validConfigYAML(9000+i))
}
defer func() {
for _, p := range paths {
os.Remove(p)
}
}()
var wg sync.WaitGroup
stop := make(chan struct{})
// 写者:并发 SetDefaultManager
for i := 0; i < 4; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
m := config.NewManager(paths[idx])
_, _ = m.Load()
config.SetDefaultManager(m)
}
}(i)
}
// 读者:并发 Get / GetViper / GetString
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = config.Get()
_ = config.GetViper()
_ = config.GetString("server.port")
}
}()
}
// 跑足够长以让 -race 采到
time.Sleep(150 * time.Millisecond)
close(stop)
wg.Wait()
// 还原全局状态,避免污染其他测试
config.SetDefaultManager(nil)
}
// TestLoadReturnsDefensiveCopy Load 返回的配置与 Get() 内部指针独立,
// 修改返回值不污染全局(C10c)。
func TestLoadReturnsDefensiveCopy(t *testing.T) {
p := writeConfig(t, "c10_defensive.yaml", validConfigYAML(8081))
defer os.Remove(p)
config.Set(nil)
cfg, err := config.Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 8081 {
t.Fatalf("port = %d, want 8081", cfg.Server.Port)
}
// 调用方修改返回值
cfg.Server.Port = 1
cfg.App.Name = "mutated"
// 全局读取不受影响
got := config.Get()
if got == nil {
t.Fatal("Get returned nil")
}
if got.Server.Port != 8081 {
t.Errorf("global port polluted = %d, want 8081 (C10c)", got.Server.Port)
}
if got.App.Name == "mutated" {
t.Errorf("global app name polluted (C10c)")
}
config.SetDefaultManager(nil)
}
// TestLoadDefensiveCopySliceContract 固化 M-G 的深拷贝语义契约:
// 标量字段与切片字段均独立(修改不污染全局)。M-G 修复后 Load() 返回 Clone() 深拷贝,
// 切片字段不再共享底层数组——调用方可安全修改切片元素。本测试锁定该行为,防止回退到浅拷贝。
func TestLoadDefensiveCopySliceContract(t *testing.T) {
content := "app:\n name: c10slice\nserver:\n port: 8090\ncors:\n allowed_origins:\n - https://a.example.com\n - https://b.example.com\n"
p := writeConfig(t, "c10_slice.yaml", content)
defer os.Remove(p)
config.Set(nil)
cfg, err := config.Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
// 标量独立
cfg.Server.Port = 1
if got := config.Get().Server.Port; got != 8090 {
t.Errorf("scalar polluted = %d, want 8090", got)
}
// M-G:切片底层数组独立(深拷贝)——修改切片元素不污染全局。
cfg.CORS.AllowedOrigins[0] = "https://mutated.example.com"
if got := config.Get().CORS.AllowedOrigins[0]; got != "https://a.example.com" {
t.Errorf("slice should be deep-copied (independent), global polluted to %q, want %q", got, "https://a.example.com")
}
// append 也不应污染全局(深拷贝后调用方持独立切片)
cfg.CORS.AllowedOrigins = append(cfg.CORS.AllowedOrigins, "https://c.example.com")
if got := config.Get().CORS.AllowedOrigins; len(got) != 2 {
t.Errorf("global slice length should be unaffected by caller append, got %d, want 2", len(got))
}
config.SetDefaultManager(nil)
}
func TestReloadInvalidConfigKeepsOld(t *testing.T) {
p := writeConfig(t, "c10_reload_bad.yaml", validConfigYAML(8082))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := m.Get().Server.Port; got != 8082 {
t.Fatalf("initial port = %d, want 8082", got)
}
// 覆盖为非法配置(端口越界)
if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
err := m.Reload()
if err == nil {
t.Fatal("Reload invalid config should return error (C10b)")
}
if !strings.Contains(err.Error(), "server.port") {
t.Errorf("error should mention server.port, got %v", err)
}
// 旧配置保留
if got := m.Get().Server.Port; got != 8082 {
t.Errorf("old config not preserved = %d, want 8082 (C10b)", got)
}
}
// TestHotReloadInvalidConfigKeepsOld 文件监听路径遇非法配置保留旧配置,
// 且不触发回调;监听仍存活,后续合法变更正常生效(C10b + C10d 监听健壮性)。
func TestHotReloadInvalidConfigKeepsOld(t *testing.T) {
p := writeConfig(t, "c10_watch_bad.yaml", validConfigYAML(8083))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
changes := make(chan int, 16)
m.RegisterCallback(func(c *config.Config) {
select {
case changes <- c.Server.Port:
default:
}
})
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
defer m.StopWatcher()
// 1) 写入非法配置:应保留旧配置、不触发回调
if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil {
t.Fatalf("WriteFile invalid: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if got := m.Get().Server.Port; got != 8083 {
t.Errorf("invalid config leaked into global = %d, want 8083 (C10b)", got)
break
}
time.Sleep(20 * time.Millisecond)
}
select {
case port := <-changes:
t.Errorf("callback fired for invalid config with port %d (C10b)", port)
default:
}
// 2) 写入合法配置:监听仍存活,应触发回调且全局更新
if err := os.WriteFile(p, []byte(validConfigYAML(8084)), 0644); err != nil {
t.Fatalf("WriteFile valid: %v", err)
}
deadline = time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if got := m.Get().Server.Port; got == 8084 {
break
}
time.Sleep(20 * time.Millisecond)
}
if got := m.Get().Server.Port; got != 8084 {
t.Fatalf("watcher did not reload valid config = %d, want 8084", got)
}
}
// TestStopWatcherReleasesGoroutine StopWatcher 后监听 goroutine 退出,无泄漏(C10d)。
func TestStopWatcherReleasesGoroutine(t *testing.T) {
p := writeConfig(t, "c10_stop.yaml", validConfigYAML(8085))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
// 等待监听 goroutine 就绪
time.Sleep(100 * time.Millisecond)
before := runtime.NumGoroutine()
m.StopWatcher()
// 轮询确认 goroutine 退出
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() < before {
break
}
time.Sleep(20 * time.Millisecond)
}
if after := runtime.NumGoroutine(); after >= before {
t.Errorf("watcher goroutine not released: before=%d after=%d (C10d)", before, after)
}
// 幂等:再次 Stop 不 panic
m.StopWatcher()
}
// TestStartWatcherIdempotent 重复 StartWatcher 不创建多个监听 goroutine(幂等)。
func TestStartWatcherIdempotent(t *testing.T) {
p := writeConfig(t, "c10_idem.yaml", validConfigYAML(8086))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher 1: %v", err)
}
time.Sleep(80 * time.Millisecond)
once := runtime.NumGoroutine()
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher 2: %v", err)
}
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher 3: %v", err)
}
time.Sleep(80 * time.Millisecond)
twice := runtime.NumGoroutine()
if twice > once {
t.Errorf("idempotent StartWatcher leaked goroutine: once=%d twice=%d", once, twice)
}
m.StopWatcher()
}
func TestStopWatcherWaitsInFlightReload(t *testing.T) {
p := writeConfig(t, "c10_stop_wait.yaml", validConfigYAML(8091))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
entered := make(chan struct{})
release := make(chan struct{})
m.RegisterCallback(func(*config.Config) {
close(entered)
<-release
})
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
if err := os.WriteFile(p, []byte(validConfigYAML(8092)), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("热更新回调未触发")
}
stopped := make(chan struct{})
go func() {
m.StopWatcher()
close(stopped)
}()
select {
case <-stopped:
t.Fatal("StopWatcher 不应在 reload 回调结束前返回")
case <-time.After(120 * time.Millisecond):
}
close(release)
select {
case <-stopped:
case <-time.After(2 * time.Second):
t.Fatal("StopWatcher 未等待到 reload 回调结束")
}
}
func TestLoadWithWatchFailureKeepsOldWatcher(t *testing.T) {
p := writeConfig(t, "c10_keep_old.yaml", validConfigYAML(8093))
defer os.Remove(p)
changes := make(chan int, 4)
if _, err := config.LoadWithWatch(p, func(c *config.Config) {
changes <- c.Server.Port
}); err != nil {
t.Fatalf("LoadWithWatch old: %v", err)
}
defer config.StopWatcher()
missing := filepath.Join(filepath.Dir(p), "missing.yaml")
if _, err := config.LoadWithWatch(missing, nil); err == nil {
t.Fatal("加载缺失配置应失败")
}
if err := os.WriteFile(p, []byte(validConfigYAML(8094)), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
select {
case port := <-changes:
if port != 8094 {
t.Fatalf("旧 watcher 回调端口错误: %d", port)
}
case <-time.After(3 * time.Second):
t.Fatal("加载失败后旧 watcher 不应被停止")
}
}
func TestSetDefaultManagerStopsOldWatcher(t *testing.T) {
oldPath := writeConfig(t, "c10_old_default.yaml", validConfigYAML(8095))
newPath := writeConfig(t, "c10_new_default.yaml", validConfigYAML(8096))
defer os.Remove(oldPath)
defer os.Remove(newPath)
oldManager := config.NewManager(oldPath)
if _, err := oldManager.Load(); err != nil {
t.Fatalf("old Load: %v", err)
}
changes := make(chan int, 4)
oldManager.RegisterCallback(func(c *config.Config) {
changes <- c.Server.Port
})
if err := oldManager.StartWatcher(); err != nil {
t.Fatalf("old StartWatcher: %v", err)
}
config.SetDefaultManager(oldManager)
newManager := config.NewManager(newPath)
if _, err := newManager.Load(); err != nil {
t.Fatalf("new Load: %v", err)
}
config.SetDefaultManager(newManager)
defer config.SetDefaultManager(nil)
if err := os.WriteFile(oldPath, []byte(validConfigYAML(8097)), 0644); err != nil {
t.Fatalf("WriteFile old: %v", err)
}
select {
case port := <-changes:
t.Fatalf("旧 watcher 已停止,不应收到端口 %d", port)
case <-time.After(500 * time.Millisecond):
}
}
func TestSetGetAndViperReturnCopies(t *testing.T) {
cfg := &config.Config{
App: config.AppConfig{Name: "copy", Env: "dev"},
CORS: config.CORSConfig{
AllowedOrigins: []string{"https://a.example.com"},
},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("Set: %v", err)
}
defer config.SetDefaultManager(nil)
cfg.App.Name = "mutated-input"
cfg.CORS.AllowedOrigins[0] = "https://mutated-input.example.com"
got := config.Get()
if got.App.Name != "copy" {
t.Fatalf("Set 应保存副本,实际 App.Name=%q", got.App.Name)
}
if got.CORS.AllowedOrigins[0] != "https://a.example.com" {
t.Fatalf("Set 应深拷贝切片,实际 origin=%q", got.CORS.AllowedOrigins[0])
}
got.App.Name = "mutated-get"
got.CORS.AllowedOrigins[0] = "https://mutated-get.example.com"
gotAgain := config.Get()
if gotAgain.App.Name != "copy" || gotAgain.CORS.AllowedOrigins[0] != "https://a.example.com" {
t.Fatalf("Get 应返回副本,实际 %+v", gotAgain)
}
if err := config.Set(&config.Config{Server: config.ServerConfig{Port: 99999}}); err == nil {
t.Fatal("Set 非法配置应返回错误")
}
if got := config.Get().App.Name; got != "copy" {
t.Fatalf("Set 非法配置不应覆盖旧配置,实际 App.Name=%q", got)
}
}
func TestGetViperReturnsSnapshot(t *testing.T) {
p := writeConfig(t, "c10_viper_snapshot.yaml", validConfigYAML(8098))
defer os.Remove(p)
if _, err := config.Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
defer config.SetDefaultManager(nil)
v := config.GetViper()
if v == nil {
t.Fatal("GetViper returned nil")
}
v.Set("app.name", "mutated")
if got := config.GetString("app.name"); got != "c10" {
t.Fatalf("GetViper 应返回快照,不应污染内部 viper,实际 app.name=%q", got)
}
}
+329
View File
@@ -0,0 +1,329 @@
package config_test
import (
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
)
// TestSetKeepsViperInSync_Hconfig1 固化 H-config-1Set(cfg) 后 Get() 与
// GetString/GetInt/GetBool/GetViper 必须读到同一配置世界,消除原"Set 只更新类型化视图、
// viper 视图停留旧值"的静默分裂(违反 C1 单一配置源)。
func TestSetKeepsViperInSync_Hconfig1(t *testing.T) {
cfg := &config.Config{
App: config.AppConfig{Name: "sync-test", SiteName: "sync_site", Env: "prod", Debug: true},
Server: config.ServerConfig{Port: 7777},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("Set: %v", err)
}
defer config.SetDefaultManager(nil)
// 类型化视图
got := config.Get()
if got == nil || got.App.Name != "sync-test" || got.Server.Port != 7777 {
t.Fatalf("Get() 不一致: %+v", got)
}
// viper 视图必须同源
if v := config.GetString("app.name"); v != "sync-test" {
t.Errorf("GetString(app.name) = %q, want sync-testH-config-1 同源)", v)
}
if v := config.GetInt("server.port"); v != 7777 {
t.Errorf("GetInt(server.port) = %d, want 7777H-config-1", v)
}
if v := config.GetBool("app.debug"); v != true {
t.Errorf("GetBool(app.debug) = %v, want trueH-config-1", v)
}
if v := config.GetString("app.env"); v != "prod" {
t.Errorf("GetString(app.env) = %q, want prodH-config-1", v)
}
vp := config.GetViper()
if vp == nil || vp.GetString("app.name") != "sync-test" {
t.Errorf("GetViper().GetString(app.name) 不一致(H-config-1: %v", vp)
}
// Set(nil) 清空,GetString 返回空(m.v=nil
if err := config.Set(nil); err != nil {
t.Fatalf("Set(nil): %v", err)
}
if v := config.GetString("app.name"); v != "" {
t.Errorf("Set(nil) 后 GetString 应为空, got %q", v)
}
}
// TestSetDurationGetDurationConsistent_Hconfig1 锁定 H-config-1 已知行为:Duration 字段经 Set
// 重建 viper 后,GetString 字面格式与文件加载不同(mapstructure 存 time.Duration -> Duration.String()),
// 但 typed view 与 GetDuration 在两条路径下一致。Duration 应经 typed view / GetDuration 读取。
func TestSetDurationGetDurationConsistent_Hconfig1(t *testing.T) {
cfg := &config.Config{
JWT: config.JWTConfig{Secret: strings.Repeat("k", 32), Expire: 24 * time.Hour},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("Set: %v", err)
}
defer config.SetDefaultManager(nil)
if got := config.Get().JWT.Expire; got != 24*time.Hour {
t.Errorf("Get().JWT.Expire = %v, want 24h", got)
}
if got := config.GetViper().GetDuration("jwt.expire"); got != 24*time.Hour {
t.Errorf("GetDuration(jwt.expire) = %v, want 24hDuration 同源应读 GetDuration/typed view", got)
}
}
// TestReloadCallbackPanicIsolation_Mconfig1 固化 M-config-1:单个热重载回调 panic 不得
// 向上传播、不得阻断后续回调、不得让 watcher/reload 链路静默失效。
func TestReloadCallbackPanicIsolation_Mconfig1(t *testing.T) {
p := writeConfig(t, "m1_panic.yaml", validConfigYAML(8301))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
called := make(chan int, 4)
m.RegisterCallback(func(*config.Config) {
panic("boom from user callback") // 模拟用户回调 panic
})
m.RegisterCallback(func(c *config.Config) {
called <- c.Server.Port
})
// Reload 不应把回调 panic 向上传播
if err := m.Reload(); err != nil {
t.Fatalf("Reload 不应传播回调 panic: %v", err)
}
// 第二个回调仍触发:panic 被隔离,未阻断后续回调
select {
case port := <-called:
if port != 8301 {
t.Errorf("第二个回调端口 = %d, want 8301", port)
}
case <-time.After(time.Second):
t.Fatal("panic 后续回调未触发(M-config-1 隔离失败)")
}
// 后续 reload 仍正常,watcher/reload 链路未静默失效
if err := os.WriteFile(p, []byte(validConfigYAML(8302)), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := m.Reload(); err != nil {
t.Fatalf("panic 后 Reload 应仍可用: %v", err)
}
select {
case port := <-called:
if port != 8302 {
t.Errorf("第二次 reload 回调端口 = %d, want 8302", port)
}
case <-time.After(time.Second):
t.Fatal("panic 后 reload 链路静默失效(M-config-1")
}
}
// TestMySQLDSN_TLS_Mconfig2 固化 M-config-2 MySQL TLSTLS=false 无 tls 参数;
// TLS=true 无 CA 用内置 tls=trueTLS=true 有 CA 用 tls=MySQLTLSConfigName。
func TestMySQLDSN_TLS_Mconfig2(t *testing.T) {
db := config.DatabaseConfig{Host: "h", Port: 3306, User: "u", Password: "p", Name: "n"}
if dsn := db.MySQLDSN(); strings.Contains(dsn, "tls=") {
t.Errorf("TLS=false 不应含 tls 参数, dsn=%s", dsn)
}
db.TLS = true
if dsn := db.MySQLDSN(); !strings.Contains(dsn, "&tls=true") {
t.Errorf("TLS=true 无 CA 应用内置 tls=true, dsn=%s", dsn)
}
db.TLSRootCA = "/path/ca.pem"
if dsn := db.MySQLDSN(); !strings.Contains(dsn, "&tls="+config.MySQLTLSConfigName) {
t.Errorf("TLS=true 有 CA 应用 tls=%s, dsn=%s", config.MySQLTLSConfigName, dsn)
}
// CustomDSN 优先,TLS 字段不影响 DSN()
db.CustomDSN = "custom-dsn"
if dsn := db.DSN(); dsn != "custom-dsn" {
t.Errorf("CustomDSN 应优先, got %s", dsn)
}
}
// TestPostgresDSN_SSLMode_Mconfig2 固化 M-config-2 Postgres SSLMode:空默认 prefer
// 显式值透传(sslmode 不加引号,与原 disable 格式一致)。
func TestPostgresDSN_SSLMode_Mconfig2(t *testing.T) {
db := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n"}
if dsn := db.PostgresDSN(); !strings.Contains(dsn, "sslmode=prefer ") {
t.Errorf("空 SSLMode 默认 prefer, dsn=%s", dsn)
}
for _, mode := range []string{"disable", "allow", "require", "verify-ca", "verify-full"} {
db.SSLMode = mode
if dsn := db.PostgresDSN(); !strings.Contains(dsn, "sslmode="+mode+" ") {
t.Errorf("SSLMode=%s 应透传, dsn=%s", mode, dsn)
}
}
}
// TestValidateMaxIdleExceedsOpen_Lconfig3 固化 L-config-3MaxOpenConns>0 时
// MaxIdleConns>MaxOpenConns 视为配置错误;MaxOpenConns=0(无限)不校验。
func TestValidateMaxIdleExceedsOpen_Lconfig3(t *testing.T) {
cfg := &config.Config{
Database: config.DatabaseConfig{
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
MaxOpenConns: 10, MaxIdleConns: 20,
},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "max_idle_conns") {
t.Fatalf("MaxIdleConns>MaxOpenConns 应报错, got: %v", err)
}
cfg.Database.MaxOpenConns = 0 // 未配置/无限,不校验 idle
if err := cfg.Validate(); err != nil {
t.Fatalf("MaxOpenConns=0 时不该校验 idle, got: %v", err)
}
cfg.Database.MaxOpenConns = 20 // 合法
if err := cfg.Validate(); err != nil {
t.Fatalf("合法配置不应报错, got: %v", err)
}
}
// TestValidatePostgresSSLMode_Mconfig2 固化 M-config-2:非法 SSLMode 在 Validate 阶段报错。
func TestValidatePostgresSSLMode_Mconfig2(t *testing.T) {
cfg := &config.Config{
Database: config.DatabaseConfig{
Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n",
SSLMode: "invalid-mode",
},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "ssl_mode") {
t.Fatalf("非法 SSLMode 应报错, got: %v", err)
}
cfg.Database.SSLMode = "require"
if err := cfg.Validate(); err != nil {
t.Fatalf("合法 SSLMode=require 不应报错, got: %v", err)
}
}
// TestValidateTLSRootCAWithoutTLS_Mconfig2 固化 M-config-2TLSRootCA 需配合 tls:true。
func TestValidateTLSRootCAWithoutTLS_Mconfig2(t *testing.T) {
cfg := &config.Config{
Database: config.DatabaseConfig{
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
TLSRootCA: "/path/ca.pem", // TLS=false
},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "tls_root_ca") {
t.Fatalf("TLSRootCA 无 tls:true 应报错, got: %v", err)
}
cfg.Database.TLS = true
if err := cfg.Validate(); err != nil {
t.Fatalf("TLS=true + TLSRootCA 合法不应报错, got: %v", err)
}
}
// TestDSNAddrNilReceiver_Lconfig6 固化 L-config-6DSN/MySQLDSN/PostgresDSN/Addr 对 nil receiver 安全。
func TestDSNAddrNilReceiver_Lconfig6(t *testing.T) {
var db *config.DatabaseConfig
if v := db.DSN(); v != "" {
t.Errorf("nil DatabaseConfig.DSN() = %q, want empty", v)
}
if v := db.MySQLDSN(); v != "" {
t.Errorf("nil MySQLDSN() = %q, want empty", v)
}
if v := db.PostgresDSN(); v != "" {
t.Errorf("nil PostgresDSN() = %q, want empty", v)
}
var r *config.RedisConfig
if v := r.Addr(); v != "" {
t.Errorf("nil RedisConfig.Addr() = %q, want empty", v)
}
}
// TestCloneDeepCopiesAllSliceFields_Mconfig4 固化 M-config-4:反射枚举 Config 所有切片/map 字段,
// 断言 Clone 深拷贝(不同底层数组/map)。fixture 必须填充每个切片字段--新增切片字段若未在 Clone
// 中处理、或未在 fixture 中填充,本测试都会失败,形成机械守卫。
func TestCloneDeepCopiesAllSliceFields_Mconfig4(t *testing.T) {
original := allSlicesPopulatedConfig()
clone := original.Clone()
var check func(o, c reflect.Value, path string)
check = func(o, c reflect.Value, path string) {
for o.Kind() == reflect.Pointer {
o = o.Elem()
}
for c.Kind() == reflect.Pointer {
c = c.Elem()
}
if o.Kind() != reflect.Struct {
return
}
for i := 0; i < o.NumField(); i++ {
of := o.Field(i)
cf := c.Field(i)
p := path + "." + o.Type().Field(i).Name
switch of.Kind() {
case reflect.Pointer, reflect.Struct:
check(of, cf, p)
case reflect.Slice:
if of.IsNil() || of.Len() == 0 {
t.Errorf("M-config-4: fixture 未填充切片 %s(新增切片字段须同步 fixture 与 Clone", p)
continue
}
if cf.IsNil() || cf.Len() != of.Len() {
t.Errorf("M-config-4: Clone 后切片 %s 为 nil 或长度不一致", p)
continue
}
if of.Pointer() == cf.Pointer() {
t.Errorf("M-config-4: Clone 未深拷贝切片 %s(共享底层数组)", p)
}
case reflect.Map:
if of.IsNil() || of.Len() == 0 {
t.Errorf("M-config-4: fixture 未填充 map %s", p)
continue
}
if of.Pointer() == cf.Pointer() {
t.Errorf("M-config-4: Clone 未深拷贝 map %s(共享 map", p)
}
}
}
}
check(reflect.ValueOf(original), reflect.ValueOf(clone), "Config")
}
// allSlicesPopulatedConfig 返回一个填充了所有切片字段的 Config,供 Clone 覆盖断言使用。
// 新增任何切片/map 字段到 Config 或其子结构体时,必须同步在此填充,否则
// TestCloneDeepCopiesAllSliceFields_Mconfig4 会以"未填充"失败提醒。
func allSlicesPopulatedConfig() *config.Config {
return &config.Config{
CORS: config.CORSConfig{
AllowedOrigins: []string{"https://a.example.com"},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"X-Test"},
ExposedHeaders: []string{"X-Exp"},
},
Upload: config.UploadConfig{
AllowedImageTypes: []string{"image/jpeg"},
AllowedVideoTypes: []string{"video/mp4"},
},
Storage: config.StorageConfig{
Local: config.LocalStorageConfig{
Upload: config.UploadPolicy{
AllowedExts: []string{".jpg"},
AllowedMIMEs: []string{"image/jpeg"},
},
},
OSS: config.OSSStorageConfig{
Upload: config.UploadPolicy{
AllowedExts: []string{".png"},
AllowedMIMEs: []string{"image/png"},
},
},
},
}
}
+66 -3
View File
@@ -3,6 +3,7 @@ package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -101,7 +102,7 @@ func TestDatabaseConfigPostgresDSN(t *testing.T) {
}
dsn := db.DSN()
expected := "host=localhost port=5432 user=postgres password=password dbname=testdb sslmode=disable TimeZone=Asia/Shanghai"
expected := "host='localhost' port=5432 user='postgres' password='password' dbname='testdb' sslmode=prefer TimeZone='Asia/Shanghai'"
if dsn != expected {
t.Errorf("Postgres DSN = %s, want %s", dsn, expected)
}
@@ -112,6 +113,68 @@ func TestDatabaseConfigPostgresDSN(t *testing.T) {
}
}
func TestDatabaseConfigUnknownDriverDoesNotFallback(t *testing.T) {
db := config.DatabaseConfig{
Driver: "no-such-driver",
Host: "localhost",
Port: 3306,
User: "root",
Password: "password",
Name: "testdb",
}
if dsn := db.DSN(); dsn != "" {
t.Fatalf("未知 driver 不应静默回退 MySQL,实际 DSN=%q", dsn)
}
cfg := &config.Config{Database: db}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "database.driver") {
t.Fatalf("未知 driver 应在 Validate 阶段报错,实际: %v", err)
}
}
// TestDatabaseConfigDSNPasswordEscape_M9:含特殊字符的密码须被转义,不破坏 DSN。
func TestDatabaseConfigDSNPasswordEscape_M9(t *testing.T) {
// MySQL:密码含 @/:/空格 → url.QueryEscape
mysql := config.DatabaseConfig{
Driver: config.DriverMySQL,
Host: "localhost",
Port: 3306,
User: "root",
Password: "p@ss w:ord",
Name: "testdb",
}
mdsn := mysql.MySQLDSN()
// 密码段应被转义,@ 不应与 DSN 的 @ 分隔符混淆
if !strings.Contains(mdsn, "root:p%40ss+w%3Aord@tcp") {
t.Errorf("MySQL DSN password not escaped: %s", mdsn)
}
// Postgres:密码含单引号 → 翻倍转义
pg := config.DatabaseConfig{
Driver: config.DriverPostgres,
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "p'ord",
Name: "testdb",
}
pdsn := pg.PostgresDSN()
if !strings.Contains(pdsn, `password='p\'ord'`) {
t.Errorf("Postgres DSN password not escaped: %s", pdsn)
}
// Timezone 可配置
pg2 := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n", Timezone: "UTC"}
if !strings.Contains(pg2.PostgresDSN(), "TimeZone='UTC'") {
t.Errorf("Postgres DSN should honor Timezone=UTC: %s", pg2.PostgresDSN())
}
mysql2 := config.DatabaseConfig{Driver: config.DriverMySQL, Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", Timezone: "UTC"}
if !strings.Contains(mysql2.MySQLDSN(), "loc=UTC") {
t.Errorf("MySQL DSN should honor Timezone=UTC: %s", mysql2.MySQLDSN())
}
}
func TestDatabaseConfigCustomDSN(t *testing.T) {
db := config.DatabaseConfig{
Driver: config.DriverPostgres,
@@ -307,7 +370,7 @@ func TestConfigManagerIsolation(t *testing.T) {
func TestConfigSet(t *testing.T) {
cfg := &config.Config{
App: config.AppConfig{
Name: "Manual",
Name: "Manual",
SiteName: "manual_site",
},
}
@@ -375,4 +438,4 @@ func TestUploadConfig(t *testing.T) {
if upload.MaxFileSize != 10 {
t.Error("UploadConfig failed")
}
}
}
+45 -5
View File
@@ -39,17 +39,40 @@ func (c *Config) Validate() error {
}
}
// Database仅当配置了 driver 时校验(未启用 mysql 的项目可留空)
if strings.TrimSpace(c.Database.Driver) != "" {
if c.Database.Host == "" {
// Database出现任一数据库字段时视为启用;driver 为空按 MySQL 兼容处理。
if c.Database.isConfigured() {
driver := strings.TrimSpace(c.Database.Driver)
if driver != "" {
if _, ok := LookupDSNBuilder(driver); !ok {
problems = append(problems, fmt.Sprintf("database.driver 未注册: %s", driver))
}
}
if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Host) == "" {
problems = append(problems, "database.host 启用数据库后必填")
}
if c.Database.Name == "" {
if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Name) == "" {
problems = append(problems, "database.name 启用数据库后必填")
}
if c.Database.Port <= 0 || c.Database.Port > 65535 {
if strings.TrimSpace(c.Database.CustomDSN) == "" && (c.Database.Port <= 0 || c.Database.Port > 65535) {
problems = append(problems, fmt.Sprintf("database.port 超出范围(1-65535): %d", c.Database.Port))
}
// L-config-3:连接池配置交叉校验。MaxOpenConns>0 时 MaxIdleConns 不应超过它,
// 否则 database/sql 会按 MaxOpenConns 截断空闲连接,配置意图与实际不符。
if c.Database.MaxOpenConns > 0 && c.Database.MaxIdleConns > c.Database.MaxOpenConns {
problems = append(problems, fmt.Sprintf(
"database.max_idle_conns(%d) 不应大于 max_open_conns(%d)", c.Database.MaxIdleConns, c.Database.MaxOpenConns))
}
// M-config-2Postgres SSLMode 合法性(非空时校验,空由 PostgresDSN 默认 prefer)。
if sslmode := strings.TrimSpace(c.Database.SSLMode); sslmode != "" {
if !validPostgresSSLMode(sslmode) {
problems = append(problems, fmt.Sprintf(
"database.ssl_mode 非法: %s(允许: disable/allow/prefer/require/verify-ca/verify-full", sslmode))
}
}
// M-config-2TLSRootCA 仅在 TLS=true 时生效,单独配置而无 tls:true 视为配置不一致。
if strings.TrimSpace(c.Database.TLSRootCA) != "" && !c.Database.TLS {
problems = append(problems, "database.tls_root_ca 需配合 tls: true 才生效")
}
}
// Redis:仅当配置了 host 时校验
@@ -59,6 +82,14 @@ func (c *Config) Validate() error {
}
}
// Trace:仅当启用时校验采样比例。ExporterType/Propagator 的枚举校验委托 trace.Init
// (随 OTel 版本可能扩展,避免 config 与 trace 双源枚举漂移)。
if c.Trace.Enabled {
if c.Trace.SampleRatio < 0 || c.Trace.SampleRatio > 1 {
problems = append(problems, fmt.Sprintf("trace.sample_ratio 超出范围(0.0-1.0): %v", c.Trace.SampleRatio))
}
}
if len(problems) > 0 {
return fmt.Errorf("配置校验失败: %s", strings.Join(problems, "; "))
}
@@ -67,3 +98,12 @@ func (c *Config) Validate() error {
// validDuration 校验 Duration 非负(0 表示未配置/用默认,合法)。
func validDuration(d time.Duration) bool { return d >= 0 }
// validPostgresSSLMode 校验 PostgreSQL sslmode 取值(M-config-2)。
func validPostgresSSLMode(s string) bool {
switch s {
case "disable", "allow", "prefer", "require", "verify-ca", "verify-full":
return true
}
return false
}
+18
View File
@@ -43,6 +43,24 @@ func TestValidateJWTSecretAbsentSkipped(t *testing.T) {
}
}
func TestValidateTraceSampleRatioWhenEnabled(t *testing.T) {
c := validBase()
c.Trace.Enabled = true
c.Trace.SampleRatio = 1.5
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "trace.sample_ratio") {
t.Fatalf("expected trace.sample_ratio error, got %v", err)
}
}
func TestValidateTraceDisabledSkipsRatio(t *testing.T) {
c := validBase()
// Enabled=false 时即使 SampleRatio 非法也不校验(trace 未启用)
c.Trace.SampleRatio = 1.5
if err := c.Validate(); err != nil {
t.Fatalf("unexpected error when trace disabled: %v", err)
}
}
func TestValidateDatabaseMissingHost(t *testing.T) {
c := validBase()
c.Database.Driver = "mysql"
+18 -4
View File
@@ -6,18 +6,30 @@ import (
"os"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
)
// writeMu 串行化所有 Console 的写出序列(P1 #7)。
// 用包级锁而非 per-Console 锁:多个 Console 实例可能共享同一底层 writer(如 stdout),
// per-Console 锁无法阻止跨实例交错;且 Windows printColor 的"设色→写→复位"三步必须整体
// 原子,否则并发彩色输出会串色。控制台输出非热路径,单一全局写锁的串行化开销可忽略。
var writeMu sync.Mutex
// Level 日志级别
type Level int32
const (
// LevelDebug 调试级别(最低)。
LevelDebug Level = iota
// LevelInfo 普通信息。
LevelInfo
// LevelSuccess 成功信息。
LevelSuccess
// LevelWarn 警告。
LevelWarn
// LevelError 错误(最高常规级别)。
LevelError
// LevelSilent 完全静默:所有调用都不输出
@@ -203,12 +215,15 @@ func (c *Console) print(level Level, s ...any) {
// 内容
sb.WriteString(fmt.Sprint(s...))
// 输出
// 输出。P1 #7:全局写锁串行化整个写序列,避免并发交错输出;
// Windows 下 printColor 的"设色→写→复位"三步也被此锁整体保护,防止串色。
writeMu.Lock()
if c.isColor {
c.printColor(color.Code, sb.String())
} else {
fmt.Fprintln(c.output, sb.String())
}
writeMu.Unlock()
}
// getCaller 获取调用位置
@@ -217,9 +232,8 @@ func (c *Console) getCaller() string {
if !ok {
return ""
}
// 只取文件名
idx := strings.LastIndex(file, "/")
if idx >= 0 {
// 只取文件名(兼容 / 与 \ 分隔)
if idx := strings.LastIndexAny(file, "/\\"); idx >= 0 {
file = file[idx+1:]
}
return fmt.Sprintf("%s:%d", file, line)
+37 -19
View File
@@ -4,6 +4,7 @@ package console
import (
"fmt"
"os"
"syscall"
"unsafe"
)
@@ -21,31 +22,48 @@ var colorMap = map[string]uintptr{
"1;31": 12, // 亮红色
}
// printColor 彩色打印
// printColor 彩色打印
//
// 修复 M17:原实现对 syscall.Stdout 设置颜色、却把文本写到 c.output——
// 当 c.output 非 stdout(如 WithOutput 指向文件)时,颜色落到错误句柄、文本落文件,
// 二者分裂。现按 c.output 实际类型取句柄:*os.File 用其 Fd(),否则放弃着色只写文本。
func (c *Console) printColor(code, msg string) {
color := colorMap[code]
if color == 0 {
color = 7 // 默认淡灰色
}
proc := kernel32.NewProc("SetConsoleTextAttribute")
_, _, _ = proc.Call(uintptr(syscall.Stdout), color)
fmt.Fprintln(c.output, msg)
_, _, _ = proc.Call(uintptr(syscall.Stdout), 7) // 恢复默认颜色
}
// EnableVirtualTerminal 启用虚拟终端支持(Windows 10+)
func EnableVirtualTerminal() error {
// 尝试启用 ANSI 转义序列支持
proc := kernel32.NewProc("GetConsoleMode")
var mode uint32
_, _, err := proc.Call(uintptr(syscall.Stdout), uintptr(unsafe.Pointer(&mode)))
if err != syscall.Errno(0) {
return err
handle, ok := consoleHandle(c.output)
if !ok {
// 非 *os.File(如 bytes.Buffer/文件),无法设置控制台属性,退化为纯文本。
fmt.Fprintln(c.output, msg)
return
}
mode |= 0x0004 // ENABLE_VIRTUAL_TERMINAL_PROCESSING
proc = kernel32.NewProc("SetConsoleMode")
_, _, err = proc.Call(uintptr(syscall.Stdout), uintptr(mode))
return err
proc := kernel32.NewProc("SetConsoleTextAttribute")
_, _, _ = proc.Call(handle, color)
fmt.Fprintln(c.output, msg)
_, _, _ = proc.Call(handle, 7) // 恢复默认颜色
}
// consoleHandle 从 io.Writer 取 Windows 控制台句柄;非 *os.File 返回 (0, false)。
func consoleHandle(w interface{ Write([]byte) (int, error) }) (uintptr, bool) {
f, ok := w.(*os.File)
if !ok {
return 0, false
}
fd := f.Fd()
switch fd {
case uintptr(syscall.Stdout), uintptr(syscall.Stderr), uintptr(syscall.Stdin):
return fd, true
default:
// 重定向到文件/管道的 *os.FileSetConsoleTextAttribute 无意义,退化为纯文本。
return 0, false
}
}
// M17:原 EnableVirtualTerminal 为死代码且从未被调用,已移除。
// Windows 10+ 默认支持 ANSI/VT 着色;需要 VT 的调用方应自行调 kernel32。)
// 保留 unsafe 引用以备将来 VT 扩展;当前 printColor 不再需要,故显式抑制未用告警。
var _ = unsafe.Sizeof(uintptr(0))
+494 -120
View File
@@ -3,20 +3,33 @@ package cron
import (
"context"
"fmt"
"runtime/debug"
"strconv"
"strings"
"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 值类型的
// copylocks 警告;拷贝共享同一守卫状态,但该字段未导出,外部无法操作。
// nil 表示未初始化(仅非 AddTask 构造的 Task),checkAndRun/RunTask 以 nil 守卫跳过。
running *atomic.Bool
}
// TaskHandler 任务处理函数
@@ -27,19 +40,57 @@ type Schedule interface {
Next(now time.Time) time.Time
}
func validateSchedule(schedule Schedule) {
switch s := schedule.(type) {
case *IntervalSchedule:
validateInterval(s.Interval)
case *DailySchedule:
validateClock(s.Hour, s.Minute, "Daily")
case *WeeklySchedule:
validateWeekday(s.Day)
validateClock(s.Hour, s.Minute, "Weekly")
}
}
func validateInterval(interval time.Duration) {
if interval <= 0 {
panic("cron: interval must be positive")
}
}
func validateClock(hour, minute int, name string) {
if hour < 0 || hour > 23 {
panic(fmt.Sprintf("cron: %s hour must be in [0,23]", name))
}
if minute < 0 || minute > 59 {
panic(fmt.Sprintf("cron: %s minute must be in [0,59]", name))
}
}
func validateWeekday(day time.Weekday) {
if day < time.Sunday || day > time.Saturday {
panic("cron: Weekly day must be in [Sunday,Saturday]")
}
}
// Scheduler 调度器
type Scheduler struct {
tasks map[string]*Task
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
running bool
tasks map[string]*Task
mu sync.RWMutex
lifecycleMu sync.Mutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
running bool
}
func newSchedulerContext() (context.Context, context.CancelFunc) {
return context.WithCancel(context.Background())
}
// NewScheduler 创建调度器
func NewScheduler() *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := newSchedulerContext()
return &Scheduler{
tasks: make(map[string]*Task),
ctx: ctx,
@@ -49,12 +100,21 @@ func NewScheduler() *Scheduler {
// AddTask 添加任务
func (s *Scheduler) AddTask(name string, schedule Schedule, handler TaskHandler) *Task {
if schedule == nil {
panic("cron: AddTask requires a non-nil schedule")
}
if handler == nil {
panic("cron: AddTask requires a non-nil handler")
}
validateSchedule(schedule)
task := &Task{
Name: name,
Schedule: schedule,
Handler: handler,
Enabled: true,
NextRun: schedule.Next(time.Now()),
running: &atomic.Bool{},
}
s.mu.Lock()
@@ -100,7 +160,7 @@ func (s *Scheduler) DisableTask(name string) error {
return nil
}
// GetTask 获取任务
// GetTask 获取任务(返回拷贝快照,避免外部并发读 live 指针,C12a)。
func (s *Scheduler) GetTask(name string) (*Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -109,43 +169,77 @@ func (s *Scheduler) GetTask(name string) (*Task, error) {
if !ok {
return nil, fmt.Errorf("任务不存在: %s", name)
}
return task, nil
cp := *task
return &cp, nil
}
// ListTasks 获取所有任务
// ListTasks 获取所有任务(返回拷贝快照,C12a)。
func (s *Scheduler) ListTasks() []*Task {
s.mu.RLock()
defer s.mu.RUnlock()
tasks := make([]*Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
cp := *task
tasks = append(tasks, &cp)
}
return tasks
}
// RunTask 立即运行任务
func (s *Scheduler) RunTask(name string) error {
// executeTask 在 exec 边界运行 task handlerrecover 捕获 panic 转为 error(含调用栈),
// 防止调度 goroutine 内未 recover 的 panic 终止整个进程(M13)。
// 命名返回值让 defer 在 panic 时改写 err。两侧调用点(RunTask / checkAndRun goroutine
// 共用此边界,panic 一律转为 error 记入 LastError 并向上返回,不破坏 running 守卫与 wg.Done
// (recover 在本函数内部完成,外侧 defer 仍正常执行)。
func (s *Scheduler) executeTask(t *Task) (err error) {
s.mu.RLock()
task, ok := s.tasks[name]
ctx := s.ctx
s.mu.RUnlock()
return s.executeTaskWithContext(ctx, t)
}
func (s *Scheduler) executeTaskWithContext(ctx context.Context, t *Task) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("cron task %q panic recovered: %v\n%s", t.Name, r, debug.Stack())
}
}()
return t.Handler(ctx)
}
// RunTask 立即运行任务(手动触发,同步返回 handler 错误)。
//
// 占用 per-task running 守卫,与调度循环互斥,防止同一任务重叠执行(C12b)。
// 不推进 NextRun(手动触发不影响调度节奏,C12c)。LastRun/RunCount 在锁内更新(C12a)。
//
// L-Jhandler 收到的 ctx 为调度器 s.ctx。Stop 后 s.ctx 已取消,handler 会收到 canceled
// ctx——这是预期行为(手动触发在调度器停止后不应继续执行长任务)。调用方若需在 Stop 后
// 仍运行一次性任务,应在自己的 ctx 下执行而非依赖调度器。
func (s *Scheduler) RunTask(name string) error {
s.mu.Lock()
task, ok := s.tasks[name]
s.mu.Unlock()
if !ok {
return fmt.Errorf("任务不存在: %s", name)
}
return s.runTask(task)
}
// 占用 running 守卫(nil 守卫直接放行,仅防御非 AddTask 构造的 Task)。
if task.running != nil && !task.running.CompareAndSwap(false, true) {
return fmt.Errorf("任务正在执行中: %s", name)
}
defer func() {
if task.running != nil {
task.running.Store(false)
}
}()
// runTask 执行任务
func (s *Scheduler) runTask(task *Task) error {
task.LastRun = time.Now()
task.RunCount++
err := task.Handler(s.ctx)
err := s.executeTask(task)
s.mu.Lock()
task.NextRun = task.Schedule.Next(time.Now())
task.LastRun = time.Now()
task.RunCount++
task.LastError = err // M13: 手动路径也记 LastError(与调度路径对齐)
s.mu.Unlock()
return err
@@ -153,34 +247,66 @@ func (s *Scheduler) runTask(task *Task) error {
// Start 启动调度器
func (s *Scheduler) Start() {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
select {
case <-s.ctx.Done():
s.ctx, s.cancel = newSchedulerContext()
default:
}
ctx := s.ctx
s.running = true
s.wg.Add(1)
s.mu.Unlock()
s.wg.Add(1)
go s.run()
go s.run(ctx)
}
// Stop 停止调度器
// Stop 停止调度器并无限等待在跑任务退出(要求 handler 尊重 ctx.Done)。
// 若担心某 handler 不响应 ctx 而永久阻塞,请用 StopWithTimeout。
func (s *Scheduler) Stop() {
s.StopWithTimeout(0)
}
// StopWithTimeout 停止调度器,最多等待 timeout 让在跑任务退出(P1 #10)。
// 返回 true 表示所有任务已退出;false 表示超时(仍有任务未响应 ctx.Done 而运行)。
// timeout<=0 等价于无限等待(同 Stop)。幂等:未运行时直接返回 true。
func (s *Scheduler) StopWithTimeout(timeout time.Duration) bool {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return
return true
}
s.running = false
cancel := s.cancel
s.mu.Unlock()
s.cancel()
s.wg.Wait()
cancel()
if timeout <= 0 {
s.wg.Wait()
return true
}
done := make(chan struct{})
go func() { s.wg.Wait(); close(done) }()
select {
case <-done:
return true
case <-time.After(timeout):
return false
}
}
// run 运行调度循环
func (s *Scheduler) run() {
func (s *Scheduler) run(ctx context.Context) {
defer s.wg.Done()
ticker := time.NewTicker(1 * time.Second)
@@ -188,25 +314,86 @@ func (s *Scheduler) run() {
for {
select {
case <-s.ctx.Done():
case <-ctx.Done():
return
case <-ticker.C:
s.checkAndRun()
s.checkAndRun(ctx)
}
}
}
// checkAndRun 检查并运行到期任务
func (s *Scheduler) checkAndRun() {
// checkAndRun 检查并运行到期任务
//
// C12bper-task running 守卫(CAS)防止长任务跨 tick 重叠 spawn。
// C12c:占用守卫后**先推进 NextRun**(以上次 NextRun 锚定,非 time.Now()),
// 避免每周期累积 handler 时长致调度漂移;推进在 spawn 前,下次 tick 不会重复 spawn。
// C12aNextRun 推进在写锁内;LastRun/RunCount 在 goroutine 内写锁更新。
//
// M-H 修复:锁内只收集到期任务(CAS 占用 + 推进 NextRun + wg.Add),锁外再 spawn。
// 原实现整个遍历+spawn 持写锁,task 多时阻塞 GetTask/AddTask/RunTask 等管理 API。
//
// wg.Add 必须在锁内完成:Stop 先持锁置 running=false 再 wg.Wait,若 wg.Add 在锁外,
// Stop 可能在两个 due 任务之间读到计数器 0 并提前返回,留下后启动的 goroutine 在调度器
// 停止后运行(其 wg.Done 还会触发计数器下溢 panic)。锁内 Add 保证 Stop 的 wg.Wait
// 一定能等到本批全部 goroutine。收集阶段已 CAS 占用守卫并推进 NextRun,故 spawn 必须
// 执行(否则守卫不释放、NextRun 已推进却未跑)。spawn 用 s.ctx——Stop 后 ctx 已取消,
// handler 收到 canceled ctx 应及时退出。
func (s *Scheduler) checkAndRun(ctxs ...context.Context) {
now := time.Now()
s.mu.RLock()
for _, task := range s.tasks {
if task.Enabled && !task.NextRun.IsZero() && now.After(task.NextRun) {
go s.runTask(task)
}
var ctx context.Context
enforceRunning := len(ctxs) > 0 && ctxs[0] != nil
if len(ctxs) > 0 && ctxs[0] != nil {
ctx = ctxs[0]
} else {
s.mu.RLock()
ctx = s.ctx
s.mu.RUnlock()
}
s.mu.Lock()
if enforceRunning && (!s.running || ctx.Err() != nil) {
s.mu.Unlock()
return
}
var due []*Task
for _, task := range s.tasks {
if !task.Enabled || task.NextRun.IsZero() || !now.After(task.NextRun) {
continue
}
// 占用 running 守卫;正在执行则跳过本轮(防重叠 C12b)。
if task.running != nil && !task.running.CompareAndSwap(false, true) {
continue
}
// 先推进 NextRun(以上次 NextRun 锚定防漂移 C12c),再收集待 spawn。
task.NextRun = task.Schedule.Next(task.NextRun)
s.wg.Add(1) // 锁内 Add,保证 Stop 的 wg.Wait 等到本批(见上方注释)
due = append(due, task)
}
s.mu.Unlock()
for _, t := range due {
go func(t *Task) {
defer s.wg.Done()
defer func() {
if t.running != nil {
t.running.Store(false)
}
}()
err := s.executeTaskWithContext(ctx, t)
s.mu.Lock()
t.LastRun = time.Now()
t.RunCount++
t.LastError = err // CR3 修复:记录错误不再静默丢弃
s.mu.Unlock()
if err != nil {
logger.Error("cron task failed",
zap.String("task", t.Name),
zap.Error(err))
}
}(t)
}
s.mu.RUnlock()
}
// IntervalSchedule 固定间隔调度
@@ -221,6 +408,7 @@ func (s *IntervalSchedule) Next(now time.Time) time.Time {
// Every 每隔指定时间运行
func Every(interval time.Duration) *IntervalSchedule {
validateInterval(interval)
return &IntervalSchedule{Interval: interval}
}
@@ -241,6 +429,7 @@ func (s *DailySchedule) Next(now time.Time) time.Time {
// Daily 每日指定时间运行
func Daily(hour, minute int) *DailySchedule {
validateClock(hour, minute, "Daily")
return &DailySchedule{Hour: hour, Minute: minute}
}
@@ -251,27 +440,35 @@ type WeeklySchedule struct {
Minute int
}
// Next 计算下次运行时间
// Next 计算下次运行时间
//
// C12d:原实现 `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻,
// 当天目标时刻未到(如周一 9:00 目标、当前周一 12:00 之前的 8:00)被错误跳一周。
// 改为:先算今天的目标时刻,按 `((day-now)+7)%7` 加天数,再与 now 比较——
// 当天未到点则本周,当天已过则下周。
func (s *WeeklySchedule) Next(now time.Time) time.Time {
daysUntil := int(s.Day) - int(now.Weekday())
if daysUntil <= 0 {
daysUntil += 7
next := time.Date(now.Year(), now.Month(), now.Day(), s.Hour, s.Minute, 0, 0, now.Location())
daysUntil := (int(s.Day) - int(now.Weekday()) + 7) % 7
next = next.AddDate(0, 0, daysUntil)
if !next.After(now) {
next = next.AddDate(0, 0, 7)
}
next := time.Date(now.Year(), now.Month(), now.Day()+daysUntil, s.Hour, s.Minute, 0, 0, now.Location())
return next
}
// Weekly 每周指定时间运行
func Weekly(day time.Weekday, hour, minute int) *WeeklySchedule {
validateWeekday(day)
validateClock(hour, minute, "Weekly")
return &WeeklySchedule{Day: day, Hour: hour, Minute: minute}
}
// 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"
@@ -306,42 +503,115 @@ func (s *FullCronSchedule) match(t time.Time) bool {
s.matchField(s.Weekday, int(t.Weekday()), 0, 6)
}
// matchField 匹配单个字段
// matchField 匹配单个字段C12e 重写)。
//
// 旧实现先判 `-` 再判 `*/` 最后列表,且 parseInt 忽略非数字逐位累积:
// - `1-5,8` 因整字段含 `-` 被当范围,parseInt("5,8")=58 → 范围被破坏为 1..58,列表项丢失。
// - `garbage` → parseInt=0,分/时/周字段 value=0 时误触发。
// - `*/garbage` → step=0 → return true 匹配全部。
// - 周日 `7` 不匹配(Go Sunday=0)。
//
// 新实现:先按逗号拆列表,每项独立判 `*/n` / `a-b/n` / `a-b` / 单值(列表分支独立于范围分支);
// 全部用 strconv.Atoi 返错;weekday 字段(min=0,max=67→0,范围 lo>hi 环绕。
func (s *FullCronSchedule) matchField(field string, value int, min, max int) bool {
if field == "*" {
return true
}
// 处理范围 "a-b"
if strings.Contains(field, "-") {
parts := strings.Split(field, "-")
if len(parts) == 2 {
start := parseInt(parts[0])
end := parseInt(parts[1])
return value >= start && value <= end
for _, raw := range strings.Split(field, ",") {
item := strings.TrimSpace(raw)
if item == "" {
continue
}
}
// 处理步长 "*/n"
if strings.HasPrefix(field, "*/") {
step := parseInt(strings.TrimPrefix(field, "*/"))
if step > 0 {
return (value - min) % step == 0
}
return true
}
// 处理列表 "a,b,c"
for _, p := range strings.Split(field, ",") {
if parseInt(strings.TrimSpace(p)) == value {
if matchCronItem(item, value, min, max) {
return true
}
}
return false
}
// ParseCron 解析完整 Cron 表达式
// matchCronItem 处理单个 cron 字段项(已按逗号拆分)。
func matchCronItem(item string, value, min, max int) bool {
isWeekday := min == 0 && max == 6
// 步长 "*/n" 或 "a-b/n"
if idx := strings.Index(item, "/"); idx >= 0 {
base := item[:idx]
step, err := strconv.Atoi(item[idx+1:])
if err != nil || step <= 0 {
return false
}
lo, hi := min, max
if base != "*" {
rlo, rhi, err := parseCronRange(base, min, max)
if err != nil {
return false
}
lo, hi = rlo, rhi
}
return value >= lo && value <= hi && (value-lo)%step == 0
}
// 范围 "a-b"
if strings.Contains(item, "-") {
lo, hi, err := parseCronRange(item, min, max)
if err != nil {
return false
}
if isWeekday && lo > hi {
// 环绕:lo..6 0..hi(如 "6-1" = 周六、周日、周一)
return (value >= lo && value <= max) || (value >= min && value <= hi)
}
return value >= lo && value <= hi
}
// 单值
v, err := strconv.Atoi(item)
if err != nil {
return false
}
if isWeekday && v == 7 {
v = 0
}
if v < min || v > max {
return false
}
return v == value
}
// parseCronRange 解析 "a-b" 范围,含边界校验与 weekday 7→0 归一化。
func parseCronRange(s string, min, max int) (int, int, error) {
parts := strings.SplitN(s, "-", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid range %q", s)
}
lo, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
hi, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err1 != nil || err2 != nil {
return 0, 0, fmt.Errorf("invalid range %q", s)
}
isWeekday := min == 0 && max == 6
if isWeekday {
// 7 → 0(周日)。但若两端归一化后都为 0 而原始值不同(如 "0-7"/"7-0"),
// 语义为"整周"却坍缩成"仅周日",属歧义范围,拒绝以免静默错误匹配。
nlo, nhi := lo, hi
if nlo == 7 {
nlo = 0
}
if nhi == 7 {
nhi = 0
}
if nlo == nhi && lo != hi {
return 0, 0, fmt.Errorf("ambiguous weekday range %q (0 与 7 均为周日)", s)
}
lo, hi = nlo, nhi
}
if lo < min || lo > max || hi < min || hi > max {
return 0, 0, fmt.Errorf("range out of bounds %q", s)
}
return lo, hi, nil
}
// ParseCron 解析完整 Cron 表达式。
// 格式: "分钟 小时 日 月 星期"
// 示例:
//
@@ -350,11 +620,50 @@ func (s *FullCronSchedule) matchField(field string, value int, min, max int) boo
// "0 9-17 * * 1-5" - 工作日9-17点每小时
// "0 0 1 * *" - 每月1号凌晨
// "0 0 * * 0" - 每周日凌晨
//
// 非法表达式会 panicfail-fast),动态输入请用 ParseCronStrict 处理 error。
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
func ParseCron(expr string) *FullCronSchedule {
if sched, err := ParseCronStrict(expr); err == nil {
return sched
} else {
panic(err)
}
}
// ParseCronOrDefault parses a Cron expression and falls back to all "*" (every
// minute) when expr is invalid. Prefer ParseCronStrict or ParseCron for new
// code; this helper exists for callers that intentionally want legacy fallback
// semantics.
func ParseCronOrDefault(expr string) *FullCronSchedule {
if sched, err := ParseCronStrict(expr); err == nil {
return sched
}
return &FullCronSchedule{"*", "*", "*", "*", "*"}
}
// ParseCronStrict 严格解析 Cron 表达式,校验字段数与各字段范围,非法返 error。
// 字段范围:分钟 0-59,小时 0-23,日 1-31,月 1-12,星期 0-6(周日=0,7 归一为 0)。
func ParseCronStrict(expr string) (*FullCronSchedule, error) {
fields := strings.Fields(expr)
if len(fields) != 5 {
// 默认每分钟执行
return &FullCronSchedule{"*", "*", "*", "*", "*"}
return nil, fmt.Errorf("cron: 需要 5 个字段,实际 %d", len(fields))
}
specs := []struct {
val string
min, max int
name string
}{
{fields[0], 0, 59, "minute"},
{fields[1], 0, 23, "hour"},
{fields[2], 1, 31, "day"},
{fields[3], 1, 12, "month"},
{fields[4], 0, 6, "weekday"},
}
for _, sp := range specs {
if err := validateCronField(sp.val, sp.min, sp.max, sp.name); err != nil {
return nil, err
}
}
return &FullCronSchedule{
Minute: fields[0],
@@ -362,7 +671,65 @@ func ParseCron(expr string) *FullCronSchedule {
Day: fields[2],
Month: fields[3],
Weekday: fields[4],
}, nil
}
// validateCronField 校验单个 cron 字段语法与范围。
func validateCronField(field string, min, max int, name string) error {
if field == "*" {
return nil
}
for _, raw := range strings.Split(field, ",") {
item := strings.TrimSpace(raw)
if item == "" {
return fmt.Errorf("cron %s: 空列表项", name)
}
if err := validateCronItem(item, min, max, name); err != nil {
return err
}
}
return nil
}
func validateCronItem(item string, min, max int, name string) error {
isWeekday := min == 0 && max == 6
norm := func(v int) int {
if isWeekday && v == 7 {
return 0
}
return v
}
if idx := strings.Index(item, "/"); idx >= 0 {
base := item[:idx]
step, err := strconv.Atoi(item[idx+1:])
if err != nil || step <= 0 {
return fmt.Errorf("cron %s: 非法步长 %q", name, item)
}
if base == "*" {
return nil
}
lo, hi, err := parseCronRange(base, min, max)
if err != nil {
return fmt.Errorf("cron %s: %v", name, err)
}
_ = lo
_ = hi
return nil
}
if strings.Contains(item, "-") {
if _, _, err := parseCronRange(item, min, max); err != nil {
return fmt.Errorf("cron %s: %v", name, err)
}
return nil
}
v, err := strconv.Atoi(item)
if err != nil {
return fmt.Errorf("cron %s: 非数字 %q", name, item)
}
if v = norm(v); v < min || v > max {
return fmt.Errorf("cron %s: %d 超出范围 [%d,%d]", name, v, min, max)
}
return nil
}
// CronSchedule 简化 Cron 调度(仅分钟和小时)
@@ -399,55 +766,53 @@ func (s *CronSchedule) matchValue(pattern string, value int) bool {
return false
}
// splitPattern 将逗号分隔的模式解析为值列表(C12e:用 strconv.Atoi,非法项跳过)。
func splitPattern(pattern string) []int {
var values []int
for _, p := range split(pattern, ',') {
v := parseInt(p)
if v >= 0 {
values = append(values, v)
for _, p := range strings.Split(pattern, ",") {
v, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
continue
}
values = append(values, v)
}
return values
}
func split(s string, sep byte) []string {
var parts []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == sep {
parts = append(parts, s[start:i])
start = i + 1
}
}
parts = append(parts, s[start:])
return parts
}
func parseInt(s string) int {
var v int
for _, c := range s {
if c >= '0' && c <= '9' {
v = v*10 + int(c-'0')
}
}
return v
}
// Cron 创建类 Cron 调度
func Cron(minute, hour string) *CronSchedule {
return &CronSchedule{Minute: minute, Hour: hour}
}
// 全局调度器
var globalScheduler *Scheduler
var schedulerOnce sync.Once
// 全局调度器。init 时预创建默认实例(与 storage.DefaultStorage / cache.defaultCachePtr
// 对齐),使 SwapDefaultScheduler 返回非 nil、App 回滚总能恢复一个有效默认。用 atomic.Pointer
// 保护并发读写,消除原 once+裸指针读写竞态。GetScheduler 仍保留懒初始化分支作防御。
var globalScheduler atomic.Pointer[Scheduler]
// GetScheduler 获取全局调度器
func init() {
globalScheduler.Store(NewScheduler())
}
// GetScheduler 获取全局调度器(并发安全;init 后通常非 nil,保留懒初始化作防御)。
func GetScheduler() *Scheduler {
schedulerOnce.Do(func() {
globalScheduler = NewScheduler()
})
return globalScheduler
if s := globalScheduler.Load(); s != nil {
return s
}
ns := NewScheduler()
if globalScheduler.CompareAndSwap(nil, ns) {
return ns
}
return globalScheduler.Load()
}
// SwapDefaultScheduler 将指定 Scheduler 置为全局默认,并返回被替换的旧调度器。
// 旧调度器不会被停止,供 App 初始化这类需要失败回滚的生命周期流程暂存(照
// database.SwapDefaultManager / SwapDefaultRedisManager 模式)。nil 被忽略,返回当前默认。
func SwapDefaultScheduler(s *Scheduler) *Scheduler {
if s == nil {
return globalScheduler.Load()
}
return globalScheduler.Swap(s)
}
// AddTask 添加任务到全局调度器
@@ -460,7 +825,16 @@ func Start() {
GetScheduler().Start()
}
// Stop 停止全局调度器
// Stop 停止全局调度器(无限等待,见 Scheduler.Stop)。
func Stop() {
GetScheduler().Stop()
}
}
// StopGlobalWithTimeout 停止全局调度器并最多等待 timeout(P1 #10)。
// 全局调度器尚未创建时无操作返回 true,不会因此惰性创建它——供 App.Shutdown 安全调用。
func StopGlobalWithTimeout(timeout time.Duration) bool {
if s := globalScheduler.Load(); s != nil {
return s.StopWithTimeout(timeout)
}
return true
}
+190
View File
@@ -0,0 +1,190 @@
package cron
import (
"context"
"sync/atomic"
"testing"
"time"
)
// stepSchedule 是一个固定步长调度,Next(now)=now+step,用于确定性测试 C12c 锚定。
type stepSchedule struct{ step time.Duration }
func (s stepSchedule) Next(now time.Time) time.Time { return now.Add(s.step) }
// TestC12bNoOverlapManualDrive 验证 per-task running 守卫防止重叠执行。
//
// 修复前:checkAndRun 无 running 守卫,handler 未完成时下一次 checkAndRunNextRun 仍为过去)
// 会再次 spawn 同一任务,并发执行。
// 手动驱动两轮 checkAndRunhandler 阻塞至释放——修复后第二轮被 running 守卫拦截,仅 1 次 spawn。
func TestC12bNoOverlapManualDrive(t *testing.T) {
s := NewScheduler()
var started int32
release := make(chan struct{})
task := s.AddTask("block", stepSchedule{step: time.Microsecond}, func(ctx context.Context) error {
atomic.AddInt32(&started, 1)
<-release
return nil
})
task.NextRun = time.Now().Add(-time.Hour) // 过去 → 到期
s.checkAndRun() // 第一轮:spawnhandler 阻塞
for atomic.LoadInt32(&started) == 0 {
time.Sleep(time.Millisecond)
}
s.checkAndRun() // 第二轮:修复后 running 守卫拦截,不再 spawn
time.Sleep(50 * time.Millisecond) // 给潜在二次 spawn 启动时间
if n := atomic.LoadInt32(&started); n != 1 {
t.Errorf("task overlapped: started = %d, want 1 (C12b)", n)
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for task.running != nil && task.running.Load() {
if time.Now().After(deadline) {
t.Fatalf("running guard never released")
}
time.Sleep(time.Millisecond)
}
}
// TestC12cNextRunAnchoredOnPrevious 验证 NextRun 以上次 NextRun 锚定(不漂移)。
//
// 修复前:NextRun = schedule.Next(time.Now())handler 完成后的 now),每周期累积 handler 时长。
// 修复后:NextRun = schedule.Next(task.NextRun)(上次计划时间锚定)。
//
// 将 NextRun 设到过去的固定锚点 T0,手动驱动 3 轮 checkAndRun(每轮等 handler 完成),
// 断言 NextRun == T0 + 3*step(锚定)。漂移实现下 NextRun ≈ now+step(远大于 T0+3*step)。
func TestC12cNextRunAnchoredOnPrevious(t *testing.T) {
s := NewScheduler()
step := 100 * time.Millisecond
done := make(chan struct{}, 16)
task := s.AddTask("anchored", stepSchedule{step: step}, func(ctx context.Context) error {
// 模拟 handler 耗时——修复后不应影响 NextRun 锚定。
time.Sleep(50 * time.Millisecond)
done <- struct{}{}
return nil
})
// 将 NextRun 设到过去的固定锚点 T0(远早于 now,确保每轮都到期触发)。
T0 := time.Now().Add(-10 * time.Second)
task.NextRun = T0
for i := 1; i <= 3; i++ {
s.checkAndRun()
// 等 handler 执行;若 NextRun 未锚定(漂移实现下 NextRun 跳到未来,
// 后续 checkAndRun 不再到期),done 不会收到 → 超时明确失败而非挂起。
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("iter %d: handler did not fire (NextRun drifted to future, not anchored on previous) (C12c)", i)
}
// 等 running 守卫释放(defer 在 handler 返回后置 false)。
deadline := time.Now().Add(2 * time.Second)
for task.running != nil && task.running.Load() {
if time.Now().After(deadline) {
t.Fatalf("running guard never released (iter %d)", i)
}
time.Sleep(time.Millisecond)
}
}
got := task.NextRun
want := T0.Add(3 * step)
if diff := got.Sub(want); diff < -5*time.Millisecond || diff > 5*time.Millisecond {
t.Errorf("NextRun not anchored on previous: got %v, want %v (diff %v, C12c drift)", got, want, diff)
}
}
// C12e 直接测试 matchField(未导出,故 internal test)。
//
// 修复前:matchField 先判 `-` 把整字段当范围,parseInt 忽略非数字逐位累积:
// - "1-5,8" 含 `-` → parseInt("5,8")=58 → 范围 1..58 全匹配,列表项 8 丢失。
// - "garbage" → parseInt=0value=0 误触发。
// - "*/garbage" → step=0 → return true 匹配全部。
var schedForMatch = &FullCronSchedule{}
func TestC12eMatchFieldListAndRangeIndependent(t *testing.T) {
// "1-5,8" 仅匹配 1,2,3,4,5,8。
for _, v := range []int{1, 2, 3, 4, 5, 8} {
if !schedForMatch.matchField("1-5,8", v, 0, 59) {
t.Errorf("1-5,8 should match %d (C12e)", v)
}
}
for _, v := range []int{0, 6, 7, 9, 30, 58} {
if schedForMatch.matchField("1-5,8", v, 0, 59) {
t.Errorf("1-5,8 should NOT match %d (C12e range broken to 1..58)", v)
}
}
}
func TestC12eMatchFieldGarbageNotMatch(t *testing.T) {
for v := 0; v <= 59; v++ {
if schedForMatch.matchField("garbage", v, 0, 59) {
t.Errorf("garbage should not match any value, matched %d (C12e)", v)
}
}
}
func TestC12eMatchFieldStarSlashGarbageNotMatchAll(t *testing.T) {
// 修复前 step=0 → return true 匹配全部。
matched := 0
for v := 0; v <= 59; v++ {
if schedForMatch.matchField("*/garbage", v, 0, 59) {
matched++
}
}
if matched != 0 {
t.Errorf("*/garbage should match nothing, matched %d (C12e step=0 bug)", matched)
}
}
func TestC12eMatchFieldWeekday7IsSunday(t *testing.T) {
// weekday 字段 7 → 0(周日)。
if !schedForMatch.matchField("7", 0, 0, 6) {
t.Error("weekday 7 should match Sunday(0) (C12e)")
}
if schedForMatch.matchField("7", 7, 0, 6) {
t.Error("weekday 7 should not match value 7 (out of range after normalize)")
}
}
func TestC12eMatchFieldRangeAndStepAndList(t *testing.T) {
// 范围 9-17。
for _, v := range []int{9, 12, 17} {
if !schedForMatch.matchField("9-17", v, 0, 23) {
t.Errorf("9-17 should match %d", v)
}
}
for _, v := range []int{8, 18} {
if schedForMatch.matchField("9-17", v, 0, 23) {
t.Errorf("9-17 should NOT match %d", v)
}
}
// 步长 */15。
for _, v := range []int{0, 15, 30, 45} {
if !schedForMatch.matchField("*/15", v, 0, 59) {
t.Errorf("*/15 should match %d", v)
}
}
for _, v := range []int{1, 7, 16} {
if schedForMatch.matchField("*/15", v, 0, 59) {
t.Errorf("*/15 should NOT match %d", v)
}
}
// 范围步长 9-17/2 → 9,11,13,15,17。
for _, v := range []int{9, 11, 13, 15, 17} {
if !schedForMatch.matchField("9-17/2", v, 0, 23) {
t.Errorf("9-17/2 should match %d", v)
}
}
for _, v := range []int{10, 12, 14, 16} {
if schedForMatch.matchField("9-17/2", v, 0, 23) {
t.Errorf("9-17/2 should NOT match %d", v)
}
}
}
+321
View File
@@ -0,0 +1,321 @@
package cron_test
import (
"context"
"sync"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/cron"
)
// ============================================================
// C12arunTask 计数写入无锁 + GetTask/ListTasks 返回 live 指针 → data race
// ============================================================
// TestC12aConcurrentReadWriteNoRace 验证并发 RunTask/GetTask/ListTasks + 调度运行
// 无数据竞争(-race)。
//
// 修复前:runTask 无锁写 LastRun/RunCountGetTask/ListTasks 返回 live 指针并发读 →
// -race 必采到 DATA RACE。修复后:写入纳入锁、Getter 返回拷贝。
func TestC12aConcurrentReadWriteNoRace(t *testing.T) {
scheduler := cron.NewScheduler()
scheduler.AddTask("t1", cron.Every(50*time.Millisecond), func(ctx context.Context) error {
return nil
})
scheduler.AddTask("t2", cron.Every(80*time.Millisecond), func(ctx context.Context) error {
return nil
})
scheduler.Start()
t.Cleanup(scheduler.Stop)
stop := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
// 读取返回拷贝的字段——若写侧无锁,与此处读竞争(C12a)。
if g, err := scheduler.GetTask("t1"); err == nil {
_ = g.RunCount
_ = g.LastRun
}
for _, tk := range scheduler.ListTasks() {
_ = tk.RunCount
_ = tk.LastRun
}
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = scheduler.RunTask("t2")
}
}()
time.Sleep(300 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestC12aGetTaskReturnsCopy 验证 GetTask 返回拷贝,修改返回值不影响内部状态。
func TestC12aGetTaskReturnsCopy(t *testing.T) {
scheduler := cron.NewScheduler()
scheduler.AddTask("t", cron.Every(time.Minute), func(ctx context.Context) error {
return nil
})
got, err := scheduler.GetTask("t")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
got.RunCount = 9999
got.Enabled = false
again, _ := scheduler.GetTask("t")
if again.RunCount == 9999 {
t.Error("GetTask returned live pointer (RunCount mutated internally)")
}
if !again.Enabled {
t.Error("GetTask returned live pointer (Enabled mutated internally)")
}
}
// TestC12aListTasksReturnsCopies 验证 ListTasks 元素为拷贝。
func TestC12aListTasksReturnsCopies(t *testing.T) {
scheduler := cron.NewScheduler()
scheduler.AddTask("t", cron.Every(time.Minute), func(ctx context.Context) error {
return nil
})
list := scheduler.ListTasks()
list[0].RunCount = 777
again := scheduler.ListTasks()
if again[0].RunCount == 777 {
t.Error("ListTasks returned live pointer (RunCount mutated internally)")
}
}
// ============================================================
// C12b:长任务跨 tick 重叠执行
// ============================================================
// TestC12bRunTaskConcurrentManualTriggerReturnsError 验证手动 RunTask 占用守卫期间,
// 再次 RunTask 返"任务正在执行中"错误。
func TestC12bRunTaskConcurrentManualTriggerReturnsError(t *testing.T) {
scheduler := cron.NewScheduler()
started := make(chan struct{})
release := make(chan struct{})
scheduler.AddTask("block", cron.Every(time.Hour), func(ctx context.Context) error {
close(started)
<-release
return nil
})
var firstErr error
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
firstErr = scheduler.RunTask("block")
}()
<-started
secondErr := scheduler.RunTask("block")
if secondErr == nil {
t.Error("second RunTask should fail while first is running (C12b running guard)")
}
close(release)
wg.Wait()
if firstErr != nil {
t.Errorf("first RunTask error: %v", firstErr)
}
}
// ============================================================
// C12d:Weekly 当天未到点目标被跳一周
// ============================================================
func TestC12dWeeklySameDayBeforeTarget(t *testing.T) {
schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0}
// 周一 8:00(目标 9:00 未到)→ 应返回本周一 9:00,不跳周。
now := time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC) // 2026-06-29 是周一
if now.Weekday() != time.Monday {
t.Fatalf("test fixture: expected Monday, got %v", now.Weekday())
}
next := schedule.Next(now)
want := time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC)
if !next.Equal(want) {
t.Errorf("Weekly same-day-before-target: got %v, want %v (C12d skip-week bug)", next, want)
}
}
func TestC12dWeeklySameDayAfterTarget(t *testing.T) {
schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0}
// 周一 10:00(目标 9:00 已过)→ 下周一 9:00。
now := time.Date(2026, 6, 29, 10, 0, 0, 0, time.UTC)
next := schedule.Next(now)
want := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC)
if !next.Equal(want) {
t.Errorf("Weekly same-day-after-target: got %v, want %v", next, want)
}
}
func TestC12dWeeklyCrossWeek(t *testing.T) {
schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0}
// 周三 → 下周一。
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) // 周三
if now.Weekday() != time.Wednesday {
t.Fatalf("test fixture: expected Wednesday, got %v", now.Weekday())
}
next := schedule.Next(now)
want := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC)
if !next.Equal(want) {
t.Errorf("Weekly cross-week: got %v, want %v", next, want)
}
}
// ============================================================
// C12ecron 解析缺陷(周日 7 / 范围环绕 / 严格解析)
// 1-5,8 / garbage / */garbage 的 matchField 直接测试见 cron_c12_internal_test.go
// ============================================================
func TestC12eWeekdaySundayAs7(t *testing.T) {
schedule := cron.FullCronSchedule{
Minute: "0",
Hour: "0",
Day: "*",
Month: "*",
Weekday: "7",
}
// 0 0 * * 7 → 每周日凌晨。从周六 23:00 找下一个匹配应落周日 00:00。
now := time.Date(2026, 6, 27, 23, 0, 0, 0, time.UTC) // 周六
if now.Weekday() != time.Saturday {
t.Fatalf("test fixture: expected Saturday, got %v", now.Weekday())
}
next := schedule.Next(now)
if next.Weekday() != time.Sunday {
t.Errorf("0 0 * * 7 should land on Sunday, got %v (C12e 7≠Sunday)", next.Weekday())
}
if next.Hour() != 0 || next.Minute() != 0 {
t.Errorf("0 0 * * 7 should land on 00:00, got %v", next)
}
}
func TestC12eWeekdayRangeWraparound(t *testing.T) {
// "6-1" 在 weekday 环绕:周六(6)、周日(0)、周一(1)。
schedule := cron.FullCronSchedule{
Minute: "0",
Hour: "0",
Day: "*",
Month: "*",
Weekday: "6-1",
}
for _, w := range []time.Weekday{time.Saturday, time.Sunday, time.Monday} {
tt := time.Date(2026, 6, 29, 0, 0, 0, 0, time.UTC) // 周一
for tt.Weekday() != w {
tt = tt.AddDate(0, 0, 1)
}
next := schedule.Next(tt.Add(-1 * time.Minute))
if next.Weekday() != w {
t.Errorf("6-1 should match %v, got %v", w, next.Weekday())
}
}
// 周二不应被 6-1 匹配,下一个应是周六。
tt := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) // 周二
if tt.Weekday() != time.Tuesday {
t.Fatalf("test fixture: expected Tuesday, got %v", tt.Weekday())
}
next := schedule.Next(tt.Add(-1 * time.Minute))
if next.Weekday() != time.Saturday {
t.Errorf("6-1 should skip Tuesday, next match %v, want Saturday", next.Weekday())
}
}
// TestC12eParseCronStrict 验证严格解析。
func TestC12eParseCronStrict(t *testing.T) {
cases := []struct {
expr string
ok bool
}{
{"0 12 * * *", true},
{"*/15 * * * *", true},
{"0 9-17 * * 1-5", true},
{"0 0 1 * 7", true}, // 周日 7 合法
{"0 0 * * 0-7", false},
{"invalid", false},
{"1-5,8 0 * * *", true},
{"60 0 * * *", false}, // 分钟越界
{"0 25 * * *", false}, // 小时越界
{"0 0 0 * *", false}, // 日越界
{"0 0 * 13 *", false}, // 月越界
{"0 0 * * 9", false}, // 周越界
{"garbage 0 * * *", false},
{"*/0 * * * *", false}, // step=0 非法
}
for _, c := range cases {
_, err := cron.ParseCronStrict(c.expr)
if c.ok && err != nil {
t.Errorf("ParseCronStrict(%q) unexpected error: %v", c.expr, err)
}
if !c.ok && err == nil {
t.Errorf("ParseCronStrict(%q) expected error, got nil", c.expr)
}
}
}
// TestC12eParseCronFallback verifies ParseCron now fails fast on invalid input.
func TestC12eParseCronFallback(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("ParseCron(invalid) should panic")
}
}()
_ = cron.ParseCron("invalid")
}
func TestC12eParseCronOrDefaultFallback(t *testing.T) {
s := cron.ParseCronOrDefault("invalid")
if s.Minute != "*" || s.Hour != "*" || s.Day != "*" || s.Month != "*" || s.Weekday != "*" {
t.Errorf("ParseCronOrDefault(invalid) should fall back to all-*, got %+v", s)
}
// 合法表达式不回退。
s = cron.ParseCron("1-5,8 0 * * *")
if s.Minute != "1-5,8" {
t.Errorf("ParseCron valid: Minute = %q, want 1-5,8", s.Minute)
}
}
// TestC12eCronScheduleGarbageNoMatch 验证简化 Cron 不再 parseInt 容错为 0。
func TestC12eCronScheduleGarbageNoMatch(t *testing.T) {
bad := cron.CronSchedule{Minute: "garbage", Hour: "9"}
// garbage → splitPattern 返空 → matchMinute 返 false → 24h 内无匹配。
now := time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC)
next := bad.Next(now)
if !next.IsZero() {
t.Errorf("CronSchedule garbage minute should not match, got next=%v (C12e)", next)
}
}
+168
View File
@@ -0,0 +1,168 @@
package cron
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
)
func mustPanicM13(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Fatalf("%s should panic", name)
}
}()
fn()
}
// TestCronRunTaskPanicRecovered_M13 回归:RunTask handler panic 时,executeTask 边界
// recover 转为 error 返回调用方并记入 LastError,不崩进程;running 守卫随后释放。
// 修复前:panic 直接上抛 → 测试进程崩溃。
func TestCronRunTaskPanicRecovered_M13(t *testing.T) {
s := NewScheduler()
s.AddTask("panic", Every(time.Minute), func(context.Context) error { panic("boom") })
err := s.RunTask("panic")
if err == nil || !strings.Contains(err.Error(), "panic recovered") {
t.Fatalf("RunTask err = %v, want a panic-recovered error", err)
}
got, _ := s.GetTask("panic")
if got.LastError == nil || !strings.Contains(got.LastError.Error(), "panic recovered") {
t.Fatalf("LastError = %v, want panic-recovered error", got.LastError)
}
// running 守卫必须已释放:再次 RunTask 不应返“任务正在执行中”(会再次 panic→recover)。
err2 := s.RunTask("panic")
if err2 != nil && strings.Contains(err2.Error(), "任务正在执行中") {
t.Fatal("running guard not released after panic (second RunTask blocked)")
}
}
// TestCronRunTaskRecordsLastError_M13 回归:RunTask 手动路径此前只更 LastRun/RunCount
// 不记 LastError。修复后与调度路径一致,正常 error 也记入 LastError。
func TestCronRunTaskRecordsLastError_M13(t *testing.T) {
s := NewScheduler()
sentinel := errors.New("sentinel fail")
s.AddTask("err", Every(time.Minute), func(context.Context) error { return sentinel })
err := s.RunTask("err")
if !errors.Is(err, sentinel) {
t.Fatalf("RunTask err = %v, want sentinel", err)
}
got, _ := s.GetTask("err")
if !errors.Is(got.LastError, sentinel) {
t.Fatalf("LastError = %v, want sentinel", got.LastError)
}
if got.RunCount != 1 {
t.Fatalf("RunCount = %d, want 1", got.RunCount)
}
}
// TestCronCheckAndRunPanicRecovered_M13 回归:调度 goroutine 内 handler panic 时,
// recover 防止进程崩溃;同 tick 派生的兄弟 goroutine 不受影响;无 goroutine 泄漏。
// 同包测试直接驱动 checkAndRun + wg.Wait,零 sleep、确定性。
// 修复前:派生 goroutine 内未 recover 的 panic 终止测试进程。
func TestCronCheckAndRunPanicRecovered_M13(t *testing.T) {
s := NewScheduler()
var ran atomic.Int32
s.AddTask("panic", Every(time.Millisecond), func(context.Context) error { panic("boom") })
s.AddTask("canary", Every(time.Millisecond), func(context.Context) error {
ran.Add(1)
return nil
})
// AddTask 设 NextRun=now+1ms;手动改到过去确保两者 due,跨过 1s ticker 的不确定性。
s.mu.Lock()
s.tasks["panic"].NextRun = time.Now().Add(-time.Second)
s.tasks["canary"].NextRun = time.Now().Add(-time.Second)
s.mu.Unlock()
s.checkAndRun()
s.wg.Wait() // 等派生的两个 goroutine 退出,证无泄漏
pt, _ := s.GetTask("panic")
if pt.LastError == nil || !strings.Contains(pt.LastError.Error(), "panic recovered") {
t.Fatalf("panic task LastError = %v, want panic-recovered error", pt.LastError)
}
if ran.Load() == 0 {
t.Fatal("canary did not run — sibling goroutine killed by panic?")
}
}
func TestCronAddTaskRejectsNilScheduleOrHandler_M13(t *testing.T) {
s := NewScheduler()
mustPanicM13(t, "nil schedule", func() {
s.AddTask("nil-schedule", nil, func(context.Context) error { return nil })
})
mustPanicM13(t, "nil handler", func() {
s.AddTask("nil-handler", Every(time.Minute), nil)
})
}
func TestCronScheduleConstructorsRejectInvalidBounds_M13(t *testing.T) {
mustPanicM13(t, "Every(0)", func() { Every(0) })
mustPanicM13(t, "Every(negative)", func() { Every(-time.Second) })
mustPanicM13(t, "Daily hour", func() { Daily(24, 0) })
mustPanicM13(t, "Daily minute", func() { Daily(23, 60) })
mustPanicM13(t, "Weekly day", func() { Weekly(time.Weekday(9), 9, 0) })
mustPanicM13(t, "Weekly hour", func() { Weekly(time.Monday, -1, 0) })
mustPanicM13(t, "Weekly minute", func() { Weekly(time.Monday, 9, -1) })
}
func TestCronAddTaskRejectsInvalidExportedScheduleValues_M13(t *testing.T) {
s := NewScheduler()
mustPanicM13(t, "invalid interval schedule", func() {
s.AddTask("bad-interval", &IntervalSchedule{}, func(context.Context) error { return nil })
})
mustPanicM13(t, "invalid daily schedule", func() {
s.AddTask("bad-daily", &DailySchedule{Hour: 99}, func(context.Context) error { return nil })
})
mustPanicM13(t, "invalid weekly schedule", func() {
s.AddTask("bad-weekly", &WeeklySchedule{Day: time.Weekday(8)}, func(context.Context) error { return nil })
})
}
func TestCronStartAfterStopRebuildsContext_M13(t *testing.T) {
s := NewScheduler()
s.AddTask("ctx", Every(time.Hour), func(ctx context.Context) error {
return ctx.Err()
})
s.Start()
s.Stop()
s.Start()
t.Cleanup(s.Stop)
if err := s.RunTask("ctx"); err != nil {
t.Fatalf("RunTask after Stop/Start got ctx err = %v, want nil", err)
}
}
func TestCronCheckAndRunSkipsAfterStopContextCanceled_M13(t *testing.T) {
s := NewScheduler()
var ran atomic.Int32
s.AddTask("due", Every(time.Minute), func(context.Context) error {
ran.Add(1)
return nil
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
s.mu.Lock()
s.running = false
s.tasks["due"].NextRun = time.Now().Add(-time.Second)
s.mu.Unlock()
s.checkAndRun(ctx)
s.wg.Wait()
if got := ran.Load(); got != 0 {
t.Fatalf("task ran %d times after stopped/canceled ctx, want 0", got)
}
}
+9 -5
View File
@@ -232,10 +232,14 @@ func TestParseCron(t *testing.T) {
}
// 无效表达式返回默认
schedule = cron.ParseCron("invalid")
if schedule.Minute != "*" || schedule.Hour != "*" {
t.Error("ParseCron invalid should return default")
}
func() {
defer func() {
if recover() == nil {
t.Fatal("ParseCron invalid should panic")
}
}()
_ = cron.ParseCron("invalid")
}()
}
func TestFullCronScheduleMatch(t *testing.T) {
@@ -261,4 +265,4 @@ func TestFullCronScheduleMatch(t *testing.T) {
if next.Minute() != 10 || next.Hour() != 10 {
t.Errorf("Next after 10:07 should be 10:10, got %v", next)
}
}
}
+52 -7
View File
@@ -1,14 +1,18 @@
package database
import (
"errors"
"fmt"
"strings"
"sync"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// 内置驱动常量(更多驱动可通过 RegisterDialect 扩展)
@@ -28,8 +32,8 @@ type DialectSpec struct {
Aliases []string
// Dialector 由 DSN 构造 GORM Dialector
Dialector DialectorFactory
// DSN 由 DatabaseConfig 拼接连接字符串。可选——
// 不提供时使用 cfg.MySQLDSN() 兜底(适合自定义驱动通过 CustomDSN 指定连接串的场景)
// DSN 由 DatabaseConfig 拼接连接字符串。可选
// 不提供时仅 CustomDSN 能直接生效;需要由配置字段拼接连接串的自定义驱动应显式提供该函数。
DSN config.DSNBuilder
}
@@ -91,20 +95,61 @@ func RegisteredDialects() []string {
}
// Dialector 根据配置返回 GORM Dialector。
// 驱动由 cfg.Database.Driver 决定未指定或未注册时按 MySQL 兜底(向后兼容)。
// 驱动由 cfg.Database.Driver 决定未指定时默认 MySQL,非空但未注册时返回会初始化失败的
// Dialector,避免拼写错误静默回退到 MySQL。
func Dialector(cfg *config.Config) gorm.Dialector {
if cfg == nil {
logger.Warn("database: 配置为空,回退到 MySQL 空 DSN")
return mysql.Open("")
}
return dialectorForDSN(cfg.Database.Driver, cfg.Database.DSN())
}
// dialectorForDSN 根据驱动名和 DSN 返回 Dialector
func dialectorForDSN(driver, dsn string) gorm.Dialector {
if f, ok := LookupDialect(driver); ok {
normalized := normalizeDriver(driver)
if normalized == "" {
normalized = DriverMySQL
}
if f, ok := LookupDialect(normalized); ok {
return f(dsn)
}
// 未注册时回退到 MySQL,与 config.DSN() 的回退保持一致
return mysql.Open(dsn)
logger.Warnf("database: 驱动 %q 未注册(已注册: %s),拒绝静默回退到 MySQL;请修正配置或先注册方言",
normalized, strings.Join(RegisteredDialects(), ", "))
return errorDialector{
name: "invalid",
err: fmt.Errorf("数据库驱动未注册: %s", normalized),
}
}
type errorDialector struct {
name string
err error
}
func (d errorDialector) Name() string { return d.name }
func (d errorDialector) Initialize(*gorm.DB) error {
if d.err == nil {
return errors.New("数据库驱动未注册")
}
return d.err
}
func (d errorDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
func (d errorDialector) DataTypeOf(*schema.Field) string { return "" }
func (d errorDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
func (d errorDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
func (d errorDialector) QuoteTo(writer clause.Writer, str string) {
_, _ = writer.WriteString(str)
}
func (d errorDialector) Explain(sql string, _ ...any) string { return sql }
// normalizeDriver 规范化驱动名(小写、去空白)
func normalizeDriver(name string) string {
return strings.ToLower(strings.TrimSpace(name))
@@ -119,7 +164,7 @@ func driverDescription(driver string) string {
if _, ok := LookupDialect(key); ok {
return key
}
return fmt.Sprintf("%s (unregistered, fallback=%s)", key, DriverMySQL)
return fmt.Sprintf("%s (unregistered)", key)
}
func init() {
+436 -142
View File
@@ -2,9 +2,11 @@ package database
import (
"context"
cryptorand "crypto/rand"
"database/sql"
"errors"
"fmt"
"math/rand"
"math/big"
"strings"
"sync"
"sync/atomic"
@@ -20,11 +22,22 @@ import (
type dbModeContextKey struct{}
// txContextKey 携带外层事务的 *gorm.DB,使 repository 等上层在调用时能 join 到外层事务
// H6c:外层 ctx 事务无法 join)。由 WithTx 注入、TxFromContext 读取。
type txContextKey struct{}
const (
dbModeMaster = "master"
dbModeReplica = "replica"
)
func normalizeContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
// ReplicaPicker 从库选择策略
type ReplicaPicker interface {
Pick(replicas []*gorm.DB) *gorm.DB
@@ -32,7 +45,8 @@ type ReplicaPicker interface {
// RoundRobinPicker 轮询选择从库
type RoundRobinPicker struct {
counter uint64
mu sync.Mutex
counter int
}
// Pick 轮询选择一个从库
@@ -40,11 +54,17 @@ func (p *RoundRobinPicker) Pick(replicas []*gorm.DB) *gorm.DB {
if len(replicas) == 0 {
return nil
}
n := atomic.AddUint64(&p.counter, 1)
return replicas[int(n-1)%len(replicas)]
p.mu.Lock()
idx := p.counter % len(replicas)
p.counter = (idx + 1) % len(replicas)
p.mu.Unlock()
return replicas[idx]
}
// RandomPicker 随机选择从库
// RandomPicker 随机选择从库
//
// 使用 crypto/rand 生成随机索引,并发安全且不可预测。len(replicas)<=0 返回 nil
// crypto/rand 失败(极罕见,如熵池耗尽)时回退到 replicas[0],保证可用性。
type RandomPicker struct{}
// Pick 随机选择一个从库
@@ -52,7 +72,11 @@ func (p *RandomPicker) Pick(replicas []*gorm.DB) *gorm.DB {
if len(replicas) == 0 {
return nil
}
return replicas[rand.Intn(len(replicas))]
n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(len(replicas))))
if err != nil {
return replicas[0]
}
return replicas[int(n.Int64())]
}
// Manager 数据库管理器,持有主库与从库连接实例
@@ -62,13 +86,16 @@ type Manager struct {
replicas []*gorm.DB
picker ReplicaPicker
mu sync.Mutex
// opMu 串行化 InitDB / InitDBWithReplicas / Close 这类资源生命周期操作。
// 避免关闭已开始后初始化又发布新连接,或初始化发布后被并发 Close 置空。
opMu 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 创建数据库管理器
@@ -76,6 +103,20 @@ func NewManager(cfg *config.Config) *Manager {
return &Manager{cfg: cfg, picker: &RandomPicker{}}
}
// getCfg 在锁内读取 m.cfgP1 #11:消除与 InitDB 写 m.cfg 的数据竞争)。
func (m *Manager) getCfg() *config.Config {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg
}
// setCfg 在锁内写入 m.cfgP1 #11)。
func (m *Manager) setCfg(cfg *config.Config) {
m.mu.Lock()
m.cfg = cfg
m.mu.Unlock()
}
// SetPicker 设置从库选择策略
func (m *Manager) SetPicker(p ReplicaPicker) {
if p == nil {
@@ -93,25 +134,37 @@ func (m *Manager) Picker() ReplicaPicker {
return m.picker
}
// Master 返回主库实例
// Master 返回主库实例
// 经 m.mu 锁保护读取,避免与 InitDB/Close 的写竞争返回已关闭/nil 池(C11d)。
func (m *Manager) Master() *gorm.DB {
m.mu.Lock()
defer m.mu.Unlock()
return m.master
}
// Replicas 返回所有从库实例
// Replicas 返回所有从库实例的拷贝。
// 经 m.mu 锁保护读取并返回拷贝,避免调用方持活切片与 InitDBWithReplicas/Close 重置竞争(C11d)。
func (m *Manager) Replicas() []*gorm.DB {
return m.replicas
m.mu.Lock()
defer m.mu.Unlock()
if m.replicas == nil {
return nil
}
out := make([]*gorm.DB, len(m.replicas))
copy(out, m.replicas)
return out
}
// Replica 按策略选择一个从库;无从库时返回主库。
// #21:启用探活后,自动过滤不健康的从库;全不健康时回退到全部从库(仍可服务)。
// 全程持 m.mu 锁,避免 replicas/master 与重建路径写竞争(C11d)。
func (m *Manager) Replica() *gorm.DB {
if len(m.replicas) == 0 {
return m.master
}
m.mu.Lock()
defer m.mu.Unlock()
if len(m.replicas) == 0 {
return m.master
}
pool := m.replicas
// 启用探活且至少有一个健康标记时,仅从健康从库中选取
if m.replicaHealthSet {
@@ -141,6 +194,8 @@ func (m *Manager) IsHealthy() bool {
}
// initReplicaHealth 按 replicas 数量初始化健康标记(全部为健康)。
// 已初始化(replicaHealthSet=true)时早返回;重建从库前须先 resetReplicaHealth 重置,
// 否则健康切片长度与新 replicas 错位(C11a)。
func (m *Manager) initReplicaHealth() {
m.mu.Lock()
defer m.mu.Unlock()
@@ -154,20 +209,39 @@ func (m *Manager) initReplicaHealth() {
m.replicaHealthSet = true
}
// ensureReplicaHealthLocked 按当前 replicas 重建健康标记。调用方须持有 m.mu。
func (m *Manager) ensureReplicaHealthLocked() {
m.replicaHealthy = make([]atomic.Bool, len(m.replicas))
for i := range m.replicaHealthy {
m.replicaHealthy[i].Store(true)
}
m.replicaHealthSet = true
}
// resetReplicaHealth 清空从库健康标记,使下次 initReplicaHealth 按新 replicas 长度重建。
// 重建从库(InitDBWithReplicas/Close 前必须调用,避免健康切片与新 replicas 长度错位(C11a)。
// 调用方须持有 m.mu。
func (m *Manager) resetReplicaHealth() {
m.replicaHealthy = nil
m.replicaHealthSet = false
}
// StartProbing 启动主库与从库的健康探活后台循环(#21)。
// 阻塞调用方,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
// 周期 ping 主库,连续失败达阈值后标记不健康(IsHealthy=false);
// 同时 ping 各从库,失败则从读流量剔除,恢复后自动重新纳入。
func (m *Manager) StartProbing(ctx context.Context) {
ctx = normalizeContext(ctx)
m.initReplicaHealth()
cfg := m.getCfg() // P1 #11:锁内快照,避免与 InitDB 写 m.cfg 竞态
interval := 30 * time.Second
if m.cfg != nil && m.cfg.Database.HealthCheckInterval > 0 {
interval = m.cfg.Database.HealthCheckInterval
if cfg != nil && cfg.Database.HealthCheckInterval > 0 {
interval = cfg.Database.HealthCheckInterval
}
threshold := 3
if m.cfg != nil && m.cfg.Database.HealthCheckFailureThreshold > 0 {
threshold = m.cfg.Database.HealthCheckFailureThreshold
if cfg != nil && cfg.Database.HealthCheckFailureThreshold > 0 {
threshold = cfg.Database.HealthCheckFailureThreshold
}
ticker := time.NewTicker(interval)
@@ -210,7 +284,11 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) {
m.mu.Lock()
replicas := make([]*gorm.DB, len(m.replicas))
copy(replicas, m.replicas)
if len(replicas) > 0 && !m.replicaHealthSet {
m.ensureReplicaHealthLocked()
}
healthSet := m.replicaHealthSet
replicaHealthy := m.replicaHealthy // 快照切片头,避免与 resetReplicaHealth 写竞争
m.mu.Unlock()
if !healthSet {
return
@@ -221,21 +299,22 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) {
}
sqlDB, err := r.DB()
if err != nil {
if i < len(m.replicaHealthy) {
m.replicaHealthy[i].Store(false)
if i < len(replicaHealthy) {
replicaHealthy[i].Store(false)
}
continue
}
if err := sqlDB.PingContext(ctx); err != nil {
if i < len(m.replicaHealthy) && m.replicaHealthy[i].Load() {
// M11H-db-1):从库探活 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞探活 goroutine。
if err := pingWithTimeout(sqlDB, ctx); err != nil {
if i < len(replicaHealthy) && replicaHealthy[i].Load() {
logger.Warnf("数据库从库 #%d 探活失败,暂时剔除读流量: %v", i, err)
}
if i < len(m.replicaHealthy) {
m.replicaHealthy[i].Store(false)
if i < len(replicaHealthy) {
replicaHealthy[i].Store(false)
}
} else {
if i < len(m.replicaHealthy) {
m.replicaHealthy[i].Store(true)
if i < len(replicaHealthy) {
replicaHealthy[i].Store(true)
}
}
}
@@ -243,13 +322,14 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) {
// FromContext 根据上下文选择数据库
func (m *Manager) FromContext(ctx context.Context) *gorm.DB {
ctx = normalizeContext(ctx)
mode, ok := ctx.Value(dbModeContextKey{}).(string)
if !ok {
return m.Replica()
}
switch mode {
case dbModeMaster:
return m.master
return m.Master()
case dbModeReplica:
return m.Replica()
default:
@@ -259,72 +339,152 @@ func (m *Manager) FromContext(ctx context.Context) *gorm.DB {
// Open 打开主库连接
func (m *Manager) Open(ctx context.Context) error {
if m.cfg == nil {
cfg := m.getCfg() // P1 #11:锁内读取,避免与 InitDB 写竞态
if cfg == nil {
return errors.New("数据库配置未设置")
}
return m.InitDB(m.cfg)
return m.InitDB(ctx, cfg)
}
// OpenWithReplicas 打开主库与从库连接
func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) error {
if m.cfg == nil {
cfg := m.getCfg() // P1 #11:锁内读取
if cfg == nil {
return errors.New("数据库配置未设置")
}
return m.InitDBWithReplicas(m.cfg, replicaDSNs)
return m.InitDBWithReplicas(ctx, cfg, replicaDSNs)
}
// Close 关闭主库与全部从库连接
func (m *Manager) Close() error {
var errs []error
if m.master != nil {
sqlDB, err := m.master.DB()
if err != nil {
errs = append(errs, err)
} else if err := sqlDB.Close(); err != nil {
errs = append(errs, err)
}
// closeDB 关闭 gorm.DB 底层连接池。nil 或未初始化(无 ConnPool)时返回 nil,不 panic。
// 用于重建/关闭路径释放旧池,避免直接覆盖致泄漏(C11b/C11c)。
func closeDB(db *gorm.DB) error {
if db == nil {
return nil
}
for _, replica := range m.replicas {
if replica == nil {
continue
}
sqlDB, err := replica.DB()
if err != nil {
errs = append(errs, err)
continue
}
if err := sqlDB.Close(); err != nil {
errs = append(errs, err)
}
}
m.master = nil
m.replicas = nil
return errors.Join(errs...)
}
// HealthCheck 健康检查,主库不可达时返回错误
func (m *Manager) HealthCheck(ctx context.Context) error {
if m.master == nil {
return errors.New("database master not initialized")
}
sqlDB, err := m.master.DB()
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.PingContext(ctx)
return sqlDB.Close()
}
// DefaultManager 默认数据库管理器
var DefaultManager = &Manager{picker: &RandomPicker{}}
func warnCloseDB(db *gorm.DB, context string) {
if err := closeDB(db); err != nil {
logger.Warnf("%s: %v", context, err)
}
}
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
func (m *Manager) InitDB(cfg *config.Config) error {
var err error
m.cfg = cfg
// Close 关闭主库与全部从库连接,并重置从库健康状态。
// 字段置空在锁内完成(保证新读取得到 nil),实际关闭在锁外执行避免持锁阻塞。
func (m *Manager) Close() error {
m.opMu.Lock()
defer m.opMu.Unlock()
m.mu.Lock()
master := m.master
replicas := m.replicas
m.master = nil
m.replicas = nil
m.resetReplicaHealth()
m.healthy.Store(false)
m.mu.Unlock()
var errs []error
if err := closeDB(master); err != nil {
errs = append(errs, err)
}
for _, replica := range replicas {
if err := closeDB(replica); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// HealthCheck 健康检查,主库不可达时返回错误。
//
// M11 完整覆盖(H-db-1 修复):ping 经 pingWithTimeout 受 healthCheckTimeout(3s) 约束,
// 使后台探活(probeOnce)与 /health 端点(app.go 经本方法)都不会被挂起 DB(连接活但不
// 响应)无限阻塞。ctx 自带更短 deadline 时优先尊重 ctx。
func (m *Manager) HealthCheck(ctx context.Context) error {
ctx = normalizeContext(ctx)
m.mu.Lock()
db := m.master
m.mu.Unlock()
if db == nil {
return errors.New("数据库主库未初始化")
}
sqlDB, err := db.DB()
if err != nil {
return err
}
return pingWithTimeout(sqlDB, ctx)
}
// DefaultManager 默认数据库管理器,包级 facade 代理到它。
//
// 主线A 修复:改用 atomic.Pointer 保护读写,消除原裸指针(外部直接
// `database.DefaultManager = ...` 赋值与请求 goroutine 经 facade 读取)之间的数据竞争。
// 与 config.defaultManager / database.DefaultRedis / storage.DefaultStorage /
// cache.defaultCachePtr / jwt.defaultManager 对齐——框架内包级可变全局一律 atomic.Pointer。
//
// 类型由 *Manager 变更为 atomic.Pointer[Manager]breaking):下游若直接调用
// DefaultManager.Init/Master 等方法需改用 InitDB/GetDB 等 facade,或
// DefaultManager.Load().Init(...),或经 GetDefaultManager() 取实例后再调方法。
var DefaultManager atomic.Pointer[Manager]
func init() {
DefaultManager.Store(NewManager(nil))
}
// SwapDefaultManager 将指定 Manager 置为全局默认,并返回被替换的旧 Manager。
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存。
// nil 被忽略,以防 facade Load 到 nil panic。
func SwapDefaultManager(m *Manager) *Manager {
if m == nil {
return DefaultManager.Load()
}
return DefaultManager.Swap(m)
}
// SetDefaultManager 提升指定 Manager 为全局默认,并关闭被替换的旧 Manager。
// 用于多实例场景或测试注入。nil 被忽略以防 facade Load 到 nil panic。
// 若调用方需要保留旧 Manager 用于回滚,请使用 SwapDefaultManager。
func SetDefaultManager(m *Manager) {
if m == nil {
return
}
old := SwapDefaultManager(m)
if old != nil && old != m {
if err := old.Close(); err != nil {
logger.Warnf("关闭被替换的旧数据库 manager 失败: %v", err)
}
}
}
// GetDefaultManager 返回全局默认 Manageratomic 读取,并发安全)。
// 替代直接读 DefaultManager 包级变量(类型已改为 atomic.Pointer,直接读得到的是 atomic
// 值而非 *Manager)。需直接持有 Manager 调用其方法时用本函数或 DefaultManager.Load()。
func GetDefaultManager() *Manager {
return DefaultManager.Load()
}
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。
// ctx 控制 Ping 与重试等待;调用方取消 ctx 时初始化会尽快返回。
func (m *Manager) InitDB(ctx context.Context, cfg *config.Config) error {
ctx = normalizeContext(ctx)
m.opMu.Lock()
defer m.opMu.Unlock()
return m.initDB(ctx, cfg)
}
func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error {
if cfg == nil {
return errors.New("数据库配置未设置")
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("数据库初始化已取消: %w", err)
}
m.setCfg(cfg) // P1 #11:锁内写入,避免与 StartProbing/Open 读竞态
// GORM 日志配置
var gormLogLevel gormlogger.LogLevel
@@ -336,6 +496,17 @@ func (m *Manager) InitDB(cfg *config.Config) error {
gormConfig := &gorm.Config{
Logger: gormlogger.Default.LogMode(gormLogLevel),
// H-db-1:禁用 gorm.Open 的自动 Pinggorm.go:204ConnPool 为 *sql.DB 时会调
// pinger.Ping() 无超时)。挂起 DB(连接活但不响应)下该 Ping 无 ctx deadline 无限阻塞,
// 发生在 initDB 的 pingWithTimeout 之前,使启动卡死。框架改用 pingWithTimeout3s)自管
// 启动 ping,故禁用 gorm 无超时自动 ping。InitDBWithReplicas 的 replica 路径同理。
DisableAutomaticPing: true,
}
// M-config-2MySQL 启用 TLS 且配置自定义 CA 时,注册命名 TLS 配置,使 DSN 中 tls=<name> 生效。
// 失败 fail-fast 返回错误,绝不静默回退明文连接。非 MySQL / 未配 CA 为 no-op。
if err := ensureMySQLTLSRegistered(cfg); err != nil {
return err
}
// 重试配置
@@ -344,20 +515,40 @@ func (m *Manager) InitDB(cfg *config.Config) error {
var lastErr error
for i := range maxRetries {
// 连接主库
m.master, err = gorm.Open(Dialector(cfg), gormConfig)
if err == nil {
sqlDB, err := m.master.DB()
if err == nil {
if err := ctx.Err(); err != nil {
return fmt.Errorf("数据库初始化已取消: %w", err)
}
// 连接主库:先打开到局部变量,仅 Ping 成功后才安装为 m.master
// 避免 Ping 失败时下轮覆盖 m.master 泄漏旧池(C11b)。
db, err := gorm.Open(Dialector(cfg), gormConfig)
if err != nil {
lastErr = err
// 不可恢复的错误(认证失败、未知数据库、DSN 非法等)直接返回,不必重试
if !isTransientDBError(err) {
return fmt.Errorf("数据库连接失败(不可恢复): %w", err)
}
} else {
sqlDB, err := db.DB()
if err != nil {
lastErr = err
warnCloseDB(db, "关闭刚打开的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮泄漏
} else {
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
sqlDB.SetConnMaxLifetime(time.Hour)
if cfg.Database.ConnMaxIdleTime > 0 {
sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime)
}
m.healthy.Store(true) // 主库连通即标记健康(#21
if err := sqlDB.Ping(); err == nil {
// M11H-db-1):启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 致 InitDB 重试失败而非无限阻塞。
if err := pingWithTimeout(sqlDB, ctx); err == nil {
// 成功:安装为新主库,关闭旧主库池(重建路径覆盖前先释放旧资源,C11b)
m.mu.Lock()
old := m.master
m.master = db
m.mu.Unlock()
m.healthy.Store(true) // Ping 通过才标记健康(#21)
warnCloseDB(old, "关闭旧数据库主库连接池失败")
logger.Info("数据库主库连接成功",
zap.String("driver", driverDescription(cfg.Database.Driver)),
zap.String("host", cfg.Database.Host),
@@ -366,20 +557,20 @@ func (m *Manager) InitDB(cfg *config.Config) error {
} else {
// Ping 失败(如服务端暂时不可达)视作可重试
lastErr = err
warnCloseDB(db, "关闭 Ping 失败的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮覆盖泄漏
}
} else {
lastErr = err
}
} else {
lastErr = err
// 不可恢复的错误(认证失败、未知数据库、DSN 非法等)直接返回,不必重试
if !isTransientDBError(err) {
return fmt.Errorf("数据库连接失败(不可恢复): %w", err)
}
}
logger.Warnf("数据库连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, lastErr)
time.Sleep(retryDelay)
if i == maxRetries-1 {
break
}
select {
case <-ctx.Done():
return fmt.Errorf("数据库初始化已取消: %w", ctx.Err())
case <-time.After(retryDelay):
}
retryDelay *= 2
if retryDelay > 30*time.Second {
retryDelay = 30 * time.Second
@@ -391,18 +582,26 @@ 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 修复;P1 #12:移除过宽的独立 "database" 子串——它会把
// "the database system is starting up" 等瞬态错误误判为非瞬态而放弃重试。
// PG 目标库不存在的消息形如 database "x" does not exist,用 "does not exist" 精确匹配)。
"password authentication failed", // pg 密码错误
"does not exist", // pg 目标库/角色不存在
"no pg_hba.conf entry", // pg_hba.conf 拒绝
}
for _, sub := range nonTransient {
if strings.Contains(msg, sub) {
@@ -412,15 +611,43 @@ func isTransientDBError(err error) bool {
return true
}
// replicaMaxOpenConns 计算从库连接池 MaxOpenConnsH-A 修复)。
// masterMax>0 时取 max(1, masterMax/2)(从库适当减少,但绝不截断为 0);
// masterMax<=0(未配置/无限)时返回 0,与主库"无限"语义一致。
func replicaMaxOpenConns(masterMax int) int {
if masterMax <= 0 {
return 0
}
half := masterMax / 2
if half < 1 {
half = 1
}
return half
}
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
// replicaDSNs: 从库连接字符串列表(需与主库驱动匹配)
func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error {
func (m *Manager) InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error {
ctx = normalizeContext(ctx)
m.opMu.Lock()
defer m.opMu.Unlock()
if cfg == nil {
return errors.New("数据库配置未设置")
}
// 先初始化主库
if err := m.InitDB(cfg); err != nil {
if err := m.initDB(ctx, cfg); err != nil {
return err
}
// C11c: 重建从库前关闭旧从库池;C11a: 重置健康状态,使下次 initReplicaHealth 按新 replicas 长度重建
m.mu.Lock()
oldReplicas := m.replicas
m.replicas = nil
m.resetReplicaHealth()
m.mu.Unlock()
for _, r := range oldReplicas {
warnCloseDB(r, "关闭旧数据库从库连接池失败")
}
// 初始化从库
if len(replicaDSNs) > 0 {
@@ -433,9 +660,19 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
gormConfig := &gorm.Config{
Logger: gormlogger.Default.LogMode(gormLogLevel),
// H-db-1:禁用 gorm.Open 自动 Ping(同 initDB),框架用 pingWithTimeout 自管 replica 启动 ping。
DisableAutomaticPing: true,
}
// 先构建到局部切片,全部成功后再安装,避免部分构建期间外部读到中间态
var newReplicas []*gorm.DB
for i, dsn := range replicaDSNs {
if err := ctx.Err(); err != nil {
for _, r := range newReplicas {
warnCloseDB(r, "关闭已打开的数据库从库连接池失败")
}
return fmt.Errorf("数据库从库初始化已取消: %w", err)
}
replicaDB, err := gorm.Open(dialectorForDSN(cfg.Database.Driver, dsn), gormConfig)
if err != nil {
logger.Warnf("数据库从库 %d 连接失败: %v", i+1, err)
@@ -445,74 +682,118 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
sqlDB, err := replicaDB.DB()
if err != nil {
logger.Warnf("数据库从库 %d 获取连接池失败: %v", i+1, err)
warnCloseDB(replicaDB, "关闭刚打开的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
continue
}
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns / 2) // 从库连接数可适当减少
// H-A 修复:从库 MaxOpenConns 适当减少,但须避免截断为 0。
// database/sql 中 SetMaxOpenConns(0) 表示"无限制"——原实现 MaxOpenConns/2 在
// 配置为 1 时得 0,反而让从库连接池无上限(与"减少"意图相反、高并发下打爆 DB);
// 配置为 0(未配置/无限)时 0/2=0 恰好"无限",与主库一致,保持语义。
// 现规则:MaxOpenConns>0 时取 max(1, /2)<=0 时从库亦无限(0),与主库对齐。
sqlDB.SetMaxOpenConns(replicaMaxOpenConns(cfg.Database.MaxOpenConns))
sqlDB.SetConnMaxLifetime(time.Hour)
if err := sqlDB.Ping(); err != nil {
// M11H-db-1):从库启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞 InitDBWithReplicas。
if err := pingWithTimeout(sqlDB, ctx); err != nil {
logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err)
warnCloseDB(replicaDB, "关闭 Ping 失败的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
continue
}
m.replicas = append(m.replicas, replicaDB)
newReplicas = append(newReplicas, replicaDB)
logger.Info("数据库从库连接成功", zap.Int("index", i+1))
}
m.mu.Lock()
m.replicas = newReplicas
m.ensureReplicaHealthLocked()
m.mu.Unlock()
}
return nil
}
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
func InitDB(cfg *config.Config) error {
return DefaultManager.InitDB(cfg)
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
func InitDB(ctx context.Context, cfg *config.Config) error {
return DefaultManager.Load().InitDB(ctx, cfg)
}
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
func InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error {
return DefaultManager.InitDBWithReplicas(cfg, replicaDSNs)
func InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error {
return DefaultManager.Load().InitDBWithReplicas(ctx, cfg, replicaDSNs)
}
// GetReadDB 获取读库实例(按策略选择从库)
func GetReadDB() *gorm.DB {
return DefaultManager.Replica()
return DefaultManager.Load().Replica()
}
// GetWriteDB 获取写库实例(主库)
func GetWriteDB() *gorm.DB {
return DefaultManager.Master()
return DefaultManager.Load().Master()
}
// GetDB 获取数据库实例(默认主库,兼容旧代码)
func GetDB() *gorm.DB {
return DefaultManager.Master()
return DefaultManager.Load().Master()
}
// GetReplicas 获取所有从库实例
func GetReplicas() []*gorm.DB {
return DefaultManager.Replicas()
return DefaultManager.Load().Replicas()
}
// SetReplicaPicker 设置默认管理器的从库选择策略
func SetReplicaPicker(p ReplicaPicker) {
DefaultManager.SetPicker(p)
DefaultManager.Load().SetPicker(p)
}
// UseMaster 强制使用主库(用于事务或需要实时数据的场景)
func UseMaster(ctx context.Context) context.Context {
ctx = normalizeContext(ctx)
return context.WithValue(ctx, dbModeContextKey{}, dbModeMaster)
}
// UseReplica 强制使用从库(用于报表查询等场景)
func UseReplica(ctx context.Context) context.Context {
ctx = normalizeContext(ctx)
return context.WithValue(ctx, dbModeContextKey{}, dbModeReplica)
}
// GetDBFromContext 根据上下文选择数据库
func GetDBFromContext(ctx context.Context) *gorm.DB {
return DefaultManager.FromContext(ctx)
return DefaultManager.Load().FromContext(ctx)
}
// WithTx 将外层事务注入 ctx,使上层(如 repository.BaseRepo)在调用时能 join 到该事务
// 而非另开连接/路由到主从库(H6c)。
//
// 用法:
//
// err := database.TransactionWithContext(ctx, func(tx *gorm.DB) error {
// ctx2 := database.WithTx(ctx, tx)
// // 传 ctx2 给 repo 方法,repo 内部会优先使用该 tx
// return repo.FindByID(ctx2, id) // 此查询参与外层事务
// })
//
// 注意:tx 仅在该 ctx 的生命周期内有效;事务提交/回滚后不得再用该 ctx 携带的 tx。
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
ctx = normalizeContext(ctx)
if tx == nil {
return ctx
}
return context.WithValue(ctx, txContextKey{}, tx)
}
// TxFromContext 取出 ctx 携带的外层事务;无则返回 nil。
func TxFromContext(ctx context.Context) *gorm.DB {
ctx = normalizeContext(ctx)
if tx, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
return tx
}
return nil
}
// AutoMigrate 自动迁移数据库表结构(由应用通过 WithMigrator/WithModels 注册)
@@ -521,43 +802,39 @@ func AutoMigrate() error {
return nil
}
// Close 关闭主库连接(兼容旧代码,从库连接请使用 CloseAll
// Close 关闭所有数据库连接(主库与从库),等价于 CloseAll
// 历史上仅关闭主库、遗留从库池泄漏(C11f),已修正为委托 CloseAll。
func Close() error {
if DefaultManager.master == nil {
return nil
}
sqlDB, err := DefaultManager.master.DB()
if err != nil {
return err
}
err = sqlDB.Close()
DefaultManager.master = nil
return err
return CloseAll()
}
// CloseAll 关闭所有数据库连接(包括从库)
func CloseAll() error {
return DefaultManager.Close()
return DefaultManager.Load().Close()
}
// Transaction 事务操作(自动使用主库)
func Transaction(fn func(tx *gorm.DB) error) error {
if DefaultManager.master == nil {
db := DefaultManager.Load().Master()
if db == nil {
return errors.New("数据库未初始化")
}
return DefaultManager.master.Transaction(fn)
return db.Transaction(fn)
}
// TransactionWithContext 带上下文的事务操作
func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error {
if DefaultManager.master == nil {
ctx = normalizeContext(ctx)
db := DefaultManager.Load().Master()
if db == nil {
return errors.New("数据库未初始化")
}
return DefaultManager.master.WithContext(ctx).Transaction(fn)
return db.WithContext(ctx).Transaction(fn)
}
// ReadQuery 读查询(自动路由到从库
// ReadQuery 读查询。遵循 ctx 中的数据库路由标记;未指定时默认走从库
func ReadQuery(ctx context.Context, model any, query string, args ...any) error {
ctx = normalizeContext(ctx)
db := GetDBFromContext(ctx)
if db == nil {
return errors.New("数据库未初始化")
@@ -565,22 +842,39 @@ func ReadQuery(ctx context.Context, model any, query string, args ...any) error
return db.WithContext(ctx).Where(query, args...).Find(model).Error
}
// WriteQuery 写查询(强制使用主库)
// WriteQuery 在主库上执行查询并扫描到 model(强制主库,绕过从库延迟)。
// 注意:命名沿用历史,实际用 .Find() 扫描结果集(读取语义),并非写操作——
// 强制主库是为了读到刚写入的最新数据(read-your-writes)。命名误导见 M11。
func WriteQuery(ctx context.Context, model any, query string, args ...any) error {
if DefaultManager.master == nil {
ctx = normalizeContext(ctx)
db := DefaultManager.Load().Master()
if db == nil {
return errors.New("数据库未初始化")
}
return DefaultManager.master.WithContext(ctx).Where(query, args...).Find(model).Error
return db.WithContext(ctx).Where(query, args...).Find(model).Error
}
// HealthCheck 健康检查
// healthCheckTimeout 健康检查单次 ping 超时,避免探针被慢/挂起的 DB 长期阻塞(M11)。
const healthCheckTimeout = 3 * time.Second
// pingWithTimeout 带超时的 pingctx 由调用方传入时优先尊重其 deadline。
func pingWithTimeout(sqlDB *sql.DB, parent context.Context) error {
parent = normalizeContext(parent)
ctx, cancel := context.WithTimeout(parent, healthCheckTimeout)
defer cancel()
return sqlDB.PingContext(ctx)
}
// HealthCheck 健康检查(主库 + 从库),单次 ping 限 3s 超时(M11)。
func HealthCheck() map[string]bool {
result := make(map[string]bool)
ctx := context.Background()
m := DefaultManager.Load()
// 检查主库
if DefaultManager.master != nil {
sqlDB, err := DefaultManager.master.DB()
if err == nil && sqlDB.Ping() == nil {
if master := m.Master(); master != nil {
sqlDB, err := master.DB()
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
result["master"] = true
} else {
result["master"] = false
@@ -590,10 +884,10 @@ func HealthCheck() map[string]bool {
}
// 检查从库
for i, replica := range DefaultManager.replicas {
for i, replica := range m.Replicas() {
if replica != nil {
sqlDB, err := replica.DB()
if err == nil && sqlDB.Ping() == nil {
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
result[fmt.Sprintf("replica_%d", i+1)] = true
} else {
result[fmt.Sprintf("replica_%d", i+1)] = false
@@ -610,11 +904,11 @@ func HealthCheck() map[string]bool {
// 与 HealthCheck()(实时 ping)不同,这是后台探活维护的缓存标记,
// 供 readiness 探针快速判断是否接流量,避免每次探针都同步 ping。
func IsDBHealthy() bool {
return DefaultManager.IsHealthy()
return DefaultManager.Load().IsHealthy()
}
// StartDBProbing 启动主库/从库探活后台循环(#21)。
// 阻塞,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
func StartDBProbing(ctx context.Context) {
DefaultManager.StartProbing(ctx)
DefaultManager.Load().StartProbing(ctx)
}
+404
View File
@@ -0,0 +1,404 @@
package database
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
func init() {
// 部分用例触发 logger.Warnf 路径,确保 logger 已初始化避免 nil deref。
logger.Close()
}
// sentinelDB 返回一个可安全调用 .DB() 的 gorm.DB 占位实例。
// 直接用 &gorm.DB{} 会令内嵌 *Config 为 nil.DB() 访问提升字段 ConnPool 时 nil deref
// 这里给定非 nil Config.DB() 走到 nil ConnPool 分支返回 ErrInvalidDB 而不 panic。
func sentinelDB() *gorm.DB {
return &gorm.DB{Config: &gorm.Config{}}
}
// TestC11MasterReplicasConcurrentReadWrite 验证 Master/Replicas/Replica 全程加锁,
// 与并发重建路径(写 master/replicas)无数据竞争(C11d)。
// 修复前 Master/Replicas 为裸读,-race 必采到竞争。
func TestC11MasterReplicasConcurrentReadWrite(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
sentinels := []*gorm.DB{{}, {}, {}}
replicaSets := [][]*gorm.DB{
{sentinels[0], sentinels[1]},
{sentinels[2]},
{},
}
var wg sync.WaitGroup
stop := make(chan struct{})
// 写者:在锁内置换 master/replicas(模拟 InitDB/InitDBWithReplicas/Close 的写路径)
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
m.mu.Lock()
m.master = sentinels[i%len(sentinels)]
m.replicas = replicaSets[i%len(replicaSets)]
m.mu.Unlock()
}
}()
// 读者:并发调用读取方法
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = m.Master()
_ = m.Replicas()
_ = m.Replica()
_ = m.FromContext(context.Background())
}
}()
}
// 让竞争窗口运行一段时间,确保 -race 能采到(若有未加锁读)
time.Sleep(50 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestC11ReplicasReturnsCopy 验证 Replicas 返回拷贝,调用方修改不影响内部状态(C11d)。
func TestC11ReplicasReturnsCopy(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
m.mu.Lock()
m.replicas = []*gorm.DB{{}, {}}
m.mu.Unlock()
got := m.Replicas()
if len(got) != 2 {
t.Fatalf("expected 2 replicas, got %d", len(got))
}
got[0] = nil
m.mu.Lock()
defer m.mu.Unlock()
if m.replicas[0] == nil {
t.Fatal("Replicas should return a copy, not the live slice")
}
}
// TestC11ReplicaHealthResetOnRebuild 验证重建从库前重置健康状态,
// 使 initReplicaHealth 按新 replicas 长度重建(C11a)。
// 修复前 initReplicaHealth 的 replicaHealthSet 早返回导致健康切片与新 replicas 长度错位。
func TestC11ReplicaHealthResetOnRebuild(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
// 首轮:2 个从库 + 健康初始化
m.mu.Lock()
m.replicas = []*gorm.DB{{}, {}}
m.mu.Unlock()
m.initReplicaHealth()
if !m.replicaHealthSet || len(m.replicaHealthy) != 2 {
t.Fatalf("expected health init for 2 replicas, got set=%v len=%d", m.replicaHealthSet, len(m.replicaHealthy))
}
// 重建:3 个从库 + 重置健康状态(C11a/C11c 路径)
m.mu.Lock()
m.replicas = []*gorm.DB{{}, {}, {}}
m.resetReplicaHealth()
m.mu.Unlock()
m.initReplicaHealth()
if !m.replicaHealthSet || len(m.replicaHealthy) != 3 {
t.Fatalf("C11a: expected re-init aligned with 3 new replicas, got set=%v len=%d", m.replicaHealthSet, len(m.replicaHealthy))
}
for i := range m.replicaHealthy {
if !m.replicaHealthy[i].Load() {
t.Fatal("re-init should mark all replicas healthy")
}
}
}
func TestM4ProbeOnceReinitializesReplicaHealthAfterRebuild(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
m.mu.Lock()
m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
m.resetReplicaHealth()
m.mu.Unlock()
m.probeOnce(context.Background(), 1)
m.mu.Lock()
defer m.mu.Unlock()
if !m.replicaHealthSet {
t.Fatal("probeOnce 应在从库重建后自动重建健康标记")
}
if len(m.replicaHealthy) != 2 {
t.Fatalf("健康标记长度应与从库数量一致,实际 %d", len(m.replicaHealthy))
}
}
// TestC11ReplicaHealthStaleWithoutReset 反向验证:不调 resetReplicaHealth 时
// initReplicaHealth 早返回,健康切片长度不随新 replicas 变化(复现 C11a 缺陷根因)。
func TestC11ReplicaHealthStaleWithoutReset(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
m.mu.Lock()
m.replicas = []*gorm.DB{{}, {}}
m.mu.Unlock()
m.initReplicaHealth()
// 仅换 replicas 不重置健康状态
m.mu.Lock()
m.replicas = []*gorm.DB{{}, {}, {}}
m.mu.Unlock()
m.initReplicaHealth() // replicaHealthSet 仍为 true → 早返回
m.mu.Lock()
defer m.mu.Unlock()
if len(m.replicaHealthy) == 3 {
t.Fatal("without reset, initReplicaHealth should NOT re-align (early-returns); this proves reset is required")
}
if len(m.replicaHealthy) != 2 {
t.Fatalf("expected stale len=2, got %d", len(m.replicaHealthy))
}
}
// TestC11ManagerCloseResetsState 验证 Close 清空 master/replicas 并重置健康状态(C11a/C11c/C11d)。
func TestC11ManagerCloseResetsState(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
m.mu.Lock()
m.master = sentinelDB()
m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
m.replicaHealthy = make([]atomic.Bool, 2)
m.replicaHealthy[0].Store(true)
m.replicaHealthy[1].Store(true)
m.replicaHealthSet = true
m.mu.Unlock()
m.healthy.Store(true)
// closeDB 对空 gorm.DB{}(无 ConnPool)返回 ErrInvalidDBClose 收集后 join 返回;
// 这里不关心关闭错误,只断言状态被重置。
_ = m.Close()
m.mu.Lock()
defer m.mu.Unlock()
if m.master != nil {
t.Fatal("expected master nil after Close")
}
if m.replicas != nil {
t.Fatal("expected replicas nil after Close")
}
if m.replicaHealthSet {
t.Fatal("expected replicaHealthSet reset after Close")
}
if m.replicaHealthy != nil {
t.Fatal("expected replicaHealthy nil after Close")
}
if m.healthy.Load() {
t.Fatal("expected healthy=false after Close")
}
}
// TestC11PackageCloseClosesReplicas 验证包级 Close 委托 CloseAll
// 关闭从库而非仅主库(C11f)。修复前包级 Close 仅关 masterreplicas 残留。
func TestC11PackageCloseClosesReplicas(t *testing.T) {
// 保存并恢复 DefaultManager 状态,避免污染其他用例
defer func() {
DefaultManager.Load().mu.Lock()
DefaultManager.Load().master = nil
DefaultManager.Load().replicas = nil
DefaultManager.Load().resetReplicaHealth()
DefaultManager.Load().healthy.Store(false)
DefaultManager.Load().mu.Unlock()
}()
DefaultManager.Load().mu.Lock()
DefaultManager.Load().master = sentinelDB()
DefaultManager.Load().replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
DefaultManager.Load().mu.Unlock()
// 包级 Close → CloseAll → DefaultManager.Load().Close(),应同时清空 master 与 replicas
_ = Close()
DefaultManager.Load().mu.Lock()
master := DefaultManager.Load().master
repl := DefaultManager.Load().replicas
DefaultManager.Load().mu.Unlock()
if master != nil {
t.Fatal("C11f: expected master nil after package Close")
}
if repl != nil {
t.Fatal("C11f: package Close should close replicas too (delegates to CloseAll), got residual replicas")
}
}
// TestC11HealthCheckLockedRead 验证 HealthCheck 在锁内快照 master,未初始化时返错(C11d)。
func TestC11HealthCheckLockedRead(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
if err := m.HealthCheck(context.Background()); err == nil {
t.Fatal("expected error when health checking uninitialized master")
}
// 空 gorm.DB(无 ConnPool):DB() 返 ErrInvalidDBHealthCheck 返该错而非 panic
m.mu.Lock()
m.master = sentinelDB()
m.mu.Unlock()
if err := m.HealthCheck(context.Background()); err == nil {
t.Fatal("expected error for gorm.DB without ConnPool")
}
}
func TestSetDefaultManagerClosesReplacedManager(t *testing.T) {
old := NewManager(nil)
old.mu.Lock()
old.master = sentinelDB()
old.replicas = []*gorm.DB{sentinelDB()}
old.replicaHealthy = make([]atomic.Bool, 1)
old.replicaHealthy[0].Store(true)
old.replicaHealthSet = true
old.mu.Unlock()
old.healthy.Store(true)
orig := SwapDefaultManager(old)
t.Cleanup(func() {
current := SwapDefaultManager(orig)
if current != nil && current != orig {
_ = current.Close()
}
})
next := NewManager(nil)
SetDefaultManager(next)
if GetDefaultManager() != next {
t.Fatal("SetDefaultManager 应安装新的默认 manager")
}
old.mu.Lock()
defer old.mu.Unlock()
if old.master != nil || old.replicas != nil || old.replicaHealthy != nil || old.replicaHealthSet {
t.Fatal("SetDefaultManager 应关闭并清空被替换的旧 manager,避免连接池泄漏")
}
if old.healthy.Load() {
t.Fatal("SetDefaultManager 关闭旧 manager 后健康状态应为 false")
}
}
func TestSwapDefaultManagerPreservesReplacedManager(t *testing.T) {
old := NewManager(nil)
old.mu.Lock()
old.master = sentinelDB()
old.replicas = []*gorm.DB{sentinelDB()}
old.mu.Unlock()
orig := SwapDefaultManager(old)
t.Cleanup(func() {
current := SwapDefaultManager(orig)
if current != nil && current != orig {
_ = current.Close()
}
_ = old.Close()
})
next := NewManager(nil)
previous := SwapDefaultManager(next)
if previous != old {
t.Fatal("SwapDefaultManager 应返回被替换的旧 manager")
}
if GetDefaultManager() != next {
t.Fatal("SwapDefaultManager 应安装新的默认 manager")
}
old.mu.Lock()
defer old.mu.Unlock()
if old.master == nil || len(old.replicas) != 1 {
t.Fatal("SwapDefaultManager 不应关闭旧 manager,旧资源需可用于失败回滚")
}
}
type transientDialector struct{}
func (d transientDialector) Name() string { return "m4_transient" }
func (d transientDialector) Initialize(*gorm.DB) error {
return errors.New("temporary connection failure")
}
func (d transientDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
func (d transientDialector) DataTypeOf(*schema.Field) string { return "" }
func (d transientDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
func (d transientDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
func (d transientDialector) QuoteTo(writer clause.Writer, str string) {
_, _ = writer.WriteString(str)
}
func (d transientDialector) Explain(sql string, _ ...any) string { return sql }
func TestM4InitDBHonorsContextDuringRetrySleep(t *testing.T) {
const driver = "m4_transient_retry"
RegisterDialect(DialectSpec{
Name: driver,
Dialector: func(string) gorm.Dialector { return transientDialector{} },
DSN: func(*config.DatabaseConfig) string { return "m4://retry" },
})
m := NewManager(nil)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(30 * time.Millisecond)
cancel()
}()
start := time.Now()
err := m.InitDB(ctx, &config.Config{Database: config.DatabaseConfig{Driver: driver}})
if err == nil || !errors.Is(err, context.Canceled) {
t.Fatalf("InitDB 应返回 context.Canceled,实际: %v", err)
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("InitDB 不应等待完整 retry sleep,耗时 %s", elapsed)
}
}
func TestM4CloseWaitsForLifecycleOperation(t *testing.T) {
m := NewManager(nil)
m.opMu.Lock()
done := make(chan struct{})
go func() {
_ = m.Close()
close(done)
}()
select {
case <-done:
t.Fatal("Close 不应越过正在执行的生命周期操作")
case <-time.After(50 * time.Millisecond):
}
m.opMu.Unlock()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("释放生命周期锁后 Close 未返回")
}
}
+199
View File
@@ -0,0 +1,199 @@
package database
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// hungDriver 是测试用 database/sql 驱动,其 Conn.PingContext 阻塞直到 ctx 取消,
// 模拟"挂起 DB"(TCP 连接活但不响应查询,区别于宕机的 connection-refused 快速失败)。
// 用于回归 H-db-1ping 路径须经 pingWithTimeout 受 healthCheckTimeout(3s) 约束,
// 挂起 DB 不得无限阻塞探活 goroutine / 启动 / /health 端点。
type hungDriver struct{}
func (hungDriver) Open(name string) (driver.Conn, error) { return hungConn{}, nil }
type hungConn struct{}
func (hungConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("hung: not implemented") }
func (hungConn) Close() error { return nil }
func (hungConn) Begin() (driver.Tx, error) { return nil, errors.New("hung: not implemented") }
// Ping 阻塞直到 ctx 取消(模拟挂起 DB 永不响应 ping)。实现 driver.Pinger 接口
// (方法名为 Ping 而非 PingContext,否则 *sql.DB.PingContext 视为 no-op 返回 nil)。
func (hungConn) Ping(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
var registerHungOnce sync.Once
func registerHungDriver() {
registerHungOnce.Do(func() { sql.Register("xlgo_hung_hdb1", hungDriver{}) })
}
func newHungSqlDB(t *testing.T) *sql.DB {
t.Helper()
registerHungDriver()
db, err := sql.Open("xlgo_hung_hdb1", "")
if err != nil {
t.Fatalf("sql.Open hung driver: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// newHungGormDB 构造底层 *sql.DB 为挂起驱动的 gorm.DB,其 DB() 返回挂起 *sql.DB。
func newHungGormDB(t *testing.T) *gorm.DB {
t.Helper()
return &gorm.DB{Config: &gorm.Config{ConnPool: newHungSqlDB(t)}}
}
// assertBoundedPing 在 max+2s 内等待 fn 返回;超时则 failH-db-1ping 应被
// pingWithTimeout 3s 约束,不应无限 hang)。返回 fn 的 error 供调用方断言。
func assertBoundedPing(t *testing.T, fn func() error, max time.Duration) error {
t.Helper()
done := make(chan error, 1)
start := time.Now()
go func() { done <- fn() }()
select {
case err := <-done:
if elapsed := time.Since(start); elapsed > max {
t.Fatalf("ping 路径耗时 %v 超过上限 %vH-db-1:应被 pingWithTimeout 3s 约束)", elapsed, max)
}
return err
case <-time.After(max + 2*time.Second):
t.Fatalf("ping 路径在挂起 DB 上无限阻塞(H-db-1pingWithTimeout 未覆盖该路径)")
return nil
}
}
// TestPingWithTimeoutBoundsHungDB_Hdb1 回归 H-db-1 机制:pingWithTimeout 对挂起 DB 的
// *sql.DB.PingContext 限 healthCheckTimeout 返回,不因 ctx 无 deadline 无限阻塞。
// 修复前若直接 PingContext(Background()) 会无限阻塞。
func TestPingWithTimeoutBoundsHungDB_Hdb1(t *testing.T) {
sqlDB := newHungSqlDB(t)
err := assertBoundedPing(t, func() error {
return pingWithTimeout(sqlDB, context.Background())
}, healthCheckTimeout+1*time.Second)
if err == nil {
t.Fatalf("挂起 DB 的 ping 应返回超时错误,got nil")
}
}
// TestManagerHealthCheckBoundsHungDB_Hdb1 回归 H-db-1 主路径:m.HealthCheck(Background)
// 对挂起主库经 pingWithTimeout ~3s 返回超时错误,不无限阻塞。该路径被后台探活
// probeOncemaster)与 /health 端点(app.go 经 dbm.HealthCheck)共用。
// 修复前 m.HealthCheck 用裸 sqlDB.PingContext(ctx)Background ctx 无 deadline -> 无限阻塞。
func TestManagerHealthCheckBoundsHungDB_Hdb1(t *testing.T) {
m := NewManager(nil)
m.master = newHungGormDB(t)
err := assertBoundedPing(t, func() error {
return m.HealthCheck(context.Background())
}, healthCheckTimeout+1*time.Second)
if err == nil {
t.Fatalf("挂起主库的 HealthCheck 应返回超时错误,got nil")
}
}
// TestProbeOnceReplicaBoundsHungDB_Hdb1 回归 H-db-1 从库探活路径:probeOnce 对挂起从库
// 经 pingWithTimeout ~3s 返回,不无限阻塞探活 goroutine(#21 自愈不冻结)。master 置 nil
// 使 HealthCheck 快速返回"未初始化",让 probeOnce 只在从库 ping 路径耗时。
// 修复前 probeOnce 从库用裸 sqlDB.PingContext(ctx)Background ctx 无 deadline -> 无限阻塞。
func TestProbeOnceReplicaBoundsHungDB_Hdb1(t *testing.T) {
m := NewManager(nil)
m.master = nil // master 路径快速失败,集中测从库 ping 超时
m.replicas = []*gorm.DB{newHungGormDB(t)}
m.replicaHealthSet = true
m.replicaHealthy = make([]atomic.Bool, 1)
m.replicaHealthy[0].Store(true)
_ = assertBoundedPing(t, func() error {
m.probeOnce(context.Background(), 3)
return nil
}, healthCheckTimeout+1*time.Second)
// 挂起从库应被标记不健康(剔除读流量,#21 自愈生效)
if m.replicaHealthy[0].Load() {
t.Errorf("挂起从库应被标记不健康(replicaHealthy=false),got true")
}
}
// hungDialector 让 gorm.Open 成功并注入挂起 *sql.DB 作为 ConnPool,使 initDB 的
// db.DB() 返回挂起 *sql.DB、pingWithTimeout 触发。用于回归启动 ping 路径。
type hungDialector struct{ sqlDB *sql.DB }
func (d hungDialector) Name() string { return "hdb1_hung" }
func (d hungDialector) Initialize(db *gorm.DB) error { db.Config.ConnPool = d.sqlDB; return nil }
func (d hungDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
func (d hungDialector) DataTypeOf(*schema.Field) string { return "" }
func (d hungDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
func (d hungDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
func (d hungDialector) QuoteTo(w clause.Writer, s string) { _, _ = w.WriteString(s) }
func (d hungDialector) Explain(sql string, _ ...any) string { return sql }
// registerHungDialect 注册挂起 dialect(幂等)。
func registerHungDialect(t *testing.T) {
t.Helper()
RegisterDialect(DialectSpec{
Name: "hdb1_hung",
Dialector: func(string) gorm.Dialector { return hungDialector{sqlDB: newHungSqlDB(t)} },
DSN: func(*config.DatabaseConfig) string { return "hdb1_hung://" },
})
}
// hungCfg 构造用挂起 dialect 的 config。
func hungCfg() *config.Config {
return &config.Config{Database: config.DatabaseConfig{Driver: "hdb1_hung"}}
}
// TestInitDBBoundsHungDB_Hdb1 回归 H-db-1 启动 master ping 路径:InitDB(Background) 对
// 挂起主库的 ping 经 pingWithTimeout 3s 失败,5 次重试后总耗时 ~15s 返回错误,不无限阻塞。
// 修复前 initDB 用裸 sqlDB.PingContext(ctx)Background 无 deadline -> 首次 ping 无限阻塞。
// 用 ctx 在首次 ping 超时后取消加速(避免 15s 全跑),同时证明 ctx 仍可中断重试循环。
func TestInitDBBoundsHungDB_Hdb1(t *testing.T) {
registerHungDialect(t)
m := NewManager(nil)
ctx, cancel := context.WithCancel(context.Background())
// 4s 后 cancel:首次 ping 经 pingWithTimeout ~3s 超时返回错误,进入重试前 ctx 已取消,
// initDB 在重试循环的 ctx.Err() 检查处快速返回。若修复前裸 PingContext(Background)
// 首次即无限阻塞,cancel 无法中断 -> assertBoundedPing 超时 fail。
go func() { time.Sleep(4 * time.Second); cancel() }()
_ = assertBoundedPing(t, func() error {
return m.InitDB(ctx, hungCfg())
}, 6*time.Second)
}
// TestInitDBWithReplicasBoundsHungDB_Hdb1 回归 H-db-1 启动 replica ping 路径:
// InitDBWithReplicas 对挂起 replica DSN 的 ping 经 pingWithTimeout 失败(replica 被 continue
// 跳过),主库经挂起 dialect 同样 ping 失败。整流程有界返回,不无限阻塞。
// 修复前 replica 启动 ping 用裸 PingContext -> 挂起 replica 无限阻塞 InitDBWithReplicas。
func TestInitDBWithReplicasBoundsHungDB_Hdb1(t *testing.T) {
registerHungDialect(t)
m := NewManager(nil)
// 用可取消 ctx 限制总时长;主库挂起 ping ~3s 失败后 InitDBWithReplicas 直接返回错误
// (主库失败不进入 replica 初始化)。此处验证主库 ping 路径有界即可覆盖启动 ping 约束。
ctx, cancel := context.WithCancel(context.Background())
go func() { time.Sleep(4 * time.Second); cancel() }()
err := assertBoundedPing(t, func() error {
return m.InitDBWithReplicas(ctx, hungCfg(), []string{"hdb1_hung://replica"})
}, 6*time.Second)
if err == nil {
t.Fatalf("挂起主库的 InitDBWithReplicas 应返回错误,got nil")
}
}
+72 -11
View File
@@ -2,6 +2,7 @@ package database_test
import (
"context"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -106,11 +107,67 @@ func TestManagerSetPicker(t *testing.T) {
}
func TestDefaultManagerHealthCheckWithoutInit(t *testing.T) {
if err := database.DefaultManager.HealthCheck(context.Background()); err == nil {
if err := database.GetDefaultManager().HealthCheck(context.Background()); err == nil {
t.Fatal("expected error when health checking uninitialized master")
}
}
func TestNilContextHelpersDoNotPanic(t *testing.T) {
_ = database.CloseAll()
if ctx := database.UseMaster(nil); ctx == nil {
t.Fatal("UseMaster(nil) 应返回可用 context")
}
if ctx := database.UseReplica(nil); ctx == nil {
t.Fatal("UseReplica(nil) 应返回可用 context")
}
if db := database.GetDBFromContext(nil); db != nil {
t.Fatal("未初始化数据库时 GetDBFromContext(nil) 应返回 nil")
}
if ctx := database.WithTx(nil, nil); ctx == nil {
t.Fatal("WithTx(nil, nil) 应返回可用 context")
}
if tx := database.TxFromContext(nil); tx != nil {
t.Fatal("TxFromContext(nil) 应返回 nil")
}
if err := database.TransactionWithContext(nil, func(tx *gorm.DB) error { return nil }); err == nil {
t.Fatal("未初始化数据库时 TransactionWithContext(nil) 应返回错误")
}
if err := database.ReadQuery(nil, &struct{}{}, "1=1"); err == nil {
t.Fatal("未初始化数据库时 ReadQuery(nil) 应返回错误")
}
if err := database.WriteQuery(nil, &struct{}{}, "1=1"); err == nil {
t.Fatal("未初始化数据库时 WriteQuery(nil) 应返回错误")
}
if got := database.HealthCheck(); !got["master"] {
// HealthCheck 内部使用 background context;未初始化时 master=false 即可。
return
}
t.Fatal("未初始化数据库时 HealthCheck master 不应为 true")
}
func TestNilConfigInitializationReturnsError(t *testing.T) {
mgr := database.NewManager(nil)
if err := mgr.InitDB(context.Background(), nil); err == nil {
t.Fatal("InitDB(nil) 应返回错误")
}
if err := mgr.InitDBWithReplicas(context.Background(), nil, nil); err == nil {
t.Fatal("InitDBWithReplicas(nil) 应返回错误")
}
if err := database.InitDB(context.Background(), nil); err == nil {
t.Fatal("包级 InitDB(nil) 应返回错误")
}
if err := database.InitDBWithReplicas(context.Background(), nil, nil); err == nil {
t.Fatal("包级 InitDBWithReplicas(nil) 应返回错误")
}
}
func TestDialectorNilConfigDoesNotPanic(t *testing.T) {
if d := database.Dialector(nil); d == nil {
t.Fatal("Dialector(nil) 应返回兜底 dialector")
}
}
func TestDialectorSelectsByDriver(t *testing.T) {
mysqlCfg := &config.Config{Database: config.DatabaseConfig{
Driver: config.DriverMySQL, Host: "localhost", Port: 3306,
@@ -149,14 +206,14 @@ func TestDialectorSelectsByDriver(t *testing.T) {
// stubDialector 是一个用于测试 RegisterDialect 的占位 Dialector。
type stubDialector struct{ name string }
func (s stubDialector) Name() string { return s.name }
func (s stubDialector) Initialize(_ *gorm.DB) error { return nil }
func (s stubDialector) Migrator(db *gorm.DB) gorm.Migrator { return nil }
func (s stubDialector) DataTypeOf(*schema.Field) string { return "" }
func (s stubDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
func (s stubDialector) Name() string { return s.name }
func (s stubDialector) Initialize(_ *gorm.DB) error { return nil }
func (s stubDialector) Migrator(db *gorm.DB) gorm.Migrator { return nil }
func (s stubDialector) DataTypeOf(*schema.Field) string { return "" }
func (s stubDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
func (s stubDialector) BindVarTo(writer clause.Writer, _ *gorm.Statement, _ any) {}
func (s stubDialector) QuoteTo(writer clause.Writer, str string) { _, _ = writer.WriteString(str) }
func (s stubDialector) Explain(sql string, _ ...any) string { return sql }
func (s stubDialector) QuoteTo(writer clause.Writer, str string) { _, _ = writer.WriteString(str) }
func (s stubDialector) Explain(sql string, _ ...any) string { return sql }
func TestRegisterDialectAndCustomDriver(t *testing.T) {
const driver = "stubdb"
@@ -190,13 +247,17 @@ func TestRegisterDialectAndCustomDriver(t *testing.T) {
t.Fatalf("expected DSN built by registered builder, got %q", dsn)
}
// 未知驱动回退到 mysql
// 未知驱动应 fail-closed,避免拼写错误静默连向 MySQL。
unknownCfg := &config.Config{Database: config.DatabaseConfig{
Driver: "no-such-driver", Host: "localhost", Port: 3306,
User: "root", Password: "pass", Name: "db",
}}
if name := database.Dialector(unknownCfg).Name(); name != "mysql" {
t.Fatalf("expected fallback to mysql for unknown driver, got %q", name)
d := database.Dialector(unknownCfg)
if name := d.Name(); name != "invalid" {
t.Fatalf("expected invalid dialector for unknown driver, got %q", name)
}
if _, err := gorm.Open(d, &gorm.Config{}); err == nil || !strings.Contains(err.Error(), "数据库驱动未注册") {
t.Fatalf("未知 driver 应在 GORM 初始化时报错,实际: %v", err)
}
}
+102 -25
View File
@@ -2,8 +2,11 @@ package database
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
@@ -12,10 +15,6 @@ import (
"go.uber.org/zap"
)
// RedisClient 全局 Redis 客户端(兼容 facade,由 RedisManager.Init 同步维护)。
// 保留供存量代码直接访问;新代码建议用 GetRedis() 或持有 *RedisManager 实例。
var RedisClient *redis.Client
// RedisManager Redis 连接管理器(#10)。照 database.Manager 模式:
// 实例化 + DefaultRedis 全局默认 + 包级 facade 代理,支持多实例与测试注入。
type RedisManager struct {
@@ -25,39 +24,90 @@ type RedisManager struct {
}
// DefaultRedis 默认 Redis 管理器,包级 facade 代理到它。
var DefaultRedis = NewRedisManager()
//
// C-1/H-4 修复:改用 atomic.Pointer 保护读写,消除原裸指针置换(SetDefaultRedisManager
// 与请求 goroutine 无锁读(GetRedis 等 facade)之间的数据竞争。与 config.defaultManager
// 对齐。类型由 *RedisManager 变更为 atomic.Pointer[RedisManager]breaking:下游若直接
// 调用 DefaultRedis.Init 等方法需改用 InitRedis 等 facade,或 DefaultRedis.Load().Init)。
var DefaultRedis atomic.Pointer[RedisManager]
func init() {
DefaultRedis.Store(NewRedisManager())
}
// NewRedisManager 创建 Redis 管理器实例。
func NewRedisManager() *RedisManager { return &RedisManager{} }
// SetDefaultRedisManager 提升指定 RedisManager 为全局默认,后续包级 facade 走它
// 用于多实例场景或测试注入 mock
func SetDefaultRedisManager(m *RedisManager) {
if m != nil {
DefaultRedis = m
// SwapDefaultRedisManager 指定 RedisManager 为全局默认,并返回被替换的旧 Manager
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存
// nil 被忽略,以防 facade Load 到 nil panic。
func SwapDefaultRedisManager(m *RedisManager) *RedisManager {
if m == nil {
return DefaultRedis.Load()
}
return DefaultRedis.Swap(m)
}
// SetDefaultRedisManager 提升指定 RedisManager 为全局默认,并关闭被替换的旧 Manager。
// 用于多实例场景或测试注入 mock。并发安全。若调用方需要保留旧 Manager
// 用于回滚,请使用 SwapDefaultRedisManager。
func SetDefaultRedisManager(m *RedisManager) {
if m == nil {
return
}
old := SwapDefaultRedisManager(m)
if old != nil && old != m {
if err := old.Close(); err != nil {
logger.Warnf("关闭被替换的旧 Redis manager 失败: %v", err)
}
}
}
// GetDefaultRedisManager 返回当前默认 Redis 管理器。
func GetDefaultRedisManager() *RedisManager {
return DefaultRedis.Load()
}
// newRedisClient 构造带 D7 超时(Dial 5s / Read 3s / Write 3s)的 redis.Client。
// 由 Init 与测试共用,确保所有 client 实例一致具备超时约束(挂起 Redis 不无限阻塞)。
func newRedisClient(addr, password string, db int) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
DialTimeout: 5 * time.Second, // D7 修复:连接超时
ReadTimeout: 3 * time.Second, // D7 修复:读超时(约束 HealthCheck Ping 不被挂起 Redis 阻塞)
WriteTimeout: 3 * time.Second, // D7 修复:写超时
})
}
// Init 初始化 Redis 连接并 ping 验证。
func (m *RedisManager) Init(cfg *config.Config) error {
if cfg == nil {
return errors.New("Redis 配置未设置")
}
m.mu.Lock()
defer m.mu.Unlock()
client := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr(),
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
})
client := newRedisClient(cfg.Redis.Addr(), cfg.Redis.Password, cfg.Redis.DB)
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
client.Close()
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(pingCtx).Err(); err != nil {
if cerr := client.Close(); cerr != nil {
return errors.Join(fmt.Errorf("Redis 连接失败: %w", err), fmt.Errorf("Redis 关闭失败: %w", cerr))
}
return fmt.Errorf("Redis 连接失败: %w", err)
}
old := m.client
m.cfg = cfg
m.client = client
RedisClient = client // 同步兼容 facade
if old != nil {
if err := old.Close(); err != nil {
logger.Warnf("关闭旧 Redis 连接失败: %v", err)
}
}
logger.Info("Redis 连接成功", zap.String("addr", cfg.Redis.Addr()))
return nil
}
@@ -72,7 +122,6 @@ func (m *RedisManager) Close() error {
}
err := m.client.Close()
m.client = nil
RedisClient = nil
return err
}
@@ -83,8 +132,19 @@ func (m *RedisManager) Client() *redis.Client {
return m.client
}
// setClientForTest 仅测试用:在持锁下替换 manager 的 client,返回旧 client。
// 供 SetTestRedisClient 注入 miniredis 等 mock,消除原包级 redisClient 双源真相。
func (m *RedisManager) setClientForTest(c *redis.Client) *redis.Client {
m.mu.Lock()
defer m.mu.Unlock()
old := m.client
m.client = c
return old
}
// HealthCheck Redis 健康检查。
func (m *RedisManager) HealthCheck(ctx context.Context) error {
ctx = normalizeContext(ctx)
m.mu.Lock()
client := m.client
m.mu.Unlock()
@@ -98,20 +158,37 @@ func (m *RedisManager) HealthCheck(ctx context.Context) error {
// InitRedis 初始化 Redis 连接
func InitRedis(cfg *config.Config) error {
return DefaultRedis.Init(cfg)
return DefaultRedis.Load().Init(cfg)
}
// CloseRedis 关闭 Redis 连接
func CloseRedis() error {
return DefaultRedis.Close()
return DefaultRedis.Load().Close()
}
// HealthCheckRedis Redis 健康检查
func HealthCheckRedis(ctx context.Context) error {
return DefaultRedis.HealthCheck(ctx)
return DefaultRedis.Load().HealthCheck(ctx)
}
// GetRedis 获取 Redis 客户端
// GetRedis 获取 Redis 客户端(未初始化返回 nil)。
//
// H-4 修复:单源——仅从 DefaultRedis.Load().Client() 取,废弃原包级 redisClient
// 回退路径(其在 manager 替换后会返回 stale client,且无锁读存在竞态)。
// 测试注入的 mock client 经 SetTestRedisClient 直接设在当前 manager 上,闭环一致。
func GetRedis() *redis.Client {
return DefaultRedis.Client()
return DefaultRedis.Load().Client()
}
// SetTestRedisClient 供测试注入 miniredis 等 mock 客户端。
// 返回旧客户端引用以便测试清理时恢复。生产代码严禁调用。
//
// H-4 修复:改为在当前默认 manager 上持锁替换 clientsetClientForTest),
// 不再写独立的包级 redisClient 变量,消除双源真相与无锁写竞态。
//
// 约束:操作 DefaultRedis.Load() 返回的当前默认 manager。测试应避免在调用本函数
// 的同时并发 SetDefaultRedisManager 替换默认 manager,否则注入/恢复会作用到不同
// manager 上。典型用法为 init 阶段注入、t.Cleanup 恢复,串行执行。
func SetTestRedisClient(c *redis.Client) *redis.Client {
return DefaultRedis.Load().setClientForTest(c)
}
+84
View File
@@ -0,0 +1,84 @@
package database
import (
"context"
"net"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
// startHungRedisListener 启动一个接受 TCP 连接但不读写任何数据的 listener,
// 模拟"挂起 Redis"(连接建立但不响应 RESP,区别于宕机的 connection-refused)。
// 返回 listener 地址;t.Cleanup 关闭 listener。
func startHungRedisListener(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("hung redis listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return // listener 关闭
}
// 故意不读写、不关闭,持住连接模拟挂起。连接由 t.Cleanup 的 ln.Close 间接清理。
_ = conn
}
}()
return ln.Addr().String()
}
// TestRedisHealthCheckBoundsHungRedis_Hdb1 回归 H-db-1 边界(Redis 侧):
// RedisManager.HealthCheck(Background) 对挂起 Redis(连接活但不响应)应受
// redis client 的 ReadTimeout(3sredis.go D7) 约束有界返回错误,不无限阻塞。
// ctx 无 deadline 时由 client ReadTimeout 兜底;ctx 自带更短 deadline 时优先尊重 ctx。
// 修复前/缺 ReadTimeout 时 Ping(Background) 会无限阻塞。
func TestRedisHealthCheckBoundsHungRedis_Hdb1(t *testing.T) {
addr := startHungRedisListener(t)
cfg := redisTestConfig(t, addr)
m := NewRedisManager()
if err := m.Init(cfg); err == nil {
// Init 的 Ping 有 5s ctx,挂起 Redis 下应在 ~3sReadTimeout)失败返回错误。
t.Fatalf("挂起 Redis 的 Init 应返回错误,got nil")
}
// Init 失败后 m.client 未被安装(nil),HealthCheck 返 "Redis 未初始化"。
// 为测 HealthCheck 自身的 ping 超时约束,需 client 已安装但指向挂起 Redis。
// 用 setClientForTest 注入一个指向挂起 Redis 的 client。
client := newRedisClientForTest(addr)
old := m.setClientForTest(client)
t.Cleanup(func() {
if old != nil {
_ = old.Close()
}
_ = client.Close()
})
// HealthCheck(Background):挂起 Redis 下 client.Ping 受 ReadTimeout 3s 约束有界返回。
done := make(chan error, 1)
start := time.Now()
go func() { done <- m.HealthCheck(context.Background()) }()
select {
case err := <-done:
elapsed := time.Since(start)
if err == nil {
t.Fatalf("挂起 Redis 的 HealthCheck 应返回错误,got nil")
}
// 应在 ReadTimeout(3s) 附近返回,给 6s 上限(含连接/重试余量)。
if elapsed > 6*time.Second {
t.Fatalf("HealthCheck 耗时 %v 超过 6s 上限(应受 ReadTimeout 3s 约束)", elapsed)
}
case <-time.After(10 * time.Second):
t.Fatalf("HealthCheck 在挂起 Redis 上无限阻塞(ReadTimeout 未约束 Ping")
}
}
// newRedisClientForTest 构造指向 addr 的 redis.Client(复用 Init 的 client 配置,
// 含 DialTimeout 5s / ReadTimeout 3s / WriteTimeout 3s)。
func newRedisClientForTest(addr string) *redis.Client {
return newRedisClient(addr, "", 0)
}
+56
View File
@@ -0,0 +1,56 @@
package database
import (
"testing"
"github.com/redis/go-redis/v9"
)
func TestSetDefaultRedisManagerClosesReplacedManager(t *testing.T) {
old := NewRedisManager()
old.setClientForTest(redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}))
orig := SwapDefaultRedisManager(old)
t.Cleanup(func() {
current := SwapDefaultRedisManager(orig)
if current != nil && current != orig {
_ = current.Close()
}
})
next := NewRedisManager()
SetDefaultRedisManager(next)
if GetDefaultRedisManager() != next {
t.Fatal("SetDefaultRedisManager 应安装新的默认 manager")
}
if old.Client() != nil {
t.Fatal("SetDefaultRedisManager 应关闭并清空被替换的旧 Redis manager")
}
}
func TestSwapDefaultRedisManagerPreservesReplacedManager(t *testing.T) {
old := NewRedisManager()
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"})
old.setClientForTest(client)
orig := SwapDefaultRedisManager(old)
t.Cleanup(func() {
current := SwapDefaultRedisManager(orig)
if current != nil && current != orig {
_ = current.Close()
}
_ = old.Close()
})
next := NewRedisManager()
previous := SwapDefaultRedisManager(next)
if previous != old {
t.Fatal("SwapDefaultRedisManager 应返回被替换的旧 manager")
}
if GetDefaultRedisManager() != next {
t.Fatal("SwapDefaultRedisManager 应安装新的默认 manager")
}
if old.Client() != client {
t.Fatal("SwapDefaultRedisManager 不应关闭旧 Redis manager,旧资源需可用于失败回滚")
}
}
+45
View File
@@ -0,0 +1,45 @@
package database
import (
"context"
"net"
"strconv"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/alicebob/miniredis/v2"
)
func redisTestConfig(t *testing.T, addr string) *config.Config {
t.Helper()
host, portText, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split redis addr: %v", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("parse redis port: %v", err)
}
return &config.Config{Redis: config.RedisConfig{Host: host, Port: port}}
}
func TestRedisManagerInitClosesPreviousClient(t *testing.T) {
mr1 := miniredis.RunT(t)
mr2 := miniredis.RunT(t)
m := NewRedisManager()
if err := m.Init(redisTestConfig(t, mr1.Addr())); err != nil {
t.Fatalf("first Init: %v", err)
}
first := m.Client()
if first == nil {
t.Fatal("first client is nil")
}
if err := m.Init(redisTestConfig(t, mr2.Addr())); err != nil {
t.Fatalf("second Init: %v", err)
}
if err := first.Ping(context.Background()).Err(); err == nil {
t.Fatal("first client still usable after second Init; old Redis pool was not closed")
}
}
+43
View File
@@ -2,6 +2,7 @@ package database_test
import (
"context"
"sync"
"testing"
"github.com/EthanCodeCraft/xlgo-core/database"
@@ -21,3 +22,45 @@ func TestHealthCheckRedisWithoutInit(t *testing.T) {
t.Fatal("expected health check error without Redis init")
}
}
func TestRedisNilConfigReturnsError(t *testing.T) {
prev := database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(prev) })
if err := database.NewRedisManager().Init(nil); err == nil {
t.Fatal("RedisManager.Init(nil) 应返回错误")
}
if err := database.InitRedis(nil); err == nil {
t.Fatal("包级 InitRedis(nil) 应返回错误")
}
if err := database.HealthCheckRedis(nil); err == nil {
t.Fatal("未初始化 Redis 时 HealthCheckRedis(nil) 应返回错误")
}
}
// TestDefaultRedisConcurrentSetAndGet C-1/H-4 回归:并发 SetDefaultRedisManager
// 与 GetRedis/CloseRedis/HealthCheckRedis facade 读取不应触发数据竞争。
// 修复前 DefaultRedis 是裸 *Redis.PointerSetDefaultRedisManager 无锁写、
// facade 无锁读,-race 必采。修复后 atomic.Pointer 保护。
func TestDefaultRedisConcurrentSetAndGet(t *testing.T) {
// 保存并恢复默认 manager,避免污染其他测试。
orig := database.NewRedisManager()
database.SetDefaultRedisManager(orig)
// 不设 clientGetRedis 返回 nil;本用例只验证读写无竞态。
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(2)
go func() {
defer wg.Done()
database.SetDefaultRedisManager(database.NewRedisManager())
}()
go func() {
defer wg.Done()
_ = database.GetRedis()
_ = database.CloseRedis()
_ = database.HealthCheckRedis(context.Background())
}()
}
wg.Wait()
}
+7
View File
@@ -20,6 +20,13 @@ func TestIsTransientDBError(t *testing.T) {
{"connection refused (transient)", errors.New("dial tcp 127.0.0.1:3306: connect: connection refused"), true},
{"i/o timeout (transient)", errors.New("dial tcp 10.0.0.1:3306: i/o timeout"), true},
{"empty msg", errors.New(""), true},
// P1 #12:PG 目标库不存在应视为非瞬态(不重试)。
{"pg database not exist", errors.New(`FATAL: database "app" does not exist (SQLSTATE 3D000)`), false},
{"pg password auth failed", errors.New("FATAL: password authentication failed for user \"app\""), false},
// P1 #12 回归核心:含 "database" 但是瞬态的错误必须仍被判为瞬态(可重试),
// 修复前独立 "database" 子串会把它误判为非瞬态而放弃重试。
{"pg starting up (transient)", errors.New("FATAL: the database system is starting up (SQLSTATE 57P03)"), true},
{"pg in recovery (transient)", errors.New("FATAL: the database system is in recovery mode"), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+72
View File
@@ -0,0 +1,72 @@
package database
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"
"github.com/EthanCodeCraft/xlgo-core/config"
mysqldriver "github.com/go-sql-driver/mysql"
)
// ensureMySQLTLSRegistered 按 cfg.Database 的 TLS 配置注册命名 TLS 配置到 go-sql-driver/mysqlM-config-2)。
//
// go-sql-driver/mysql v1.7.0 的 tls DSN 参数语义:
// - tls=true:内置安全(&tls.Config{},系统根 CA + ServerName 自动取自 host + 证书校验),无需注册。
// - tls=<name>:引用经 RegisterTLSConfig 注册的命名配置,用于私有 CA/自签证书。
//
// 本函数仅在「TLS=true 且 TLSRootCA 非空」时注册 config.MySQLTLSConfigName 命名配置:
// 加载 TLSRootCA 的 PEM 到 RootCAsServerName 取自 HostMinVersion=TLS1.2。其余情况(TLS 未启用、
// 或启用但用内置 tls=true)直接返回 nil。
//
// 非 MySQL 驱动(如 postgres,用 SSLMode)跳过。失败返回错误,由 initDB fail-fast
// 绝不静默回退明文连接(生产 DB 流量明文是安全风险)。
//
// 注意:RegisterTLSConfig 是驱动级全局状态——一个名字对应唯一 *tls.Config(含唯一 ServerName),
// 同名重复注册会覆盖前者且不报错,故无法用「同名」同时匹配多个不同 host。
//
// 覆盖范围(注册配置的 ServerName 固定取自主库 Host):
// - 单 host 集群:replica DSN 的 host 与主库 Host 相同(同机不同端口,或经同一 LB/主机名暴露)——
// master 与 replica DSN 均用 tls=MySQLTLSConfigNameServerName 一致,握手通过。
// - 多 host replicas + 私有 CAreplica DSN 的 host 与主库 Host 不同时,本注册的 ServerName(主库
// host)与 replica 证书 SAN 不匹配,握手失败。此场景须用户为每个 replica host 注册**不同名**的
// TLS 配置(各自 ServerName=该 replica host)并自行构造对应 replica DSNtls=<该名>);框架
// MySQLDSN() 硬编码 tls=MySQLTLSConfigName,不能用于这些 replica DSN。切勿对 MySQLTLSConfigName
// 同名重复注册——会覆盖主库注册、导致主库握手失败。
//
// replica 路径(InitDBWithReplicas 的 replicaDSNs)由调用方传原始 DSN,不经本函数注册。
// 多集群不同 CA 亦属已知多 App 限制,需用户按上述方式自行注册。
func ensureMySQLTLSRegistered(cfg *config.Config) error {
if cfg == nil {
return nil
}
db := &cfg.Database
if !db.TLS || strings.TrimSpace(db.TLSRootCA) == "" {
return nil
}
// 仅 MySQL 走注册路径;postgres 用 SSLMode,其他驱动自管 TLS。空 driver 默认 MySQL。
drv := normalizeDriver(db.Driver)
if drv != "" && drv != DriverMySQL {
return nil
}
pem, err := os.ReadFile(db.TLSRootCA)
if err != nil {
return fmt.Errorf("读取 MySQL TLS CA 文件失败 %q: %w", db.TLSRootCA, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return fmt.Errorf("解析 MySQL TLS CA 文件失败 %q: 非 PEM 格式或无有效证书", db.TLSRootCA)
}
tlsCfg := &tls.Config{
RootCAs: pool,
ServerName: db.Host,
MinVersion: tls.VersionTLS12, // 禁用 TLS1.0/1.1,符合安全基线
}
if err := mysqldriver.RegisterTLSConfig(config.MySQLTLSConfigName, tlsCfg); err != nil {
return fmt.Errorf("注册 MySQL TLS 配置 %q 失败: %w", config.MySQLTLSConfigName, err)
}
return nil
}
+146
View File
@@ -0,0 +1,146 @@
package database
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
mysqldriver "github.com/go-sql-driver/mysql"
)
// writeSelfSignedCAPEM 生成一个自签名 CA 证书并写入临时 PEM 文件,返回路径。
func writeSelfSignedCAPEM(t *testing.T) string {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "xlgo-test-ca"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
p := filepath.Join(os.TempDir(), "xlgo_tls_ca_test.pem")
if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return p
}
// TestEnsureMySQLTLSRegistered_NoOpCases 固化 M-config-2:非 MySQL / 未启用 TLS / 无 CA 时
// ensureMySQLTLSRegistered 为 no-op,不注册、不报错。
func TestEnsureMySQLTLSRegistered_NoOpCases(t *testing.T) {
if err := ensureMySQLTLSRegistered(nil); err != nil {
t.Errorf("nil cfg 应 no-op, got %v", err)
}
mysqlNoTLS := &config.Config{Database: config.DatabaseConfig{
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
}}
if err := ensureMySQLTLSRegistered(mysqlNoTLS); err != nil {
t.Errorf("TLS=false 应 no-op, got %v", err)
}
mysqlBuiltIn := &config.Config{Database: config.DatabaseConfig{
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
TLS: true, // 无 TLSRootCA,用内置 tls=true
}}
if err := ensureMySQLTLSRegistered(mysqlBuiltIn); err != nil {
t.Errorf("TLS=true 无 CA 应 no-op(内置 tls=true, got %v", err)
}
// postgres 用 SSLMode,不走 MySQL TLS 注册
pg := &config.Config{Database: config.DatabaseConfig{
Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n",
TLS: true, TLSRootCA: "/path/ca.pem",
}}
if err := ensureMySQLTLSRegistered(pg); err != nil {
t.Errorf("postgres 应跳过 MySQL TLS 注册, got %v", err)
}
// 空 driver(默认 MySQL+ 无 TLSno-op
emptyDrv := &config.Config{Database: config.DatabaseConfig{
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
}}
if err := ensureMySQLTLSRegistered(emptyDrv); err != nil {
t.Errorf("空 driver 无 TLS 应 no-op, got %v", err)
}
}
// TestEnsureMySQLTLSRegistered_BadCA 固化 M-config-2CA 文件不可读或非 PEM 时 fail-fast
// 绝不静默回退明文连接。
func TestEnsureMySQLTLSRegistered_BadCA(t *testing.T) {
missing := &config.Config{Database: config.DatabaseConfig{
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
TLS: true, TLSRootCA: "/no/such/ca.pem",
}}
if err := ensureMySQLTLSRegistered(missing); err == nil || !strings.Contains(err.Error(), "读取") {
t.Fatalf("CA 文件不存在应报读取错误, got: %v", err)
}
nonPEM := filepath.Join(os.TempDir(), "xlgo_tls_nonpem.pem")
if err := os.WriteFile(nonPEM, []byte("this is not a pem file"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
bad := &config.Config{Database: config.DatabaseConfig{
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
TLS: true, TLSRootCA: nonPEM,
}}
if err := ensureMySQLTLSRegistered(bad); err == nil || !strings.Contains(err.Error(), "解析") {
t.Fatalf("非 PEM 文件应报解析错误, got: %v", err)
}
}
// TestEnsureMySQLTLSRegistered_ResolvesViaParseDSN 固化 M-config-2 端到端:
// 注册前 ParseDSN(tls=xlgo-mysql) 报 unknown config name;注册后成功解析且 TLS 非 nil。
// 利用 go-sql-driver/mysql 的 TLS 解析发生在 ParseDSN 的 normalize() 阶段,无需真实 DB 连接。
func TestEnsureMySQLTLSRegistered_ResolvesViaParseDSN(t *testing.T) {
caPath := writeSelfSignedCAPEM(t)
defer os.Remove(caPath)
// 清理同名残留注册,保证「注册前」断言不被前序测试污染
mysqldriver.DeregisterTLSConfig(config.MySQLTLSConfigName)
defer mysqldriver.DeregisterTLSConfig(config.MySQLTLSConfigName)
cfg := &config.Config{Database: config.DatabaseConfig{
Driver: "mysql", Host: "127.0.0.1", Port: 3306, User: "u", Password: "p", Name: "n",
TLS: true, TLSRootCA: caPath,
}}
dsn := cfg.Database.DSN()
if !strings.Contains(dsn, "tls="+config.MySQLTLSConfigName) {
t.Fatalf("DSN 应含 tls=%s: %s", config.MySQLTLSConfigName, dsn)
}
// 注册前:ParseDSN 应报 unknown config name
if _, err := mysqldriver.ParseDSN(dsn); err == nil {
t.Fatal("注册前 ParseDSN 不应成功(未知 TLS 配置名)")
} else if !strings.Contains(err.Error(), "unknown config name") {
t.Logf("注册前 ParseDSN 错误(预期未知配置名): %v", err)
}
// 注册后:ParseDSN 成功,TLS 已解析为非 nil
if err := ensureMySQLTLSRegistered(cfg); err != nil {
t.Fatalf("ensureMySQLTLSRegistered: %v", err)
}
mc, err := mysqldriver.ParseDSN(dsn)
if err != nil {
t.Fatalf("注册后 ParseDSN 应成功, got: %v", err)
}
if mc.TLS == nil {
t.Error("注册后 ParseDSN 的 TLS 配置应为非 nilCA 已注册)")
}
if mc.TLS.ServerName != "127.0.0.1" {
t.Errorf("TLS ServerName = %q, want 127.0.0.1", mc.TLS.ServerName)
}
}
-21
View File
@@ -1,21 +0,0 @@
# xlgo 文档
| 文档 | 位置 | 说明 |
|---|---|---|
| README.md | 仓库根 | 快速开始、功能概览、更新日志 |
| GUIDE.md | 仓库根 | 完整使用指南 |
| CHANGELOG.md | 仓库根 | 变更日志(遵循 Keep a Changelog |
| CLAUDE.md | 仓库根 | Claude Code 协作指引 |
| docs/plans/ | 本目录 | 历史版本规划与体检报告归档 |
## docs/plans/ 归档文档
这些是各版本开发时的规划/评审文档,**反映当时状态**,代码可能已演进,阅读时请对照 CHANGELOG 确认现状。
| 文件 | 说明 |
|---|---|
| Version_Update_Plan_v1.0.2.md | v1.0.2 更新计划(已完成) |
| Version_v1.0.2_report.md | v1.0.2 体检报告,含 #1-#30 改进建议与版本路线图 |
| v2.0-review.md | 更早期的 v2.0 评估报告 |
> v1.0.2 体检报告中的 #1-#30 改进项,完成情况见根目录 [CHANGELOG.md](../CHANGELOG.md) 各版本章节。
+2 -2
View File
@@ -236,7 +236,7 @@ cache.KSession("sid") // -> "session:user_api:sid"
cache.Lock(ctx, key, ttl)
cache.Unlock(ctx, key)
cache.TryLock(ctx, key, ttl, retry, maxRetry)
cache.WithLock(ctx, key, ttl, fn) // 自动管理锁
cache.WithLock(ctx, key, ttl, func(context.Context) error) // 自动管理锁
// 计数器
cache.Incr(ctx, key)
@@ -482,4 +482,4 @@ go test ./cache/... ./handler/... ./middleware/...
*评估日期:2026-04-29*
*评估版本:v2.0.0*
*优化内容:新增5个包 + 增强5个包 + 配置扩展 + CLI重构 = 约120个函数*
*优化内容:新增5个包 + 增强5个包 + 配置扩展 + CLI重构 = 约120个函数*
+10 -2
View File
@@ -24,7 +24,7 @@ go run ./examples/minimal
go run ./examples/full
```
启动后自动迁移 user 表。接口:
启动后自动迁移 user 表,并初始化示例用户 `alice/secret`(密码以 bcrypt 哈希保存)。接口:
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
@@ -39,4 +39,12 @@ curl -X POST http://localhost:8082/api/v1/login \
-d '{"username":"alice","password":"secret"}'
```
> 示例代码为演示用途,密码明文存储、未做参数校验,生产环境请使用 bcrypt 哈希密码并配合 `validation` 包校验入参。
创建用户示例:
```bash
curl -X POST http://localhost:8082/api/v1/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"username":"bob","password":"s3cret"}'
```
> 示例代码为演示用途,已演示 bcrypt 密码哈希与登录校验;生产环境仍需配合 `validation` 包完善入参校验、密码强度策略与用户注册流程。
+1
View File
@@ -4,6 +4,7 @@ app:
debug: true
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
port: 8082
mode: development
read_timeout: 15s
+61 -11
View File
@@ -8,7 +8,9 @@
//
// POST /api/v1/login {"username":"alice","password":"secret"} → 返回 token
// GET /api/v1/users/:id (需 Authorization: Bearer <token>
// POST /api/v1/users (创建用户)
// POST /api/v1/users 需 Authorization: Bearer <token>创建用户)
//
// 示例会在启动时初始化 alice/secret,密码以 bcrypt 哈希保存。
//
// 运行:
//
@@ -16,6 +18,8 @@
package main
import (
"context"
"errors"
"fmt"
"os"
@@ -26,6 +30,7 @@ import (
"github.com/EthanCodeCraft/xlgo-core/repository"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/EthanCodeCraft/xlgo-core/validation"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -34,7 +39,7 @@ import (
type User struct {
gorm.Model
Username string `gorm:"uniqueIndex;size:64" json:"username"`
Password string `gorm:"size:128" json:"-"` // 实际项目应存 bcrypt 哈希
Password string `gorm:"size:128" json:"-"` // bcrypt 哈希,永不返回给客户端
UserType string `gorm:"size:32" json:"user_type"`
}
@@ -46,11 +51,14 @@ func main() {
xlgo.WithModels(&User{}),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
xlgo.WithHook(xlgo.Hook{
Name: "seed-example-user",
OnInit: func(*xlgo.App) error {
return ensureExampleUser(context.Background())
},
}),
)
// 初始化 user repositoryApp.Init 之后 master DB 才可用,这里在 registerRoutes 里延迟拿)
_ = app
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
@@ -82,10 +90,13 @@ func login(c *gin.Context) {
return
}
// 示例简化:查询用户,不校验密码哈希
u, err := userRepo.FindOne(c.Request.Context(), "username = ?", req.Username)
if err != nil {
response.Fail(c, "用户不存在")
response.Fail(c, "用户名或密码错误")
return
}
if !validation.CheckPassword(u.Password, req.Password) {
response.Fail(c, "用户名或密码错误")
return
}
@@ -101,7 +112,10 @@ func getUser(c *gin.Context) {
id := c.Param("id")
// 示例简化:直接用 repo 查询,实际应转 uint
var uid uint
fmt.Sscanf(id, "%d", &uid)
if _, err := fmt.Sscanf(id, "%d", &uid); err != nil {
response.Fail(c, "用户ID无效")
return
}
u, err := userRepo.FindByID(c.Request.Context(), uid)
if err != nil {
response.NotFound(c, "用户不存在")
@@ -111,15 +125,51 @@ func getUser(c *gin.Context) {
}
func createUser(c *gin.Context) {
var u User
if err := c.ShouldBindJSON(&u); err != nil {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
UserType string `json:"user_type"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, "参数错误")
return
}
u.UserType = "user"
hash, err := validation.HashPassword(req.Password)
if err != nil {
response.ServerError(c, "密码加密失败")
return
}
u := User{
Username: req.Username,
Password: hash,
UserType: req.UserType,
}
if u.UserType == "" {
u.UserType = "user"
}
if err := userRepo.Create(c.Request.Context(), &u); err != nil {
response.Fail(c, "创建失败: "+err.Error())
return
}
response.Success(c, u)
}
func ensureExampleUser(ctx context.Context) error {
_, err := userRepo.FindOne(ctx, "username = ?", "alice")
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
hash, err := validation.HashPassword("secret")
if err != nil {
return err
}
return userRepo.Create(ctx, &User{
Username: "alice",
Password: hash,
UserType: "user",
})
}
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/jwt"
"github.com/EthanCodeCraft/xlgo-core/validation"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
func setupExampleRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
database.RegisterDialect(database.DialectSpec{
Name: "sqlite_full_example_test",
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
DSN: func(c *config.DatabaseConfig) string {
return c.CustomDSN
},
})
cfg := &config.Config{
Database: config.DatabaseConfig{
Driver: "sqlite_full_example_test",
CustomDSN: filepath.Join(t.TempDir(), "full-example.db"),
},
JWT: config.JWTConfig{
Secret: "test-secret-for-full-example-at-least-32-bytes",
Expire: time.Hour,
RefreshExpire: 24 * time.Hour,
Issuer: "xlgo",
Algorithm: "HS256",
},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("设置测试配置失败: %v", err)
}
mgr := database.NewManager(cfg)
if err := mgr.InitDB(context.Background(), cfg); err != nil {
t.Fatalf("初始化测试数据库失败: %v", err)
}
db := mgr.Master()
if err := db.AutoMigrate(&User{}); err != nil {
t.Fatalf("迁移示例用户表失败: %v", err)
}
prev := database.SwapDefaultManager(mgr)
t.Cleanup(func() {
current := database.SwapDefaultManager(prev)
if current != nil && current != prev {
_ = current.Close()
}
})
// JWT 黑名单走 RedisParseToken 默认 fail-closed,无 Redis 会拒绝所有 token)。
// 测试用 miniredis 注入,避免依赖外部 Redis。
mr := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = redisClient.Close() })
prevJWTMgr := jwt.GetDefaultJWT()
jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(redisClient))
t.Cleanup(func() { jwt.SetDefaultJWTManager(prevJWTMgr) })
gin.SetMode(gin.TestMode)
r := gin.New()
registerRoutes(r.Group(""))
if err := ensureExampleUser(context.Background()); err != nil {
t.Fatalf("初始化示例用户失败: %v", err)
}
return r, db
}
func postJSON(t *testing.T, r http.Handler, path string, body string, token string) (int, map[string]any) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("解析响应 JSON 失败: %v, body=%s", err, rec.Body.String())
}
return rec.Code, payload
}
func tokenFromResponse(t *testing.T, payload map[string]any) string {
t.Helper()
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("响应缺少 data 对象: %#v", payload)
}
token, ok := data["token"].(string)
if !ok || strings.TrimSpace(token) == "" {
t.Fatalf("响应缺少 token: %#v", payload)
}
return token
}
func TestFullExampleLoginAndPasswordFlow(t *testing.T) {
r, db := setupExampleRouter(t)
var seeded User
if err := db.Where("username = ?", "alice").First(&seeded).Error; err != nil {
t.Fatalf("示例用户 alice 应在启动时初始化: %v", err)
}
if seeded.Password == "" || seeded.Password == "secret" {
t.Fatal("示例用户密码必须保存为 bcrypt 哈希,不能保存明文")
}
if !validation.CheckPassword(seeded.Password, "secret") {
t.Fatal("示例用户 alice/secret 应能通过密码校验")
}
status, wrong := postJSON(t, r, "/api/v1/login", `{"username":"alice","password":"wrong"}`, "")
if status != http.StatusOK {
t.Fatalf("业务失败模式下 HTTP 状态应为 200got %d", status)
}
if code, _ := wrong["code"].(float64); code == 0 {
t.Fatalf("错误密码应返回业务失败: %#v", wrong)
}
_, loginPayload := postJSON(t, r, "/api/v1/login", `{"username":"alice","password":"secret"}`, "")
token := tokenFromResponse(t, loginPayload)
_, createPayload := postJSON(t, r, "/api/v1/users", `{"username":"bob","password":"s3cret"}`, token)
if code, _ := createPayload["code"].(float64); code != 0 {
t.Fatalf("带 token 创建用户应成功: %#v", createPayload)
}
var bob User
if err := db.Where("username = ?", "bob").First(&bob).Error; err != nil {
t.Fatalf("创建后的用户应写入数据库: %v", err)
}
if bob.Password == "" || bob.Password == "s3cret" {
t.Fatal("创建用户时必须保存 bcrypt 哈希,不能保存明文")
}
if !validation.CheckPassword(bob.Password, "s3cret") {
t.Fatal("创建用户的密码哈希应能通过校验")
}
_, bobLogin := postJSON(t, r, "/api/v1/login", `{"username":"bob","password":"s3cret"}`, "")
_ = tokenFromResponse(t, bobLogin)
}
+235
View File
@@ -0,0 +1,235 @@
# xlgo 框架文档/注释/代码一致性审查报告
> 审查日期:2026-07-12
> 审查范围:60 个非测试 `.go` 源文件,25 个包目录
> 方法:每条结论均亲自读源码确认;对初筛标"无问题"的文件做抽查复审;专门核查跨函数契约。不转述初筛结论。
## 关于本次审查
本次审查对每一条结论都亲自读取了源码确认,并对标"无问题"的文件做了抽查复审,还专门查了跨函数契约。下方为经得起复核的结果。
**真实数字:23 项**,分三档,每档标了置信度。
---
## 一、整体审查概览
- **审查文件总数**:60(25 个包目录,全部非测试源文件)
- **亲自读源码确认的问题**:23 项
- **A 档·实质错误(误导下游)**:10 项 - 注释与代码逻辑矛盾,会让人按注释写错代码
- **B 档·导出符号注释缺失/不完整**:6 项 - 不致命但违反 godoc 规范或同块内风格不一
- **C 档·描述不完整**:7 项 - 边界/默认行为没写全,不算错但该补
- **代码健康度评分****7.5 / 10**
**整体评价**:xlgo 注释质量**两极分化**。核心安全/并发路径(`tls.go``validate.go``recover.go``repository.go` 的 H6 修复块、`logger` 的 H7 块)注释极其详尽且与代码逐条对应,是全仓库最好的;问题集中在两类边缘:① 历史修复后注释没同步(RandomPicker、closeResources 三连、model/base);② 工具函数/utils 的边界行为和导出常量注释缺失。**本轮主要发现是注释/文档问题,未确认 CRITICAL/HIGH 代码缺陷**`storage.go` 超限错误类型是唯一存疑点,见第四节)。
---
## 二、A 档·实质错误(10 项,高置信,该修)
这些全部亲自读了源码,证据确凿。
### 1. app.go:462 / 495 / 1053 - closeResources 三连契约错误(跨函数)
这是最严重的一组,**同一个契约错误散布在三处注释,互相印证却全错**:
- **【位置 1】第 462 行 `failAfterInit` 注释**
```go
// 顺序:... -> closeResources。先停 goroutine 再关资源...
```
实际第 483-488 行调的是 `stopCron` + `rollbackReplacedResources`**不调 `closeResources`**。
- **【位置 2】第 495 行 `closeResources` 注释**
```go
// closeResources 关闭 db->redis->loggerM1)。...供 failAfterInit 与 doShutdown 复用。
```
grep 确认 `closeResources` 只被 `doShutdown`(第 1057 行)调用,`failAfterInit` 不调它。**"供 failAfterInit 复用"是假的**。
- **【位置 3】第 1053 行 `doShutdown` 注释**
```go
// db/redis/logger 经 closeResources 统一关闭(与 failAfterInit 复用,M1)。
```
同样错--`failAfterInit` 走 `rollbackReplacedResources`,不复用 `closeResources`。
**建议**:把三处注释统一修正--`failAfterInit` 回滚走 `rollbackReplacedResources`(恢复 Init 前默认 manager 再关新建 manager),`closeResources` 仅供 `doShutdown` 复用。`failAfterInit` 选 `rollback` 而非 `close` 是有意的(Init 失败要恢复默认 manager 不直接关),注释应点明这个区别。
### 2. database/manager.go:64-67 - RandomPicker 注释引用了已废弃的 rand.Intn
```go
// RandomPicker 随机选择从库。
//
// D2 注释:rand.Intn 自 Go 1.20 起(go.dev/issue/54899)内部使用 per-goroutine
// 随机源,并发安全无需额外同步。本模块 go.mod 声明 go 1.25.0,满足此要求。
```
实际第 75 行:`cryptorand.Int(cryptorand.Reader, big.NewInt(...))` - 已改用 `crypto/rand`,与 `math/rand.Intn`、issue 54899、per-goroutine 随机源**全部无关**。并发安全的原因现在是 `crypto/rand` 本身线程安全。注释整段过时。
**建议修改为**
```go
// RandomPicker 随机选择从库。
//
// 使用 crypto/rand 生成随机索引,并发安全且不可预测。len(replicas)<=0 返回 nil
// crypto/rand 失败(极罕见,如熵池耗尽)时回退到 replicas[0],保证可用性。
```
### 3. model/base.go:9-10 - 注释内部自相矛盾
```go
// 时间列类型(MySQL 通常为 datetime(0) 或 timestamp,保留亚秒精度)。
```
`datetime(0)` = 0 位小数秒(秒级精度),与"保留亚秒精度"直接冲突。GORM MySQL 驱动默认是 `datetime(6)`。应为 `datetime(6)`。
**建议修改为**
```go
// BaseModel 基础模型。CreatedAt/UpdatedAt 不显式指定 type,由 GORM 按驱动选默认
// 时间列类型(MySQL 通常为 datetime(6),保留亚秒精度)。
```
### 4. cache/keybuilder.go:47 / 75 / 156 - 三处示例分隔符全错
默认分隔符是 `:`(第 17、62 行),但三处示例都用下划线:
- 第 47 行:`WithCacheType("session") -> "session_site_a_user:1"` -> 应为 `session:site_a:user:1`
- 第 75 行:`kb.Build("user:1") -> "cache_site_a_user:1"` -> 应为 `cache:site_a:user:1`(且与同注释内格式说明 `{separator}` 矛盾)
- 第 156 行:`kb.BuildPattern("user:*") -> "cache_site_a_user:*"` -> 应为 `cache:site_a:user:*`
同文件第 44 行 `WithSeparator` 示例却正确用 `:`,说明这是历史遗留笔误。
### 5. repository/repository.go:37 - Delete 接口注释绝对化
```go
// Delete 删除记录(软删除)
Delete(ctx context.Context, id uint) error
```
实现 `BaseRepo.Delete`(第 156-160 行)契约:"若 T 内嵌 gorm.DeletedAt(或 gorm.Model)为软删除;否则为硬删除"。接口注释说死"软删除",下游按接口理解会误判。
**建议修改为**
```go
// Delete 删除记录(T 含软删除字段时为软删除,否则为硬删除;详见 BaseRepo.Delete
Delete(ctx context.Context, id uint) error
```
### 6. repository/repository.go:144 - UpdateFields 示例语义错误
```go
// repo.UpdateFields(ctx, &User{Name: "new"}, "name") // 仅更新 name
```
函数第 150-152 行 `db.Where(conds[0], conds[1:]...)` 把 `"name"` 当 WHERE 条件(`WHERE name`),不是"仅更新 name 字段"的选择器。struct 模式 `Updates(&User{Name:"new"})` 本就只更新非零字段,无需传 `"name"`。示例会误导人把字段名当条件传。第二行(map + `"id = ?"`)正确。
**建议修改为**
```go
// 示例:
//
// repo.UpdateFields(ctx, &User{Name: "new"}) // struct:仅更新非零字段 name
// repo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map:显式置零,带条件
```
### 7. middleware/csrf.go:260 - CSRFToken 注释"用于 API 模式"误导
```go
// CSRFToken 返回 CSRF Token 的处理器(用于 API 模式)
```
实现第 262-274 行:取 gin context 的 "csrf_token"Cookie 模式中间件写入),取不到就地生成一次性 token 直接返回,**不写入 `apiTokens`**。而 API 模式可校验 token 由 `GenerateAPIToken`(第 345 行)颁发。所以 `CSRFToken` 产出的 token **不能被 `CSRFForAPI` 消费**,注释会让人误以为它产出 API 模式可校验 token。
**建议修改为**
```go
// CSRFToken 返回当前请求上下文中的 CSRF Token;若上下文无 Token 则现场生成一个返回。
// 适用于 Cookie/双重提交模式(与 CSRF/DoubleSubmitCookie 中间件配合),
// 不与 CSRFForAPI 配套--API 模式的可校验 Token 请用 GenerateAPIToken 颁发。
```
### 8. examples/full/main.go:11 - POST /users 认证标注缺失
```go
// GET /api/v1/users/:id (需 Authorization: Bearer <token>
// POST /api/v1/users (创建用户)
```
实际第 82-83 行:`auth := api.Group("/", middleware.AuthRequired())``auth.POST("/users", createUser)` 在 auth 组内,**同样需 token**。注释对 GET 标了认证,POST 没标,会让人以为 POST 是公开接口。
**建议修改为**
```go
// POST /api/v1/login {"username":"alice","password":"secret"} -> 返回 token(公开)
// GET /api/v1/users/:id (需 Authorization: Bearer <token>
// POST /api/v1/users (需 Authorization: Bearer <token>,创建用户)
```
### 9. utils/random.go:39 - RandInt 与 RandInt64 的 min==max 边界与区间注释矛盾
```go
// RandInt 返回 [min, max) 范围内的随机整数。
func RandInt(min, max int) int {
if min == max {
return min // 等于 max,与半开区间 [min,max) 矛盾
}
```
`[min, max)` 在 `min==max` 时应为空集,但代码返回 `min`(即 max)。`RandIntSecure`(第 112 行)与 `RandInt64Secure`(第 128 行)注释均已明确说明此例外("min==max 返回 minmax<min 自动交换"),但 `RandInt`(第 39 行)与 `RandInt64`(第 55 行)没说。同问题影响 2 个函数。
**建议修改为**
```go
// RandInt 返回 [min, max) 范围内的随机整数。min==max 时返回 minmax<min 自动交换。
```
### 10. examples/full/main.go:63 - `_ = app` 死代码 + 误导注释
```go
// 初始化 user repositoryApp.Init 之后 master DB 才可用,这里在 registerRoutes 里延迟拿)
_ = app
```
`_ = app` 是无意义空赋值(`app` 在 49 行声明、65 行 `app.Run()` 已用),注释紧贴它让人误以为二者相关。实际延迟初始化在 73 行 `registerRoutes` 内。建议删掉 `_ = app`,把注释移至 `registerRoutes` 内 repo 初始化处。
---
## 三、B 档·导出符号注释缺失/不完整(6 项,中置信,该补)
这些是导出符号(const/var 块常量及部分函数)注释缺失或不完整,不误导但违反 godoc 规范或同块内风格不一。
| 文件 | 位置 | 符号 | 说明 |
|---|---|---|---|
| ws/ws.go | 94-100 | `TypeText`/`TypeBinary`/`TypePing`/`TypePong`/`TypeClose` | 5 个导出常量无注释,类型 `MessageType` 有 |
| ws/ws.go | 352-357 | `ErrHubNotRunning`/`ErrHubStopped`/`ErrHubQueueFull`/`ErrNilConnection` | 4 个导出错误变量无注释 |
| console/console.go | 23-32 | `LevelDebug`/`LevelInfo`/`LevelSuccess`/`LevelWarn`/`LevelError` | 5 个级别常量无注释(仅 `LevelSilent` 有) |
| jwt/jwt.go | 126-129 | `BlacklistFailOpen`/`BlacklistFailClosed` | 2 个导出常量无注释,类型 `BlacklistPolicy` 有 |
| router/router.go | 680 | 包级 `GroupWithMiddlewareGroup` | 方法版(672 行)有详细注释,包级版无注释 |
| middleware/ratelimit.go | 37 | `NewRateLimiter` | 已有简短注释("创建速率限制器(内存版)"),但未说明 `rate<=0`/`window<=0` panic(已确认 `mustValidRateLimit` 第 57-62 行真 panic)及必须 `Stop()` 释放 goroutine |
---
## 四、C 档·描述不完整(7 项,低置信,可选)
边界/默认行为没写全,注释本身不算错,建议补但不急。
| 文件 | 位置 | 符号 | 缺什么 |
|---|---|---|---|
| utils/strings.go | 105 | `Substr` | 未说 `length<=0` 返回空、`start` 越界返回空 |
| utils/convert.go | 89/97 | `CalcPageCount`/`CalcOffset` | 未说非法入参返回 0 / 默认值(1、20) |
| utils/datetime.go | 76/81 | `StartOfMonth`/`EndOfMonth` | 未说具体时刻(1日00:00 / 末日23:59:59.999999999 |
| utils/file.go | 79 | `CopyFile` | 未说自动建目录、覆盖、参数顺序 dst,src |
| utils/validator.go | 49 | `IsChinese` | 未说空串返回 false |
| utils/crypto.go | 52 | `HashFile` | 未说返回 hex 编码、`newHash` 须返回新实例 |
| response/error.go | 90 | `WithDetail` | 未文档化 nil 接收者行为(返回 `&Error{Detail:detail}``Code=0`/`Message=""`,不 panic |
### 唯一存疑的代码问题(非纯文档)
**storage/storage.go:461 与 :664** - `LocalStorage.Get`/`OSSStorage.Get` 读取超 `maxReadBytes` 时返回包装 `ErrInvalidPath` 的错误。"超过最大读取限制"用 `ErrInvalidPath`(路径无效)语义不贴切,建议改 `ErrUploadTooLarge` 或新增专用错误。**这是唯一可能涉及代码(非注释)的问题**,但属错误类型选用,非功能 bug,标存疑待定夺。
---
## 五、关于"漏看/错看"的说明
本次审查纠正了初筛阶段的若干问题:
1. **虚高数字已修正**:初筛报出 55 项,其中大量为"可以写得更详细"类软问题。剔除噪声后真实硬伤 10 项。
2. **漏报已补**:初筛只报了 `failAfterInit` 一处,本次亲自 grep 确认了 `closeResources` 三连错误(第 462/495/1053 行),补上了漏掉的 2 处。这是典型的"局部对全局错"--三处注释单独看都像对,拼起来契约对不上。
3. **"无问题"文件复审**:抽查了 `cache.go`、`validation`hash+password)、`compress`、`logger`+field)、`test`、`uuid`、`examples/minimal`、`database/tls`、`config/validate`、`middleware`cors/recover/timeout)。这些文件初筛标"无问题"**整体可信**--尤其 `tls.go`、`validate.go`、`recover.go` 注释质量很高。抽查中只在 `cache.go:264` `Init()` 注释发现一处边界(标"初始化全局缓存实例"但实际不启 Redis,依赖 `database.InitRedis`),属"注释过度承诺"边界,未列入硬伤,供参考。
4. **方法局限坦白**:未对全部 60 个文件逐行重读,而是对初筛报的所有问题源码逐条复核 + 抽查"无问题"文件覆盖各类情况 + 专门查跨函数契约。**仍可能存在的盲区**:① 某些没被重点提及、也没抽查的文件里可能藏着问题;② 跨文件断言只深挖了 closeResources 和 repository 路由两处,其他跨文件引用没全查。
---
## 附:审查覆盖
- **A 档 10 项**:全部亲自 Read 源码确认,含 7 项二次交叉复核(keybuilder、model/base、RandomPicker、repository Delete/UpdateFields、csrf CSRFToken、examples/full)。
- **B 档 6 项**:全部亲自 Read 确认注释缺失或不完整(含 `NewRateLimiter` 的 panic/Stop 语义缺口经 `mustValidRateLimit` 源码确认)。
- **C 档 7 项**:全部亲自 Read 确认边界行为。
- **跨函数契约**`closeResources`/`rollbackReplacedResources` 经 grep 全仓库调用点确认;`repository` 经 Read 确认接入 `database.GetDBFromContext`H6 修复属实)。
- **无问题文件**:抽查 10+ 个覆盖接口密集/注释最详尽/中间件/示例/工具各类型,初筛"无问题"裁定整体可信。
+12
View File
@@ -3,6 +3,7 @@ module github.com/EthanCodeCraft/xlgo-core
go 1.25.0
require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-gonic/gin v1.9.1
@@ -16,10 +17,12 @@ require (
github.com/spf13/viper v1.18.2
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
go.opentelemetry.io/contrib/propagators/b3 v1.43.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.0
@@ -41,8 +44,11 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
@@ -75,6 +81,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
@@ -85,6 +92,7 @@ require (
github.com/swaggo/swag v1.16.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
@@ -104,4 +112,8 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+25
View File
@@ -4,6 +4,8 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible h1:Sg/2xHwDrioHpxTN6WMiwbXTpUEinBpHsN7mG21Rc2k=
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -29,6 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -41,6 +45,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -148,6 +156,9 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
@@ -189,8 +200,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
@@ -199,6 +214,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+J
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
@@ -302,4 +319,12 @@ gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkw
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+43 -14
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
@@ -10,22 +11,39 @@ import (
"github.com/gin-gonic/gin"
)
// DefaultMaxJSONBodyBytes 是 BindJSON 的默认请求体上限。
// 入口层无上限读 JSON 会让任意 handler 暴露 OOM 面;需要更大请求体的业务可改用
// BindJSONWithMaxBytes 显式声明上限。
const DefaultMaxJSONBodyBytes int64 = 1 << 20 // 1 MiB
// HealthCheck 健康检查
// @Summary 健康检查
// @Description 检查服务是否正常运行
// @Tags 系统
// @Accept json
// @Produce json
// @Success 200 {object} response.Response
// @Success 200 {object} map[string]string
// @Router /health [get]
//
// 响应体与 router.RegisterHealthRoute 收敛为同一 schemaH8d):
// 恒 200 + {"status":"ok"},不走 response 业务信封,便于 K8s 探针直读。
// 需要依赖探活(mysql/redis…失败 503)时改用 router.RegisterHealthRoute 传入 checks。
func HealthCheck(c *gin.Context) {
response.Success(c, gin.H{
"status": "ok",
})
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// BindJSON 绑定 JSON 请求
func BindJSON(c *gin.Context, req any) error {
return BindJSONWithMaxBytes(c, req, DefaultMaxJSONBodyBytes)
}
// BindJSONWithMaxBytes 绑定 JSON 请求,并限制最大 body 大小。
// maxBytes<=0 时返回明确错误,避免 http.MaxBytesReader 的非正上限产生不可读语义。
func BindJSONWithMaxBytes(c *gin.Context, req any, maxBytes int64) error {
if maxBytes <= 0 {
return errors.New("handler: max JSON body bytes must be positive")
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
return c.ShouldBindJSON(req)
}
@@ -34,6 +52,11 @@ func BindQuery(c *gin.Context, req any) error {
return c.ShouldBindQuery(req)
}
// MaxPage GetPage 允许的最大页码(M-D 修复:防深分页 DoS)。
// 超大 page 产生超大 OFFSET,大表上为性能灾难/DoS 面。钳制到该上限;
// 需更深度遍历的业务应改用游标/keyset 分页(框架不内置,避免功能扩张)。
const MaxPage = 10000
// GetPage 获取分页参数
func GetPage(c *gin.Context) (int, int) {
page := c.DefaultQuery("page", "1")
@@ -45,6 +68,10 @@ func GetPage(c *gin.Context) (int, int) {
if p < 1 {
p = 1
}
// M-D 修复:钳制 page 上限,防 ?page=999999999 产生超大 OFFSET 拖垮 DB。
if p > MaxPage {
p = MaxPage
}
if ps < 1 {
ps = 20
}
@@ -153,18 +180,20 @@ func ParseInt(s string, defaultValue int) int {
return utils.ToIntDefault(s, defaultValue)
}
// BadRequest 返回 400 错误
// BadRequest 返回参数/请求错误响应。
//
// 委托 response.FailWithCodeCodeFail),遵循当前响应模式(ModeBusiness 下 HTTP 200
// 错误信息通过 body 中的 code 表达;ModeREST 下 CodeFail 属业务失败不映射 HTTP 错误,仍 200),
// 并写入 RequestID——与 response 模式系统一致,不再硬编 HTTP 状态码、不再丢失链路追踪。
func BadRequest(c *gin.Context, msg string) {
c.JSON(http.StatusBadRequest, response.Response{
Code: response.CodeFail,
Msg: msg,
})
response.FailWithCode(c, response.CodeFail, msg)
}
// InternalError 返回 500 错误
// InternalError 返回服务器错误响应。
//
// 委托 response.ServerErrorCodeServerError),遵循当前响应模式(ModeBusiness 下 HTTP 200
// ModeREST 下 HTTP 500),并写入 RequestID——与 response 模式系统一致,
// 不再硬编 HTTP 状态码、不再丢失链路追踪。
func InternalError(c *gin.Context, msg string) {
c.JSON(http.StatusInternalServerError, response.Response{
Code: response.CodeServerError,
Msg: msg,
})
response.ServerError(c, msg)
}
+161 -8
View File
@@ -1,12 +1,14 @@
package handler_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/handler"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
)
@@ -29,6 +31,25 @@ func TestHealthCheck(t *testing.T) {
}
}
// TestHealthCheckSchemaConverged_H8dhandler.HealthCheck 响应体应与
// router.RegisterHealthRoute 收敛为同一 schema200 + {"status":"ok"}),
// 不再走 response 业务信封 {code,msg,data}。
func TestHealthCheckSchemaConverged_H8d(t *testing.T) {
r := setupTestRouter()
r.GET("/health", handler.HealthCheck)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/health", nil))
body := w.Body.String()
if !strings.Contains(body, `"status":"ok"`) {
t.Fatalf("HealthCheck body = %s, want {\"status\":\"ok\"}", body)
}
if strings.Contains(body, `"code"`) || strings.Contains(body, `"data"`) {
t.Fatalf("HealthCheck should not use response envelope, got %s", body)
}
}
func TestQueryInt(t *testing.T) {
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
@@ -41,8 +62,8 @@ func TestQueryInt(t *testing.T) {
expected int
}{
{"?page=10", 10},
{"?page=abc", 1}, // 无效返回默认
{"", 1}, // 无参数返回默认
{"?page=abc", 1}, // 无效返回默认
{"", 1}, // 无参数返回默认
}
for _, tt := range tests {
@@ -164,9 +185,9 @@ func TestGetPage(t *testing.T) {
expectedSize int
}{
{"?page=2&page_size=50", 2, 50},
{"?page=0&page_size=0", 1, 20}, // 边界修正
{"?page=0&page_size=0", 1, 20}, // 边界修正
{"?page=-1&page_size=200", 1, 100}, // 超限修正
{"", 1, 20}, // 默认值
{"", 1, 20}, // 默认值
}
for _, tt := range tests {
@@ -206,7 +227,26 @@ func TestGetIDFromPath(t *testing.T) {
}
}
// withResponseMode 切换响应模式并在测试结束后恢复为默认 ModeBusiness。
func withResponseMode(t *testing.T, m response.Mode) {
t.Helper()
response.SetMode(m)
t.Cleanup(func() { response.SetMode(response.ModeBusiness) })
}
// decodeBody 解析统一响应体。
func decodeBody(t *testing.T, w *httptest.ResponseRecorder) response.Response {
t.Helper()
var body response.Response
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v (body=%q)", err, w.Body.String())
}
return body
}
func TestBadRequest(t *testing.T) {
// 默认 ModeBusiness:所有失败响应 HTTP 200,错误经 body code 表达。
withResponseMode(t, response.ModeBusiness)
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
handler.BadRequest(c, "参数错误")
@@ -216,12 +256,21 @@ func TestBadRequest(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("BadRequest status = %d, want 400", w.Code)
if w.Code != http.StatusOK {
t.Errorf("BadRequest (ModeBusiness) status = %d, want 200", w.Code)
}
body := decodeBody(t, w)
if body.Code != response.CodeFail {
t.Errorf("BadRequest code = %d, want %d", body.Code, response.CodeFail)
}
if body.Msg != "参数错误" {
t.Errorf("BadRequest msg = %q, want %q", body.Msg, "参数错误")
}
}
func TestInternalError(t *testing.T) {
// 默认 ModeBusiness:服务器错误也走 HTTP 200,错误经 body code 表达。
withResponseMode(t, response.ModeBusiness)
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
handler.InternalError(c, "服务器错误")
@@ -231,8 +280,88 @@ func TestInternalError(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("InternalError (ModeBusiness) status = %d, want 200", w.Code)
}
body := decodeBody(t, w)
if body.Code != response.CodeServerError {
t.Errorf("InternalError code = %d, want %d", body.Code, response.CodeServerError)
}
}
// TestBadRequestRESTModeModeREST 下 CodeFail 属业务失败不映射 HTTP 错误,仍 200。
// 修复前 BadRequest 硬编 400 绕过 Mode;修复后委托 response.FailWithCode 遵循 Mode。
func TestBadRequestRESTMode(t *testing.T) {
withResponseMode(t, response.ModeREST)
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
handler.BadRequest(c, "参数错误")
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
if w.Code != http.StatusOK {
t.Errorf("BadRequest (ModeREST) status = %d, want 200 (CodeFail 不映射)", w.Code)
}
body := decodeBody(t, w)
if body.Code != response.CodeFail {
t.Errorf("BadRequest code = %d, want %d", body.Code, response.CodeFail)
}
}
// TestInternalErrorRESTModeModeREST 下 CodeServerError 映射 HTTP 500。
func TestInternalErrorRESTMode(t *testing.T) {
withResponseMode(t, response.ModeREST)
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
handler.InternalError(c, "服务器错误")
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
if w.Code != http.StatusInternalServerError {
t.Errorf("InternalError status = %d, want 500", w.Code)
t.Errorf("InternalError (ModeREST) status = %d, want 500", w.Code)
}
body := decodeBody(t, w)
if body.Code != response.CodeServerError {
t.Errorf("InternalError code = %d, want %d", body.Code, response.CodeServerError)
}
}
// TestBadRequestWritesRequestID:修复前 BadRequest 直接 c.JSON 不写 RequestID(丢链路);
// 修复后委托 response 体系,经 writeResp 写入上下文中的 request_id。
func TestBadRequestWritesRequestID(t *testing.T) {
r := setupTestRouter()
r.Use(func(c *gin.Context) { c.Set("request_id", "req-123"); c.Next() })
r.GET("/test", func(c *gin.Context) {
handler.BadRequest(c, "参数错误")
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
body := decodeBody(t, w)
if body.RequestID != "req-123" {
t.Errorf("BadRequest request_id = %q, want %q (修复前为空)", body.RequestID, "req-123")
}
}
// TestInternalErrorWritesRequestID:同上,覆盖 InternalError。
func TestInternalErrorWritesRequestID(t *testing.T) {
r := setupTestRouter()
r.Use(func(c *gin.Context) { c.Set("request_id", "req-456"); c.Next() })
r.GET("/test", func(c *gin.Context) {
handler.InternalError(c, "服务器错误")
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
body := decodeBody(t, w)
if body.RequestID != "req-456" {
t.Errorf("InternalError request_id = %q, want %q (修复前为空)", body.RequestID, "req-456")
}
}
@@ -258,4 +387,28 @@ func TestBindJSON(t *testing.T) {
if w.Code != 200 {
t.Errorf("BindJSON valid status = %d", w.Code)
}
}
}
func TestBindJSONWithMaxBytesRejectsOversizedBody_M6(t *testing.T) {
r := setupTestRouter()
r.POST("/test", func(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := handler.BindJSONWithMaxBytes(c, &req, 8); err == nil {
c.JSON(http.StatusOK, req)
return
}
handler.BadRequest(c, "绑定失败")
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"name":"body-too-large"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
body := decodeBody(t, w)
if body.Code != response.CodeFail {
t.Fatalf("oversized BindJSON code = %d, want %d", body.Code, response.CodeFail)
}
}
+279 -86
View File
@@ -8,12 +8,15 @@ import (
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// Claims JWT 声明
@@ -37,8 +40,50 @@ var (
ErrTokenNotValidYet = errors.New("令牌尚未生效")
//ErrTokenRevoked 令牌已被撤销
ErrTokenRevoked = errors.New("令牌已被撤销")
// ErrBlacklistUnavailable Redis 未初始化或不可用,黑名单功能失效(C9a 修复)。
// Add 返回此错误使调用方(RefreshToken/InvalidateToken)感知黑名单不可用并 fail-closed
// 避免无 Redis 时静默成功致撤销/刷新失效、新旧 token 双有效。
// IsBlacklisted 在无 Redis 时仍返回 false(验证侧 fail-open 是无 Redis 部署的固有局限,
// 文档约束:安全敏感场景必须启用 Redis)。
ErrBlacklistUnavailable = errors.New("token 黑名单不可用:Redis 未初始化")
// ErrEmptySecret JWT 密钥为空(P0 修复)。空 secret 意味着以零长度 HMAC 密钥签发/校验,
// 任何以 "" 签名的 token 都会通过——签发与校验一律 fail-closed 拒绝,杜绝该空密钥绕过。
ErrEmptySecret = errors.New("jwt.secret 未配置:拒绝签发/校验(防空密钥导致任意 token 通过)")
// ErrUnsupportedAlgorithm 配置了不支持的签名算法(P0 修复)。
// 本实现仅支持 HMAC 族(HS256/HS384/HS512);RS256 等非对称算法暂不支持,
// 不再静默回退 HS256——避免用户误以为在用非对称算法、实则 HMAC,并助长算法混淆攻击。
ErrUnsupportedAlgorithm = errors.New("jwt: 不支持的签名算法(仅支持 HS256/HS384/HS512")
// ErrInvalidExpiry 过期时间非法。签发非正过期时间的 token 会立即失效或产生不可预期会话语义。
ErrInvalidExpiry = errors.New("jwt: 过期时间必须大于 0")
// ErrEmptyJTI 空 JTI 无法匹配任何正常 token,却会写入 jwt_bl: 这种永不命中的黑名单键。
ErrEmptyJTI = errors.New("jwt: jti 不能为空")
)
// validMethods 允许的签名算法名(HMAC 族)。ParseWithClaims 传 jwt.WithValidMethods 固定算法,
// 防算法混淆(alg confusionP0):拒绝 alg=none 及非 HMAC 算法——否则若部署配置为非对称算法,
// 攻击者可用公钥作为 HMAC 密钥伪造 token 通过校验。
var validMethods = []string{"HS256", "HS384", "HS512"}
// secretKey 返回 HMAC 密钥字节;cfg 为 nil 或密钥为空时 fail-closed 返回 ErrEmptySecretP0)。
func secretKey(cfg *config.Config) ([]byte, error) {
if cfg == nil || cfg.JWT.Secret == "" {
return nil, ErrEmptySecret
}
return []byte(cfg.JWT.Secret), nil
}
// hmacKeyfunc 构造校验签名方法为 HMAC 族并返回密钥的 jwt.Keyfunc(P0:防算法混淆 + 空密钥)。
// 双重防护:① 断言 token.Method 为 *jwt.SigningMethodHMAC,拒绝非 HMAC(含 none/RS/ES);
// ② 经 secretKey 拒绝空密钥。配合 ParseWithClaims 的 jwt.WithValidMethods(validMethods) 使用。
func hmacKeyfunc(cfg *config.Config) jwt.Keyfunc {
return func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("%w: 期望 HMAC,实际 alg=%v", ErrUnsupportedAlgorithm, token.Header["alg"])
}
return secretKey(cfg)
}
}
// generateJTI 生成唯一的 JWT ID
func generateJTI() (string, error) {
bytes := make([]byte, 16)
@@ -66,16 +111,48 @@ func (tb *TokenBlacklist) redisClient() *redis.Client {
return database.GetRedis()
}
// blacklistOpTimeout 黑名单 Redis 操作的上下文超时(M-A 修复)。
// Redis 客户端已配 ReadTimeout/WriteTimeout=3sredis.go D7),但鉴权热路径(ParseToken
// 每次调 IsBlacklisted)需更紧边界——显式 ctx 超时把鉴权阻塞上限收敛到 1s,避免 Redis
// 挂起时每个鉴权请求被长时间拖住。健康 Redis 下 Exists/SET 为亚毫秒级,1s 余量充足。
// 注:ctx 超时只约束命令往返,不影响 Set 的服务端 TTL(ttl 可远大于 1s)。
const blacklistOpTimeout = 1 * time.Second
// BlacklistPolicy 控制解析 Token 时遇到黑名单查询错误的处理策略。
// ParseToken 默认 BlacklistFailClosed(黑名单不可检查即拒绝);
// 需显式 fail-open(仅无 Redis 或低安全场景)用 ParseTokenWithBlacklistPolicy(..., BlacklistFailOpen)。
type BlacklistPolicy int
const (
// BlacklistFailOpen 黑名单查询出错时放行 Token(仅用于无 Redis 或低安全场景,非默认)。
BlacklistFailOpen BlacklistPolicy = iota
// BlacklistFailClosed 黑名单查询出错时拒绝 Token(默认;ParseToken 即用此策略)。
BlacklistFailClosed
)
// blacklistCtx 创建带超时的 context 用于黑名单 Redis 操作(M-A)。
func blacklistCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), blacklistOpTimeout)
}
// Add 将 Token 的 JTI 加入黑名单
// 参数: jti JWT IDexpiry Token 过期时间
//
// 无 Redis 时返回 ErrBlacklistUnavailableC9a 修复):让调用方(RefreshToken/InvalidateToken
// 感知黑名单不可用并 fail-closed,避免无 Redis 时静默成功致撤销失效。
func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
client := tb.redisClient()
if client == nil {
// Redis 未启用,跳过黑名单
return nil
if strings.TrimSpace(jti) == "" {
return ErrEmptyJTI
}
ctx := context.Background()
client := tb.redisClient()
if client == nil {
// Redis 未启用,黑名单不可用——fail-closed 让调用方决策。
return ErrBlacklistUnavailable
}
ctx, cancel := blacklistCtx()
defer cancel()
ttl := time.Until(expiry)
if ttl <= 0 {
// Token 已过期,无需加入黑名单
@@ -87,17 +164,23 @@ func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
return client.Set(ctx, key, "1", ttl).Err()
}
// IsBlacklisted 检查 JTI 是否在黑名单中
func (tb *TokenBlacklist) IsBlacklisted(jti string) bool {
// IsBlacklisted 检查 JTI 是否在黑名单中
// 命中返回 (true, nil);未命中返回 (false, nil)
// Redis 未启用或不可达返回 (false, ErrBlacklistUnavailable),由调用方决定 fail-open/fail-closed。
func (tb *TokenBlacklist) IsBlacklisted(jti string) (bool, error) {
client := tb.redisClient()
if client == nil {
// Redis 未启用,不检查黑名单
return false
return false, ErrBlacklistUnavailable
}
ctx := context.Background()
ctx, cancel := blacklistCtx()
defer cancel()
key := fmt.Sprintf("jwt_bl:%s", jti)
return client.Exists(ctx, key).Val() > 0
n, err := client.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
// Manager JWT 管理器(#10)。持有独立的 TokenBlacklist
@@ -107,8 +190,34 @@ type Manager struct {
blacklist *TokenBlacklist
}
// DefaultJWT 默认 JWT 管理器,包级 facade 代理到它的 blacklist
var DefaultJWT = NewJWTManager()
// defaultManager 是全局默认 JWT 管理器的真实存储,经 atomic 读写(C9c)
var defaultManager atomic.Pointer[Manager]
func init() {
defaultManager.Store(NewJWTManager())
}
// currentManager 返回全局默认 JWT 管理器(atomic 读取,C9c)。
// 正常情况下 init 后永不为 nil;防御性地在极罕见的 nil 情况下回退一个懒取 Redis 的实例。
func currentManager() *Manager {
if m := defaultManager.Load(); m != nil {
return m
}
m := NewJWTManager()
defaultManager.Store(m)
return m
}
// GetDefaultJWT 返回全局默认 JWT 管理器(并发安全,C9c/J1 修复)。
// 替代已删除的 DefaultJWT 包级变量。
func GetDefaultJWT() *Manager {
return currentManager()
}
// currentBlacklist 返回全局默认 Manager 持有的黑名单(atomic 读取 Manager 后经 Blacklist()C9c)。
func currentBlacklist() *TokenBlacklist {
return currentManager().Blacklist()
}
// NewJWTManager 创建 JWT 管理器实例(blacklist 懒取全局 Redis)。
func NewJWTManager() *Manager {
@@ -120,12 +229,12 @@ func NewJWTManagerWithRedis(client *redis.Client) *Manager {
return &Manager{blacklist: NewTokenBlacklist(client)}
}
// SetDefaultJWTManager 提升指定 Manager 为全局默认。
// SetDefaultJWTManager 提升指定 Manager 为全局默认(atomic 置换,并发安全,J1 修复)
func SetDefaultJWTManager(m *Manager) {
if m != nil {
DefaultJWT = m
tokenBlacklist = m.blacklist
if m == nil {
return
}
defaultManager.Store(m)
}
// Blacklist 返回 Manager 持有的黑名单实例。
@@ -135,12 +244,30 @@ func (m *Manager) Blacklist() *TokenBlacklist {
return m.blacklist
}
// 全局黑名单实例(指向 DefaultJWT 的 blacklist,兼容存量包级函数)
var tokenBlacklist = DefaultJWT.blacklist
// GenerateToken 生成 JWT Token
func GenerateToken(userID uint, username, role, userType string) (string, error) {
cfg := config.Get()
var expiry time.Duration
if cfg != nil {
expiry = cfg.JWT.Expire
}
return generateTokenWithExpiry(cfg, userID, username, role, userType, expiry)
}
func generateTokenWithExpiry(cfg *config.Config, userID uint, username, role, userType string, expiry time.Duration) (string, error) {
if expiry <= 0 {
return "", ErrInvalidExpiry
}
// P0:先校验密钥非空与算法受支持(fail-closed)。secretKey 亦守卫 cfg==nil
// 通过后 cfg 保证非空,后续访问 cfg.JWT.* 安全。
key, err := secretKey(cfg)
if err != nil {
return "", err
}
method, err := signingMethod(cfg.JWT.Algorithm)
if err != nil {
return "", err
}
// 生成唯一的 JWT ID
jti, err := generateJTI()
@@ -155,7 +282,7 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
UserType: userType,
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.JWT.Expire)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: issuerOrDefault(cfg.JWT.Issuer),
@@ -163,36 +290,17 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
},
}
token := jwt.NewWithClaims(signingMethod(cfg.JWT.Algorithm), claims)
return token.SignedString([]byte(cfg.JWT.Secret))
token := jwt.NewWithClaims(method, claims)
return token.SignedString(key)
}
// GenerateTokenWithCustomExpiry 生成带自定义过期时间的 Token
func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) {
cfg := config.Get()
jti, err := generateJTI()
if err != nil {
return "", err
if expireSeconds <= 0 {
return "", ErrInvalidExpiry
}
claims := Claims{
UserID: userID,
Username: username,
Role: role,
UserType: userType,
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireSeconds) * time.Second)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: issuerOrDefault(cfg.JWT.Issuer),
ID: jti,
},
}
token := jwt.NewWithClaims(signingMethod(cfg.JWT.Algorithm), claims)
return token.SignedString([]byte(cfg.JWT.Secret))
return generateTokenWithExpiry(cfg, userID, username, role, userType, time.Duration(expireSeconds)*time.Second)
}
// issuerOrDefault 返回配置的 issuer,未配置时回退 "xlgo"。
@@ -204,44 +312,101 @@ func issuerOrDefault(issuer string) string {
}
// signingMethod 根据 algorithm 配置返回 HMAC 签名方法。
// 目前支持 HS256(默认)/HS384/HS512;其它值回退 HS256。
// RS256 等非对称算法需扩展密钥类型,暂不支持
func signingMethod(algorithm string) jwt.SigningMethod {
switch strings.ToUpper(algorithm) {
// 支持 HS256默认,空值等价)/HS384/HS512;其它值(含 RS256 等非对称算法,暂不支持)
// 返回 ErrUnsupportedAlgorithm,不再静默回退 HS256P0:防"配 RS256 实得 HMAC"的算法混淆隐患)
func signingMethod(algorithm string) (jwt.SigningMethod, error) {
switch strings.ToUpper(strings.TrimSpace(algorithm)) {
case "", "HS256":
return jwt.SigningMethodHS256, nil
case "HS384":
return jwt.SigningMethodHS384
return jwt.SigningMethodHS384, nil
case "HS512":
return jwt.SigningMethodHS512
return jwt.SigningMethodHS512, nil
default:
return jwt.SigningMethodHS256
return nil, fmt.Errorf("%w: %q", ErrUnsupportedAlgorithm, algorithm)
}
}
// ParseToken 解析 JWT Token
func parseOptions(cfg *config.Config) []jwt.ParserOption {
issuer := "xlgo"
if cfg != nil {
issuer = issuerOrDefault(cfg.JWT.Issuer)
}
return []jwt.ParserOption{
jwt.WithValidMethods(validMethods),
jwt.WithIssuer(issuer),
}
}
func validateIssuer(cfg *config.Config, claims *Claims) error {
want := "xlgo"
if cfg != nil {
want = issuerOrDefault(cfg.JWT.Issuer)
}
if claims == nil || claims.Issuer != want {
return ErrTokenInvalid
}
return nil
}
func mapParseTokenError(err error) error {
if errors.Is(err, jwt.ErrTokenExpired) {
return ErrTokenExpired
}
if errors.Is(err, jwt.ErrTokenMalformed) {
return ErrTokenMalformed
}
if errors.Is(err, jwt.ErrTokenNotValidYet) {
return ErrTokenNotValidYet
}
if errors.Is(err, ErrEmptySecret) {
return ErrEmptySecret
}
if errors.Is(err, ErrUnsupportedAlgorithm) {
return ErrUnsupportedAlgorithm
}
return fmt.Errorf("%w: %w", ErrTokenInvalid, err)
}
func checkTokenBlacklist(claims *Claims, policy BlacklistPolicy) error {
if claims == nil || claims.JTI == "" {
return nil
}
revoked, err := currentBlacklist().IsBlacklisted(claims.JTI)
if err != nil {
if policy == BlacklistFailClosed {
return err
}
logger.Warn("JWT 黑名单检查失败,fail-open 策略放行 token", zap.String("jti", claims.JTI), zap.Error(err))
return nil
}
if revoked {
return ErrTokenRevoked
}
return nil
}
// ParseToken 解析 JWT Token。默认 fail-closed:黑名单后端不可检查时返回
// ErrBlacklistUnavailable,拒绝该 Token。需要显式 fail-open(仅无 Redis 或低安全场景)
// 请用 ParseTokenWithBlacklistPolicy(token, BlacklistFailOpen)。
func ParseToken(tokenString string) (*Claims, error) {
return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailClosed)
}
// ParseTokenWithBlacklistPolicy 使用显式黑名单查询策略解析 JWT Token。
func ParseTokenWithBlacklistPolicy(tokenString string, policy BlacklistPolicy) (*Claims, error) {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
return []byte(cfg.JWT.Secret), nil
})
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), parseOptions(cfg)...)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
if errors.Is(err, jwt.ErrTokenMalformed) {
return nil, ErrTokenMalformed
}
if errors.Is(err, jwt.ErrTokenNotValidYet) {
return nil, ErrTokenNotValidYet
}
return nil, ErrTokenInvalid
return nil, mapParseTokenError(err)
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
// 使用 JTI 检查黑名单(更高效)
if claims.JTI != "" && tokenBlacklist.IsBlacklisted(claims.JTI) {
return nil, ErrTokenRevoked
if err := checkTokenBlacklist(claims, policy); err != nil {
return nil, err
}
return claims, nil
}
@@ -253,18 +418,23 @@ func ParseToken(tokenString string) (*Claims, error) {
func InvalidateToken(tokenString string) error {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
return []byte(cfg.JWT.Secret), nil
})
opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation())
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...)
if err != nil {
// Token 无效或已过期,无需加入黑名单
// Token 签名/格式/issuer 无效,无需加入黑名单
return nil
}
if claims, ok := token.Claims.(*Claims); ok {
if claims.JTI != "" && claims.ExpiresAt != nil {
return tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time)
if err := validateIssuer(cfg, claims); err != nil {
return nil
}
if strings.TrimSpace(claims.JTI) == "" {
return ErrEmptyJTI
}
if claims.ExpiresAt != nil {
return currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time)
}
}
@@ -274,22 +444,42 @@ func InvalidateToken(tokenString string) error {
// InvalidateTokenByID 直接通过 JTI 使 Token 失效
// 参数: jti JWT IDexpiry 过期时间
func InvalidateTokenByID(jti string, expiry time.Time) error {
return tokenBlacklist.Add(jti, expiry)
if strings.TrimSpace(jti) == "" {
return ErrEmptyJTI
}
return currentBlacklist().Add(jti, expiry)
}
// RefreshToken 刷新 Token
//
// 安全约束(C9b 修复):将旧 Token 加入黑名单的 Add 错误必须向上传播——若 Add 失败
// (Redis 抖动或未启用)仍签发新 token,会导致旧 token 未拉黑、新旧 token 双有效,
// 形成会话固定窗口。故 Add 失败时不签发新 tokenfail-closed)。
func RefreshToken(tokenString string) (string, error) {
claims, err := ParseToken(tokenString)
if err != nil {
return "", err
}
// 将旧 Token 加入黑名单
if claims.JTI != "" && claims.ExpiresAt != nil {
tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time)
// 将旧 Token 加入黑名单;失败则不签发新 token(C9b:禁止吞 Add 错误)。
if strings.TrimSpace(claims.JTI) == "" {
return "", ErrEmptyJTI
}
if claims.ExpiresAt != nil {
if err := currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time); err != nil {
return "", fmt.Errorf("刷新令牌失败:旧令牌撤销失败: %w", err)
}
}
return GenerateToken(claims.UserID, claims.Username, claims.Role, claims.UserType)
cfg := config.Get()
var expiry time.Duration
if cfg != nil {
expiry = cfg.JWT.RefreshExpire
}
if expiry <= 0 && cfg != nil {
expiry = cfg.JWT.Expire
}
return generateTokenWithExpiry(cfg, claims.UserID, claims.Username, claims.Role, claims.UserType, expiry)
}
// GetJTI 从 Token 中提取 JTI(不验证签名)
@@ -307,9 +497,10 @@ func GetJTI(tokenString string) (string, error) {
return "", ErrTokenInvalid
}
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
func IsTokenRevoked(jti string) bool {
return tokenBlacklist.IsBlacklisted(jti)
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
// 返回 (是否撤销, 错误);黑名单后端不可用时返回 (false, ErrBlacklistUnavailable)。
func IsTokenRevoked(jti string) (bool, error) {
return currentBlacklist().IsBlacklisted(jti)
}
// GetClaimsFromToken 获取 Token 的 Claims(不验证过期)
@@ -317,17 +508,19 @@ func IsTokenRevoked(jti string) bool {
func GetClaimsFromToken(tokenString string) (*Claims, error) {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
return []byte(cfg.JWT.Secret), nil
}, jwt.WithoutClaimsValidation())
opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation())
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...)
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok {
if err := validateIssuer(cfg, claims); err != nil {
return nil, err
}
return claims, nil
}
return nil, ErrTokenInvalid
}
}
+142
View File
@@ -0,0 +1,142 @@
package jwt
import (
"sync"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// c9cSetupConfig 注入测试用 JWT 配置(内部测试无法访问 jwt_test 的 setupTestConfig)。
func c9cSetupConfig(t *testing.T) {
t.Helper()
config.Set(&config.Config{
JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
Expire: time.Hour,
},
})
}
// TestC9cConcurrentSetDefaultAndRead 验证 SetDefaultJWTManager(写)与包级函数
// (读 currentBlacklist → currentManager → defaultManager.Load)并发无数据竞争(C9c)。
// 修复前 tokenBlacklist 为裸包级指针,SetDefaultJWTManager 裸写、ParseToken/IsTokenRevoked/
// InvalidateTokenByID 裸读,-race 必采到指针读写竞争。
func TestC9cConcurrentSetDefaultAndRead(t *testing.T) {
c9cSetupConfig(t)
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) }) // 还原无 Redis 基线
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
// 预生成一个合法 token 供读者 ParseTokenJTI 随机,不会被下方 InvalidateTokenByID 的固定 jti 命中)
token, err := GenerateToken(1, "u", "admin", "super_admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
var wg sync.WaitGroup
stop := make(chan struct{})
// 写者:在"注入 Redis"与"无 Redis"Manager 间原子置换(模拟启动期/测试期 SetDefaultJWTManager
wg.Add(1)
go func() {
defer wg.Done()
withRedis := NewJWTManagerWithRedis(client)
withoutRedis := NewJWTManager()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
if i%2 == 0 {
SetDefaultJWTManager(withRedis)
} else {
SetDefaultJWTManager(withoutRedis)
}
}
}()
// 读者:并发调用经 currentBlacklist() 的包级函数 + currentManager
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_, _ = ParseToken(token)
_, _ = IsTokenRevoked("some-jti")
_ = InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
_ = currentManager()
}
}()
}
// 让竞争窗口运行一段时间,确保 -race 能采到(若有未加锁读)
time.Sleep(50 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestC9cCurrentManagerReflectsSwap 验证 SetDefaultJWTManager 原子置换后,
// 包级函数经 currentManager 读到的是新 Manager 的 blacklistC9c 一致性)。
func TestC9cCurrentManagerReflectsSwap(t *testing.T) {
c9cSetupConfig(t)
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) })
// 基线:无 RedisInvalidateTokenByID 返 ErrBlacklistUnavailable
if err := InvalidateTokenByID("jti-1", time.Now().Add(time.Hour)); !errorIsBlacklistUnavailable(err) {
t.Fatalf("baseline without Redis: want ErrBlacklistUnavailable, got %v", err)
}
// 置换为注入 Redis 的 Manager
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
SetDefaultJWTManager(NewJWTManagerWithRedis(client))
// 置换后包级函数应读到新 blacklistRedis 可用),InvalidateTokenByID 成功
if err := InvalidateTokenByID("jti-2", time.Now().Add(time.Hour)); err != nil {
t.Fatalf("after swap to Redis Manager: InvalidateTokenByID err = %v, want nil", err)
}
// currentManager 应等于刚 Store 的 Manager
m := currentManager()
if m == nil {
t.Fatal("currentManager should not be nil")
}
}
// TestC9cDefaultJWTAliasConsistent 验证 GetDefaultJWT 与 defaultManager 真实存储一致。
func TestC9cDefaultJWTAliasConsistent(t *testing.T) {
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) })
// init 后 GetDefaultJWT 与 currentManager 应指向同一实例
if GetDefaultJWT() != currentManager() {
t.Fatal("GetDefaultJWT should equal currentManager after init")
}
// SetDefaultJWTManager 后同步
m := NewJWTManagerWithRedis(nil)
SetDefaultJWTManager(m)
if GetDefaultJWT() != m {
t.Fatal("GetDefaultJWT should sync after SetDefaultJWTManager")
}
if currentManager() != m {
t.Fatal("currentManager should reflect SetDefaultJWTManager")
}
}
// errorIsBlacklistUnavailable 避免内部测试依赖 errors.Is 包装判断。
func errorIsBlacklistUnavailable(err error) bool {
return err != nil && err.Error() == ErrBlacklistUnavailable.Error()
}
+390 -29
View File
@@ -1,11 +1,15 @@
package jwt_test
import (
"errors"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/jwt"
"github.com/alicebob/miniredis/v2"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
)
func setupTestConfig() {
@@ -13,7 +17,7 @@ func setupTestConfig() {
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
Expire: time.Hour, // 1小时
Expire: time.Hour, // 1小时
},
}
config.Set(cfg)
@@ -40,6 +44,7 @@ func TestGenerateToken(t *testing.T) {
func TestParseToken(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
// 先生成 token
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
@@ -89,11 +94,13 @@ func TestParseTokenWrongSecret(t *testing.T) {
// 修改 secret
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "different-secret",
Secret: "different-secret-key-12345678901234567890",
Expire: 3600,
},
}
config.Set(cfg)
if err := config.Set(cfg); err != nil {
t.Fatalf("Set wrong secret config: %v", err)
}
// 应该解析失败
_, err := jwt.ParseToken(token)
@@ -108,25 +115,27 @@ func TestRefreshToken(t *testing.T) {
// 生成 token
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
// 新 token
newToken, err := jwt.RefreshToken(token)
if err != nil {
t.Fatalf("RefreshToken error: %v", err)
// 无 Redis 时 RefreshToken 必须 fail-closedC9b 修复:旧 token 撤销失败不签发新 token
_, err := jwt.RefreshToken(token)
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("RefreshToken without Redis should fail with ErrBlacklistUnavailable, got %v", err)
}
}
if newToken == "" {
t.Error("RefreshToken should return non-empty token")
}
// 新 token 应可解析
claims, err := jwt.ParseToken(newToken)
if err != nil {
t.Fatalf("ParseToken new token error: %v", err)
}
if claims.Username != "testuser" {
t.Error("RefreshToken claims should match original")
}
// setupMiniRedis 启动 miniredis 并注入到 jwt 包级 tokenBlacklist(经 SetDefaultJWTManager)。
// 测试结束 cleanup 还原为无 Redis 的默认 Manager,避免污染后续测试(-shuffle 下尤其关键)。
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()
// 还原包级 tokenBlacklist 为无 Redis 默认 Manager,避免残留指向已关闭 miniredis 的 client。
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
})
// 用注入 Redis 的 Manager 替换默认,使包级 tokenBlacklist 指向 miniredis。
jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(client))
return mr
}
func TestClaimsStructure(t *testing.T) {
@@ -169,15 +178,16 @@ func TestErrorDefinitions(t *testing.T) {
func TestTokenBlacklist(t *testing.T) {
tb := jwt.TokenBlacklist{}
// 无 Redis 时,Add 应返回 nil
// 无 Redis 时,Add 应返回 ErrBlacklistUnavailableC9a 修复:fail-closed
err := tb.Add("test-token", time.Now().Add(time.Hour))
if err != nil {
t.Errorf("TokenBlacklist.Add without Redis should return nil, got %v", err)
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err)
}
// 无 Redis 时,IsBlacklisted 返回 false
if tb.IsBlacklisted("test-token") {
t.Error("TokenBlacklist.IsBlacklisted without Redis should return false")
// 无 Redis 时,IsBlacklisted 返回 (false, ErrBlacklistUnavailable)fail-closed:错误上抛)
revoked, err := tb.IsBlacklisted("test-token")
if revoked || !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("TokenBlacklist.IsBlacklisted without Redis = (%v, %v), want (false, ErrBlacklistUnavailable)", revoked, err)
}
}
@@ -186,10 +196,10 @@ func TestInvalidateToken(t *testing.T) {
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
// 无 Redis 时应返回 nil
// 无 Redis 时应返回 ErrBlacklistUnavailableC9a 修复:fail-closed,不再静默成功)
err := jwt.InvalidateToken(token)
if err != nil {
t.Errorf("InvalidateToken without Redis should return nil, got %v", err)
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("InvalidateToken without Redis should return ErrBlacklistUnavailable, got %v", err)
}
}
@@ -215,3 +225,354 @@ func splitToken(token string) []string {
result = append(result, token[start:])
return result
}
// ===== C9b 回归:刷新令牌撤销闭环 =====
// 回归 C9bRefreshToken 成功后,旧 token 必须被拉黑(ParseToken 返 ErrTokenRevoked),
// 新 token 可用。旧实现丢弃 Add 错误仍签发新 token,旧 token 仍有效(双有效)。
func TestRefreshTokenRevokesOldToken(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
// 刷新
newToken, err := jwt.RefreshToken(token)
if err != nil {
t.Fatalf("RefreshToken: %v", err)
}
if newToken == "" {
t.Fatal("RefreshToken should return non-empty token")
}
// 新 token 可解析
newClaims, err := jwt.ParseToken(newToken)
if err != nil {
t.Fatalf("ParseToken new token: %v", err)
}
if newClaims.Username != "testuser" {
t.Error("new token claims should match original")
}
// 旧 token 必须已被拉黑(C9b 核心)
_, err = jwt.ParseToken(token)
if !errors.Is(err, jwt.ErrTokenRevoked) {
t.Errorf("old token after refresh err = %v, want ErrTokenRevoked (C9b: old must be blacklisted)", err)
}
}
// 回归 C9bRedis 抖动(Add 失败)时 RefreshToken 必须 fail-closed,不签发新 token。
// 旧实现丢弃 Add 错误仍签发新 token → 旧 token 未拉黑、新旧双有效。
func TestRefreshTokenFailsOnRedisError(t *testing.T) {
setupTestConfig()
mr := setupMiniRedis(t)
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
// 模拟 Redis 抖动:关闭 miniredis,使 Add 的 Set 失败。
mr.Close()
_, err := jwt.RefreshToken(token)
if err == nil {
t.Fatal("RefreshToken should fail when Redis unavailable (C9b: must not issue new token)")
}
// 不应是 ErrBlacklistUnavailable(那是无 Redis 路径),而是 Add 的 Set 错误包装。
// 关键:未签发新 token,旧 token 未被拉黑(因 Add 失败)。
}
// 回归 C9a/bInvalidateToken 闭环——登出后 token 被拉黑、ParseToken 返 ErrTokenRevoked。
func TestInvalidateTokenRevokesToken(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
// 登出前可解析
if _, err := jwt.ParseToken(token); err != nil {
t.Fatalf("ParseToken before invalidate: %v", err)
}
// 登出(拉黑)
if err := jwt.InvalidateToken(token); err != nil {
t.Fatalf("InvalidateToken: %v", err)
}
// 登出后必须被拉黑
_, err := jwt.ParseToken(token)
if !errors.Is(err, jwt.ErrTokenRevoked) {
t.Errorf("ParseToken after invalidate err = %v, want ErrTokenRevoked", err)
}
}
// 回归 C9a:无 Redis 时 InvalidateTokenByID 返 ErrBlacklistUnavailablefail-closed)。
func TestInvalidateTokenByIDNoRedis(t *testing.T) {
setupTestConfig()
// 不 setupMiniRedis → tokenBlacklist 指向无 Redis 的 Manager
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) }) // 还原基线
err := jwt.InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("InvalidateTokenByID without Redis err = %v, want ErrBlacklistUnavailable", err)
}
}
// ===== P0 回归:算法混淆 / 空密钥 / 不支持算法 =====
// 回归 P0alg=none 的 token 必须被拒(jwt.WithValidMethods 固定 HMAC 族)。
// 这是算法混淆攻击的一种典型形态——攻击者去掉签名并将 alg 置为 none。
func TestParseTokenRejectsAlgNone(t *testing.T) {
setupTestConfig()
claims := jwt.Claims{UserID: 1, Username: "attacker", Role: "admin", UserType: "super_admin"}
// 构造 alg=none 的未签名 token。
noneToken := gojwt.NewWithClaims(gojwt.SigningMethodNone, claims)
signed, err := noneToken.SignedString(gojwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("构造 none token 失败: %v", err)
}
if _, err := jwt.ParseToken(signed); err == nil {
t.Error("ParseToken 必须拒绝 alg=none 的 token(算法混淆防护)")
}
}
// 回归 P0:空密钥时 GenerateToken/ParseToken 必须 fail-closed 返 ErrEmptySecret
// 杜绝以零长度 HMAC 密钥签发/校验导致任意 token 通过。
func TestEmptySecretFailsClosed(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
t.Cleanup(setupTestConfig) // 还原基线,避免污染其它测试
_, err := jwt.GenerateToken(1, "u", "admin", "admin")
if !errors.Is(err, jwt.ErrEmptySecret) {
t.Errorf("空密钥 GenerateToken err = %v, want ErrEmptySecret", err)
}
// 校验侧:任意 token 在空密钥下都不得通过。
if _, err := jwt.ParseToken("a.b.c"); err == nil {
t.Error("空密钥 ParseToken 不得通过任何 token")
}
}
// 回归 P0:配置不支持的算法(如 RS256)时 GenerateToken 必须返 ErrUnsupportedAlgorithm
// 不再静默回退 HS256。
func TestUnsupportedAlgorithmFailsClosed(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Algorithm: "RS256",
Expire: time.Hour,
}})
t.Cleanup(setupTestConfig)
_, err := jwt.GenerateToken(1, "u", "admin", "admin")
if !errors.Is(err, jwt.ErrUnsupportedAlgorithm) {
t.Errorf("RS256 GenerateToken err = %v, want ErrUnsupportedAlgorithm", err)
}
}
// 回归 P0:显式支持的 HS384 仍可正常签发与校验(确认算法固定未误伤合法 HMAC 族)。
func TestSupportedAlgorithmHS384(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Algorithm: "HS384",
Expire: time.Hour,
}})
t.Cleanup(setupTestConfig)
setupMiniRedis(t)
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
if err != nil {
t.Fatalf("HS384 GenerateToken: %v", err)
}
if _, err := jwt.ParseToken(token); err != nil {
t.Errorf("HS384 ParseToken: %v", err)
}
}
func TestParseTokenRejectsWrongIssuer(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Expire: time.Hour,
Issuer: "trusted-issuer",
}})
t.Cleanup(setupTestConfig)
claims := jwt.Claims{
UserID: 1,
Username: "attacker",
Role: "admin",
UserType: "super_admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "evil-issuer",
},
}
token := signTokenForTest(t, claims)
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
t.Errorf("ParseToken wrong issuer err = %v, want ErrTokenInvalid", err)
}
if _, err := jwt.GetClaimsFromToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
t.Errorf("GetClaimsFromToken wrong issuer err = %v, want ErrTokenInvalid", err)
}
}
func TestRefreshTokenUsesRefreshExpire(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Expire: time.Hour,
RefreshExpire: 4 * time.Hour,
}})
t.Cleanup(setupTestConfig)
setupMiniRedis(t)
token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
newToken, err := jwt.RefreshToken(token)
if err != nil {
t.Fatalf("RefreshToken: %v", err)
}
claims, err := jwt.ParseToken(newToken)
if err != nil {
t.Fatalf("ParseToken refreshed token: %v", err)
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl < 3*time.Hour || ttl > 4*time.Hour+time.Minute {
t.Errorf("refreshed token ttl = %v, want about 4h", ttl)
}
}
func TestGenerateTokenWithCustomExpiryRejectsNonPositive(t *testing.T) {
setupTestConfig()
for _, seconds := range []int{0, -1} {
if _, err := jwt.GenerateTokenWithCustomExpiry(1, "u", "admin", "admin", seconds); !errors.Is(err, jwt.ErrInvalidExpiry) {
t.Errorf("GenerateTokenWithCustomExpiry(%d) err = %v, want ErrInvalidExpiry", seconds, err)
}
}
}
func TestInvalidateTokenByIDRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
if err := jwt.InvalidateTokenByID("", time.Now().Add(time.Hour)); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("InvalidateTokenByID empty err = %v, want ErrEmptyJTI", err)
}
}
func TestRefreshTokenRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token := signTokenForTest(t, jwt.Claims{
UserID: 1,
Username: "legacy",
Role: "admin",
UserType: "admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
},
})
if _, err := jwt.RefreshToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("RefreshToken empty JTI err = %v, want ErrEmptyJTI", err)
}
}
func TestInvalidateTokenRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token := signTokenForTest(t, jwt.Claims{
UserID: 1,
Username: "legacy",
Role: "admin",
UserType: "admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
},
})
if err := jwt.InvalidateToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("InvalidateToken empty JTI err = %v, want ErrEmptyJTI", err)
}
}
func TestParseTokenEmptySecretReturnsSentinel(t *testing.T) {
setupTestConfig()
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
if err != nil {
t.Fatalf("GenerateToken before empty secret: %v", err)
}
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
t.Cleanup(setupTestConfig)
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrEmptySecret) {
t.Fatalf("ParseToken empty secret err = %v, want ErrEmptySecret", err)
}
}
func TestParseTokenBlacklistPolicy(t *testing.T) {
setupTestConfig()
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) })
token, err := jwt.GenerateToken(1, "test", "admin", "admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Fatalf("ParseToken default fail-closed should reject without Redis, err = %v, want ErrBlacklistUnavailable", err)
}
if _, err := jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen); err != nil {
t.Fatalf("ParseTokenWithBlacklistPolicy fail-open should pass without Redis: %v", err)
}
}
func TestInvalidateTokenAllowsFutureNotBeforeToken(t *testing.T) {
setupTestConfig()
mr := setupMiniRedis(t)
claims := jwt.Claims{
UserID: 1,
Username: "external",
Role: "admin",
UserType: "admin",
JTI: "future-jti",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now().Add(10 * time.Minute)),
Issuer: "xlgo",
ID: "future-jti",
},
}
token := signTokenForTest(t, claims)
if err := jwt.InvalidateToken(token); err != nil {
t.Fatalf("InvalidateToken future nbf: %v", err)
}
if !mr.Exists("jwt_bl:future-jti") {
t.Fatal("future nbf token JTI should be blacklisted")
}
}
func signTokenForTest(t *testing.T, claims jwt.Claims) string {
t.Helper()
token, err := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims).SignedString([]byte("test-secret-key-1234567890123456789012"))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return token
}
+10 -15
View File
@@ -1,6 +1,8 @@
package logger
import (
"time"
"go.uber.org/zap"
)
@@ -12,23 +14,16 @@ var Field = struct {
Bool func(key string, value bool) zap.Field
Uint func(key string, value uint) zap.Field
Float64 func(key string, value float64) zap.Field
Duration func(key string, value interface{}) zap.Field
Duration func(key string, value time.Duration) zap.Field
Error func(err error) zap.Field
}{
String: zap.String,
Int: zap.Int,
Int64: zap.Int64,
Bool: zap.Bool,
Uint: zap.Uint,
Float64: zap.Float64,
Duration: func(key string, value interface{}) zap.Field {
switch v := value.(type) {
case zap.Field:
return v
default:
return zap.Any(key, value)
}
},
String: zap.String,
Int: zap.Int,
Int64: zap.Int64,
Bool: zap.Bool,
Uint: zap.Uint,
Float64: zap.Float64,
Duration: zap.Duration,
Error: func(err error) zap.Field {
return zap.Error(err)
},
+307 -57
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"sync"
"sync/atomic"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -15,40 +16,217 @@ import (
)
var (
// Logger 全局通用日志实例。Init 之前为 Nop,调用安全
// 内部 atomic 存储——四个 logger 的真实源(H7 修复)
// 所有包级函数(Info/Debug/.../APILog/DBLog/Sync)经 atomic Load 读取,
// 使请求 goroutine 不与 Init/Close 重新装配全局变量竞争。
// 原实现用 m.mu(实例锁)保护包级全局变量,锁与被保护对象作用域错配,
// 读侧无锁裸读 → 热重载 re-Init/Close 与请求日志存在数据竞争。
loggerPtr atomic.Pointer[zap.Logger]
sugarPtr atomic.Pointer[zap.SugaredLogger]
apiLogPtr atomic.Pointer[zap.Logger]
dbLogPtr atomic.Pointer[zap.Logger]
// defaultLoggerPtr 是包级 facade 使用的默认 manager 原子快照。
// 保留导出的 DefaultLogger *LogManager 作为兼容变量;SetDefaultLogManager
// 只替换这个内部指针,避免 facade 裸读/裸写导出变量。
defaultLoggerPtr atomic.Pointer[LogManager]
globalLogGeneration uint64
// globalMu 串行化包级 logger/writer 的发布与关闭。
// 不能仅依赖 LogManager.mu:多个 LogManager 实例并发 Init/Close 时,
// 实例锁无法保护 Logger/fileWriters 这类包级状态。
globalMu sync.Mutex
// Logger 全局通用日志实例(兼容别名)。Init 之前为 Nop,调用安全。
//
// Deprecated: 此导出变量由 Init/Close 在 globalMu 下同步维护,但直接读它在 re-Init/Close
// 期间非并发安全(L-E)。请改用包级函数 Info/Debug/Warn/Error/...(经 atomic 读取,
// 并发安全)。保留仅为向后兼容,未来大版本可能移除。
Logger = zap.NewNop()
sugar = Logger.Sugar()
apiLog = zap.NewNop()
dbLog = zap.NewNop()
// fileWriters 持有所有 lumberjack 实例引用,
// Close() 调用时显式释放文件句柄。
// 必要性:lumberjack 不依赖 GC 关闭文件,进程长跑或测试场景下
// 不显式关闭会持有句柄导致 Windows 上无法删除日志目录。
// 仅在 globalMu 下访问(Init/Close),无读侧竞争。
fileWriters []*lumberjack.Logger
)
func init() {
// 初始化 atomic 存储为 Nop,保证包级函数在任何时刻 Load 均非 nil。
nop := zap.NewNop()
loggerPtr.Store(nop)
sugarPtr.Store(nop.Sugar())
apiLogPtr.Store(nop)
dbLogPtr.Store(nop)
defaultLoggerPtr.Store(DefaultLogger)
}
// currentLogger 返回通用 logger 的 atomic 快照(永不 nil)。
func currentLogger() *zap.Logger {
if l := loggerPtr.Load(); l != nil {
return l
}
return zap.NewNop() // 防御:init 后不可达
}
// currentSugar 返回 sugared logger 的 atomic 快照(永不 nil)。
func currentSugar() *zap.SugaredLogger {
if s := sugarPtr.Load(); s != nil {
return s
}
return zap.NewNop().Sugar() // 防御:init 后不可达
}
// currentAPILog 返回 API logger 的 atomic 快照(永不 nil)。
func currentAPILog() *zap.Logger {
if l := apiLogPtr.Load(); l != nil {
return l
}
return zap.NewNop()
}
// currentDBLog 返回 DB logger 的 atomic 快照(永不 nil)。
func currentDBLog() *zap.Logger {
if l := dbLogPtr.Load(); l != nil {
return l
}
return zap.NewNop()
}
// LogManager 日志管理器(#10)。照 database.Manager 模式:
// 实例化 + DefaultLogger 全局默认 + 包级 facade 代理。
// 包级 Logger/sugar/apiLog/dbLog 由 Init 同步维护,下游 8 个包零改动。
type LogManager struct {
mu sync.Mutex
cfg *config.Config
// level 是所有 core 共享的 AtomicLevel,支持运行期 SetLevel 热切换(M19)。
// Init 前为 nilSetLevel 守卫之。
level zap.AtomicLevel
generation uint64
}
// DefaultSnapshot 是默认 logger 全局状态的 opaque 快照。
// App 初始化用它实现“先安装新 logger,后续失败可恢复旧 logger”的事务边界。
type DefaultSnapshot struct {
manager *LogManager
logger *zap.Logger
sugar *zap.SugaredLogger
apiLog *zap.Logger
dbLog *zap.Logger
writers []*lumberjack.Logger
generation uint64
}
// DefaultLogger 默认日志管理器,包级 facade 代理到它。
//
// Deprecated: 直接替换该导出变量(logger.DefaultLogger = m)不会更新包级 facade
// 使用的 atomic 快照,也不是并发安全的替换方式。请用 SetDefaultLogManager(m)。
// 直接调用 DefaultLogger.Init/Close/SetLevel/GetLevel 仍保留兼容,并受 LogManager
// 自身锁保护。
var DefaultLogger = NewLogManager()
// NewLogManager 创建日志管理器实例。
func NewLogManager() *LogManager { return &LogManager{} }
// SetDefaultLogManager 提升指定 LogManager 为全局默认
// GetDefaultLogManager 返回全局默认 LogManageratomic 读取,并发安全)
func GetDefaultLogManager() *LogManager {
if m := defaultLoggerPtr.Load(); m != nil {
return m
}
if DefaultLogger != nil && defaultLoggerPtr.CompareAndSwap(nil, DefaultLogger) {
return DefaultLogger
}
m := NewLogManager()
if defaultLoggerPtr.CompareAndSwap(nil, m) {
return m
}
return defaultLoggerPtr.Load()
}
// SetLevel 运行期热切换日志级别(M19)。需先 Init;未 Init 时返回 false。
// 影响所有 coreapp/api/db/console)——它们共享同一 AtomicLevel。
func (m *LogManager) SetLevel(l zapcore.Level) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.level == (zap.AtomicLevel{}) {
return false
}
m.level.SetLevel(l)
return true
}
// GetLevel 返回当前日志级别(未 Init 返回 InfoLevel)。
func (m *LogManager) GetLevel() zapcore.Level {
m.mu.Lock()
defer m.mu.Unlock()
if m.level == (zap.AtomicLevel{}) {
return zapcore.InfoLevel
}
return m.level.Level()
}
// SetLevel 包级 facade:运行期热切换默认日志级别(M19)。未 Init 时无操作返回 false。
func SetLevel(l zapcore.Level) bool { return GetDefaultLogManager().SetLevel(l) }
// GetLevel 包级 facade:返回默认日志当前级别。
func GetLevel() zapcore.Level { return GetDefaultLogManager().GetLevel() }
// SetDefaultLogManager 提升指定 LogManager 为全局默认(atomic.Store,并发安全)。
func SetDefaultLogManager(m *LogManager) {
if m != nil {
DefaultLogger = m
defaultLoggerPtr.Store(m)
}
}
// SnapshotDefault 返回默认 logger 全局状态快照。
// 快照仅用于 App 初始化回滚;调用方成功提交后必须 CloseDefaultSnapshot 释放旧 writers。
func SnapshotDefault() *DefaultSnapshot {
globalMu.Lock()
defer globalMu.Unlock()
return &DefaultSnapshot{
manager: GetDefaultLogManager(),
logger: currentLogger(),
sugar: currentSugar(),
apiLog: currentAPILog(),
dbLog: currentDBLog(),
writers: append([]*lumberjack.Logger(nil), fileWriters...),
generation: globalLogGeneration,
}
}
// RestoreDefaultSnapshot 恢复 SnapshotDefault 捕获的默认 logger 状态,并关闭当前新 writers。
func RestoreDefaultSnapshot(s *DefaultSnapshot) error {
if s == nil {
return nil
}
globalMu.Lock()
newLogger := currentLogger()
newAPILog := currentAPILog()
newDBLog := currentDBLog()
newWriters := fileWriters
loggerPtr.Store(s.logger)
sugarPtr.Store(s.sugar)
apiLogPtr.Store(s.apiLog)
dbLogPtr.Store(s.dbLog)
Logger = s.logger
fileWriters = append([]*lumberjack.Logger(nil), s.writers...)
globalLogGeneration = s.generation
globalMu.Unlock()
if s.manager != nil {
SetDefaultLogManager(s.manager)
}
return errors.Join(syncLoggers(newLogger, newAPILog, newDBLog), closeFileWriters(newWriters))
}
// CloseDefaultSnapshot 关闭 SnapshotDefault 捕获的旧 writers;用于新 logger 已提交成功后释放旧资源。
func CloseDefaultSnapshot(s *DefaultSnapshot) error {
if s == nil {
return nil
}
return errors.Join(syncLoggers(s.logger, s.apiLog, s.dbLog), closeFileWriters(s.writers))
}
// Init 初始化日志。
//
// 三个 logger 的分流策略:
@@ -60,12 +238,28 @@ func SetDefaultLogManager(m *LogManager) {
// 导致每条 logger.Info(...) 都会同时落到 api.log + database.log + console
// 三份,磁盘占用翻倍且分流形同虚设。新实现通用 Logger 只走独立的 app.log。
func (m *LogManager) Init(cfg *config.Config) error {
return m.init(cfg, true)
}
// InitPreservingPrevious 初始化并发布 logger,但暂不关闭被替换的旧 writers。
// App.Init 使用该方法配合 SnapshotDefault,在后续 hook 失败时可恢复旧 logger。
func (m *LogManager) InitPreservingPrevious(cfg *config.Config) error {
return m.init(cfg, false)
}
func (m *LogManager) init(cfg *config.Config, closePrevious bool) error {
if cfg == nil {
return errors.New("logger: config is nil")
return errors.New("logger: 配置为空")
}
if err := validateLogConfig(cfg.Log); err != nil {
return err
}
// 确保日志目录存在
if err := os.MkdirAll(cfg.Log.Dir, 0o755); err != nil {
m.mu.Lock()
defer m.mu.Unlock()
// 确保日志目录存在(0750owner+group 可访问,与 storage 目录权限一致)
if err := os.MkdirAll(cfg.Log.Dir, 0o750); err != nil {
return err
}
@@ -85,10 +279,10 @@ func (m *LogManager) Init(cfg *config.Config) error {
EncodeCaller: zapcore.ShortCallerEncoder,
}
// 根据运行模式设置日志级别
level := zapcore.DebugLevel
// 根据运行模式设置日志级别M19:用 AtomicLevel 支持运行期 SetLevel 热切换)
level := zap.NewAtomicLevelAt(zapcore.DebugLevel)
if cfg.IsProduction() {
level = zapcore.InfoLevel
level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
}
jsonEncoder := zapcore.NewJSONEncoder(encoderConfig)
@@ -118,24 +312,51 @@ func (m *LogManager) Init(cfg *config.Config) error {
zap.AddCaller(), zap.AddCallerSkip(1),
)
m.mu.Lock()
defer m.mu.Unlock()
// 全部构造成功后再原子替换全局变量,避免半初始化状态。
// 同时关闭旧 writer 释放句柄(重复 Init 场景,主要服务于测试)。
closeFileWriters()
// 先发布新 logger,再关闭旧 writer,避免请求 goroutine 在替换窗口内
// 通过包级函数拿到仍指向已关闭 writer 的旧 logger。
// H7:四个 logger 经 atomic.Pointer Store,读侧(请求 goroutine)无锁原子 load
// fileWriters 仅在 globalMu 下访问。Logger 兼容别名同步维护。
globalMu.Lock()
oldWriters := fileWriters
globalLogGeneration++
m.generation = globalLogGeneration
loggerPtr.Store(newLogger)
sugarPtr.Store(newLogger.Sugar())
apiLogPtr.Store(newAPILog)
dbLogPtr.Store(newDBLog)
Logger = newLogger
sugar = Logger.Sugar()
apiLog = newAPILog
dbLog = newDBLog
fileWriters = []*lumberjack.Logger{appWriter, apiWriter, dbWriter}
globalMu.Unlock()
m.level = level
m.cfg = cfg
if closePrevious {
return closeFileWriters(oldWriters)
}
return nil
}
// Init 包级 facade:初始化日志(代理到 DefaultLogger)。
func Init(cfg *config.Config) error {
return DefaultLogger.Init(cfg)
return GetDefaultLogManager().Init(cfg)
}
func validateLogConfig(cfg config.LogConfig) error {
if strings.TrimSpace(cfg.Dir) == "" {
return errors.New("logger: 日志目录为空")
}
if cfg.MaxSize < 0 {
return errors.New("logger: MaxSize 不能为负数")
}
if cfg.MaxBackups < 0 {
return errors.New("logger: MaxBackups 不能为负数")
}
if cfg.MaxAge < 0 {
return errors.New("logger: MaxAge 不能为负数")
}
return nil
}
// newRotatingWriter 创建带 lumberjack 滚动归档的 writer
@@ -149,24 +370,22 @@ func newRotatingWriter(cfg config.LogConfig, filename string) *lumberjack.Logger
}
}
// closeFileWriters 关闭并清空当前持有的 lumberjack writer
func closeFileWriters() {
for _, w := range fileWriters {
// closeFileWriters 关闭传入的 lumberjack writer,并聚合关闭错误。
func closeFileWriters(writers []*lumberjack.Logger) error {
var errs []error
for _, w := range writers {
if w != nil {
_ = w.Close()
if err := w.Close(); err != nil {
errs = append(errs, err)
}
}
}
fileWriters = nil
return errors.Join(errs...)
}
// Sync 同步全部 logger 缓冲到底层 writer。
//
// 注意:在 Windows / 部分 *nix 平台上对 stdout/stderr 调用 Sync 会返回
// "invalid argument" / "inappropriate ioctl for device",属于 zap 已知行为,
// 这里把这类错误识别并忽略,只返回真实的写入失败。
func (m *LogManager) Sync() error {
func syncLoggers(loggers ...*zap.Logger) error {
var errs []error
for _, l := range []*zap.Logger{Logger, apiLog, dbLog} {
for _, l := range loggers {
if l == nil {
continue
}
@@ -177,9 +396,18 @@ func (m *LogManager) Sync() error {
return errors.Join(errs...)
}
// Sync 同步全部 logger 缓冲到底层 writer。
//
// 注意:在 Windows / 部分 *nix 平台上对 stdout/stderr 调用 Sync 会返回
// "invalid argument" / "inappropriate ioctl for device",属于 zap 已知行为,
// 这里把这类错误识别并忽略,只返回真实的写入失败。
func (m *LogManager) Sync() error {
return syncLoggers(currentLogger(), currentAPILog(), currentDBLog())
}
// Sync 包级 facade:同步全部 logger 缓冲(代理到 DefaultLogger)。
func Sync() error {
return DefaultLogger.Sync()
return GetDefaultLogManager().Sync()
}
// Close 关闭日志文件句柄,重置全局 logger 为 Nop。
@@ -188,23 +416,45 @@ func Sync() error {
// 调用后再次写日志不会 panicfall back to nop logger),但不会写入文件。
// 如需重新启用,请再次调用 Init。
func (m *LogManager) Close() error {
syncErr := m.Sync()
m.mu.Lock()
closeFileWriters()
globalMu.Lock()
if m.generation == 0 || m.generation != globalLogGeneration {
m.level = zap.AtomicLevel{}
m.cfg = nil
m.generation = 0
globalMu.Unlock()
m.mu.Unlock()
return nil
}
oldLogger := currentLogger()
oldAPILog := currentAPILog()
oldDBLog := currentDBLog()
oldWriters := fileWriters
Logger = zap.NewNop()
sugar = Logger.Sugar()
apiLog = zap.NewNop()
dbLog = zap.NewNop()
// H7:重置为 Nop 经 atomic Store,与读侧一致;Logger 兼容别名同步。
nop := zap.NewNop()
loggerPtr.Store(nop)
sugarPtr.Store(nop.Sugar())
apiLogPtr.Store(nop)
dbLogPtr.Store(nop)
Logger = nop
fileWriters = nil
globalMu.Unlock()
m.level = zap.AtomicLevel{}
m.cfg = nil
m.generation = 0
m.mu.Unlock()
return syncErr
return errors.Join(
syncLoggers(oldLogger, oldAPILog, oldDBLog),
closeFileWriters(oldWriters),
)
}
// Close 包级 facade:关闭日志文件句柄,重置为 Nop(代理到 DefaultLogger)。
func Close() error {
return DefaultLogger.Close()
return GetDefaultLogManager().Close()
}
// isHarmlessSyncError 识别 stdout/stderr Sync 在不同平台返回的预期错误。
@@ -215,9 +465,9 @@ func isHarmlessSyncError(err error) bool {
}
msg := err.Error()
for _, sub := range []string{
"invalid argument", // Linux stdout
"inappropriate ioctl for device", // macOS stdout
"bad file descriptor", // Windows stdout
"invalid argument", // Linux stdout
"inappropriate ioctl for device", // macOS stdout
"bad file descriptor", // Windows stdout
} {
if strings.Contains(msg, sub) {
return true
@@ -228,60 +478,60 @@ func isHarmlessSyncError(err error) bool {
// Debug 调试日志
func Debug(msg string, fields ...zap.Field) {
Logger.Debug(msg, fields...)
currentLogger().Debug(msg, fields...)
}
// Info 信息日志
func Info(msg string, fields ...zap.Field) {
Logger.Info(msg, fields...)
currentLogger().Info(msg, fields...)
}
// Warn 警告日志
func Warn(msg string, fields ...zap.Field) {
Logger.Warn(msg, fields...)
currentLogger().Warn(msg, fields...)
}
// Error 错误日志
func Error(msg string, fields ...zap.Field) {
Logger.Error(msg, fields...)
currentLogger().Error(msg, fields...)
}
// Fatal 致命错误日志(仅供应用层使用,框架内部禁止调用)
func Fatal(msg string, fields ...zap.Field) {
Logger.Fatal(msg, fields...)
currentLogger().Fatal(msg, fields...)
}
// Debugf 格式化调试日志
func Debugf(template string, args ...any) {
sugar.Debugf(template, args...)
currentSugar().Debugf(template, args...)
}
// Infof 格式化信息日志
func Infof(template string, args ...any) {
sugar.Infof(template, args...)
currentSugar().Infof(template, args...)
}
// Warnf 格式化警告日志
func Warnf(template string, args ...any) {
sugar.Warnf(template, args...)
currentSugar().Warnf(template, args...)
}
// Errorf 格式化错误日志
func Errorf(template string, args ...any) {
sugar.Errorf(template, args...)
currentSugar().Errorf(template, args...)
}
// Fatalf 格式化致命错误日志(仅供应用层使用,框架内部禁止调用)
func Fatalf(template string, args ...any) {
sugar.Fatalf(template, args...)
currentSugar().Fatalf(template, args...)
}
// APILog 返回 API 专用日志器(写 logs/api.log + console
func APILog() *zap.Logger {
return apiLog
return currentAPILog()
}
// DBLog 返回数据库专用日志器(写 logs/database.log + console
func DBLog() *zap.Logger {
return dbLog
return currentDBLog()
}
+482
View File
@@ -0,0 +1,482 @@
package logger
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// tmpCfg 构造一个指向临时目录的日志配置,用于 Init。
func tmpCfg(dir string) *config.Config {
return &config.Config{
Server: config.ServerConfig{Mode: "production"},
Log: config.LogConfig{
Dir: dir,
MaxSize: 1,
MaxBackups: 1,
MaxAge: 1,
Compress: false,
},
}
}
// TestH7ConcurrentInitCloseAndRead 验证请求 goroutine 的包级日志读路径
// 与 Init/Close 重新装配全局 logger 无数据竞争(-race)。
//
// H7 根因:原实现 Info/Error/APILog/DBLog/Sync 裸读包级 Logger/sugar/apiLog/dbLog
// 而 Init/Close 在 m.mu 下写这些变量——实例锁保护全局变量、读侧无锁 → re-Init/Close
// 与请求日志竞争。修复后读路径经 atomic.Pointer Load。
//
// 红/绿验证:将 currentLogger/currentSugar/currentAPILog/currentDBLog 临时改为
// 裸读 Logger/sugar/apiLog/dbLog(或等价地恢复包级裸读),-race 必复现 DATA RACE
// 恢复 atomic 后绿。
func TestH7ConcurrentInitCloseAndRead(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
// 确保默认 LogManager 起点干净。
_ = GetDefaultLogManager().Close()
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
stop := make(chan struct{})
var wg sync.WaitGroup
// 写者:循环 Init/Close(重新装配全局 logger)。
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = GetDefaultLogManager().Init(cfg)
_ = GetDefaultLogManager().Close()
}
}()
// 读者:并发调用读路径(包级函数 + atomic 快照读取)。
// 仅读取 logger 指针——这正是被竞态的访问;不调用 .Info/.Errorf 等写方法,
// 避免对已被 re-Init 关闭的旧 writer 触发 lumberjack 重新打开同一文件,
// 导致测试 TempDir 清理时句柄仍占用(框架不支持 re-Init 与在途写入并发,
// 该约束非 H7 范围)。
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
// 这些调用内部经 atomic Load 读取包级 logger 指针。
_ = currentLogger()
_ = currentSugar()
_ = currentAPILog()
_ = currentDBLog()
_ = APILog()
_ = DBLog()
}
}()
}
// 运行窗口足以让 race detector 采到任何竞争。
time.Sleep(120 * time.Millisecond)
close(stop)
wg.Wait()
// 收尾到 Nop,避免后续测试持有临时目录句柄。
_ = GetDefaultLogManager().Close()
}
// TestH7CurrentLoggerReflectsInitAndClose 验证 atomic 快照随 Init/Close 正确切换:
// Init 后 currentLogger 写入文件;Close 后回到 Nop(不写文件)。
func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
_ = GetDefaultLogManager().Close()
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
// Init 前:Nop,调用安全且不写文件。
before := currentLogger()
if before == nil {
t.Fatal("currentLogger nil before Init")
}
if err := GetDefaultLogManager().Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
// Init 后:currentLogger 与 Logger 兼容别名一致,且为非 Nop 实例。
after := currentLogger()
if after == nil {
t.Fatal("currentLogger nil after Init")
}
if after != Logger {
t.Error("after Init, currentLogger() != Logger (compat alias out of sync)")
}
// 写一条日志并 flush,验证落到文件(说明拿到的是真实 logger,非 Nop)。
const mark = "H7_INIT_REFLECT_xyz"
after.Info(mark)
_ = GetDefaultLogManager().Sync()
data, err := readFile(t, filepath.Join(dir, "app.log"))
if err != nil {
t.Fatalf("read app.log: %v", err)
}
if !strings.Contains(data, mark) {
t.Errorf("app.log missing mark %q after Init (got Nop?)", mark)
}
// Close 后:currentLogger 回到 Nop(与 Init 前实例不同,但同为 Nop 行为)。
if err := GetDefaultLogManager().Close(); err != nil {
t.Fatalf("Close: %v", err)
}
closed := currentLogger()
if closed == nil {
t.Fatal("currentLogger nil after Close")
}
// Nop logger 写不报错也不落盘;验证 Close 后再写不新增 mark。
closed.Info("H7_SHOULD_NOT_APPEAR_xyz")
_ = GetDefaultLogManager().Sync()
data2, _ := readFile(t, filepath.Join(dir, "app.log"))
if strings.Contains(data2, "H7_SHOULD_NOT_APPEAR_xyz") {
t.Error("wrote to file after Close (expected Nop)")
}
}
// TestH7AtomicPointersNonNil 验证 init 后四个 atomic 快照永不 nil
// 且 APILog/DBLog 返回与内部 atomic 一致的实例。
func TestH7AtomicPointersNonNil(t *testing.T) {
if loggerPtr.Load() == nil {
t.Error("loggerPtr nil after init")
}
if sugarPtr.Load() == nil {
t.Error("sugarPtr nil after init")
}
if apiLogPtr.Load() == nil {
t.Error("apiLogPtr nil after init")
}
if dbLogPtr.Load() == nil {
t.Error("dbLogPtr nil after init")
}
if APILog() != apiLogPtr.Load() {
t.Error("APILog() != apiLogPtr")
}
if DBLog() != dbLogPtr.Load() {
t.Error("DBLog() != dbLogPtr")
}
if currentLogger() != loggerPtr.Load() {
t.Error("currentLogger() != loggerPtr")
}
}
// TestH7DefaultLoggerCompatibility 锁定 M3 的兼容边界:
// DefaultLogger 仍保持 *LogManager 类型,旧代码可继续直接调用其方法;
// 包级 facade 的默认 manager 替换则必须走 SetDefaultLogManager 的 atomic 快照。
func TestH7DefaultLoggerCompatibility(t *testing.T) {
var _ *LogManager = DefaultLogger
old := GetDefaultLogManager()
t.Cleanup(func() { SetDefaultLogManager(old) })
next := NewLogManager()
SetDefaultLogManager(next)
if got := GetDefaultLogManager(); got != next {
t.Fatalf("GetDefaultLogManager() = %p, want %p", got, next)
}
}
// TestM3StaleManagerCloseDoesNotCloseCurrentLogger 回归:旧 manager 的 Close
// 不得关闭另一个 manager 后续发布的全局 logger/writer。
func TestM3StaleManagerCloseDoesNotCloseCurrentLogger(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
m1 := NewLogManager()
m2 := NewLogManager()
t.Cleanup(func() {
_ = m1.Close()
_ = m2.Close()
SetDefaultLogManager(NewLogManager())
})
if err := m1.Init(tmpCfg(dir1)); err != nil {
t.Fatalf("m1.Init: %v", err)
}
if err := m2.Init(tmpCfg(dir2)); err != nil {
t.Fatalf("m2.Init: %v", err)
}
if err := m1.Close(); err != nil {
t.Fatalf("stale m1.Close: %v", err)
}
const mark = "M3_CURRENT_LOGGER_SURVIVES_STALE_CLOSE"
currentLogger().Info(mark)
_ = syncLoggers(currentLogger())
data, err := readFile(t, filepath.Join(dir2, "app.log"))
if err != nil {
t.Fatalf("read current app.log: %v", err)
}
if !strings.Contains(data, mark) {
t.Fatal("stale manager Close closed the current logger")
}
}
// TestH7DurationFieldFix 验证 H7bField.Duration 签名改为
// func(key string, value time.Duration) zap.Field,且 key 不再被丢弃。
//
// 旧实现 func(key string, value interface{}) 在 case zap.Field 分支 return v
// 丢弃 key,签名与实现矛盾。修复后直接委托 zap.Duration(key, value)。
func TestH7DurationFieldFix(t *testing.T) {
f := Field.Duration("elapsed", 5*time.Second)
if f.Key != "elapsed" {
t.Errorf("Duration key lost: got %q, want %q (H7b regression)", f.Key, "elapsed")
}
// zap.Duration 用 zapcore.DurationType 编码,Integer 字段承载纳秒。
if f.Integer != int64(5*time.Second) {
t.Errorf("Duration value mismatch: got %d, want %d", f.Integer, int64(5*time.Second))
}
// 零值与其他字段不受影响。
f2 := Field.Duration("d0", 0)
if f2.Key != "d0" {
t.Errorf("Duration zero key lost: %q", f2.Key)
}
}
// TestH7DurationFieldSignature 编译期锁定 Field.Duration 的签名为
// func(string, time.Duration) zap.FieldH7b 修复的核心:类型安全签名)。
//
// 旧签名为 func(string, interface{}) zap.Field,其 case zap.Field 分支 return v
// 丢弃传入的 key。若回退旧签名,下方赋值将因参数类型不匹配(interface{} ≠ time.Duration
// 编译失败——即"修复前红、修复后绿"由编译器强制保证。
func TestH7DurationFieldSignature(t *testing.T) {
var _ func(string, time.Duration) zap.Field = Field.Duration
}
// readFile 读取文件内容,文件不存在时返回空串(不报错)。
func readFile(t *testing.T, path string) (string, error) {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(b), nil
}
// TestSetLevelHotSwap_M19Init 后可运行期热切换日志级别(M19)。
func TestSetLevelHotSwap_M19(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
_ = GetDefaultLogManager().Close()
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
if err := GetDefaultLogManager().Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
// Init 后(tmpCfg 为 production 模式,默认 InfoLevel)可热切换到 ErrorLevel。
before := GetDefaultLogManager().GetLevel()
if before != zapcore.InfoLevel {
t.Errorf("default level = %v, want InfoLevel (production)", before)
}
if ok := GetDefaultLogManager().SetLevel(zapcore.ErrorLevel); !ok {
t.Fatal("SetLevel after Init should return true")
}
if got := GetDefaultLogManager().GetLevel(); got != zapcore.ErrorLevel {
t.Errorf("after SetLevel, level = %v, want ErrorLevel", got)
}
// 包级 facade 同步。
SetLevel(zapcore.WarnLevel)
if got := GetLevel(); got != zapcore.WarnLevel {
t.Errorf("package GetLevel = %v, want WarnLevel", got)
}
}
// TestM3ConcurrentInitSetLevelAndClose 验证 Init/SetLevel/GetLevel/Close
// 共享同一个 manager 临界区,不再出现 m.level 锁外写与锁内读写竞争(-race)。
func TestM3ConcurrentInitSetLevelAndClose(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
m := NewLogManager()
SetDefaultLogManager(m)
t.Cleanup(func() {
_ = m.Close()
SetDefaultLogManager(NewLogManager())
})
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = m.Init(cfg)
_ = m.Close()
}
}()
for i := 0; i < 4; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
levels := []zapcore.Level{
zapcore.DebugLevel,
zapcore.InfoLevel,
zapcore.WarnLevel,
zapcore.ErrorLevel,
}
for {
select {
case <-stop:
return
default:
}
_ = m.SetLevel(levels[i%len(levels)])
_ = m.GetLevel()
}
}(i)
}
time.Sleep(120 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestM3ConcurrentSetDefaultAndFacades 验证默认 LogManager 经 atomic.Pointer
// 读写,SetDefaultLogManager 与包级 facade 并发时无裸全局指针竞态(-race)。
func TestM3ConcurrentSetDefaultAndFacades(t *testing.T) {
t.Cleanup(func() {
_ = Close()
SetDefaultLogManager(NewLogManager())
})
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
SetDefaultLogManager(NewLogManager())
}
}()
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = GetLevel()
_ = SetLevel(zapcore.WarnLevel)
_ = Sync()
_ = APILog()
_ = DBLog()
}
}()
}
time.Sleep(120 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestM3ConcurrentManagersInitClose 验证不同 LogManager 实例并发 Init/Close 时,
// 包级 Logger/fileWriters 的发布与关闭由 globalMu 串行化,不再依赖实例锁保护包级状态。
func TestM3ConcurrentManagersInitClose(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
stop := make(chan struct{})
var wg sync.WaitGroup
managers := make([]*LogManager, 3)
for i := 0; i < 3; i++ {
managers[i] = NewLogManager()
wg.Add(1)
go func(m *LogManager) {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
SetDefaultLogManager(m)
_ = m.Init(cfg)
_ = m.Close()
}
}(managers[i])
}
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = currentLogger()
_ = currentSugar()
_ = APILog()
_ = DBLog()
}
}()
time.Sleep(120 * time.Millisecond)
close(stop)
wg.Wait()
for _, m := range managers {
_ = m.Close()
}
_ = Close()
SetDefaultLogManager(NewLogManager())
}
// TestM3InitRejectsInvalidLogConfig 验证 logger.Init 对明显非法日志配置失败,
// 避免空目录意外把 app.log 写到进程工作目录。
func TestM3InitRejectsInvalidLogConfig(t *testing.T) {
if err := NewLogManager().Init(&config.Config{}); err == nil {
t.Fatal("Init with empty log dir should fail")
}
cfg := tmpCfg(t.TempDir())
cfg.Log.MaxAge = -1
if err := NewLogManager().Init(cfg); err == nil {
t.Fatal("Init with negative MaxAge should fail")
}
}
+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."}
+1 -1
View File
@@ -49,7 +49,7 @@ func AuthRequired() gin.HandlerFunc {
// 解析 Bearer Token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
response.Unauthorized(c, "认证格式错误")
c.Abort()
return
+38
View File
@@ -5,7 +5,10 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/jwt"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/gin-gonic/gin"
)
@@ -38,6 +41,41 @@ func setAuthUser(userType, role string) func(*gin.Context) {
}
}
func TestAuthRequiredAcceptsCaseInsensitiveBearer_M8(t *testing.T) {
setupMiddlewareMiniRedis(t)
if err := config.Set(&config.Config{
JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Expire: time.Hour,
},
}); err != nil {
t.Fatalf("set config: %v", err)
}
token, err := jwt.GenerateToken(1, "tester", "owner", "tenant_admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.AuthRequired())
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "bearer "+token)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"ok":true`) {
t.Fatalf("expected request to pass, got body %s", w.Body.String())
}
}
func TestRequireUserTypes(t *testing.T) {
w := performAuthMiddlewareRequest(middleware.RequireUserTypes("tenant_admin"), setAuthUser("tenant_admin", "owner"))
if w.Code != http.StatusOK {
+72 -22
View File
@@ -2,6 +2,7 @@ package middleware
import (
"net/http"
"net/url"
"strconv"
"strings"
@@ -48,15 +49,7 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc {
matchedWildcard = true
break
}
// 支持通配符匹配(如 *.example.com
if strings.HasPrefix(ao, "*.") {
domain := ao[2:]
if strings.HasSuffix(origin, domain) {
allowedOrigin = origin
break
}
}
if ao == origin {
if matchOrigin(origin, ao) {
allowedOrigin = origin
break
}
@@ -72,32 +65,34 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc {
} else {
allowedOrigin = "*"
}
} else if cfg != nil && cfg.IsDevelopment() && origin != "" {
// 开发环境兜底:回显具体 Origin(兼容 credentials
} else if cfg != nil && cfg.IsDevelopment() && origin != "" && isLocalhostOrigin(origin) {
// 开发环境兜底:仅对 localhost 来源回显具体 OriginC7b 修复)。
// 旧实现无条件回显任意 Origin,若同时 AllowCredentials=true 则构成凭据型反射。
allowedOrigin = origin
}
}
// 设置 CORS 响应头
// 仅在 origin 匹配时发送 CORS 头(C7 收尾:未匹配 origin 不发 Allow-Methods/Headers 等,
// 收敛信息泄露——避免向未授权 origin 暴露 API 允许的方法/头清单)。
if allowedOrigin != "" {
c.Header("Access-Control-Allow-Origin", allowedOrigin)
// Origin 不是 "*" 时,下游缓存(CDN / 网关)必须按 Origin 区分缓存
if allowedOrigin != "*" {
c.Header("Vary", "Origin")
}
methods := corsConfig.GetAllowedMethods()
headers := corsConfig.GetAllowedHeaders()
exposedHeaders := corsConfig.GetExposedHeaders()
maxAge := corsConfig.GetMaxAge()
c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", "))
c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", "))
c.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
c.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
}
// 从配置获取允许的方法、请求头等
methods := corsConfig.GetAllowedMethods()
headers := corsConfig.GetAllowedHeaders()
exposedHeaders := corsConfig.GetExposedHeaders()
maxAge := corsConfig.GetMaxAge()
c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", "))
c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", "))
c.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
c.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
// 仅在显式启用且 Origin 不是 "*" 时才发 Allow-Credentials
// CORS 规范:Allow-Origin: * 时禁止携带凭证)
if allowCredentials && allowedOrigin != "" && allowedOrigin != "*" {
@@ -114,6 +109,61 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc {
}
}
// matchOrigin 判断 origin 是否匹配允许的 Origin 模式 ao。
//
// 支持三种模式(C7a 修复):
// 1. 精确匹配:ao == originscheme+host+port 全等)。
// 2. 通配子域:ao 形如 "*.example.com",匹配 example.com 的任意子域(含 a.example.com、
// a.b.example.com),但**不匹配 apex example.com 自身**,也**不匹配 notexample.com、
// evil-example.com 等后缀相同但非真实子域的域名**。旧实现用 strings.HasSuffix(origin, domain)
// 未锚定 host 边界,导致上述绕过。
// 3. 通配 apex+子域:ao 形如 "*.example.com" 经本函数仅匹配子域;若需同时允许 apex,
// 配置中需显式列出 "https://example.com"。
//
// 解析 origin 的 host 做真实子域边界判断(而非字符串后缀),杜绝 notexample.com 类绕过。
func matchOrigin(origin, ao string) bool {
if origin == "" || ao == "" {
return false
}
// 精确匹配。
if origin == ao {
return true
}
// 通配子域 *.domain。
if strings.HasPrefix(ao, "*.") {
wildcardDomain := strings.ToLower(strings.TrimSuffix(ao[2:], ".")) // 如 "example.com"
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
// 去端口、去尾点(FQDN 尾点表示 a.example.com. 应等同 a.example.com)。
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
// host 必须是 *.domain 的真实子域:host == "x." + wildcardDomain
// 且 x 非空、不含点(即直接子域)或为多级子域(a.b.example.com)。
// 关键:host 必须以 "." + wildcardDomain 结尾(锚定边界),杜绝 notexample.com。
if !strings.HasSuffix(host, "."+wildcardDomain) {
return false
}
// 排除 host == wildcardDomain 自身(apex 不由通配匹配,需显式配置)。
if host == wildcardDomain {
return false
}
return true
}
return false
}
// isLocalhostOrigin 判断 origin 是否为 localhost 来源(开发态兜底用,C7b 修复)。
// 仅允许 localhost / 127.0.0.1 / ::1 的任意端口,杜绝开发态回显任意 Origin。
func isLocalhostOrigin(origin string) bool {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
host := strings.TrimSuffix(u.Hostname(), ".")
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
// getAllowedOrigins 获取允许的域名列表
// 优先使用配置文件,生产环境必须显式配置,开发环境提供 localhost 兜底。
func getAllowedOrigins(cfg *config.Config, corsConfig *config.CORSConfig) []string {
+94
View File
@@ -0,0 +1,94 @@
package middleware
import "testing"
// 回归 C7a:通配符子域匹配必须锚定真实子域边界,拒绝后缀相同但非子域的域名。
// 旧实现 strings.HasSuffix(origin, domain) 接受 notexample.com / evil-example.com。
func TestMatchOriginWildcardBoundary(t *testing.T) {
cases := []struct {
name string
origin string
ao string
want bool
}{
// 精确匹配
{"exact", "https://example.com", "https://example.com", true},
{"exact with port", "https://example.com:8443", "https://example.com:8443", true},
{"exact mismatch", "https://example.com", "https://other.com", false},
// 通配子域 *.example.com
{"subdomain ok", "https://a.example.com", "*.example.com", true},
{"deep subdomain ok", "https://a.b.example.com", "*.example.com", true},
{"subdomain with port ok", "https://a.example.com:3000", "*.example.com", true},
{"apex not matched by wildcard", "https://example.com", "*.example.com", false},
// C7a 核心:后缀相同但非真实子域必须拒绝
{"notexample.com rejected", "https://notexample.com", "*.example.com", false},
{"evil-example.com rejected", "https://evil-example.com", "*.example.com", false},
{"villainexample.com rejected", "https://villainexample.com", "*.example.com", false},
// 端口/协议不同的 origin host 仍按 host 判断
{"http subdomain ok", "http://a.example.com", "*.example.com", true},
// 通配仅匹配 host,scheme 不同的精确配置不被通配覆盖
{"different scheme exact not wildcard", "http://example.com", "https://example.com", false},
// 边界
{"empty origin", "", "*.example.com", false},
{"empty ao", "https://a.example.com", "", false},
{"malformed origin", "://:bad", "*.example.com", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := matchOrigin(c.origin, c.ao)
if got != c.want {
t.Errorf("matchOrigin(%q, %q) = %v, want %v", c.origin, c.ao, got, c.want)
}
})
}
}
// 回归 C7a:通配大小写不敏感(host 归一化为小写比较)。
func TestMatchOriginCaseInsensitive(t *testing.T) {
if !matchOrigin("https://A.Example.COM", "*.example.com") {
t.Error("matchOrigin should be case-insensitive on host")
}
}
// 回归 C7atrailing dot FQDNa.example.com.)应等同 a.example.com 被接受(功能正确性)。
func TestMatchOriginTrailingDot(t *testing.T) {
if !matchOrigin("https://a.example.com.", "*.example.com") {
t.Error("trailing-dot subdomain should match wildcard")
}
if !matchOrigin("https://a.example.com", "*.example.com.") {
t.Error("trailing-dot wildcard should match plain subdomain")
}
}
// 回归 C7bisLocalhostOrigin 仅允许 localhost/127.0.0.1/::1,拒绝任意其他来源。
func TestIsLocalhostOrigin(t *testing.T) {
allowed := []string{
"http://localhost:3000",
"http://localhost",
"http://127.0.0.1:8080",
"http://127.0.0.1",
"http://[::1]:4000",
"http://[::1]",
}
for _, o := range allowed {
if !isLocalhostOrigin(o) {
t.Errorf("isLocalhostOrigin(%q) = false, want true", o)
}
}
denied := []string{
"https://evil.com",
"https://localhost.evil.com", // 后缀含 localhost 但非 localhost host
"https://notlocalhost.com",
"https://example.com",
"",
"://bad",
}
for _, o := range denied {
if isLocalhostOrigin(o) {
t.Errorf("isLocalhostOrigin(%q) = true, want false", o)
}
}
}
+152 -49
View File
@@ -2,13 +2,17 @@ package middleware
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
)
const (
@@ -20,6 +24,8 @@ const (
CSRFCookieName = "csrf_token"
// CSRFFormField 表单字段名称
CSRFFormField = "_csrf"
// CSRFMaxBodyBytes JSON body 中读取 CSRF token 的最大字节数,防止 pre-auth OOM。
CSRFMaxBodyBytes = 1 << 20 // 1 MiB
)
// CSRFConfig CSRF 配置
@@ -44,6 +50,12 @@ type CSRFConfig struct {
Path string
// MaxAge Cookie 有效期(秒)
MaxAge int
// SessionCookie 为 true 时显式使用会话 CookieMaxAge=0)。
// 为保持 CSRFWithConfig(CSRFConfig{}) 的默认行为,MaxAge=0 且本字段为 false 时仍使用默认 1 小时。
SessionCookie bool
// MaxBodyBytes 从 JSON body 提取 token 时允许读取的最大字节数。
// <=0 时使用默认 1MiB。Header/Form token 不受此限制。
MaxBodyBytes int64
// ErrorFunc 错误处理函数
ErrorFunc func(c *gin.Context)
// SkipFunc 跳过检查函数
@@ -52,21 +64,25 @@ type CSRFConfig struct {
// DefaultCSRFConfig 默认 CSRF 配置
var DefaultCSRFConfig = CSRFConfig{
TokenLength: CSRFTokenLength,
HeaderName: CSRFHeaderName,
CookieName: CSRFCookieName,
FormField: CSRFFormField,
Secure: false,
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: 3600, // 1 小时
ErrorFunc: defaultCSRFError,
SkipFunc: nil,
TokenLength: CSRFTokenLength,
HeaderName: CSRFHeaderName,
CookieName: CSRFCookieName,
FormField: CSRFFormField,
Secure: false,
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: 3600, // 1 小时
MaxBodyBytes: CSRFMaxBodyBytes,
ErrorFunc: defaultCSRFError,
SkipFunc: nil,
}
// generateCSRFToken 生成 CSRF Token
func generateCSRFToken(length int) (string, error) {
if length <= 0 {
length = CSRFTokenLength
}
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
@@ -80,15 +96,13 @@ func defaultCSRFError(c *gin.Context) {
c.Abort()
}
// CSRF 创建 CSRF 中间件
func CSRF(config ...CSRFConfig) gin.HandlerFunc {
cfg := DefaultCSRFConfig
if len(config) > 0 {
cfg = config[0]
}
func csrfBodyTooLarge(c *gin.Context) {
response.Custom(c, http.StatusRequestEntityTooLarge, response.CodeFileTooLarge, "请求体过大", nil)
c.Abort()
}
// 设置默认值
if cfg.TokenLength == 0 {
func normalizeCSRFConfig(cfg CSRFConfig) CSRFConfig {
if cfg.TokenLength <= 0 {
cfg.TokenLength = CSRFTokenLength
}
if cfg.HeaderName == "" {
@@ -97,9 +111,45 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
if cfg.CookieName == "" {
cfg.CookieName = CSRFCookieName
}
if cfg.FormField == "" {
cfg.FormField = CSRFFormField
}
if cfg.Path == "" {
cfg.Path = "/"
}
if cfg.SameSite == 0 {
cfg.SameSite = DefaultCSRFConfig.SameSite
}
if cfg.MaxAge == 0 && !cfg.SessionCookie {
cfg.MaxAge = DefaultCSRFConfig.MaxAge
}
if cfg.MaxBodyBytes <= 0 {
cfg.MaxBodyBytes = CSRFMaxBodyBytes
}
if cfg.ErrorFunc == nil {
cfg.ErrorFunc = defaultCSRFError
}
return cfg
}
func pathMatchesSkip(path, prefix string) bool {
if prefix == "" {
return false
}
if prefix == "/" {
return true
}
prefix = strings.TrimRight(prefix, "/")
return path == prefix || strings.HasPrefix(path, prefix+"/")
}
// CSRF 创建 CSRF 中间件
func CSRF(config ...CSRFConfig) gin.HandlerFunc {
cfg := DefaultCSRFConfig
if len(config) > 0 {
cfg = config[0]
}
cfg = normalizeCSRFConfig(cfg)
return func(c *gin.Context) {
// 检查是否跳过
@@ -120,6 +170,7 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
cookieToken = token
// 设置 Cookie
c.SetSameSite(cfg.SameSite)
c.SetCookie(
cfg.CookieName,
token,
@@ -151,18 +202,28 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
clientToken = c.PostForm(cfg.FormField)
}
// 最后从 JSON body 获取
// 最后从 JSON body 获取
// P1 #9:用 ShouldBindBodyWith(binding.JSON) 而非 ShouldBindJSON——后者会读干
// c.Request.Body,导致下游 handler 再 ShouldBindJSON 拿到空 body(EOF)。前者把原始
// body 缓存进 gin context,下游可重复读取。
if clientToken == "" {
var body map[string]any
if err := c.ShouldBindJSON(&body); err == nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, cfg.MaxBodyBytes)
if err := c.ShouldBindBodyWith(&body, binding.JSON); err == nil {
if token, ok := body[cfg.FormField].(string); ok {
clientToken = token
}
} else {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
csrfBodyTooLarge(c)
return
}
}
}
// 验证 Token 是否匹配
if clientToken == "" || clientToken != cookieToken {
// 验证 Token 是否匹配H-7: 恒定时间比较防时序侧信道)
if len(clientToken) == 0 || subtle.ConstantTimeCompare([]byte(clientToken), []byte(cookieToken)) != 1 {
cfg.ErrorFunc(c)
return
}
@@ -187,10 +248,18 @@ func GetCSRFToken(c *gin.Context) string {
if !exists {
return ""
}
return token.(string)
// H-7: comma-ok 防裸断言 panic。csrf_token 虽由本包以 string 写入,
// 但 gin context 是共享 map,下游误写其他类型即 panic。
s, ok := token.(string)
if !ok {
return ""
}
return s
}
// CSRFToken 返回 CSRF Token 的处理器(用于 API 模式)
// CSRFToken 返回当前请求上下文中的 CSRF Token;若上下文无 Token 则现场生成一个返回。
// 适用于 Cookie/双重提交模式(与 CSRF/DoubleSubmitCookie 中间件配合),
// 不与 CSRFForAPI 配套--API 模式的可校验 Token 请用 GenerateAPIToken 颁发。
func CSRFToken(c *gin.Context) {
token := GetCSRFToken(c)
if token == "" {
@@ -213,7 +282,7 @@ func CSRFWithSkip(skipPaths []string) gin.HandlerFunc {
cfg.SkipFunc = func(c *gin.Context) bool {
path := c.Request.URL.Path
for _, p := range skipPaths {
if strings.HasPrefix(path, p) {
if pathMatchesSkip(path, p) {
return true
}
}
@@ -223,11 +292,13 @@ func CSRFWithSkip(skipPaths []string) gin.HandlerFunc {
}
// CSRFForAPI 适用于 API 的 CSRF 中间件(不使用 Cookie
// 客户端需要先调用 /csrf-token 获取 Token
// 客户端需要先调用 GenerateAPIToken 获取 Token,随后在每个非安全方法请求的
// X-CSRF-Token 头中携带。Token 单次消费(验证通过即删除)且受 TTL 约束,
// 防止重放与内存无限增长。
//
// 注意:存储为进程内内存,仅适用于单实例部署。多实例请自行用 Redis
// SETEX + GETDEL 实现等价语义。
func CSRFForAPI() gin.HandlerFunc {
tokens := make(map[string]bool)
var mu sync.RWMutex
return func(c *gin.Context) {
// 安全方法不需要验证
if isSafeMethod(c.Request.Method) {
@@ -243,12 +314,25 @@ func CSRFForAPI() gin.HandlerFunc {
return
}
// 验证 Token
mu.RLock()
valid := tokens[clientToken]
mu.RUnlock()
// 单次消费 + TTL:校验通过即删除,防止同一 token 被重放。
// 写锁内完成“查—删—过期清理”以保证原子性。
apiTokensMu.Lock()
issuedAt, ok := apiTokens[clientToken]
if ok {
delete(apiTokens, clientToken)
}
// 懒清理:map 较大时顺带淘汰过期项,避免内存无限增长。
if len(apiTokens) > 256 {
now := time.Now()
for t, at := range apiTokens {
if now.Sub(at) > apiTokenTTL {
delete(apiTokens, t)
}
}
}
apiTokensMu.Unlock()
if !valid {
if !ok || time.Since(issuedAt) > apiTokenTTL {
response.Fail(c, "CSRF Token 无效")
c.Abort()
return
@@ -259,6 +343,7 @@ func CSRFForAPI() gin.HandlerFunc {
}
// GenerateAPIToken 生成 API CSRF Token(用于 API 模式)
// 颁发的 Token 写入进程内存储,供 CSRFForAPI 校验。
func GenerateAPIToken(c *gin.Context) {
token, err := generateCSRFToken(CSRFTokenLength)
if err != nil {
@@ -266,23 +351,34 @@ func GenerateAPIToken(c *gin.Context) {
return
}
// 存储 Token(实际应用中应使用 Redis)
// 这里简化为内存存储
mu.Lock()
tokens[token] = true
mu.Unlock()
apiTokensMu.Lock()
cleanupExpiredAPITokensLocked(time.Now())
apiTokens[token] = time.Now()
apiTokensMu.Unlock()
response.Success(c, gin.H{
"csrf_token": token,
})
}
// 内存存储(用于 API 模式)
// API 模式 CSRF Token 存储:token -> 颁发时间。
// 受 apiTokensMu 保护,单次消费 + TTL(见 CSRFForAPI)。
var (
tokens = make(map[string]bool)
mu sync.RWMutex
apiTokens = make(map[string]time.Time)
apiTokensMu sync.RWMutex
)
// apiTokenTTL API 模式 CSRF Token 有效期
const apiTokenTTL = 30 * time.Minute
func cleanupExpiredAPITokensLocked(now time.Time) {
for t, at := range apiTokens {
if now.Sub(at) > apiTokenTTL {
delete(apiTokens, t)
}
}
}
// CSRFExempt 标记路由不需要 CSRF 保护
// 使用方法:在路由组上使用此中间件
func CSRFExempt() gin.HandlerFunc {
@@ -298,12 +394,16 @@ func CSRFWithExempt(config ...CSRFConfig) gin.HandlerFunc {
if len(config) > 0 {
cfg = config[0]
}
cfg = normalizeCSRFConfig(cfg)
originalSkipFunc := cfg.SkipFunc
cfg.SkipFunc = func(c *gin.Context) bool {
// 检查是否标记为 exempt
if exempt, exists := c.Get("csrf_exempt"); exists && exempt.(bool) {
return true
// 检查是否标记为 exemptP1 #9comma-ok 防裸断言 panic——gin context 为共享 map
// 下游若误以非 bool 写入 csrf_exemptexempt.(bool) 会 panic)。
if exempt, exists := c.Get("csrf_exempt"); exists {
if b, ok := exempt.(bool); ok && b {
return true
}
}
// 调用原始 SkipFunc
if originalSkipFunc != nil {
@@ -325,7 +425,10 @@ func DoubleSubmitCookie() gin.HandlerFunc {
cookieToken, err := c.Cookie(CSRFCookieName)
if err != nil || cookieToken == "" {
token, _ := generateCSRFToken(CSRFTokenLength)
c.SetCookie(CSRFCookieName, token, 3600, "/", "", false, true)
// 双重提交模式要求前端 JS 读取 cookie 并回填 X-CSRF-Token 头,
// 故 HttpOnly 必须为 false(与 CSRF() cookie 模式相反)。
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(CSRFCookieName, token, 3600, "/", "", false, false)
c.Set("csrf_token", token)
} else {
c.Set("csrf_token", cookieToken)
@@ -350,8 +453,8 @@ func DoubleSubmitCookie() gin.HandlerFunc {
return
}
// 验证 Cookie 和 Header 中的 Token 是否一致
if cookieToken != headerToken {
// 验证 Cookie 和 Header 中的 Token 是否一致H-7: 恒定时间比较防时序侧信道)
if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) != 1 {
response.Fail(c, "CSRF Token 不匹配")
c.Abort()
return
+234
View File
@@ -0,0 +1,234 @@
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
)
// 回归 C6b:TTL 过期分支。直接向包级存储注入一个“已过期”的 token,
// 断言 CSRFForAPI 拒绝它(即使该 token 从未被消费过)。
// 这条分支(time.Since(issuedAt) > apiTokenTTL)无法靠单次消费用例覆盖,
// 必须单独构造过期时间戳。
func TestCSRFForAPITTLExpiry(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRFForAPI())
r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
expiredToken := "expired-test-token"
apiTokensMu.Lock()
// 注入一个早于 TTL 的颁发时间,模拟 token 已过期。
apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second))
apiTokensMu.Unlock()
defer func() {
apiTokensMu.Lock()
delete(apiTokens, expiredToken)
apiTokensMu.Unlock()
}()
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/action", nil)
req.Header.Set("X-CSRF-Token", expiredToken)
r.ServeHTTP(w, req)
var resp response.Response
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response %q: %v", w.Body.String(), err)
}
if resp.Code == response.CodeSuccess {
t.Errorf("expired token must be rejected, got code=%d (success)", resp.Code)
}
}
// 回归 P1 #9CSRF 从 JSON body 取 token 后,下游 handler 仍能读到完整请求体。
// 修复前用 ShouldBindJSON 读干 body,下游再绑定拿到 EOF;改用 ShouldBindBodyWith 缓存 body。
func TestCSRFJSONBodyPreservedForDownstream(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF())
var gotData string
r.POST("/action", func(c *gin.Context) {
// 下游再次绑定 body,应能读到完整内容(而非被 CSRF 中间件读空)。
var payload struct {
Data string `json:"data"`
}
if err := c.ShouldBindBodyWith(&payload, binding.JSON); err != nil {
c.JSON(500, gin.H{"err": err.Error()})
return
}
gotData = payload.Data
c.JSON(200, gin.H{"status": "ok"})
})
// token 仅放在 JSON body 里(不放 header/form),触发中间件读 body 的分支。
const tok = "csrf-token-abc-1234567890"
body := `{"_csrf":"` + tok + `","data":"hello-downstream"}`
req := httptest.NewRequest("POST", "/action", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// cookie 携带同一 token,使 CSRF 校验通过。
req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: tok})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d, body=%s", w.Code, w.Body.String())
}
if gotData != "hello-downstream" {
t.Errorf("downstream read data=%q, want 'hello-downstream' (P1 #9: body must survive CSRF)", gotData)
}
}
func TestCSRFJSONBodyTooLargeRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF(CSRFConfig{MaxBodyBytes: 16}))
r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
body := `{"_csrf":"token","data":"` + strings.Repeat("x", 64) + `"}`
req := httptest.NewRequest(http.MethodPost, "/action", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: "token"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413; body=%s", w.Code, w.Body.String())
}
}
func TestCSRFNegativeTokenLengthFallsBack(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF(CSRFConfig{TokenLength: -1}))
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"token": GetCSRFToken(c)}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if len(w.Result().Cookies()) == 0 {
t.Fatal("expected csrf cookie to be set")
}
}
func TestCSRFCookieSameSiteLax(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF())
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
for _, c := range w.Result().Cookies() {
if c.Name == CSRFCookieName {
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
}
return
}
}
t.Fatal("csrf cookie not set")
}
func TestCSRFPartialConfigKeepsCookieDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF(CSRFConfig{TokenLength: -1}))
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
for _, c := range w.Result().Cookies() {
if c.Name == CSRFCookieName {
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax for partial config", c.SameSite)
}
if c.MaxAge != DefaultCSRFConfig.MaxAge {
t.Fatalf("MaxAge = %d, want %d for partial config", c.MaxAge, DefaultCSRFConfig.MaxAge)
}
return
}
}
t.Fatal("csrf cookie not set")
}
func TestDoubleSubmitCookieSameSiteLax(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(DoubleSubmitCookie())
r.GET("/get", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/get", nil))
for _, c := range w.Result().Cookies() {
if c.Name == CSRFCookieName {
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
}
return
}
}
t.Fatal("csrf cookie not set")
}
func TestCSRFWithSkipAnchorsPathBoundary(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRFWithSkip([]string{"/api"}))
r.POST("/api", func(c *gin.Context) { c.Status(http.StatusNoContent) })
r.POST("/api/users", func(c *gin.Context) { c.Status(http.StatusNoContent) })
r.POST("/apix", func(c *gin.Context) { c.Status(http.StatusNoContent) })
for _, path := range []string{"/api", "/api/users"} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil))
if w.Code != http.StatusNoContent {
t.Fatalf("%s status = %d, want skipped 204", path, w.Code)
}
}
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/apix", nil))
if w.Code == http.StatusNoContent {
t.Fatal("/apix was skipped by /api prefix; want CSRF rejection")
}
}
func TestGenerateAPITokenCleansExpiredTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
expiredToken := "expired-before-issue"
apiTokensMu.Lock()
apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second))
apiTokensMu.Unlock()
defer func() {
apiTokensMu.Lock()
delete(apiTokens, expiredToken)
apiTokensMu.Unlock()
}()
r := gin.New()
r.GET("/csrf-token", GenerateAPIToken)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/csrf-token", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
apiTokensMu.RLock()
_, exists := apiTokens[expiredToken]
apiTokensMu.RUnlock()
if exists {
t.Fatal("GenerateAPIToken did not clean expired token")
}
}
+109 -44
View File
@@ -3,6 +3,8 @@ package middleware
import (
"bytes"
"io"
"net/url"
"regexp"
"strings"
"time"
@@ -44,6 +46,10 @@ func Logger() gin.HandlerFunc {
// LoggerWithConfig 使用自定义配置的日志中间件
func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
// 统一封顶:MaxBodyLength<=0 时回退默认值,确保请求/响应 body 捕获均有上限(防 OOM)
if cfg.MaxBodyLength <= 0 {
cfg.MaxBodyLength = DefaultLoggerConfig.MaxBodyLength
}
return func(c *gin.Context) {
// 检查是否跳过此路径
path := c.Request.URL.Path
@@ -57,26 +63,17 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
// 记录请求体(可选)
var requestBody []byte
if cfg.LogRequestBody && c.Request.Body != nil {
requestBody, _ = io.ReadAll(c.Request.Body)
// 恢复请求体供后续处理
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
// 限制记录长度
if len(requestBody) > cfg.MaxBodyLength {
requestBody = requestBody[:cfg.MaxBodyLength]
}
requestBody = readBodyBounded(c, cfg.MaxBodyLength)
}
// 记录响应体(可选)
var responseBody []byte
if cfg.LogResponseBody {
// 使用 ResponseWriter 包装器捕获响应体
blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
// 使用 ResponseWriter 包装器捕获响应体(缓冲区封顶,防止大响应 OOM
blw := &bodyLogWriter{body: bytes.NewBufferString(""), maxLen: cfg.MaxBodyLength, ResponseWriter: c.Writer}
c.Writer = blw
c.Next()
responseBody = blw.body.Bytes()
if len(responseBody) > cfg.MaxBodyLength {
responseBody = responseBody[:cfg.MaxBodyLength]
}
} else {
c.Next()
}
@@ -88,7 +85,7 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
zap.Int("status", c.Writer.Status()),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.String("query", c.Request.URL.RawQuery),
zap.String("query", filterSensitiveQuery(c.Request.URL.RawQuery)),
zap.String("ip", c.ClientIP()),
zap.Duration("latency", latency),
zap.String("user-agent", c.Request.UserAgent()),
@@ -124,32 +121,69 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
if latency > cfg.SlowRequestThreshold {
// 慢请求警告
fields = append(fields, zap.Bool("slow_request", true))
logger.APILog().Warn("Slow API Request", fields...)
logger.APILog().Warn("慢请求", fields...)
} else if status >= 500 {
logger.APILog().Error("API Request Error", fields...)
logger.APILog().Error("请求错误", fields...)
} else if status >= 400 {
logger.APILog().Warn("API Request Client Error", fields...)
logger.APILog().Warn("客户端请求错误", fields...)
} else {
logger.APILog().Info("API Request", fields...)
logger.APILog().Info("API 请求", fields...)
}
}
}
// readBodyBounded 读取请求体用于日志记录,封顶 maxLen 字节以防 OOM。
//
// 仅向内存读入最多 maxLen+1 字节(+1 用于检测截断),其余部分不读入内存;
// 通过 io.MultiReader 把「已读前缀 + 原始 body 剩余」复原为 c.Request.Body
// 因此下游处理器仍能拿到完整请求体。返回的日志副本截断为 maxLen。
func readBodyBounded(c *gin.Context, maxLen int) []byte {
if maxLen <= 0 {
maxLen = DefaultLoggerConfig.MaxBodyLength
}
// io.ReadAll 永远返回非 nil 切片(即使出错也带已读部分)。LimitReader 封顶至 maxLen+1 字节。
read, _ := io.ReadAll(io.LimitReader(c.Request.Body, int64(maxLen)+1))
// 复原完整请求体供后续处理:已读前缀 + 原始 body 剩余部分(出错时保留已读字节,下游可重试读取剩余)
c.Request.Body = io.NopCloser(io.MultiReader(bytes.NewReader(read), c.Request.Body))
// 日志副本截断到 maxLen
if len(read) > maxLen {
return read[:maxLen]
}
return read
}
// bodyLogWriter 响应体记录包装器
type bodyLogWriter struct {
gin.ResponseWriter
body *bytes.Buffer
body *bytes.Buffer
maxLen int // 缓冲区上限(字节),<=0 表示不限制
}
// Write 捕获响应体
func (w *bodyLogWriter) Write(b []byte) (int, error) {
// appendBounded 向缓冲区追加字节,但不超过 maxLen 上限,防止大响应 OOM。
func (w *bodyLogWriter) appendBounded(b []byte) {
if w.maxLen <= 0 {
w.body.Write(b)
return
}
remaining := w.maxLen - w.body.Len()
if remaining <= 0 {
return
}
if len(b) > remaining {
b = b[:remaining]
}
w.body.Write(b)
}
// Write 捕获响应体(缓冲区封顶,完整响应仍写入下游 ResponseWriter
func (w *bodyLogWriter) Write(b []byte) (int, error) {
w.appendBounded(b)
return w.ResponseWriter.Write(b)
}
// WriteString 捕获字符串响应
func (w *bodyLogWriter) WriteString(s string) (int, error) {
w.body.WriteString(s)
w.appendBounded([]byte(s))
return w.ResponseWriter.WriteString(s)
}
@@ -164,7 +198,7 @@ func shouldSkipPath(path string, cfg LoggerConfig) bool {
// 检查路径前缀
for _, prefix := range cfg.SkipPathPrefixes {
if strings.HasPrefix(path, prefix) {
if pathPrefixMatch(path, prefix) {
return true
}
}
@@ -172,29 +206,60 @@ func shouldSkipPath(path string, cfg LoggerConfig) bool {
return false
}
// filterSensitiveFields 过滤敏感字段(密码、token等)
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",
func pathPrefixMatch(path, prefix string) bool {
if prefix == "" {
return false
}
cleanPrefix := strings.TrimRight(prefix, "/")
if cleanPrefix == "" {
return path == "/"
}
return path == cleanPrefix || strings.HasPrefix(path, cleanPrefix+"/")
}
for _, field := range sensitiveFields {
// 检查是否包含敏感字段
keyPattern := `"` + field + `":`
if strings.Contains(bodyStr, keyPattern) {
// 简单替换:将值替换为 [FILTERED]
// 注意:这是一个简化的实现,复杂的 JSON 可能需要更精确的处理
bodyStr = strings.ReplaceAll(bodyStr, keyPattern, keyPattern+"\"[FILTERED]\"")
// 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 {
return sensitiveFieldsRE.ReplaceAllString(string(body), `"$1":"[FILTERED]"`)
}
// sensitiveQueryKeys 需要在访问日志中脱敏的 query 参数名(小写匹配,P1 #14)。
// 与 sensitiveFieldsRE 覆盖的字段保持一致。
var sensitiveQueryKeys = map[string]struct{}{
"password": {}, "passwd": {}, "pwd": {}, "token": {},
"access_token": {}, "refresh_token": {}, "secret": {},
"api_key": {}, "apikey": {}, "credit_card": {}, "card_number": {},
}
// filterSensitiveQuery 脱敏 query 字符串中的敏感参数值(P1 #14):
// 如 ?access_token=xxx 会被记为 access_token=%5BFILTERED%5D,防止令牌等随访问日志落盘。
// 无敏感键时原样返回(不重排);解析失败时整体标记以免原文泄露。
func filterSensitiveQuery(rawQuery string) string {
if rawQuery == "" {
return ""
}
values, err := url.ParseQuery(rawQuery)
if err != nil {
return "[redacted: unparsable query]"
}
changed := false
for k := range values {
if _, ok := sensitiveQueryKeys[strings.ToLower(k)]; ok {
values[k] = []string{"[FILTERED]"}
changed = true
}
}
return bodyStr
if !changed {
return rawQuery
}
return values.Encode()
}
// LoggerForAPI API 专用日志中间件(更详细)
@@ -224,7 +289,7 @@ func LoggerMinimal() gin.HandlerFunc {
path := c.Request.URL.Path
// 跳过健康检查和静态资源
if path == "/health" || strings.HasPrefix(path, "/public") || strings.HasPrefix(path, "/static") {
if path == "/health" || pathPrefixMatch(path, "/public") || pathPrefixMatch(path, "/static") {
c.Next()
return
}
@@ -232,7 +297,7 @@ func LoggerMinimal() gin.HandlerFunc {
start := time.Now()
c.Next()
logger.APILog().Info("Request",
logger.APILog().Info("请求",
zap.Int("status", c.Writer.Status()),
zap.String("method", c.Request.Method),
zap.String("path", path),
@@ -240,4 +305,4 @@ func LoggerMinimal() gin.HandlerFunc {
zap.Duration("latency", time.Since(start)),
)
}
}
}
+275
View File
@@ -0,0 +1,275 @@
package middleware
import (
"bytes"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// newCtxWithBody 构造一个带请求体的 gin.Context,仅用于 logger 内部测试。
func newCtxWithBody(body []byte) *gin.Context {
req := httptest.NewRequest("POST", "/", bytes.NewReader(body))
c := &gin.Context{}
c.Request = req
return c
}
// TestReadBodyBounded_TruncatesLogCopy 复现 H3:日志副本必须封顶到 maxLen,
// 而不是把整个 body 读入内存后再截断(原 io.ReadAll 无上限 → OOM)。
func TestReadBodyBounded_TruncatesLogCopy(t *testing.T) {
cases := []struct {
name string
bodyLen int
maxLen int
}{
{"smaller_than_limit", 100, 1024},
{"equal_to_limit", 1024, 1024},
{"larger_than_limit", 100_000, 1024},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := bytes.Repeat([]byte("a"), tc.bodyLen)
c := newCtxWithBody(body)
got := readBodyBounded(c, tc.maxLen)
want := tc.bodyLen
if want > tc.maxLen {
want = tc.maxLen
}
if len(got) != want {
t.Fatalf("log copy len = %d, want %d (maxLen=%d, bodyLen=%d)", len(got), want, tc.maxLen, tc.bodyLen)
}
})
}
}
// TestReadBodyBounded_RestoresFullBody 是 H3 修复的闭环断言:
// 即使日志副本被截断,下游处理器仍必须能读到完整请求体(io.MultiReader 复原)。
func TestReadBodyBounded_RestoresFullBody(t *testing.T) {
const maxLen = 64
body := []byte(strings.Repeat("ABCDEFGH", 1000)) // 8000 字节,远超 maxLen
c := newCtxWithBody(body)
logCopy := readBodyBounded(c, maxLen)
// 日志副本封顶
if len(logCopy) != maxLen {
t.Fatalf("log copy len = %d, want %d", len(logCopy), maxLen)
}
// 下游必须拿到完整原始 body
restored, err := io.ReadAll(c.Request.Body)
if err != nil {
t.Fatalf("read restored body: %v", err)
}
if !bytes.Equal(restored, body) {
t.Fatalf("downstream body corrupted: got len=%d, want len=%d", len(restored), len(body))
}
}
// TestReadBodyBounded_PreservesSmallBodyExactly 小于上限时日志副本与原始一致。
func TestReadBodyBounded_PreservesSmallBodyExactly(t *testing.T) {
body := []byte(`{"user":"alice","password":"secret"}`)
c := newCtxWithBody(body)
got := readBodyBounded(c, 1024)
if !bytes.Equal(got, body) {
t.Fatalf("log copy = %q, want %q", got, body)
}
restored, _ := io.ReadAll(c.Request.Body)
if !bytes.Equal(restored, body) {
t.Fatalf("downstream body = %q, want %q", restored, body)
}
}
// TestBodyLogWriter_Bounded 复现 H3 响应侧:响应体捕获缓冲区必须封顶,
// 完整响应仍写入下游 ResponseWriter。
func TestBodyLogWriter_Bounded(t *testing.T) {
gin.SetMode(gin.TestMode)
const maxLen = 32
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: maxLen,
}
large := bytes.Repeat([]byte("Z"), 10_000)
n, err := w.Write(large)
if err != nil {
t.Fatalf("write: %v", err)
}
if n != len(large) {
t.Fatalf("Write returned %d, want %d (下游必须收到完整响应)", n, len(large))
}
if w.body.Len() > maxLen {
t.Fatalf("captured buffer len = %d, must be <= %d (OOM 防护失效)", w.body.Len(), maxLen)
}
if w.body.Len() != maxLen {
t.Fatalf("captured buffer len = %d, want exactly %d", w.body.Len(), maxLen)
}
// 下游 ResponseWriter 收到完整内容
if rec.Body.Len() != len(large) {
t.Fatalf("downstream response len = %d, want %d", rec.Body.Len(), len(large))
}
}
// TestBodyLogWriter_NoLimit maxLen<=0 时不限制(向后兼容)。
func TestBodyLogWriter_NoLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: 0,
}
data := []byte("hello world")
if _, err := w.Write(data); err != nil {
t.Fatalf("write: %v", err)
}
if !bytes.Equal(w.body.Bytes(), data) {
t.Fatalf("captured = %q, want %q", w.body.Bytes(), data)
}
}
// TestBodyLogWriter_MultiWriteAccumulation 多次小写累积不超过 maxLen,
// 下游仍收到完整拼接结果。
func TestBodyLogWriter_MultiWriteAccumulation(t *testing.T) {
gin.SetMode(gin.TestMode)
const maxLen = 32
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: maxLen,
}
chunks := [][]byte{[]byte("0123456789"), []byte("abcdefghij"), []byte("ABCDEFGHIJ"), []byte("zzzzzzzzzz")}
var downstream bytes.Buffer
for _, ch := range chunks {
n, err := w.Write(ch)
if err != nil || n != len(ch) {
t.Fatalf("Write chunk %q: n=%d err=%v", ch, n, err)
}
downstream.Write(ch)
}
if w.body.Len() > maxLen {
t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen)
}
if w.body.Len() != maxLen {
t.Fatalf("captured len = %d, want exactly %d", w.body.Len(), maxLen)
}
if !bytes.Equal(rec.Body.Bytes(), downstream.Bytes()) {
t.Fatalf("downstream = %q, want %q", rec.Body.Bytes(), downstream.Bytes())
}
}
// TestBodyLogWriter_WriteStringBounded WriteString 路径同样封顶。
func TestBodyLogWriter_WriteStringBounded(t *testing.T) {
gin.SetMode(gin.TestMode)
const maxLen = 16
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: maxLen,
}
large := strings.Repeat("S", 500)
n, err := w.WriteString(large)
if err != nil {
t.Fatalf("WriteString: %v", err)
}
if n != len(large) {
t.Fatalf("WriteString returned %d, want %d", n, len(large))
}
if w.body.Len() > maxLen {
t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen)
}
if rec.Body.Len() != len(large) {
t.Fatalf("downstream len = %d, want %d", rec.Body.Len(), len(large))
}
}
// TestFilterSensitiveQuery P1 #14query 中的敏感参数值必须脱敏,非敏感原样保留。
func TestFilterSensitiveQuery(t *testing.T) {
cases := []struct {
name string
in string
contains string // 期望结果包含
absent string // 期望结果不含(敏感原值)
}{
{"empty", "", "", ""},
{"no sensitive", "page=1&size=20", "page=1", ""},
{"access_token redacted", "access_token=supersecret123&page=1", "FILTERED", "supersecret123"},
{"password redacted", "user=alice&password=hunter2", "FILTERED", "hunter2"},
{"case insensitive key", "Token=abc.def.ghi", "FILTERED", "abc.def.ghi"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := filterSensitiveQuery(tc.in)
if tc.contains != "" && !strings.Contains(got, tc.contains) {
t.Errorf("filterSensitiveQuery(%q) = %q, want contains %q", tc.in, got, tc.contains)
}
if tc.absent != "" && strings.Contains(got, tc.absent) {
t.Errorf("filterSensitiveQuery(%q) = %q, must NOT contain sensitive value %q", tc.in, got, tc.absent)
}
})
}
}
// TestLoggerWithConfig_NormalizesMaxBodyLength MaxBodyLength<=0 时归一化为默认值,
// 确保响应侧捕获缓冲区仍有上限(H3 复审 MEDIUM:消除请求/响应侧 maxLen<=0 不对称)。
func TestLoggerWithConfig_NormalizesMaxBodyLength(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
cfg := LoggerConfig{
LogRequestBody: true,
LogResponseBody: true,
MaxBodyLength: 0, // 手滑置 0,应被归一化为默认 1024
SkipPaths: []string{},
SkipPathPrefixes: []string{},
SlowRequestThreshold: 500 * time.Millisecond,
}
r.Use(LoggerWithConfig(cfg))
r.POST("/", func(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
c.Data(200, "text/plain", body)
})
// 请求体 5000 字节(> 默认 1024),下游应仍得完整 body
reqBody := bytes.Repeat([]byte("a"), 5000)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/", bytes.NewReader(reqBody))
r.ServeHTTP(w, req)
if w.Body.Len() != len(reqBody) {
t.Fatalf("downstream body len = %d, want %d (请求体必须完整复原)", w.Body.Len(), len(reqBody))
}
if !bytes.Equal(w.Body.Bytes(), reqBody) {
t.Fatalf("downstream body corrupted")
}
}
func TestLoggerSkipPathPrefixesAnchorsBoundary_M8(t *testing.T) {
cfg := LoggerConfig{SkipPathPrefixes: []string{"/api"}}
if !shouldSkipPath("/api", cfg) {
t.Fatal("/api should be skipped")
}
if !shouldSkipPath("/api/users", cfg) {
t.Fatal("/api/users should be skipped")
}
if shouldSkipPath("/api2/users", cfg) {
t.Fatal("/api2/users should not be skipped by /api prefix")
}
}
+4 -4
View File
@@ -18,7 +18,7 @@ var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests.",
Help: "HTTP 请求总数。",
},
[]string{"method", "route", "status"},
)
@@ -26,7 +26,7 @@ var (
httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency in seconds.",
Help: "HTTP 请求耗时(秒)。",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "route"},
@@ -35,7 +35,7 @@ var (
httpRequestsInFlight = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "http_requests_in_flight",
Help: "Current number of in-flight HTTP requests.",
Help: "正在处理的 HTTP 请求数。",
},
)
)
@@ -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()
File diff suppressed because it is too large Load Diff
+422 -166
View File
@@ -2,13 +2,17 @@ package middleware
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// RateLimiter 速率限制器(内存版,单实例使用)
@@ -20,15 +24,22 @@ type RateLimiter struct {
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
nowFunc func() time.Time // 时间源(默认 time.Now,测试可注入可控时钟)
}
type visitor struct {
lastSeen time.Time
count int
// windowStart 当前固定窗口的起点。仅在新窗口开始时设置,放行时不变更(H4a 修复)。
// 旧实现每次放行都更新 lastSeen,导致 time.Since(lastSeen) > window 重置分支对持续
// 客户端永不成立、count 单调累加,稳态客户端(低于 rate)被误限流。
windowStart time.Time
count int
}
// NewRateLimiter 创建速率限制器(内存版)
// NewRateLimiter 创建速率限制器(内存版)
// rate<=0 或 window<=0 时 panic。内部启动 cleanup goroutine,调用方负责在不再使用时
// 调用 (*RateLimiter).Stop 释放,否则 goroutine 泄漏。
func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
mustValidRateLimit(rate, window)
ctx, cancel := context.WithCancel(context.Background())
limiter := &RateLimiter{
visitors: make(map[string]*visitor),
@@ -36,6 +47,7 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
window: window,
ctx: ctx,
cancel: cancel,
nowFunc: time.Now,
}
limiter.wg.Add(1)
@@ -44,32 +56,67 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
return limiter
}
// Allow 检查是否允许请求
func mustValidRateLimit(rate int, window time.Duration) {
if rate <= 0 {
panic("rate limiter: rate must be positive")
}
if window <= 0 {
panic("rate limiter: window must be positive")
}
}
// now 返回当前时间(可被测试注入 nowFunc 覆盖)。
func (rl *RateLimiter) now() time.Time {
if rl.nowFunc != nil {
return rl.nowFunc()
}
return time.Now()
}
// SetNowFunc 注入时间源(默认 time.Now),供测试用可控时钟验证窗口语义。
// 生产代码通常无需调用。
func (rl *RateLimiter) SetNowFunc(f func() time.Time) {
rl.mu.Lock()
rl.nowFunc = f
rl.mu.Unlock()
}
// Allow 检查是否允许请求。
//
// 固定窗口语义(H4a 修复):windowStart 是当前窗口起点,仅在窗口过期重置时变更;
// 放行时不再更新 windowStart。窗口内 count 达到 rate 即拒绝,窗口过期则重置为新窗口。
// 旧实现每次放行更新 lastSeen,致重置分支对持续客户端永不成立、稳态客户端被误限流。
//
// 注意:固定窗口算法允许窗口边界突发(两窗口交界处瞬时可达 2×rate)。
// 如需平滑限流(无突发),请用 Redis 版 RedisRateLimiter(滑动窗口)。
func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := rl.now()
v, exists := rl.visitors[ip]
if !exists {
rl.visitors[ip] = &visitor{
lastSeen: time.Now(),
count: 1,
windowStart: now,
count: 1,
}
return true
}
if time.Since(v.lastSeen) > rl.window {
// 窗口过期:开新窗口,count 重置为 1。
if now.Sub(v.windowStart) > rl.window {
v.windowStart = now
v.count = 1
v.lastSeen = time.Now()
return true
}
// 当前窗口内已达上限:拒绝(不更新 windowStart)。
if v.count >= rl.rate {
return false
}
// 放行:count++windowStart 不变(固定窗口语义)。
v.count++
v.lastSeen = time.Now()
return true
}
@@ -87,7 +134,8 @@ func (rl *RateLimiter) cleanupVisitors() {
case <-ticker.C:
rl.mu.Lock()
for ip, v := range rl.visitors {
if time.Since(v.lastSeen) > rl.window {
// 窗口起点超 window 未活跃即淘汰(H4awindowStart 是窗口起点,不再被放行更新)。
if rl.now().Sub(v.windowStart) > rl.window {
delete(rl.visitors, ip)
}
}
@@ -104,11 +152,23 @@ func (rl *RateLimiter) Stop() {
// ===== Redis 分布式限流器 =====
// H4c: Redis 限流器故障相关错误。
var (
// ErrRedisRateLimiterUnavailable Redis 未启用(database.GetRedis() == nil)。
// fail-closed 限流器在此情况下拒绝请求;fail-open 限流器放行。
ErrRedisRateLimiterUnavailable = errors.New("redis rate limiter: redis client unavailable")
// ErrRedisRateLimiterUnexpectedResult Redis 返回非预期的结果类型(非 int64)。
// fail-closed 限流器拒绝;fail-open 限流器放行。旧实现裸断言会 panic。
ErrRedisRateLimiterUnexpectedResult = errors.New("redis rate limiter: unexpected result type")
)
// RedisRateLimiter Redis 分布式限流器
type RedisRateLimiter struct {
keyPrefix string // 键名前缀
rate int // 每分钟允许的请求数
window time.Duration // 时间窗口
keyPrefix string // 键名前缀
rate int // 每分钟允许的请求数
window time.Duration // 时间窗口
failClosed atomic.Bool // H4c/H-6: Redis 错误/断言失败时是否拒绝(true=安全型 fail-closed)。atomic 以支持运行期 SetFailClosed 并发安全切换。
client *redis.Client // Phase 5: 注入的 redis clientnil 回退 database.GetRedis(),照 jwt.TokenBlacklist 模型)。App 经 WithRedisClient 注入 per-App client 实现隔离。
}
// slidingWindowLua 滑动窗口限流 Lua 脚本
@@ -135,19 +195,78 @@ else
end
`
// NewRedisRateLimiter 创建 Redis 分布式限流器
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
return &RedisRateLimiter{
keyPrefix: keyPrefix,
rate: rate,
window: window,
// RedisRateLimiterOption 配置 RedisRateLimiter 的可选策略。
type RedisRateLimiterOption func(*RedisRateLimiter)
// WithFailClosed 设置 Redis 故障时 fail-closed(拒绝请求)。
// 不传或传 false 为 fail-open(放行,兼容默认)。安全敏感场景(登录防爆破等)应传 true。
func WithFailClosed(failClosed bool) RedisRateLimiterOption {
return func(rl *RedisRateLimiter) {
rl.failClosed.Store(failClosed)
}
}
// Allow 检查是否允许请求
// WithRedisClient 注入指定 Redis 客户端(Phase 5,多 Redis/测试隔离)。
// 不传则回退 database.GetRedis()(全局)。App 可经 app.RedisClient() 取自身 client 注入。
func WithRedisClient(client *redis.Client) RedisRateLimiterOption {
return func(rl *RedisRateLimiter) {
rl.client = client
}
}
// NewRedisRateLimiter 创建 Redis 分布式限流器。
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景传 WithFailClosed(true)。
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) *RedisRateLimiter {
mustValidRateLimit(rate, window)
rl := &RedisRateLimiter{
keyPrefix: keyPrefix,
rate: rate,
window: window,
// failClosed 零值 falsefail-open,兼容默认)
}
for _, opt := range opts {
if opt != nil {
opt(rl)
}
}
return rl
}
// SetFailClosed 设置 Redis 故障时的策略:true=拒绝(安全型),false=放行(兼容默认)。
// 供已创建的限流器切换策略。并发安全(atomic.Store),可与并发 Allow 调用共存。
func (rl *RedisRateLimiter) SetFailClosed(failClosed bool) {
rl.failClosed.Store(failClosed)
}
// redisClient 返回注入的 redis client,未注入则回退 database.GetRedis()jwt 模型)。
// M-C 教训:取一次复用,避免 nil 检查与 Eval 各调一次 GetRedis() 之间的 CloseRedis 竞态。
func (rl *RedisRateLimiter) redisClient() *redis.Client {
if rl != nil && rl.client != nil {
return rl.client
}
return database.GetRedis()
}
// Allow 检查是否允许请求。
//
// H4c 修复:
// - result.(int64) 改 comma-ok,断言失败返 ErrRedisRateLimiterUnexpectedResult 而非 panic。
// - Redis 错误/断言失败时按 failClosed 策略决定:fail-closed 返 (false, err) 拒绝,
// fail-open 返 (true, err) 放行(兼容旧行为)。中间件层据此 allowed 值决定放行/拒绝,
// 不再无条件 fail-open--登录防爆破等安全场景用 fail-closed 限流器即可在 Redis 故障时拒绝。
//
// Redis 未启用(redisClient() == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable)
// fail-open 返 (true, nil)(兼容旧行为)。安全场景必须确保 Redis 已启用。
//
// Phase 5redisClient() 注入优先、全局兜底,App 经 WithRedisClient 注入 per-App client。
func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) {
if database.RedisClient == nil {
// Redis 未启用,默认允许
// M-C 修复:取一次复用。原实现 nil 检查与 Eval 各调一次 GetRedis()
// 两次之间若 CloseRedis,第二次返回 nil -> nil.Eval panic。
rdb := rl.redisClient()
if rdb == nil {
if rl.failClosed.Load() {
return false, ErrRedisRateLimiterUnavailable
}
return true, nil
}
@@ -155,18 +274,30 @@ 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 := rdb.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
if err != nil {
return true, err // 出错时允许请求,避免影响业务
if rl.failClosed.Load() {
return false, err
}
return true, err // 出错时允许请求,避免影响业务(兼容旧行为)
}
count := result.(int64)
// H4c: comma-ok 断言,避免 Redis 返回非 int64 时 panic。
count, ok := result.(int64)
if !ok {
if rl.failClosed.Load() {
return false, ErrRedisRateLimiterUnexpectedResult
}
return true, ErrRedisRateLimiterUnexpectedResult
}
return count == 0, nil
}
// GetCount 获取当前窗口内的请求数
func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (int64, error) {
if database.RedisClient == nil {
// M-C 修复(Phase 5:改 redisClient):取一次复用(原三次调用存在 nil-deref 窗口)。
rdb := rl.redisClient()
if rdb == nil {
return 0, nil
}
@@ -175,136 +306,287 @@ 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()
rdb.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
return rdb.ZCard(ctx, key).Result()
}
// Reset 重置限流计数
func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error {
if database.RedisClient == nil {
// M-C 修复(Phase 5:改 redisClient):取一次复用(原两次调用存在 nil-deref 窗口)。
rdb := rl.redisClient()
if rdb == nil {
return nil
}
key := rl.keyPrefix + ":" + identifier
return database.RedisClient.Del(ctx, key).Err()
return rdb.Del(ctx, key).Err()
}
// ===== 全局限速器 =====
// ===== 全局限速器 RegistryPhase 5 实例化) =====
//
// 内存限流器(RateLimiter)持 cleanup goroutine,进程级共享会在多 App 下互相污染
// App A Shutdown 经 StopRateLimiters 把 App B 的 loginLimiter 也停了)。故照
// cache.CacheManager / cron.Scheduler 模式引入 RateLimitRegistry:实例化 + 全局默认
// + 包级 facade 代理,App 持自己的 Registry。
//
// 包级 LoginRateLimit() 等改为"请求时解析当前默认 Registry"懒创建限流器,使 App.Init
// swap 后请求落到 App 的 Registry(避免 pre-Init 注册到 init Registry 被 swap 丢弃)。
var (
loginLimiter *RateLimiter
apiLimiter *RateLimiter
uploadLimiter *RateLimiter
redisLimiters map[string]*RedisRateLimiter
limitersMu sync.Mutex
)
// RateLimitRegistry 持一组内存限流器,支持 per-App 隔离。
type RateLimitRegistry struct {
mu sync.Mutex
loginLimiter *RateLimiter
apiLimiter *RateLimiter
uploadLimiter *RateLimiter
customLimiters []*RateLimiter
}
// NewRateLimitRegistry 创建限流器注册表实例。
func NewRateLimitRegistry() *RateLimitRegistry {
return &RateLimitRegistry{}
}
func (r *RateLimitRegistry) loginLimiterInstance() *RateLimiter {
r.mu.Lock()
defer r.mu.Unlock()
if r.loginLimiter == nil {
r.loginLimiter = NewRateLimiter(10, time.Minute)
}
return r.loginLimiter
}
func (r *RateLimitRegistry) apiLimiterInstance() *RateLimiter {
r.mu.Lock()
defer r.mu.Unlock()
if r.apiLimiter == nil {
r.apiLimiter = NewRateLimiter(100, time.Minute)
}
return r.apiLimiter
}
func (r *RateLimitRegistry) uploadLimiterInstance() *RateLimiter {
r.mu.Lock()
defer r.mu.Unlock()
if r.uploadLimiter == nil {
r.uploadLimiter = NewRateLimiter(20, time.Minute)
}
return r.uploadLimiter
}
// registerCustom 登记自定义限流器供 Stop 统一停止(H4b)。
func (r *RateLimitRegistry) registerCustom(l *RateLimiter) {
r.mu.Lock()
r.customLimiters = append(r.customLimiters, l)
r.mu.Unlock()
}
// Init 预初始化标准限流器(可选;不调则首次请求时懒创建)。先停旧的同名限流器释放 goroutine。
func (r *RateLimitRegistry) Init() {
r.mu.Lock()
defer r.mu.Unlock()
if r.loginLimiter != nil {
r.loginLimiter.Stop()
}
if r.apiLimiter != nil {
r.apiLimiter.Stop()
}
if r.uploadLimiter != nil {
r.uploadLimiter.Stop()
}
r.loginLimiter = NewRateLimiter(10, time.Minute)
r.apiLimiter = NewRateLimiter(100, time.Minute)
r.uploadLimiter = NewRateLimiter(20, time.Minute)
}
// Stop 停止注册表中所有限流器(释放 cleanup goroutine)。幂等。
func (r *RateLimitRegistry) Stop() {
r.mu.Lock()
defer r.mu.Unlock()
if r.loginLimiter != nil {
r.loginLimiter.Stop()
r.loginLimiter = nil
}
if r.apiLimiter != nil {
r.apiLimiter.Stop()
r.apiLimiter = nil
}
if r.uploadLimiter != nil {
r.uploadLimiter.Stop()
r.uploadLimiter = nil
}
for _, l := range r.customLimiters {
l.Stop()
}
r.customLimiters = nil
}
// --- App-bound 中间件(捕获此 Registry,不查全局默认,多 App per-App 隔离) ---
//
// 与包级 LoginRateLimit() 等的区别:包级在请求时查 GetDefaultRateLimitRegistry()(多 App 下
// 仅最后 Init 的 App 是全局默认,故包级 facade 不提供 per-App 计数隔离);本组方法捕获 r,
// 始终用此 Registry 的 limiter。多 App 场景用 app.RateLimitRegistry().LoginRateLimit() 装配路由。
// LoginRateLimit 返回绑定到此 Registry 的登录限流中间件。
func (r *RateLimitRegistry) LoginRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, r.loginLimiterInstance())
}
}
// APIRateLimit 返回绑定到此 Registry 的普通 API 限流中间件。
func (r *RateLimitRegistry) APIRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, r.apiLimiterInstance())
}
}
// UploadRateLimit 返回绑定到此 Registry 的上传限流中间件。
func (r *RateLimitRegistry) UploadRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, r.uploadLimiterInstance())
}
}
// CustomRateLimit 返回绑定到此 Registry 的自定义限流中间件。limiter 在首次请求时创建并
// 登记到此 Registryr.registerCustom),由 App.Shutdown 的 registry.Stop() 收口,无泄漏。
func (r *RateLimitRegistry) CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
var (
once sync.Once
limiter *RateLimiter
)
return func(c *gin.Context) {
once.Do(func() {
limiter = NewRateLimiter(rate, window)
r.registerCustom(limiter)
})
rateLimitAllow(c, limiter)
}
}
// defaultRateLimitRegistry 全局默认 Registryatomic,照 cache/cron 模式)。
var defaultRateLimitRegistry atomic.Pointer[RateLimitRegistry]
func init() {
redisLimiters = make(map[string]*RedisRateLimiter)
defaultRateLimitRegistry.Store(NewRateLimitRegistry())
}
// InitRateLimiters 初始化限速器
func InitRateLimiters() {
limitersMu.Lock()
defer limitersMu.Unlock()
// 先停止旧的限流器
if loginLimiter != nil {
loginLimiter.Stop()
}
if apiLimiter != nil {
apiLimiter.Stop()
}
if uploadLimiter != nil {
uploadLimiter.Stop()
}
// 内存限流器(单实例)
loginLimiter = NewRateLimiter(10, time.Minute)
apiLimiter = NewRateLimiter(100, time.Minute)
uploadLimiter = NewRateLimiter(20, time.Minute)
// GetDefaultRateLimitRegistry 返回全局默认 Registry。
func GetDefaultRateLimitRegistry() *RateLimitRegistry {
return defaultRateLimitRegistry.Load()
}
// StopRateLimiters 停止所有限速器(应用关闭时调用)
func StopRateLimiters() {
limitersMu.Lock()
defer limitersMu.Unlock()
if loginLimiter != nil {
loginLimiter.Stop()
loginLimiter = nil
}
if apiLimiter != nil {
apiLimiter.Stop()
apiLimiter = nil
}
if uploadLimiter != nil {
uploadLimiter.Stop()
uploadLimiter = nil
// SwapDefaultRateLimitRegistry 置为全局默认,返回被替换的旧(照 Swap 模式)。nil 忽略,返回当前默认。
func SwapDefaultRateLimitRegistry(r *RateLimitRegistry) *RateLimitRegistry {
if r == nil {
return defaultRateLimitRegistry.Load()
}
return defaultRateLimitRegistry.Swap(r)
}
// RateLimit 通用速率限制中间件(内存版)
// rateLimitAllow 公共放行/拒绝逻辑(内存版)
func rateLimitAllow(c *gin.Context, rl *RateLimiter) {
if rl == nil {
response.Custom(c, http.StatusServiceUnavailable, response.CodeServiceUnavailable,
"限流器未初始化", nil)
c.Abort()
return
}
if !rl.Allow(c.ClientIP()) {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
}
// RateLimit 通用速率限制中间件(内存版,用户自持 limiter 时使用)。
func RateLimit(limiter *RateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !limiter.Allow(ip) {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
rateLimitAllow(c, limiter)
}
}
// LoginRateLimit 登录接口速率限制
// LoginRateLimit 登录接口速率限制。限流器在首次请求时从当前默认 Registry 懒创建,
// 故 App.Init swap 后用的是 App 的 Registry(避免 pre-Init 注册到 init Registry 被丢弃)。
func LoginRateLimit() gin.HandlerFunc {
limitersMu.Lock()
if loginLimiter == nil {
loginLimiter = NewRateLimiter(10, time.Minute)
return func(c *gin.Context) {
rateLimitAllow(c, GetDefaultRateLimitRegistry().loginLimiterInstance())
}
limiter := loginLimiter
limitersMu.Unlock()
return RateLimit(limiter)
}
// APIRateLimit 普通 API 速率限制
// APIRateLimit 普通 API 速率限制
func APIRateLimit() gin.HandlerFunc {
limitersMu.Lock()
if apiLimiter == nil {
apiLimiter = NewRateLimiter(100, time.Minute)
return func(c *gin.Context) {
rateLimitAllow(c, GetDefaultRateLimitRegistry().apiLimiterInstance())
}
limiter := apiLimiter
limitersMu.Unlock()
return RateLimit(limiter)
}
// UploadRateLimit 上传接口速率限制
// UploadRateLimit 上传接口速率限制
func UploadRateLimit() gin.HandlerFunc {
limitersMu.Lock()
if uploadLimiter == nil {
uploadLimiter = NewRateLimiter(20, time.Minute)
return func(c *gin.Context) {
rateLimitAllow(c, GetDefaultRateLimitRegistry().uploadLimiterInstance())
}
limiter := uploadLimiter
limitersMu.Unlock()
return RateLimit(limiter)
}
// CustomRateLimit 自定义速率限制(内存版)
// CustomRateLimit 自定义速率限制(内存版)。限流器在首次请求时创建并登记到当前默认
// Registry(供 Stop 统一停止,避免 cleanup goroutine 泄漏,H4b)。
func CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
limiter := NewRateLimiter(rate, window)
return RateLimit(limiter)
var (
once sync.Once
limiter *RateLimiter
)
return func(c *gin.Context) {
once.Do(func() {
limiter = NewRateLimiter(rate, window)
GetDefaultRateLimitRegistry().registerCustom(limiter)
})
rateLimitAllow(c, limiter)
}
}
// InitRateLimiters 初始化默认 Registry 的标准限流器(可选,不调则懒创建)。
func InitRateLimiters() {
GetDefaultRateLimitRegistry().Init()
}
// StopRateLimiters 停止默认 Registry 的所有限流器。注意:仅停默认 Registry--
// App 持自己的 Registry 时应在 Shutdown 调 app 自己的 StopPhase 5)。
func StopRateLimiters() {
GetDefaultRateLimitRegistry().Stop()
}
// ===== Redis 分布式限流中间件 =====
// RedisRateLimit Redis 分布式限流中间件
// 参数: keyPrefix 键名前缀(如 "login_limit"),rate 每分钟请求数
func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
// redisLimitDecision 处理 RedisRateLimiter.Allow 的结果,按 allowed 值决定放行/拒绝。
//
// H4c: 不再无条件 fail-open。Allow 已按 limiter 的 failClosed 策略把 Redis 故障翻成
// allowed 值--fail-closed 时 allowed=false(拒绝),fail-open 时 allowed=true(放行)。
// 故 err 与 allowed 的组合语义:
// - err==nil, allowed==true -> 放行
// - err==nil, allowed==false -> 真超限,返 429response.RateLimit
// - err!=nil, allowed==false -> fail-closed 限流器在 Redis 故障下拒绝,返 503(服务不可用)
// - err!=nil, allowed==true -> fail-open 限流器在 Redis 故障下放行(兼容旧行为)
func redisLimitDecision(c *gin.Context, allowed bool, err error) {
if err != nil && !allowed {
// fail-closedRedis 故障时拒绝(防限流静默失效)。返 503 区别于真实超限的 429。
response.Custom(c, http.StatusServiceUnavailable, response.CodeServiceUnavailable,
"限流服务暂时不可用", nil)
c.Abort()
return
}
if !allowed {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
}
// RedisRateLimit Redis 分布式限流中间件。
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景(登录防爆破等)
// 传 WithFailClosed(true)Redis 故障时拒绝以防限流失效。
func RedisRateLimit(keyPrefix string, rate int, opts ...RedisRateLimiterOption) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
return func(c *gin.Context) {
identifier := c.ClientIP()
@@ -316,83 +598,57 @@ func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
// }
allowed, err := limiter.Allow(c.Request.Context(), identifier)
if err != nil {
// Redis 错误时允许请求,避免影响业务
c.Next()
return
}
if !allowed {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
redisLimitDecision(c, allowed, err)
}
}
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流
// 参数: keyPrefix 键名前缀,rate 每分钟请求数,identifierFunc 标识获取函数
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流
// 参数: keyPrefix 键名前缀,rate 1 分钟窗口内允许的请求数,identifierFunc 标识获取函数
// identifierFunc 为 nil 或返回空串时回退到 c.ClientIP()。默认 fail-open,传 WithFailClosed(true) 切换。
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string, opts ...RedisRateLimiterOption) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
return func(c *gin.Context) {
identifier := identifierFunc(c)
identifier := ""
if identifierFunc != nil {
identifier = identifierFunc(c)
}
if identifier == "" {
identifier = c.ClientIP()
}
allowed, err := limiter.Allow(c.Request.Context(), identifier)
if err != nil {
c.Next()
return
}
if !allowed {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
redisLimitDecision(c, allowed, err)
}
}
// LoginRedisRateLimit 登录接口 Redis 分布式限流
// LoginRedisRateLimit 登录接口 Redis 分布式限流fail-closed)。
//
// H4c: 登录防爆破场景必须 fail-closed--Redis 故障时若 fail-open 则限流失效、
// 攻击者可借 Redis 抖动窗口无限爆破。改为 fail-closedRedis 故障时返 503 拒绝。
func LoginRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("login_limit", 10)
return RedisRateLimit("login_limit", 10, WithFailClosed(true))
}
// APIRedisRateLimit API Redis 分布式限流
// APIRedisRateLimit API Redis 分布式限流fail-open,避免影响业务)。
func APIRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("api_limit", 100)
}
// UploadRedisRateLimit 上传接口 Redis 分布式限流
// UploadRedisRateLimit 上传接口 Redis 分布式限流fail-closed)。
// 上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效。
func UploadRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("upload_limit", 20)
return RedisRateLimit("upload_limit", 20, WithFailClosed(true))
}
// CustomRedisRateLimit 自定义 Redis 分布式限流
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, window)
// CustomRedisRateLimit 自定义 Redis 分布式限流
// 默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed。
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, window, opts...)
return func(c *gin.Context) {
identifier := c.ClientIP()
allowed, err := limiter.Allow(c.Request.Context(), identifier)
if err != nil {
c.Next()
return
}
if !allowed {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
redisLimitDecision(c, allowed, err)
}
}
}
@@ -0,0 +1,96 @@
package middleware
import (
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// TestRedisRateLimiterInjectedClientPriority 固化 Phase 5jwt 模型):
// RedisRateLimiter.redisClient() 注入优先、全局兜底。
func TestRedisRateLimiterInjectedClientPriority(t *testing.T) {
mrG := miniredis.RunT(t)
clientG := redis.NewClient(&redis.Options{Addr: mrG.Addr()})
t.Cleanup(func() { _ = clientG.Close() })
origGlobal := database.SetTestRedisClient(clientG)
t.Cleanup(func() { database.SetTestRedisClient(origGlobal) })
mrA := miniredis.RunT(t)
clientA := redis.NewClient(&redis.Options{Addr: mrA.Addr()})
t.Cleanup(func() { _ = clientA.Close() })
// 注入优先
rl := NewRedisRateLimiter("test", 10, time.Minute, WithRedisClient(clientA))
if got := rl.redisClient(); got != clientA {
t.Fatalf("injected redisClient() = %v, want clientA", got)
}
// 未注入 -> 全局兜底
rl2 := NewRedisRateLimiter("test", 10, time.Minute)
if got := rl2.redisClient(); got != clientG {
t.Fatalf("fallback redisClient() = %v, want clientG (global)", got)
}
}
// TestSwapDefaultRateLimitRegistryReturnsOld 固化 Phase 5Swap 返回被替换的旧 Registry。
func TestSwapDefaultRateLimitRegistryReturnsOld(t *testing.T) {
orig := defaultRateLimitRegistry.Load()
defer SwapDefaultRateLimitRegistry(orig)
first := NewRateLimitRegistry()
if prev := SwapDefaultRateLimitRegistry(first); prev != orig {
t.Fatalf("Swap returned %v, want orig", prev)
}
second := NewRateLimitRegistry()
if returned := SwapDefaultRateLimitRegistry(second); returned != first {
t.Fatalf("Swap returned %v, want first", returned)
}
if defaultRateLimitRegistry.Load() != second {
t.Fatal("Swap did not install second as default")
}
if got := SwapDefaultRateLimitRegistry(nil); got != second {
t.Fatalf("Swap(nil) = %v, want second (current default)", got)
}
}
// TestRateLimitRegistryStopIdempotent 固化 Phase 5Stop 幂等,重复调用不 panic。
func TestRateLimitRegistryStopIdempotent(t *testing.T) {
r := NewRateLimitRegistry()
_ = r.loginLimiterInstance()
_ = r.apiLimiterInstance()
_ = r.uploadLimiterInstance()
r.registerCustom(NewRateLimiter(5, time.Minute))
r.Stop()
r.Stop() // 幂等
}
// TestRateLimitRegistryIsolation 固化 Phase 5:两个 Registry 实例的 limiter 互相独立。
func TestRateLimitRegistryIsolation(t *testing.T) {
rA := NewRateLimitRegistry()
rB := NewRateLimitRegistry()
defer rA.Stop()
defer rB.Stop()
la := rA.loginLimiterInstance()
lb := rB.loginLimiterInstance()
if la == lb {
t.Fatal("two registries returned the same loginLimiter instance (not isolated)")
}
// rA 用尽 10 次;rB 不受影响
for i := 0; i < 10; i++ {
if !la.Allow("1.1.1.1") {
t.Fatalf("rA request %d should be allowed", i+1)
}
}
// rA 第 11 次应被拒绝(count=10 >= rate=10
if la.Allow("1.1.1.1") {
t.Fatal("rA 11th should be rejected (count reached limit)")
}
// rB 的 loginLimiter 独立计数,1.1.1.1 仍可放行
if !lb.Allow("1.1.1.1") {
t.Fatal("rB loginLimiter should be independent of rA (still allowed)")
}}
+33 -8
View File
@@ -20,7 +20,7 @@ func Recover() gin.HandlerFunc {
requestID := GetRequestID(c)
// 记录错误日志
logger.Error("Panic recovered",
logger.Error("panic 已恢复",
zap.String("request_id", requestID),
zap.String("error", fmt.Sprintf("%v", err)),
zap.String("path", c.Request.URL.Path),
@@ -28,9 +28,26 @@ func Recover() gin.HandlerFunc {
zap.String("stack", string(debug.Stack())),
)
// 返回错误响应
response.FailWithCode(c, response.CodeServerError, "服务器内部错误")
c.AbortWithStatus(http.StatusInternalServerError)
// 返回错误响应
// 必须用 response.Custom 显式写 HTTP 500FailWithCode 经 writeResp→httpStatusFor
// 在默认 ModeBusiness 下对 CodeServerError 返回 200c.JSON(200,...) 会先 flush
// 锁定状态,随后 AbortWithStatus(500) 因 w.Written()==true 沦为 no-op
// 导致客户端收到 HTTP 200 + body code:500,网关/APM 按 status 看不到 panic。
// Custom 不受 Mode 影响,直接写 500 且保留 RequestID。
//
// M-B 修复:若 panic 发生在响应已开始写出之后(流式/部分写),c.Writer.Written()==true
// gin 拒绝再次写响应,response.Custom 沦为 no-op,客户端会收到截断响应而非 500。
// 此时不再尝试写 500(无效),仅 Abort + 记录,避免无意义的二次写入。
if !c.Writer.Written() {
response.Custom(c, http.StatusInternalServerError, response.CodeServerError, "服务器内部错误", nil)
} else {
logger.Warn("panic 发生在响应已开始写入后,无法写入 500,客户端将收到截断响应",
zap.String("request_id", requestID),
zap.String("path", c.Request.URL.Path),
zap.Int("bytes_written", c.Writer.Size()),
)
}
c.Abort()
}
}()
c.Next()
@@ -45,7 +62,7 @@ func RecoverWithDetail() gin.HandlerFunc {
if err := recover(); err != nil {
requestID := GetRequestID(c)
logger.Error("Panic recovered",
logger.Error("panic 已恢复",
zap.String("request_id", requestID),
zap.String("error", fmt.Sprintf("%v", err)),
zap.String("path", c.Request.URL.Path),
@@ -53,9 +70,17 @@ func RecoverWithDetail() gin.HandlerFunc {
zap.String("stack", string(debug.Stack())),
)
// 开发环境返回详细错误
response.FailWithCode(c, response.CodeServerError, fmt.Sprintf("Panic: %v", err))
c.AbortWithStatus(http.StatusInternalServerError)
// 开发环境返回详细错误。同样用 Custom 显式写 500(见 Recover 注释)。
// M-B 修复:响应已写出时不重复写 500(同 Recover)。
if !c.Writer.Written() {
response.Custom(c, http.StatusInternalServerError, response.CodeServerError, fmt.Sprintf("服务器内部错误: %v", err), nil)
} else {
logger.Warn("panic 发生在响应已开始写入后,无法写入 500,客户端将收到截断响应",
zap.String("request_id", requestID),
zap.String("path", c.Request.URL.Path),
)
}
c.Abort()
}
}()
c.Next()
+27 -2
View File
@@ -5,10 +5,35 @@ import (
"github.com/gin-gonic/gin"
)
// RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪
// 请求 ID 校验约束(M15 修复:不无条件信任客户端 X-Request-ID,防头注入/日志伪造)。
const (
// requestIDMaxLen 客户端传入请求 ID 的最大长度。超长视为非法并重新生成,避免日志膨胀/注入。
requestIDMaxLen = 128
)
// sanitizeRequestID 校验客户端传入的 X-Request-ID:仅允许可见 ASCII0x20-0x7e)且
// 不含 CR/LF(防 HTTP 头注入与日志换行伪造),长度不超过 requestIDMaxLen。
// 非法或为空时返回 "",由调用方重新生成。
func sanitizeRequestID(id string) string {
if len(id) == 0 || len(id) > requestIDMaxLen {
return ""
}
for i := 0; i < len(id); i++ {
c := id[i]
if c < 0x20 || c > 0x7e {
return ""
}
}
return id
}
// RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪。
//
// 客户端可经 X-Request-ID 头传入 ID 用于链路串联,但会做校验(M15 修复):
// 仅接受可见 ASCII、无换行、长度 ≤128 的值,否则忽略并重新生成——防头注入与日志伪造。
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
requestID := sanitizeRequestID(c.GetHeader("X-Request-ID"))
if requestID == "" {
requestID = utils.UUID()
}
+5
View File
@@ -13,6 +13,11 @@ import (
// 为每个请求的 context 设置 deadline,下游 GORM / Redis / HTTP 调用
// 走 c.Request.Context() 即可级联取消,避免单个慢请求拖垮协程。
//
// ⚠️ 软超时语义(M14 文档化):本中间件仅注入带 deadline 的 ctx,不会主动中断 handler。
// 生效前提是下游实际消费 ctx——GORM/Redis/HTTP 客户端等会响应 ctx 取消;但若 handler
// 是纯 CPU 循环、阻塞于不查 ctx 的调用、或不读 c.Request.Context(),则 deadline 到期
// 也不会停。需要硬中断的场景请配合 http.Server.WriteTimeout 或在 handler 内显式 select ctx.Done。
//
// 用法:
//
// r.Use(middleware.Timeout(5 * time.Second))
+7 -2
View File
@@ -6,7 +6,8 @@ import (
"gorm.io/gorm"
)
// BaseModel 基础模型
// BaseModel 基础模型。CreatedAt/UpdatedAt 不显式指定 type,由 GORM 按驱动选默认
// 时间列类型(MySQL 通常为 datetime(6),保留亚秒精度)。
type BaseModel struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
@@ -14,7 +15,11 @@ type BaseModel struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// BaseModelWithTime 带时间戳的模型
// BaseModelWithTime 显式指定时间列为 datetime 类型。
//
// 命名注意(N1):本类型与 BaseModel 的唯一区别是 CreatedAt/UpdatedAt 带 `gorm:"type:datetime"`
// 二者都有时间戳——名字里的 "WithTime" 易误导。datetime 类型在部分 MySQL 版本下精度为秒
// (丢毫秒),需毫秒精度请用 BaseModel 或显式 `type:datetime(3)`。保留此类型仅为向后兼容。
type BaseModelWithTime struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `gorm:"type:datetime" json:"created_at"`
+423 -127
View File
@@ -2,10 +2,30 @@ package repository
import (
"context"
"errors"
"strings"
"unicode"
"github.com/EthanCodeCraft/xlgo-core/database"
"gorm.io/gorm"
)
const (
// DefaultFindAllLimit caps FindAll to avoid accidental full-table scans.
DefaultFindAllLimit = 1000
// DefaultPageSize is used when pageSize<=0.
DefaultPageSize = 20
// MaxPageSize caps page queries to avoid Limit(0)/huge-limit footguns.
MaxPageSize = 100
// MaxPage caps deep-offset queries; deeper traversal should use keyset pagination.
MaxPage = 10000
)
var (
ErrUnsafeOrder = errors.New("repository: unsafe order clause")
ErrUnsafeField = errors.New("repository: unsafe field name")
)
// BaseRepository 基础仓库接口
type BaseRepository[T any] interface {
// FindByID 根据 ID 查询
@@ -14,15 +34,27 @@ type BaseRepository[T any] interface {
Create(ctx context.Context, model *T) error
// Update 更新记录
Update(ctx context.Context, model *T) error
// Delete 删除记录(软删除
// Delete 删除记录(T 含软删除字段时为软删除,否则为硬删除;详见 BaseRepo.Delete
Delete(ctx context.Context, id uint) error
// FindByIDs 批量查询
FindByIDs(ctx context.Context, ids []uint) ([]T, error)
}
// BaseRepo 基础仓库实现
// BaseRepo 基础仓库实现
//
// 连接路由契约(H6c 修复):
// - 读操作经 readConn(ctx) 路由:优先 join 外层事务,否则走 database.GetDBFromContext
// (默认从库,支持 UseMaster/UseReplica 读写分离),DefaultManager 未初始化时回退 r.db。
// - 写操作经 writeConn(ctx) 路由:优先 join 外层事务,否则走主库 database.GetWriteDB()
// 回退 r.db。写操作不路由到从库(从库只读)。
// - 事务内(WithTransaction 创建的 txRepo)所有方法 join 同一事务。
//
// 下游典型用法 NewBaseRepo[T](database.GetDB()) 仍兼容:GetDB 返回主库,DefaultManager
// 初始化后读操作自动路由到从库;未初始化(如单测注入 sqlite)时回退到 r.db。
type BaseRepo[T any] struct {
db *gorm.DB
// tx 在事务内(WithTransaction)非 nil,使 txRepo 的方法 join 该事务而非另开连接/路由。
tx *gorm.DB
}
// NewBaseRepo 创建基础仓库
@@ -30,10 +62,61 @@ func NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T] {
return &BaseRepo[T]{db: db}
}
func normalizeContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
func (r *BaseRepo[T]) requireDB() *gorm.DB {
if r.db == nil {
panic("repository.BaseRepo: db is nil. Use NewBaseRepo with a valid *gorm.DB")
}
return r.db
}
// readConn 返回读连接:先校验 repo 构造时注入的 r.db 非 nil,再按
// 外层 ctx 事务 > 本 repo 事务 > 读写分离路由 > r.db 回退。
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息,即使 ctx 携带外层事务也要求 repo 构造合法。
func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB {
ctx = normalizeContext(ctx)
base := r.requireDB()
if tx := database.TxFromContext(ctx); tx != nil {
return tx.WithContext(ctx)
}
if r.tx != nil {
return r.tx.WithContext(ctx)
}
if gdb := database.GetDBFromContext(ctx); gdb != nil {
return gdb.WithContext(ctx)
}
return base.WithContext(ctx)
}
// writeConn 返回写连接:先校验 repo 构造时注入的 r.db 非 nil,再按
// 外层 ctx 事务 > 本 repo 事务 > 主库 > r.db 回退。
// 写操作始终走主库,不路由到只读从库。
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息,即使 ctx 携带外层事务也要求 repo 构造合法。
func (r *BaseRepo[T]) writeConn(ctx context.Context) *gorm.DB {
ctx = normalizeContext(ctx)
base := r.requireDB()
if tx := database.TxFromContext(ctx); tx != nil {
return tx.WithContext(ctx)
}
if r.tx != nil {
return r.tx.WithContext(ctx)
}
if mdb := database.GetWriteDB(); mdb != nil {
return mdb.WithContext(ctx)
}
return base.WithContext(ctx)
}
// FindByID 根据 ID 查询
func (r *BaseRepo[T]) FindByID(ctx context.Context, id uint) (*T, error) {
var model T
err := r.db.WithContext(ctx).First(&model, id).Error
err := r.readConn(ctx).First(&model, id).Error
if err != nil {
return nil, err
}
@@ -42,54 +125,92 @@ func (r *BaseRepo[T]) FindByID(ctx context.Context, id uint) (*T, error) {
// Create 创建记录
func (r *BaseRepo[T]) Create(ctx context.Context, model *T) error {
return r.db.WithContext(ctx).Create(model).Error
return r.writeConn(ctx).Create(model).Error
}
// Update 更新记录
// Update 更新记录(全列覆写,基于 Save)。
//
// 注意(H6a):Save 会写入所有字段(包括零值),无法区分"未设置"与"清零"
// 且可能用零值覆盖并发更新。需要局部更新(仅更新非零字段或指定字段)请用 UpdateFields。
func (r *BaseRepo[T]) Update(ctx context.Context, model *T) error {
return r.db.WithContext(ctx).Save(model).Error
return r.writeConn(ctx).Save(model).Error
}
// Delete 删除记录(软删除)
// UpdateFields 局部更新(H6a):基于 gorm.Updates,仅更新非零字段(struct)或指定字段(map)。
//
// - 传 struct:仅更新非零字段(零值被忽略,避免覆盖)。
// - 传 map[string]any:更新指定字段(可显式置零)。
//
// 示例:
//
// repo.UpdateFields(ctx, &User{Name: "new"}) // struct:仅更新非零字段 name
// repo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map:显式置零,带条件
func (r *BaseRepo[T]) UpdateFields(ctx context.Context, model any, conds ...any) error {
db := r.writeConn(ctx).Model(new(T))
if len(conds) > 0 {
db = db.Where(conds[0], conds[1:]...)
}
return db.Updates(model).Error
}
// Delete 删除记录。
//
// 行为契约(H6b):若 T 内嵌 gorm.DeletedAt(或 gorm.Model),为软删除;
// 否则为硬删除(泛型类型约束无法在编译期强制)。需硬删用 HardDelete,需恢复用 Restore。
func (r *BaseRepo[T]) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(new(T), id).Error
return r.writeConn(ctx).Delete(new(T), id).Error
}
// HardDelete 硬删除记录(物理删除)
func (r *BaseRepo[T]) HardDelete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Unscoped().Delete(new(T), id).Error
return r.writeConn(ctx).Unscoped().Delete(new(T), id).Error
}
// FindByIDs 批量查询
func (r *BaseRepo[T]) FindByIDs(ctx context.Context, ids []uint) ([]T, error) {
if len(ids) == 0 {
return []T{}, nil
}
var models []T
err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&models).Error
err := r.readConn(ctx).Where("id IN ?", ids).Find(&models).Error
return models, err
}
// FindAll 查询所有记录
// FindAll 查询记录,默认最多返回 DefaultFindAllLimit 条,避免误用导致全表扫描。
// 需要明确全量扫描时使用 FindAllUnbounded。
func (r *BaseRepo[T]) FindAll(ctx context.Context) ([]T, error) {
return r.FindLimited(ctx, DefaultFindAllLimit)
}
// FindAllUnbounded 查询所有记录(无默认 limit)。
// 仅用于明确需要全表扫描的后台任务/小表场景;在线请求优先使用分页或 FindAll。
func (r *BaseRepo[T]) FindAllUnbounded(ctx context.Context) ([]T, error) {
var models []T
err := r.db.WithContext(ctx).Find(&models).Error
err := r.readConn(ctx).Find(&models).Error
return models, err
}
// Count 统计数量
func (r *BaseRepo[T]) Count(ctx context.Context) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(new(T)).Count(&count).Error
err := r.readConn(ctx).Model(new(T)).Count(&count).Error
return count, err
}
// CountWhere 条件统计
func (r *BaseRepo[T]) CountWhere(ctx context.Context, query string, args ...any) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Count(&count).Error
err := r.readConn(ctx).Model(new(T)).Where(query, args...).Count(&count).Error
return count, err
}
// GetDB 获取数据库实例
// GetDB 获取数据库实例
// 事务内(txRepo)返回当前事务的 tx;否则返回构造时注入的 db。
// 注意:此方法无 ctx 参数,不参与读写分离路由;需要路由请用具体方法(FindByID/FindPage 等)。
func (r *BaseRepo[T]) GetDB() *gorm.DB {
if r.tx != nil {
return r.tx
}
return r.db
}
@@ -98,7 +219,7 @@ func (r *BaseRepo[T]) GetDB() *gorm.DB {
// FindOne 条件查询单条记录
func (r *BaseRepo[T]) FindOne(ctx context.Context, query string, args ...any) (*T, error) {
var model T
err := r.db.WithContext(ctx).Where(query, args...).First(&model).Error
err := r.readConn(ctx).Where(query, args...).First(&model).Error
if err != nil {
return nil, err
}
@@ -108,21 +229,31 @@ func (r *BaseRepo[T]) FindOne(ctx context.Context, query string, args ...any) (*
// FindWhere 条件查询多条记录
func (r *BaseRepo[T]) FindWhere(ctx context.Context, query string, args ...any) ([]T, error) {
var models []T
err := r.db.WithContext(ctx).Where(query, args...).Find(&models).Error
err := r.readConn(ctx).Where(query, args...).Find(&models).Error
return models, err
}
// FindWhereOrdered 条件查询并排序
func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, query string, args []any, order string) ([]T, error) {
// FindWhereOrdered 条件查询并排序
//
// H-15 修复(breaking):签名由 (ctx, query, args []any, order) 改为
// (ctx, order, query string, args ...any),与 FindWhere 等同类方法统一为变长 args。
// Go 语法要求变长参数必须为最后一个,故 order 前置于 query。
func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, order, query string, args ...any) ([]T, error) {
if err := validateOrderClause(order); err != nil {
return nil, err
}
var models []T
err := r.db.WithContext(ctx).Where(query, args...).Order(order).Find(&models).Error
err := r.readConn(ctx).Where(query, args...).Order(order).Find(&models).Error
return models, err
}
// FindOrdered 查询并排序
func (r *BaseRepo[T]) FindOrdered(ctx context.Context, order string, limit int) ([]T, error) {
if err := validateOrderClause(order); err != nil {
return nil, err
}
var models []T
query := r.db.WithContext(ctx).Order(order)
query := r.readConn(ctx).Order(order)
if limit > 0 {
query = query.Limit(limit)
}
@@ -132,8 +263,11 @@ func (r *BaseRepo[T]) FindOrdered(ctx context.Context, order string, limit int)
// FindLimited 查询指定数量记录
func (r *BaseRepo[T]) FindLimited(ctx context.Context, limit int) ([]T, error) {
if limit <= 0 {
return []T{}, nil
}
var models []T
err := r.db.WithContext(ctx).Limit(limit).Find(&models).Error
err := r.readConn(ctx).Limit(limit).Find(&models).Error
return models, err
}
@@ -147,133 +281,152 @@ type PageResult[T any] struct {
PageSize int `json:"page_size"`
}
// FindPage 分页查询
func normalizePage(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if page > MaxPage {
page = MaxPage
}
if pageSize <= 0 {
pageSize = DefaultPageSize
}
if pageSize > MaxPageSize {
pageSize = MaxPageSize
}
return page, pageSize
}
// pageOffset 计算 (page-1)*pageSize,负值归零。
func pageOffset(page, pageSize int) int {
page, pageSize = normalizePage(page, pageSize)
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
return offset
}
// FindPage 分页查询。
//
// count 与 list 包进单事务(H6d),保证 total 与 items 在同一快照下一致,
// 避免高并发下两条独立语句间数据变动致 total/items 不一致。
//
// 若 ctx 已携带外层事务(database.WithTx),readConn 返回该 tx,此处 .Transaction
// 在其上开 savepointgorm 行为),count+list 仍同快照一致;无外层事务则开独立读事务。
func (r *BaseRepo[T]) FindPage(ctx context.Context, page, pageSize int) (*PageResult[T], error) {
var models []T
var total int64
// 统计总数
if err := r.db.WithContext(ctx).Model(new(T)).Count(&total).Error; err != nil {
page, pageSize = normalizePage(page, pageSize)
offset := pageOffset(page, pageSize)
err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error {
if e := tx.Model(new(T)).Count(&total).Error; e != nil {
return e
}
return tx.Offset(offset).Limit(pageSize).Find(&models).Error
})
if err != nil {
return nil, err
}
// 计算偏移量
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
// 查询数据
if err := r.db.WithContext(ctx).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
return nil, err
}
return &PageResult[T]{
Items: models,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil
}
// FindPageOrdered 分页查询并排序
// FindPageOrdered 分页查询并排序count+list 单事务,H6d
func (r *BaseRepo[T]) FindPageOrdered(ctx context.Context, page, pageSize int, order string) (*PageResult[T], error) {
if err := validateOrderClause(order); err != nil {
return nil, err
}
var models []T
var total int64
if err := r.db.WithContext(ctx).Model(new(T)).Count(&total).Error; err != nil {
page, pageSize = normalizePage(page, pageSize)
offset := pageOffset(page, pageSize)
err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error {
if e := tx.Model(new(T)).Count(&total).Error; e != nil {
return e
}
return tx.Order(order).Offset(offset).Limit(pageSize).Find(&models).Error
})
if err != nil {
return nil, err
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
if err := r.db.WithContext(ctx).Order(order).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
return nil, err
}
return &PageResult[T]{
Items: models,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil
}
// FindPageWhere 条件分页查询
// FindPageWhere 条件分页查询count+list 单事务,H6d
func (r *BaseRepo[T]) FindPageWhere(ctx context.Context, page, pageSize int, query string, args ...any) (*PageResult[T], error) {
var models []T
var total int64
if err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Count(&total).Error; err != nil {
page, pageSize = normalizePage(page, pageSize)
offset := pageOffset(page, pageSize)
err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error {
if e := tx.Model(new(T)).Where(query, args...).Count(&total).Error; e != nil {
return e
}
return tx.Where(query, args...).Offset(offset).Limit(pageSize).Find(&models).Error
})
if err != nil {
return nil, err
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
if err := r.db.WithContext(ctx).Where(query, args...).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
return nil, err
}
return &PageResult[T]{
Items: models,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil
}
// FindPageWhereOrdered 条件分页查询并排序
func (r *BaseRepo[T]) FindPageWhereOrdered(ctx context.Context, page, pageSize int, query string, args []any, order string) (*PageResult[T], error) {
// FindPageWhereOrdered 条件分页查询并排序count+list 单事务,H6d)。
//
// H-15 修复(breaking):签名由 (ctx, page, pageSize, query, args []any, order) 改为
// (ctx, page, pageSize, order, query string, args ...any),与 FindPageWhere 统一为变长 args。
// Go 语法要求变长参数必须为最后一个,故 order 前置于 query。
func (r *BaseRepo[T]) FindPageWhereOrdered(ctx context.Context, page, pageSize int, order, query string, args ...any) (*PageResult[T], error) {
if err := validateOrderClause(order); err != nil {
return nil, err
}
var models []T
var total int64
if err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Count(&total).Error; err != nil {
page, pageSize = normalizePage(page, pageSize)
offset := pageOffset(page, pageSize)
err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error {
if e := tx.Model(new(T)).Where(query, args...).Count(&total).Error; e != nil {
return e
}
return tx.Where(query, args...).Order(order).Offset(offset).Limit(pageSize).Find(&models).Error
})
if err != nil {
return nil, err
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
if err := r.db.WithContext(ctx).Where(query, args...).Order(order).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
return nil, err
}
return &PageResult[T]{
Items: models,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil
}
// ===== 批量操作 =====
// CreateBatch 批量创建
func (r *BaseRepo[T]) CreateBatch(ctx context.Context, models []T) error {
return r.db.WithContext(ctx).Create(models).Error
return r.writeConn(ctx).Create(models).Error
}
// UpdateBatch 批量更新(指定字段)
func (r *BaseRepo[T]) UpdateBatch(ctx context.Context, ids []uint, field string, value any) error {
return r.db.WithContext(ctx).Model(new(T)).Where("id IN ?", ids).Update(field, value).Error
if len(ids) == 0 {
return nil
}
if err := validateIdentifier(field); err != nil {
return err
}
return r.writeConn(ctx).Model(new(T)).Where("id IN ?", ids).Update(field, value).Error
}
// DeleteBatch 批量删除
func (r *BaseRepo[T]) DeleteBatch(ctx context.Context, ids []uint) error {
return r.db.WithContext(ctx).Delete(new(T), ids).Error
if len(ids) == 0 {
return nil
}
return r.writeConn(ctx).Delete(new(T), ids).Error
}
// HardDeleteBatch 批量硬删除
func (r *BaseRepo[T]) HardDeleteBatch(ctx context.Context, ids []uint) error {
return r.db.WithContext(ctx).Unscoped().Delete(new(T), ids).Error
if len(ids) == 0 {
return nil
}
return r.writeConn(ctx).Unscoped().Delete(new(T), ids).Error
}
// ===== 存在性检查 =====
@@ -281,64 +434,106 @@ func (r *BaseRepo[T]) HardDeleteBatch(ctx context.Context, ids []uint) error {
// Exists 检查是否存在
func (r *BaseRepo[T]) Exists(ctx context.Context, id uint) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id).Count(&count).Error
err := r.readConn(ctx).Model(new(T)).Where("id = ?", id).Count(&count).Error
return count > 0, err
}
// ExistsWhere 条件检查是否存在
func (r *BaseRepo[T]) ExistsWhere(ctx context.Context, query string, args ...any) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Limit(1).Count(&count).Error
err := r.readConn(ctx).Model(new(T)).Where(query, args...).Limit(1).Count(&count).Error
return count > 0, err
}
// ===== 软删除操作 =====
// 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.db.WithContext(ctx).Model(new(T)).Unscoped().Where("id = ?", id).Update("deleted_at", nil).Error
if err := validateIdentifier(r.SoftDeleteColumn()); err != nil {
return err
}
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.db.WithContext(ctx).Model(new(T)).Unscoped().Where("id IN ?", ids).Update("deleted_at", nil).Error
if len(ids) == 0 {
return nil
}
if err := validateIdentifier(r.SoftDeleteColumn()); err != nil {
return err
}
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) {
if err := validateIdentifier(r.SoftDeleteColumn()); err != nil {
return nil, err
}
var models []T
err := r.db.WithContext(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
}
// FindAllWithDeleted 查询所有记录(包括软删除)
func (r *BaseRepo[T]) FindAllWithDeleted(ctx context.Context) ([]T, error) {
var models []T
err := r.db.WithContext(ctx).Unscoped().Find(&models).Error
err := r.readConn(ctx).Unscoped().Find(&models).Error
return models, err
}
// ===== 事务支持 =====
// WithTransaction 在事务中执行操作
// WithTransaction 在事务中执行操作
//
// fn 收到的 txRepo 处于事务内,其所有方法(FindByID/Create/Update/...)自动 join 该事务,
// 不再路由到主从库(H6c)。事务在主库上开启。
//
// 跨 repo / 跨层 join:若 fn 内需调用其它 repo 或 database 层方法参与同一事务,
// 用 database.WithTx(ctx, tx) 把事务注入 ctx 后传递:
//
// return repo.WithTransaction(ctx, func(txRepo *BaseRepo[T]) error {
// if err := txRepo.Create(ctx, a); err != nil { return err }
// // 另一个 repo 也加入同一事务:
// ctx2 := database.WithTx(ctx, txRepo.GetDB())
// return otherRepo.Update(ctx2, b)
// })
//
// 注:fn 内捕获的 ctx 不会自动携带 tx;仅 txRepo 的方法 join 事务。
func (r *BaseRepo[T]) WithTransaction(ctx context.Context, fn func(txRepo *BaseRepo[T]) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txRepo := NewBaseRepo[T](tx)
return r.writeConn(ctx).Transaction(func(tx *gorm.DB) error {
txRepo := &BaseRepo[T]{db: r.db, tx: tx}
return fn(txRepo)
})
}
// ===== QueryBuilder 链式查询 =====
//
// QueryBuilder 为单次使用、非并发安全(H6e):链式方法 Where/Or/Order/Limit/Offset 会
// mutate qb.db,禁止跨 goroutine 共用同一实例或多次终结调用复用累积条件。
// 终结方法(Find/First/Count/Page)基于 Session 克隆,不污染 qb.dbCount 额外剥离
// Limit/Offset 避免残留分页条件截断统计。
//
// 路由:QueryBuilder 查询经构造时注入的 db(通常主库),不参与读写分离路由;
// 需读写分离请用具体方法(FindPage/FindWhere 等)。
// QueryBuilder 链式查询构建器
type QueryBuilder[T any] struct {
db *gorm.DB
limit int
err error
}
// NewQueryBuilder 创建查询构建器
func (r *BaseRepo[T]) NewQueryBuilder() *QueryBuilder[T] {
return &QueryBuilder[T]{db: r.db.Model(new(T))}
return &QueryBuilder[T]{db: r.requireDB().Model(new(T))}
}
// Where 添加条件
@@ -355,12 +550,22 @@ func (qb *QueryBuilder[T]) Or(query string, args ...any) *QueryBuilder[T] {
// Order 设置排序
func (qb *QueryBuilder[T]) Order(order string) *QueryBuilder[T] {
if qb.err != nil {
return qb
}
if err := validateOrderClause(order); err != nil {
qb.err = err
return qb
}
qb.db = qb.db.Order(order)
return qb
}
// Limit 设置数量限制
func (qb *QueryBuilder[T]) Limit(limit int) *QueryBuilder[T] {
if limit < 0 {
limit = 0
}
qb.limit = limit
qb.db = qb.db.Limit(limit)
return qb
@@ -372,44 +577,69 @@ func (qb *QueryBuilder[T]) Offset(offset int) *QueryBuilder[T] {
return qb
}
// clone 返回 qb.db 的 Session 克隆,确保终结方法不污染 qb.db(H6e)。
func (qb *QueryBuilder[T]) clone() *gorm.DB {
return qb.db.Session(&gorm.Session{})
}
// Find 执行查询
func (qb *QueryBuilder[T]) Find(ctx context.Context) ([]T, error) {
if qb.err != nil {
return nil, qb.err
}
ctx = normalizeContext(ctx)
var models []T
err := qb.db.WithContext(ctx).Find(&models).Error
err := qb.clone().WithContext(ctx).Find(&models).Error
return models, err
}
// First 执行查询并返回第一条
func (qb *QueryBuilder[T]) First(ctx context.Context) (*T, error) {
if qb.err != nil {
return nil, qb.err
}
ctx = normalizeContext(ctx)
var model T
err := qb.db.WithContext(ctx).First(&model).Error
err := qb.clone().WithContext(ctx).First(&model).Error
if err != nil {
return nil, err
}
return &model, nil
}
// Count 执行统计
// Count 执行统计
// 克隆并剥离 Limit/OffsetH6e),避免先前 Limit()/Offset() 残留截断统计行数。
func (qb *QueryBuilder[T]) Count(ctx context.Context) (int64, error) {
if qb.err != nil {
return 0, qb.err
}
ctx = normalizeContext(ctx)
var count int64
err := qb.db.WithContext(ctx).Count(&count).Error
err := qb.clone().Limit(-1).Offset(-1).WithContext(ctx).Count(&count).Error
return count, err
}
// Page 执行分页查询
// Page 执行分页查询
// count 与 list 基于同一 Session 克隆,count 剥离 Limit/OffsetH6e)。
// 注意:与 FindPage 不同,Page 的 count+list 不包单事务(QueryBuilder 为轻量构建器);
// 需要 total/items 快照一致请用 BaseRepo.FindPage。
func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageResult[T], error) {
if qb.err != nil {
return nil, qb.err
}
ctx = normalizeContext(ctx)
var models []T
var total int64
page, pageSize = normalizePage(page, pageSize)
// 复制 query 用于统计(避免影响原查询)
// 清除残留的 Limit/Offset,否则统计行数会被截断(GORM 中 Limit(-1)/Offset(-1) 表示移除该条件)
countDB := qb.db.Session(&gorm.Session{}).Limit(-1).Offset(-1)
// count 克隆并剥离 Limit/Offset
countDB := qb.clone().Limit(-1).Offset(-1)
if err := countDB.WithContext(ctx).Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
if err := qb.db.WithContext(ctx).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
offset := pageOffset(page, pageSize)
if err := qb.clone().WithContext(ctx).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
return nil, err
}
@@ -419,4 +649,70 @@ func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageR
Page: page,
PageSize: pageSize,
}, nil
}
}
func validateIdentifier(field string) error {
if !isIdentifierPath(field) {
return ErrUnsafeField
}
return nil
}
func isIdentifierPath(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return false
}
parts := strings.Split(s, ".")
for _, part := range parts {
if !isIdentifier(part) {
return false
}
}
return true
}
func isIdentifier(s string) bool {
if s == "" {
return false
}
for i, r := range s {
if i == 0 {
if r != '_' && !unicode.IsLetter(r) {
return false
}
continue
}
if r != '_' && !unicode.IsLetter(r) && !unicode.IsDigit(r) {
return false
}
}
return true
}
func validateOrderClause(order string) error {
order = strings.TrimSpace(order)
if order == "" {
return ErrUnsafeOrder
}
for _, raw := range strings.Split(order, ",") {
item := strings.TrimSpace(raw)
if item == "" {
return ErrUnsafeOrder
}
fields := strings.Fields(item)
if len(fields) == 0 || len(fields) > 2 {
return ErrUnsafeOrder
}
if !isIdentifierPath(fields[0]) {
return ErrUnsafeOrder
}
if len(fields) == 2 {
dir := strings.ToUpper(fields[1])
if dir != "ASC" && dir != "DESC" {
return ErrUnsafeOrder
}
}
}
return nil
}
+491
View File
@@ -0,0 +1,491 @@
package repository
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// h6User 测试模型,内嵌 gorm.Model 支持软删除。
type h6User struct {
gorm.Model
Name string
Age int
}
func init() {
// 注册 SQLite 方言,供路由测试通过 database 包级 facadeInitDB 等,代理到 DefaultManager)初始化主从库使用。
database.RegisterDialect(database.DialectSpec{
Name: "sqlite",
Aliases: []string{"sqlite3"},
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
})
}
// newH6SqliteDB 构造一个独立 sqlite 文件的 GORM 实例(不走 DefaultManager),
// 用于 fallback 路径(DefaultManager 未初始化)的行为闭环测试。
func newH6SqliteDB(t *testing.T) *gorm.DB {
t.Helper()
dir := t.TempDir()
db, err := gorm.Open(sqlite.Open(filepath.Join(dir, "test.db")), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&h6User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}
// namesFrom 提取 h6User 切片的 Name 集合,便于路由断言。
func namesFrom(users []h6User) map[string]struct{} {
out := make(map[string]struct{}, len(users))
for i := range users {
out[users[i].Name] = struct{}{}
}
return out
}
// ===== H6c fallback 路径(DefaultManager 未初始化,readConn/writeConn 回退 r.db=====
// TestH6CrudRoundTrip 验证 CRUD 闭环经 readConn/writeConn 回退 r.db 正常工作。
func TestH6CrudRoundTrip(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
u := &h6User{Name: "alice", Age: 30}
if err := repo.Create(ctx, u); err != nil {
t.Fatalf("Create: %v", err)
}
if u.ID == 0 {
t.Fatal("ID not populated after Create")
}
got, err := repo.FindByID(ctx, u.ID)
if err != nil {
t.Fatalf("FindByID: %v", err)
}
if got.Name != "alice" || got.Age != 30 {
t.Fatalf("unexpected user: %+v", got)
}
got.Age = 31
if err := repo.Update(ctx, got); err != nil {
t.Fatalf("Update: %v", err)
}
got2, _ := repo.FindByID(ctx, u.ID)
if got2.Age != 31 {
t.Fatalf("Update not persisted, age=%d", got2.Age)
}
if err := repo.Delete(ctx, u.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := repo.FindByID(ctx, u.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("after Delete want ErrRecordNotFound, got %v", err)
}
}
// ===== H6a UpdateFields 局部更新 =====
// TestH6UpdateFieldsStructNonZeroOnly 传 struct 仅更新非零字段(零值 Age=0 不覆盖)。
func TestH6UpdateFieldsStructNonZeroOnly(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
u := &h6User{Name: "bob", Age: 30}
_ = repo.Create(ctx, u)
// Name="carol"(非零),Age=0(零值,不应写入)
if err := repo.UpdateFields(ctx, &h6User{Name: "carol"}, "id = ?", u.ID); err != nil {
t.Fatalf("UpdateFields: %v", err)
}
got, _ := repo.FindByID(ctx, u.ID)
if got.Name != "carol" {
t.Fatalf("Name should be carol, got %q", got.Name)
}
if got.Age != 30 {
t.Fatalf("Age should remain 30 (struct zero-value not written), got %d", got.Age)
}
}
// TestH6UpdateFieldsMapExplicitZero 传 map 可显式置零。
func TestH6UpdateFieldsMapExplicitZero(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
u := &h6User{Name: "dave", Age: 30}
_ = repo.Create(ctx, u)
if err := repo.UpdateFields(ctx, map[string]any{"age": 0}, "id = ?", u.ID); err != nil {
t.Fatalf("UpdateFields map: %v", err)
}
got, _ := repo.FindByID(ctx, u.ID)
if got.Age != 0 {
t.Fatalf("Age should be 0 (map explicit zero), got %d", got.Age)
}
}
// ===== H6c 事务 joinr.tx 字段(WithTransaction=====
// TestH6WithTransactionRollback fn 返回错误 → 回滚,已写记录不持久化。
func TestH6WithTransactionRollback(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
err := repo.WithTransaction(ctx, func(txRepo *BaseRepo[h6User]) error {
if e := txRepo.Create(ctx, &h6User{Name: "rollback"}); e != nil {
return e
}
return errors.New("boom")
})
if err == nil {
t.Fatal("want error from WithTransaction")
}
all, _ := repo.FindAll(ctx)
if _, ok := namesFrom(all)["rollback"]; ok {
t.Fatal("rollback row should not persist after failed tx")
}
}
// TestH6WithTransactionCommit fn 成功 → 提交,记录持久化。
func TestH6WithTransactionCommit(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
err := repo.WithTransaction(ctx, func(txRepo *BaseRepo[h6User]) error {
return txRepo.Create(ctx, &h6User{Name: "commit"})
})
if err != nil {
t.Fatalf("WithTransaction: %v", err)
}
all, _ := repo.FindAll(ctx)
if _, ok := namesFrom(all)["commit"]; !ok {
t.Fatal("commit row should persist after successful tx")
}
}
// TestH6WithTransactionTxRepoJoinsTx fn 内 txRepo 的写与读都参与同一事务。
// 关键:txRepo.Create 写入后,txRepo.FindOne 在事务内可见;若 txRepo 不 join tx
// (走 r.db 回退),FindOne 仍可见但写已 autocommit,回滚测试(上一用例)会失败。
func TestH6WithTransactionTxRepoJoinsTx(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
var seenInTx bool
err := repo.WithTransaction(ctx, func(txRepo *BaseRepo[h6User]) error {
if e := txRepo.Create(ctx, &h6User{Name: "in-tx"}); e != nil {
return e
}
got, e := txRepo.FindOne(ctx, "name = ?", "in-tx")
if e != nil {
return e
}
seenInTx = got != nil && got.Name == "in-tx"
return nil
})
if err != nil {
t.Fatalf("WithTransaction: %v", err)
}
if !seenInTx {
t.Fatal("txRepo.FindOne should see row created within the same tx")
}
}
// ===== H6c 事务 joinctx 携带 txdatabase.WithTx 跨层 join=====
// TestH6WithTxCtxJoinsOuterTx 外层 gorm 事务通过 database.WithTx 注入 ctx
// repo 方法必须 join 该事务;外层回滚则 repo 写入随之回滚。
// 红绿:修复前 repo 走 r.dbfallbackautocommit,回滚后行仍存在(红);
// 修复后 repo 取 ctx 的 tx,随外层回滚(绿)。
func TestH6WithTxCtxJoinsOuterTx(t *testing.T) {
db := newH6SqliteDB(t)
repo := NewBaseRepo[h6User](db)
ctx := context.Background()
err := db.Transaction(func(tx *gorm.DB) error {
ctx2 := database.WithTx(ctx, tx)
if e := repo.Create(ctx2, &h6User{Name: "outer-tx"}); e != nil {
return e
}
return errors.New("force rollback")
})
if err == nil {
t.Fatal("want error from outer tx")
}
all, _ := repo.FindAll(ctx)
if _, ok := namesFrom(all)["outer-tx"]; ok {
t.Fatal("outer-tx row should be rolled back when repo joins outer tx")
}
}
// TestH6WithTxCtxCommitsWhenOuterCommits 外层提交则 repo 写入持久化(正向闭环)。
func TestH6WithTxCtxCommitsWhenOuterCommits(t *testing.T) {
db := newH6SqliteDB(t)
repo := NewBaseRepo[h6User](db)
ctx := context.Background()
err := db.Transaction(func(tx *gorm.DB) error {
ctx2 := database.WithTx(ctx, tx)
return repo.Create(ctx2, &h6User{Name: "outer-commit"})
})
if err != nil {
t.Fatalf("outer tx: %v", err)
}
all, _ := repo.FindAll(ctx)
if _, ok := namesFrom(all)["outer-commit"]; !ok {
t.Fatal("outer-commit row should persist when outer tx commits")
}
}
// ===== H6d 分页 count+list 单事务 + 基本正确性 =====
// TestH6FindPage 验证分页 total 与 items 正确。
func TestH6FindPage(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
for i := 0; i < 5; i++ {
_ = repo.Create(ctx, &h6User{Name: "u"})
}
p1, err := repo.FindPage(ctx, 1, 2)
if err != nil {
t.Fatalf("FindPage: %v", err)
}
if p1.Total != 5 || len(p1.Items) != 2 {
t.Fatalf("page1: total=%d items=%d, want 5/2", p1.Total, len(p1.Items))
}
p3, _ := repo.FindPage(ctx, 3, 2)
if len(p3.Items) != 1 {
t.Fatalf("page3 items=%d, want 1", len(p3.Items))
}
}
// TestH6FindPageWhere 验证条件分页。
func TestH6FindPageWhere(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
for i := 0; i < 3; i++ {
_ = repo.Create(ctx, &h6User{Name: "match"})
}
_ = repo.Create(ctx, &h6User{Name: "other"})
p, err := repo.FindPageWhere(ctx, 1, 10, "name = ?", "match")
if err != nil {
t.Fatalf("FindPageWhere: %v", err)
}
if p.Total != 3 || len(p.Items) != 3 {
t.Fatalf("want total=3 items=3, got %d/%d", p.Total, len(p.Items))
}
}
// ===== H6b 软删除契约 =====
// TestH6DeleteSoftDeletesWithGormModel T 内嵌 gorm.Model 时 Delete 为软删除:
// FindByID 不可见、FindDeleted 可见、Restore 恢复。
func TestH6DeleteSoftDeletesWithGormModel(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
u := &h6User{Name: "soft"}
_ = repo.Create(ctx, u)
if err := repo.Delete(ctx, u.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := repo.FindByID(ctx, u.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("soft-deleted should be invisible to FindByID, got %v", err)
}
deleted, _ := repo.FindDeleted(ctx)
if _, ok := namesFrom(deleted)["soft"]; !ok {
t.Fatal("FindDeleted should see soft-deleted row")
}
if err := repo.Restore(ctx, u.ID); err != nil {
t.Fatalf("Restore: %v", err)
}
if _, err := repo.FindByID(ctx, u.ID); err != nil {
t.Fatalf("after Restore FindByID should succeed, got %v", err)
}
}
// ===== H6e QueryBuilder 终结方法克隆 + Count 剥离 Limit/Offset =====
// TestH6QueryBuilderCountStripsLimit Count 不受残留 Limit/Offset 截断。
// 红绿:修复前 Count 沿用 qb.db(带 Limit(2)),返回 2(红);修复后剥离返回 5(绿)。
func TestH6QueryBuilderCountStripsLimit(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
for i := 0; i < 5; i++ {
_ = repo.Create(ctx, &h6User{Name: "q"})
}
count, err := repo.NewQueryBuilder().Limit(2).Offset(1).Count(ctx)
if err != nil {
t.Fatalf("Count: %v", err)
}
if count != 5 {
t.Fatalf("Count should be 5 (stripped Limit/Offset), got %d", count)
}
}
// TestH6QueryBuilderFindKeepsLimit Find 仍受 Limit 约束(回归保护)。
func TestH6QueryBuilderFindKeepsLimit(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
for i := 0; i < 5; i++ {
_ = repo.Create(ctx, &h6User{Name: "q"})
}
got, err := repo.NewQueryBuilder().Limit(2).Find(ctx)
if err != nil {
t.Fatalf("Find: %v", err)
}
if len(got) != 2 {
t.Fatalf("Find with Limit(2) should return 2, got %d", len(got))
}
}
// TestH6QueryBuilderPageNoAccumulation 连续 Page 调用不因克隆失效而累积污染。
func TestH6QueryBuilderPageNoAccumulation(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
for i := 0; i < 4; i++ {
_ = repo.Create(ctx, &h6User{Name: "q"})
}
qb := repo.NewQueryBuilder().Where("name = ?", "q")
p1, err := qb.Page(ctx, 1, 2)
if err != nil {
t.Fatalf("Page1: %v", err)
}
p2, err := qb.Page(ctx, 2, 2)
if err != nil {
t.Fatalf("Page2: %v", err)
}
if p1.Total != 4 || len(p1.Items) != 2 {
t.Fatalf("page1: total=%d items=%d, want 4/2", p1.Total, len(p1.Items))
}
if p2.Total != 4 || len(p2.Items) != 2 {
t.Fatalf("page2: total=%d items=%d, want 4/2", p2.Total, len(p2.Items))
}
}
// ===== H6c 路由:DefaultManager 主从读写分离(headline=====
// setupH6Manager 初始化 DefaultManager 为 sqlite 主+从(独立文件),
// 并在两侧建表。返回 cleanup 还原全局状态(CloseAll + 置 nil-master)。
func setupH6Manager(t *testing.T) (masterFile, replicaFile string) {
t.Helper()
dir := t.TempDir()
masterFile = filepath.Join(dir, "master.db")
replicaFile = filepath.Join(dir, "replica.db")
cfg := &config.Config{}
cfg.Database.Driver = "sqlite"
cfg.Database.CustomDSN = masterFile
if err := database.InitDBWithReplicas(context.Background(), cfg, []string{replicaFile}); err != nil {
t.Fatalf("InitDBWithReplicas: %v", err)
}
if err := database.GetWriteDB().AutoMigrate(&h6User{}); err != nil {
t.Fatalf("migrate master: %v", err)
}
if err := database.GetReadDB().AutoMigrate(&h6User{}); err != nil {
t.Fatalf("migrate replica: %v", err)
}
t.Cleanup(func() {
_ = database.CloseAll()
})
return masterFile, replicaFile
}
// TestH6RoutingReadWriteSplit 验证读操作路由到从库、写操作路由到主库(H6c 核心)。
// 红绿:修复前 BaseRepo 全部走 r.db(构造捕获的主库),默认读会看到主库标记(红);
// 修复后默认读路由到从库,仅 UseMaster 读主库,写永远落主库(绿)。
func TestH6RoutingReadWriteSplit(t *testing.T) {
setupH6Manager(t)
ctx := context.Background()
// 主从分别写入不同标记
_ = database.GetWriteDB().Create(&h6User{Name: "MASTER"})
_ = database.GetReadDB().Create(&h6User{Name: "REPLICA"})
// 下游典型用法:r.db = 主库(database.GetDB()
repo := NewBaseRepo[h6User](database.GetDB())
// 默认 ctx(无 UseMaster/UseReplica)→ GetDBFromContext → 从库 → 仅 REPLICA
all, err := repo.FindAll(ctx)
if err != nil {
t.Fatalf("FindAll default: %v", err)
}
names := namesFrom(all)
if _, ok := names["REPLICA"]; !ok {
t.Errorf("default read should route to replica and see REPLICA, got %v", names)
}
if _, ok := names["MASTER"]; ok {
t.Errorf("default read should NOT see master-only MASTER, got %v", names)
}
// UseMaster → 主库 → MASTER
allM, _ := repo.FindAll(database.UseMaster(ctx))
namesM := namesFrom(allM)
if _, ok := namesM["MASTER"]; !ok {
t.Errorf("UseMaster read should see MASTER, got %v", namesM)
}
// UseReplica → 从库 → REPLICA
allR, _ := repo.FindAll(database.UseReplica(ctx))
namesR := namesFrom(allR)
if _, ok := namesR["REPLICA"]; !ok {
t.Errorf("UseReplica read should see REPLICA, got %v", namesR)
}
// 写操作 → 主库:Create 后 UseMaster 可见,默认(从库)不可见
if err := repo.Create(ctx, &h6User{Name: "WRITE"}); err != nil {
t.Fatalf("Create: %v", err)
}
afterWriteM, _ := repo.FindAll(database.UseMaster(ctx))
if _, ok := namesFrom(afterWriteM)["WRITE"]; !ok {
t.Errorf("write should land on master (visible via UseMaster), got %v", namesFrom(afterWriteM))
}
afterWriteR, _ := repo.FindAll(ctx) // 默认从库
if _, ok := namesFrom(afterWriteR)["WRITE"]; ok {
t.Errorf("write should NOT appear on replica, got %v", namesFrom(afterWriteR))
}
}
// TestH6RoutingWriteConnNeverHitsReplica 写操作即便 UseReplica(ctx) 也走主库。
func TestH6RoutingWriteConnNeverHitsReplica(t *testing.T) {
setupH6Manager(t)
ctx := context.Background()
repo := NewBaseRepo[h6User](database.GetDB())
// 即便 ctx 标记 UseReplica,写也必须落主库
if err := repo.Create(database.UseReplica(ctx), &h6User{Name: "W2"}); err != nil {
t.Fatalf("Create under UseReplica: %v", err)
}
onMaster, _ := repo.FindAll(database.UseMaster(ctx))
if _, ok := namesFrom(onMaster)["W2"]; !ok {
t.Errorf("write under UseReplica must still land on master, got %v", namesFrom(onMaster))
}
}
+261
View File
@@ -0,0 +1,261 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
)
func seedM5Users(t *testing.T, repo *BaseRepo[h6User], n int) {
t.Helper()
for i := 0; i < n; i++ {
if err := repo.Create(context.Background(), &h6User{Name: fmt.Sprintf("m5-%04d", i), Age: i}); err != nil {
t.Fatalf("seed user %d: %v", i, err)
}
}
}
func TestM5NilContextFallsBackToBackground(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
u := &h6User{Name: "nil-ctx"}
if err := repo.Create(nil, u); err != nil {
t.Fatalf("Create(nil ctx): %v", err)
}
got, err := repo.FindByID(nil, u.ID)
if err != nil {
t.Fatalf("FindByID(nil ctx): %v", err)
}
if got.Name != "nil-ctx" {
t.Fatalf("got %q, want nil-ctx", got.Name)
}
}
func TestM5UnsafeOrderRejected(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
cases := []struct {
name string
fn func() error
}{
{"FindOrdered", func() error {
_, err := repo.FindOrdered(ctx, "name desc; drop table h6_users", 10)
return err
}},
{"FindWhereOrdered", func() error {
_, err := repo.FindWhereOrdered(ctx, "name desc --", "name <> ?", "")
return err
}},
{"FindPageOrdered", func() error {
_, err := repo.FindPageOrdered(ctx, 1, 10, "lower(name) desc")
return err
}},
{"FindPageWhereOrdered", func() error {
_, err := repo.FindPageWhereOrdered(ctx, 1, 10, "name collate nocase", "name <> ?", "")
return err
}},
{"QueryBuilder", func() error {
_, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Find(ctx)
return err
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := tc.fn(); !errors.Is(err, ErrUnsafeOrder) {
t.Fatalf("err = %v, want ErrUnsafeOrder", err)
}
})
}
}
func TestM5QueryBuilderUnsafeOrderPropagatesToAllTerminators(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
seedM5Users(t, repo, 1)
if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Find(ctx); !errors.Is(err, ErrUnsafeOrder) {
t.Fatalf("Find err = %v, want ErrUnsafeOrder", err)
}
if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").First(ctx); !errors.Is(err, ErrUnsafeOrder) {
t.Fatalf("First err = %v, want ErrUnsafeOrder", err)
}
if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Count(ctx); !errors.Is(err, ErrUnsafeOrder) {
t.Fatalf("Count err = %v, want ErrUnsafeOrder", err)
}
if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Page(ctx, 1, 10); !errors.Is(err, ErrUnsafeOrder) {
t.Fatalf("Page err = %v, want ErrUnsafeOrder", err)
}
}
func TestM5SafeOrderStillWorks(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
if err := repo.Create(ctx, &h6User{Name: "b"}); err != nil {
t.Fatalf("Create b: %v", err)
}
if err := repo.Create(ctx, &h6User{Name: "a"}); err != nil {
t.Fatalf("Create a: %v", err)
}
got, err := repo.FindOrdered(ctx, "name ASC, id DESC", 10)
if err != nil {
t.Fatalf("FindOrdered safe order: %v", err)
}
if len(got) != 2 || got[0].Name != "a" || got[1].Name != "b" {
t.Fatalf("unexpected order: %+v", got)
}
}
func TestM5UpdateBatchRejectsUnsafeFieldAndEmptyIDsNoop(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
ctx := context.Background()
u := &h6User{Name: "field", Age: 1}
if err := repo.Create(ctx, u); err != nil {
t.Fatalf("Create: %v", err)
}
if err := repo.UpdateBatch(ctx, []uint{u.ID}, "age = age + 1", 2); !errors.Is(err, ErrUnsafeField) {
t.Fatalf("unsafe field err = %v, want ErrUnsafeField", err)
}
if err := repo.UpdateBatch(ctx, nil, "age = age + 1", 2); err != nil {
t.Fatalf("empty ids should noop before field validation, got %v", err)
}
if err := repo.UpdateBatch(ctx, []uint{u.ID}, "age", 2); err != nil {
t.Fatalf("safe field UpdateBatch: %v", err)
}
got, _ := repo.FindByID(ctx, u.ID)
if got.Age != 2 {
t.Fatalf("age = %d, want 2", got.Age)
}
}
func TestM5FindByIDsEmptyReturnsEmptySlice(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
got, err := repo.FindByIDs(context.Background(), nil)
if err != nil {
t.Fatalf("FindByIDs empty: %v", err)
}
if got == nil {
t.Fatal("FindByIDs empty should return non-nil empty slice")
}
if len(got) != 0 {
t.Fatalf("len = %d, want 0", len(got))
}
}
func TestM5FindAllAppliesDefaultLimit(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
seedM5Users(t, repo, DefaultFindAllLimit+5)
got, err := repo.FindAll(context.Background())
if err != nil {
t.Fatalf("FindAll: %v", err)
}
if len(got) != DefaultFindAllLimit {
t.Fatalf("FindAll len = %d, want default limit %d", len(got), DefaultFindAllLimit)
}
all, err := repo.FindAllUnbounded(context.Background())
if err != nil {
t.Fatalf("FindAllUnbounded: %v", err)
}
if len(all) != DefaultFindAllLimit+5 {
t.Fatalf("FindAllUnbounded len = %d, want %d", len(all), DefaultFindAllLimit+5)
}
}
func TestM5PaginationBounds(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
seedM5Users(t, repo, MaxPageSize+5)
p, err := repo.FindPage(context.Background(), -10, 0)
if err != nil {
t.Fatalf("FindPage default bounds: %v", err)
}
if p.Page != 1 || p.PageSize != DefaultPageSize || len(p.Items) != DefaultPageSize {
t.Fatalf("page defaults = page:%d size:%d items:%d, want 1/%d/%d",
p.Page, p.PageSize, len(p.Items), DefaultPageSize, DefaultPageSize)
}
p, err = repo.FindPage(context.Background(), 1, MaxPageSize+100)
if err != nil {
t.Fatalf("FindPage max size: %v", err)
}
if p.PageSize != MaxPageSize || len(p.Items) != MaxPageSize {
t.Fatalf("page max = size:%d items:%d, want %d/%d", p.PageSize, len(p.Items), MaxPageSize, MaxPageSize)
}
p, err = repo.FindPage(context.Background(), MaxPage+1, 10)
if err != nil {
t.Fatalf("FindPage max page: %v", err)
}
if p.Page != MaxPage {
t.Fatalf("page = %d, want MaxPage %d", p.Page, MaxPage)
}
}
func TestM5QueryBuilderNilDBPanicsClearly(t *testing.T) {
repo := NewBaseRepo[h6User](nil)
defer func() {
if rec := recover(); rec == nil {
t.Fatal("NewQueryBuilder with nil db should panic")
}
}()
_ = repo.NewQueryBuilder()
}
func TestM5QueryBuilderNilContextAndPageBounds(t *testing.T) {
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
seedM5Users(t, repo, 25)
count, err := repo.NewQueryBuilder().Where("created_at <= ?", time.Now().Add(time.Hour)).Count(nil)
if err != nil {
t.Fatalf("Count nil ctx: %v", err)
}
if count != 25 {
t.Fatalf("count = %d, want 25", count)
}
p, err := repo.NewQueryBuilder().Page(nil, 1, 0)
if err != nil {
t.Fatalf("Page nil ctx: %v", err)
}
if p.PageSize != DefaultPageSize || len(p.Items) != DefaultPageSize {
t.Fatalf("QueryBuilder page size/items = %d/%d, want %d/%d",
p.PageSize, len(p.Items), DefaultPageSize, DefaultPageSize)
}
}
func TestM5DeleteBatchEmptyIDsNoop(t *testing.T) {
db := newDryRunDB(t)
repo := NewBaseRepo[pageCountModel](db)
if err := repo.DeleteBatch(context.Background(), nil); err != nil {
t.Fatalf("DeleteBatch empty ids: %v", err)
}
if got := db.Statement.SQL.String(); got != "" {
t.Fatalf("DeleteBatch empty ids should not issue SQL, got %s", got)
}
if err := repo.HardDeleteBatch(context.Background(), nil); err != nil {
t.Fatalf("HardDeleteBatch empty ids: %v", err)
}
if got := db.Statement.SQL.String(); got != "" {
t.Fatalf("HardDeleteBatch empty ids should not issue SQL, got %s", got)
}
}
func TestM5FindAllUsesLimitSQL(t *testing.T) {
db := newDryRunDB(t)
repo := NewBaseRepo[pageCountModel](db)
var models []pageCountModel
result := repo.readConn(context.Background()).Limit(DefaultFindAllLimit).Find(&models)
sql := strings.ToUpper(result.Statement.SQL.String())
if !strings.Contains(sql, "LIMIT") {
t.Fatalf("FindAll default path should include LIMIT, got %s", sql)
}
}
+14 -53
View File
@@ -1,65 +1,26 @@
package repository_test
import (
"context"
"testing"
"github.com/EthanCodeCraft/xlgo-core/repository"
"gorm.io/gorm"
)
func TestNewBaseRepo(t *testing.T) {
// BaseRepo 需要数据库连接,这里测试结构
// 无法直接实例化,验证泛型定义
// N2:原 repository_test.go 全为空注释壳,CRUD 零覆盖。
// 此处用编译期断言锁定 BaseRepo 实现 BaseRepository 接口契约——
// 接口一旦与实现漂移,编译即失败(比运行期空壳更有价值)。
type testModel struct {
gorm.Model
Name string
}
func TestBaseRepoInterface(t *testing.T) {
// BaseRepository 接口验证
// FindByID, Create, Update, Delete, FindByIDs 方法
}
// 编译期保证 *BaseRepo[T] 满足 BaseRepository[T] 接口(N2 接口契约守卫)。
var _ repository.BaseRepository[testModel] = (*repository.BaseRepo[testModel])(nil)
func TestBaseRepoMethods(t *testing.T) {
// 测试方法签名,实际使用需要 DB 连接
// FindByID(ctx context.Context, id uint) (*T, error)
// Create(ctx context.Context, model *T) error
// Update(ctx context.Context, model *T) error
// Delete(ctx context.Context, id uint) error
// FindByIDs(ctx context.Context, ids []uint) ([]T, error)
// FindAll(ctx context.Context) ([]T, error)
// Count(ctx context.Context) (int64, error)
// GetDB() *gorm.DB
func TestBaseRepoSatisfiesInterface(t *testing.T) {
// 运行期占位:编译期 var _ 已是主断言,此处仅保证测试函数不被 lint 清理。
var r repository.BaseRepository[testModel] = repository.NewBaseRepo[testModel](nil)
_ = r
}
func TestRepositoryFunctionSignatures(t *testing.T) {
// 验证函数签名存在
// NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T]
}
func TestGormDependency(t *testing.T) {
// repository 依赖 gorm
// gorm.DB 用于数据库操作
}
func TestContextUsage(t *testing.T) {
ctx := context.Background()
if ctx == nil {
t.Error("context.Background should not return nil")
}
}
func TestGormDBType(t *testing.T) {
// 验证 gorm.DB 类型
var db *gorm.DB
if db != nil {
// db 未初始化应为 nil
}
}
func TestBaseRepoStruct(t *testing.T) {
// BaseRepo[T any] struct { db *gorm.DB }
// 泛型结构体,无法直接测试实例化
}
func TestBaseRepositoryInterface(t *testing.T) {
// interface 定义验证
// type BaseRepository[T any] interface { ... }
}
+47 -8
View File
@@ -87,14 +87,43 @@ func (e *Error) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
// WithDetail 添加详细信息
// WithDetail 添加详细信息
//
// H-10 修复:返回新 *Error 拷贝,不 mutate 接收者。预定义 Err*(如 ErrNotFound)是
// 包级共享指针,原实现 e.Detail = detail 会污染全局且并发调用存在数据竞争。
//
// nil 接收者:返回 &Error{Detail: detail}Code=0/Message=""),不 panic;调用方应避免
// 在 nil 上调用本方法以拿到无 Code/Message 的半成品错误。
func (e *Error) WithDetail(detail string) *Error {
e.Detail = detail
return e
if e == nil {
return &Error{Detail: detail}
}
return &Error{
Code: e.Code,
Message: e.Message,
Detail: detail,
}
}
// ToResponse 转换为响应结构
// ToResponse 转换为响应结构
// 把 Detail 放入 data.detail(若非空),保留链路细节(M7:原实现丢 Detail)。
// P1 #15:仅当 detailExposed()(默认 trueApp 生产环境置 false)时才向客户端输出 Detail,
// 避免内部错误细节在生产泄露。
func (e *Error) ToResponse() Response {
if e == nil {
return Response{
Code: CodeServerError,
Msg: ErrServerError.Message,
Data: nil,
}
}
if e.Detail != "" && detailExposed() {
return Response{
Code: e.Code,
Msg: e.Message,
Data: gin.H{"detail": e.Detail},
}
}
return Response{
Code: e.Code,
Msg: e.Message,
@@ -183,12 +212,22 @@ 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 使用预定义错误并添加详细信息
// FailWithDetail 使用预定义错误并添加详细信息
// P1 #15detail 仅在 detailExposed()(默认 trueApp 生产环境置 false)时输出给客户端。
func FailWithDetail(c *gin.Context, err *Error, detail string) {
writeResp(c, err.Code, err.Message, gin.H{"detail": detail})
if err == nil {
FailWithError(c, nil)
return
}
if detailExposed() {
writeResp(c, err.Code, err.Message, gin.H{"detail": detail})
return
}
writeResp(c, err.Code, err.Message, nil)
}
+67
View File
@@ -0,0 +1,67 @@
package response_test
import (
"sync"
"testing"
"github.com/EthanCodeCraft/xlgo-core/response"
)
// TestWithDetailDoesNotMutateOriginal H-10 回归:WithDetail 必须返回新拷贝,
// 不得 mutate 共享的预定义 Err*。修复前 WithDetail 会写 e.Detail 污染全局。
func TestWithDetailDoesNotMutateOriginal(t *testing.T) {
if response.ErrNotFound.Detail != "" {
t.Fatalf("预置条件:ErrNotFound.Detail 应为空,实际 %q", response.ErrNotFound.Detail)
}
detailed := response.ErrNotFound.WithDetail("用户 123 不存在")
// 原共享对象不得被污染
if response.ErrNotFound.Detail != "" {
t.Fatalf("WithDetail 污染了共享 ErrNotFoundDetail=%q", response.ErrNotFound.Detail)
}
// 新拷贝携带 detail
if detailed.Detail != "用户 123 不存在" {
t.Fatalf("新 Error 应携带 detail,实际 %q", detailed.Detail)
}
if detailed.Code != response.ErrNotFound.Code || detailed.Message != response.ErrNotFound.Message {
t.Fatalf("新 Error 应继承 Code/Message,实际 Code=%d Message=%q", detailed.Code, detailed.Message)
}
}
// TestWithDetailConcurrentOnSharedError H-10 回归:并发在共享 Err* 上调 WithDetail
// 不应触发数据竞争(修复前 mutate 共享对象,-race 必采)。
func TestWithDetailConcurrentOnSharedError(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
e := response.ErrNotFound.WithDetail("concurrent")
if e.Detail != "concurrent" {
t.Errorf("Detail 应为 concurrent,实际 %q", e.Detail)
}
}(i)
}
wg.Wait()
}
// TestExposeDetailGating P1 #15SetExposeDetail(false) 时 ToResponse 不得输出 Detail
// true 时输出。默认(未设置)为 true,保持存量行为。
func TestExposeDetailGating(t *testing.T) {
t.Cleanup(func() { response.SetExposeDetail(true) }) // 还原默认
err := response.NewErrorWithDetail(response.CodeServerError, "服务器错误", "pq: relation \"users\" does not exist")
// 暴露开启(默认/开发):Detail 出现在 Data 中
response.SetExposeDetail(true)
if resp := err.ToResponse(); resp.Data == nil {
t.Error("SetExposeDetail(true): ToResponse 应包含 detail")
}
// 暴露关闭(生产):Detail 不得出现,防内部错误泄露
response.SetExposeDetail(false)
if resp := err.ToResponse(); resp.Data != nil {
t.Errorf("SetExposeDetail(false): ToResponse 不得输出 detail,实际 Data=%v", resp.Data)
}
}
+25 -1
View File
@@ -28,6 +28,30 @@ func SetMode(m Mode) { currentMode.Store(int32(m)) }
// GetMode 返回当前响应模式。
func GetMode() Mode { return Mode(currentMode.Load()) }
// exposeDetail 控制是否把 Error.Detail 写入发给客户端的响应体(P1 #15)。
// 默认 true(保持存量行为:直接使用 response 包的项目不受影响)。
// App 在 Init 时按环境设置:生产环境置为 false,避免调用方误把内部错误(SQL 报错、
// 堆栈上下文等)塞进 Detail 而泄露给客户端;开发环境保持 true 便于排查。
// 1 表示暴露,0 表示隐藏。
var exposeDetail atomic.Int32
func init() {
exposeDetail.Store(1) // 默认暴露,保持存量行为
}
// SetExposeDetail 设置是否向客户端暴露 Error.DetailP1 #15)。
// 生产环境建议置为 false,防止内部错误细节泄露。并发安全。
func SetExposeDetail(expose bool) {
if expose {
exposeDetail.Store(1)
} else {
exposeDetail.Store(0)
}
}
// detailExposed 返回当前是否暴露 Detail。
func detailExposed() bool { return exposeDetail.Load() == 1 }
// statusForCode 按业务码推断 HTTP status(用于 ModeREST)。
// 已知框架错误码显式映射;用户/文件/数据模块的业务错误码(1xxxx~3xxxx
// 属业务语义而非 HTTP 错误,保持 200;操作失败类(4xxxx)映射 400。
@@ -41,7 +65,7 @@ func statusForCode(code int) int {
return http.StatusForbidden
case CodeNotFound, CodeUserNotFound, CodeFileNotFound, CodeDataNotFound:
return http.StatusNotFound
case CodeDataConflict:
case CodeDataConflict, CodeDataAlreadyExists:
return http.StatusConflict
case CodeRateLimit:
return http.StatusTooManyRequests
+13
View File
@@ -91,3 +91,16 @@ func TestFailWithErrorREST(t *testing.T) {
t.Errorf("FailWithError(ErrUnauthorized) status = %d, want 401", w.Code)
}
}
// TestDataAlreadyExistsMapsConflict_B7CodeDataAlreadyExists 应映射 409(与 CodeDataConflict 一致)。
func TestDataAlreadyExistsMapsConflict_B7(t *testing.T) {
withMode(t, response.ModeREST)
r := setupTestRouter()
r.GET("/dup", func(c *gin.Context) { response.FailWithError(c, response.ErrDataAlreadyExists) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/dup", nil))
if w.Code != 409 {
t.Errorf("ErrDataAlreadyExists status = %d, want 409", w.Code)
}
}
+65 -11
View File
@@ -1,18 +1,25 @@
package response
import (
"bytes"
"io"
"net/http"
"net/url"
"github.com/EthanCodeCraft/xlgo-core/utils"
"github.com/gin-gonic/gin"
)
// Response 统一响应结构
//
// M-38/M-39 修复(breaking):Data/RequestID 去掉 omitempty,让 data 与 request_id
// 字段在所有响应中恒存在(失败时 data=null、未装 RequestID 中间件时 request_id="")。
// 原实现用 omitempty 导致失败响应无 data 字段、空切片被 omit、未装中间件时无 request_id
// 破坏"统一响应结构"契约,下游严格按 schema 解析会出错。
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data,omitempty"`
RequestID string `json:"request_id,omitempty"` // 请求追踪ID
Data any `json:"data"`
RequestID string `json:"request_id"` // 请求追踪ID
}
// PageData 分页数据结构
@@ -88,23 +95,70 @@ func Page(c *gin.Context, items any, total int64, page, pageSize int) {
})
}
// contentDisposition 生成 RFC 5987 兼容的 Content-Disposition 值(M6 修复)。
// 同时给出 ASCII filename(向后兼容旧客户端)与 filename*(UTF-8 百分号编码,支持中文等非 ASCII),
// 避免直接拼接导致中文文件名乱码。
func contentDisposition(filename string) string {
ascii := asciiFallbackName(filename)
enc := url.PathEscape(filename)
// PathEscape 把空格编码为 %20(符合 RFC 5987),无需额外处理。
return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + enc
}
// asciiFallbackName 生成仅含 ASCII 的回退文件名:非 ASCII 字符替换为下划线,
// 空文件名回退为 "download"。
func asciiFallbackName(filename string) string {
if filename == "" {
return "download"
}
b := make([]byte, 0, len(filename))
for i := 0; i < len(filename); i++ {
c := filename[i]
if c >= 0x20 && c < 0x7f && c != '"' && c != '\\' {
b = append(b, c)
} else {
b = append(b, '_')
}
}
if len(b) == 0 {
return "download"
}
return string(b)
}
// Download 文件下载响应
// Compatibility note: for large files or object-storage streams, prefer
// DownloadReader so the whole object is not buffered in []byte first.
func Download(c *gin.Context, filename string, data []byte) {
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Header("Content-Length", utils.ToString(len(data)))
c.Data(http.StatusOK, "application/octet-stream", data)
DownloadReader(c, filename, "application/octet-stream", int64(len(data)), bytesReader(data))
}
// DownloadWithContentType 文件下载(自定义Content-Type
func DownloadWithContentType(c *gin.Context, filename string, contentType string, data []byte) {
c.Header("Content-Type", contentType)
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Header("Content-Length", utils.ToString(len(data)))
c.Data(http.StatusOK, contentType, data)
DownloadReader(c, filename, contentType, int64(len(data)), bytesReader(data))
}
// DownloadReader 流式下载响应。
//
// 对大文件/对象存储下载优先使用此函数,避免先把完整内容读入 []byte 驻留内存。
// contentLength 传 -1 表示未知长度;Gin 会省略 Content-Length。
func DownloadReader(c *gin.Context, filename, contentType string, contentLength int64, r io.Reader) {
if contentType == "" {
contentType = "application/octet-stream"
}
headers := map[string]string{
"Content-Disposition": contentDisposition(filename),
}
c.DataFromReader(http.StatusOK, contentLength, contentType, r, headers)
}
// bytesReader 避免在公开 API 中暴露 bytes 包细节,同时让旧 []byte API 复用流式写路径。
func bytesReader(data []byte) io.Reader {
return bytes.NewReader(data)
}
// HTML HTML内容响应
// Security note: HTML writes raw markup and does not escape untrusted input.
func HTML(c *gin.Context, data string) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, data)

Some files were not shown because too many files have changed in this diff Show More