33 Commits

Author SHA1 Message Date
杭州明婳科技 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
132 changed files with 20073 additions and 2284 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
+515 -3
View File
@@ -14,6 +14,517 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
---
## [Unreleased]
## [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 过时/错误描述。
@@ -273,8 +784,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)
```
**为什么现在动手**
@@ -574,7 +1085,8 @@ v1.0.2 引入可插拔方言注册表后,`gorm.io/driver/postgres` 成为直
- 基础框架功能
- 完整示例代码
[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.1.1...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
+128 -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 基础模型
@@ -509,9 +529,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 +543,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 +567,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 +592,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 +605,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 +710,9 @@ if !ok {
| `max=20` | 最大长度 |
| `email` | 邮箱格式 |
| `phone` | 手机号格式 |
| `password` | 密码强度(至少 8 位,包含字母和数字) |
| `phone_strict` | 手机号严格校验(验证运营商号段) |
| `password` | 密码强度(8-72 字节,需含大写+小写+数字) |
| `username` | 字母开头,3-20 位字母/数字/下划线 |
| `url` | URL 格式 |
| `ip` | IP 地址格式 |
@@ -749,9 +782,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 +906,35 @@ r.Use(middleware.CSRFForAPI())
token := middleware.GetCSRFToken(c)
```
### 8.3 限流(支持 Redis 分布式限流)
### 8.4 限流(支持 Redis 分布式限流)
```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 +943,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 +1087,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 +1123,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 字节)
@@ -1171,7 +1217,9 @@ storage:
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 +1237,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 +1253,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 +1459,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 +1475,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`,其余使用标准库 |
@@ -1493,6 +1559,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 启动与停止
@@ -1527,11 +1596,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 +1683,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 +1826,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 +1848,5 @@ func Login(c *gin.Context) {
---
_文档版本: v1.0.2_
_最后更新: 2026-06-20_
_文档版本: v1.2.0_
_最后更新: 2026-07-04_
+51 -12
View File
@@ -20,9 +20,8 @@ db := database.GetDB()
// 同一份代码,也能注入实例 / 跑多套 / 塞 mock
myDB := database.NewManager(cfg)
myDB.Open(ctx) // 独立实例,不受全局影响
database.SetDefaultManager(myDB) // 提升为全局默认
mockCache := &fakeCacheSvc{}
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入
database.SetDefaultManager(myDB) // 提升为全局默认,并关闭旧默认 manager
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入自定义 CacheManager
```
### 区别于一般 Gin 脚手架的几点
@@ -246,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())
@@ -275,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()` 也会识别新驱动:
@@ -348,11 +350,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")
@@ -368,8 +377,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)
@@ -393,7 +406,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
})
@@ -473,8 +486,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 流式响应
@@ -528,6 +544,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()
@@ -603,10 +622,14 @@ userType := middleware.GetUserType(c)
```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))
@@ -792,7 +815,7 @@ xlgo/
### Docker 部署
```dockerfile
FROM golang:1.22-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY . .
@@ -819,6 +842,22 @@ docker run -d -p 8080:8080 xlgo-app:latest
> 完整变更历史见 [CHANGELOG.md](./CHANGELOG.md)。
### 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 过时/错误描述(含会致启动失败的配置示例)。
+481 -69
View File
@@ -2,8 +2,10 @@ package xlgo
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
@@ -14,6 +16,7 @@ import (
"github.com/EthanCodeCraft/xlgo-core/cache"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/cron"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/EthanCodeCraft/xlgo-core/middleware"
@@ -27,7 +30,7 @@ import (
// Version 框架版本号。发版时只改这一处,避免版本字面量散落各处。
// CLIxlgo version)、脚手架生成的 go.mod 等均引用此常量。
const Version = "1.1.1"
const Version = "1.3.0"
// HealthCheckFunc 健康检查函数
type HealthCheckFunc func(context.Context) error
@@ -39,14 +42,15 @@ type Migrator func(*gorm.DB) error
// - OnInit: Init() 内组件初始化完成后、路由注册前
// - OnStart: StartServer() 监听端口前
// - OnReady: 端口就绪后(已开始接受连接)
// - OnStop: Shutdown() 开头,关 HTTP 之前
// - OnStop: Shutdown() 开头,关 HTTP 之前,受 server.shutdown_timeout 约束
//
// OnInit/OnStart/OnStop 返回 error 会中断流程并向上返回。
// OnInit/OnStart/OnReady/OnStop 返回 error 会中断流程并向上返回。
// A2 修复:OnReady 改为返回 error,失败时触发 shutdown。
type Hook struct {
Name string
OnInit func(*App) error
OnStart func(*App) error
OnReady func(*App)
OnReady func(*App) error
OnStop func(*App) error
}
@@ -55,6 +59,26 @@ type staticRoute struct {
root string
}
// appState 是 App 生命周期状态机(M1)。状态受 lifecycleMu 保护,单调推进:
//
// stateCreated → stateInitializing → stateInitialized → stateStopping → stateStopped
// ↓ (doInit 失败)
// stateStopping → stateStoppedfailAfterInit 回滚)
//
// Go() 仅在 state < stateStopping 时放行 wg.AddInit() 在 state >= stateStopping 时拒绝。
type appState int32
const (
stateCreated appState = iota
stateInitializing
stateInitialized
stateStopping
stateStopped
)
// ErrAppClosed 表示 App 已 Shutdown 或 Init 失败已回滚,拒绝再 Init(M1)。
var ErrAppClosed = errors.New("xlgo: app already shut down")
// App 应用实例
type App struct {
config *config.Config
@@ -64,23 +88,48 @@ type App struct {
registry *router.Registry
server *http.Server
enableLogger bool
enableMySQL bool
enableRedis bool
enableStorage bool
enableHealth bool
enableSwagger bool
enableAutoMigrate bool
enableLiveness bool
enableReadiness bool
enableMetrics bool
metricsPath string
enableLogger bool
enableMySQL bool
enableRedis bool
enableStorage bool
enableHealth bool
enableSwagger bool
enableAutoMigrate bool
enableLiveness bool
enableReadiness bool
enableMetrics bool
enableCron bool
metricsPath string
staticRoutes []staticRoute
migrators []Migrator
healthChecks []router.HealthCheck
hooks []Hook
initialized bool
// 生命周期状态机(M1:取代裸 initOnce,解决 Init/Shutdown 并发改资源、
// Init-after-Shutdown、App.Go 与 wg.Wait 的 WaitGroup 契约三类缺陷)。
// state 受 lifecycleMu 保护;initMu 串行化 doInit/doShutdown 长段,避免并发改资源。
// Go() 持 lifecycleMu.RLock 协调 wg.Add 与 Shutdown 的 wg.WaitAdd happens-before Wait)。
state appState
lifecycleMu sync.RWMutex
initMu sync.Mutex
initErr error
// shutdownOnce 确保 doShutdown 单次执行;shutdownErr 缓存结果供并发调用者共享。
shutdownOnce sync.Once
shutdownErr error
initializedLogger bool
initializedMySQL bool
initializedRedis bool
initializedCron bool
loggerManager *logger.LogManager
loggerSnapshot *logger.DefaultSnapshot
dbManager *database.Manager
previousDB *database.Manager
redisManager *database.RedisManager
previousRedis *database.RedisManager
// 请求级超时(#19),<=0 表示不启用
requestTimeout time.Duration
@@ -94,7 +143,13 @@ type App struct {
// Option 应用选项
type Option func(*App)
// WithConfigPath 设置配置文件路径
// WithConfigPath 设置配置文件路径
//
// M-config-5 全局可见性提示:WithConfigPath 在 Init 时会把 App 持有的 configManager 提升为
// 全局默认(config.SetDefaultManager),故 config.Get / config.GetString 等包级便捷函数可读到
// 该配置。这与 WithConfig 形成不对称:WithConfig 仅保留在 App 实例内,不污染全局,包级
// config.Get() 取不到注入配置。下游若混用 a.config 与 config.GetString,两种注入方式行为不同--
// 需要 config.Get/GetString 的下游请用 WithConfigPath(或自行 config.SetDefaultManager)。
func WithConfigPath(path string) Option {
return func(a *App) {
a.configPath = path
@@ -102,11 +157,15 @@ func WithConfigPath(path string) Option {
}
}
// WithConfig 设置配置对象
// WithConfig 设置配置对象
// 配置会在传入时深拷贝成 App 私有快照,并在 Init 时 Validate;调用方后续修改 cfg
// 不会污染 App 内部配置。不再调用 config.Set(cfg),配置仅保留在 App 实例内,不污染全局状态。
//
// M-config-5:因此 config.Get / config.GetString 等包级函数取不到此注入配置(默认空 manager)。
// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath(会提升为全局默认)。
func WithConfig(cfg *config.Config) Option {
return func(a *App) {
a.config = cfg
config.Set(cfg)
a.config = cfg.Clone()
}
}
@@ -201,8 +260,6 @@ func WithoutWire() Option {
return func(a *App) {}
}
// WithAutoMigrate 启用数据库迁移(需配合 WithMigrator/WithModels 注册迁移逻辑)
func WithAutoMigrate() Option {
return func(a *App) { a.enableAutoMigrate = true }
@@ -274,6 +331,14 @@ func WithRequestTimeout(d time.Duration) Option {
return func(a *App) { a.requestTimeout = d }
}
// WithCron 将全局 cron 调度器纳入 App 生命周期(P1 #10)。
// 启用后:Init 末尾 cron.Start() 启动调度循环;Shutdown 时在 ShutdownTimeout 约束内
// 停止全局调度器并等待在跑任务退出(cron.StopGlobalWithTimeout),避免调度 goroutine
// 与在跑任务在优雅关闭后残留。任务仍通过 cron.AddTask(...) 在 Run 前注册。
func WithCron() Option {
return func(a *App) { a.enableCron = true }
}
// WithModels 注册 GORM 自动迁移模型(自动启用 AutoMigrate
func WithModels(models ...any) Option {
return WithMigrator(func(db *gorm.DB) error {
@@ -350,12 +415,240 @@ func RunFullStack(opts ...Option) error {
return NewFullStack(opts...).Run()
}
// Init 初始化应用,不启动 HTTP 监听
// Init 初始化应用,不启动 HTTP 监听M1:状态机 + initMu 保证单次执行与并发安全)。
// 多次调用:已成功则返回缓存结果;已 Shutdown/Init 失败则返回 ErrAppClosed。
//
// initMu 串行化 doInit 与 doShutdown(避免并发改资源);lifecycleMu 仅短暂持有以翻状态,
// 不阻塞 Go()doInit 内 a.Go 只抢 lifecycleMu.RLock)。
//
// 注意:生命周期 hookOnInit/OnStart/OnReady/OnStop)不允许重入调用 Init/Shutdown/Run——
// initMu 非重入,hook 内调用会自锁死锁(M1 文档约束)。
func (a *App) Init() error {
if a.initialized {
return nil
a.initMu.Lock()
defer a.initMu.Unlock()
a.lifecycleMu.Lock()
switch a.state {
case stateCreated:
a.state = stateInitializing
a.lifecycleMu.Unlock()
case stateInitialized:
err := a.initErr
a.lifecycleMu.Unlock()
return err
case stateStopping, stateStopped:
a.lifecycleMu.Unlock()
return ErrAppClosed
default: // stateInitializing — initMu 串行下不可达,防御性返回
err := a.initErr
a.lifecycleMu.Unlock()
return err
}
a.initErr = a.doInit()
if a.initErr != nil {
a.failAfterInit()
return a.initErr
}
a.lifecycleMu.Lock()
a.state = stateInitialized
a.lifecycleMu.Unlock()
return nil
}
// failAfterInit 在 doInit 失败时执行资源回滚(M1)。
// 顺序:标记 stateStopping(阻新 Go)→ cancel rootCtx → wg.Wait(让 a.Go 启动的探活等
// goroutine 先退出,10s 超时)→ stopCron(5s) → rollbackReplacedResources。
// 先停 goroutine 再回滚资源,避免“回滚 DB 时探活 goroutine 仍在用”的竞态。
//
// 回滚走 rollbackReplacedResources(恢复 Init 前默认 manager 再关新建 manager),
// 而非 closeResources(直接关闭,仅供 doShutdown 复用)。Init 失败需恢复默认 manager
// 而非直接关,故两者不复用同一路径。cron 停止预算固定 5s,由本路径单独停止。
// 回滚错误与原 initErr 合并上抛,不吞。
func (a *App) failAfterInit() {
a.lifecycleMu.Lock()
a.state = stateStopping
a.lifecycleMu.Unlock()
// H-2cancel rootCtx 让 a.Go 启动的后台 goroutine 退出,不泄漏。
if a.cancel != nil {
a.cancel()
}
waitDone := make(chan struct{})
go func() { a.wg.Wait(); close(waitDone) }()
select {
case <-waitDone:
case <-time.After(10 * time.Second):
logger.Warnf("Init 失败后等待后台 goroutine 退出超时(10s),可能存在未响应 ctx.Done 的 goroutine")
}
// M1:回滚 doInit 已初始化的资源。cron 显式 5s 超时不卡死回滚;
// logger/db/redis 恢复 Init 前默认 manager,再关闭本 App 新建的 manager。
if err := a.stopCron(5 * time.Second); err != nil {
a.initErr = errors.Join(a.initErr, err)
}
if rbErr := a.rollbackReplacedResources(); rbErr != nil {
a.initErr = errors.Join(a.initErr, fmt.Errorf("回滚已初始化资源失败: %w", rbErr))
}
a.lifecycleMu.Lock()
a.state = stateStopped
a.lifecycleMu.Unlock()
}
// closeResources 关闭 db→redis→loggerM1)。各均为幂等 no-op(未 init 时安全),
// 仅供 doShutdown 复用。cron 因停止预算随调用方不同(Init 失败 5s / Shutdown 剩余
// 预算),由各调用方单独停止,不在此处。返回聚合错误。
// 注意:Init 失败的回滚不经本函数,而走 failAfterInit 的 rollbackReplacedResources
// (需恢复 Init 前默认 manager,而非直接关闭)。
func (a *App) closeResources() error {
var errs []error
if a.initializedMySQL {
if a.dbManager != nil {
if err := a.dbManager.Close(); err != nil {
errs = append(errs, err)
}
} else if err := database.CloseAll(); err != nil {
errs = append(errs, err)
}
a.initializedMySQL = false
a.dbManager = nil
}
if a.initializedRedis {
if a.redisManager != nil {
if err := a.redisManager.Close(); err != nil {
errs = append(errs, err)
}
} else if err := database.CloseRedis(); err != nil {
errs = append(errs, err)
}
a.initializedRedis = false
a.redisManager = nil
}
if a.initializedLogger {
if a.loggerManager != nil {
if err := a.loggerManager.Close(); err != nil {
errs = append(errs, err)
}
} else if err := logger.Close(); err != nil {
errs = append(errs, err)
}
a.initializedLogger = false
a.loggerManager = nil
}
// M-config-3:停止 App 持有的 configManager 热重载 watcher(若已启用),避免 App 关闭后
// 遗留监听 goroutine。默认流程(resolveConfig 用 Load 不启 watcher)为 no-op;用户对
// configManager 调用 LoadWithWatch/StartWatcher 后由这里统一收口。StopWatcher 幂等且会等待
// 进行中的 reload 回调结束,符合 C7 生命周期契约。configManager 可能为 nilWithConfig 注入)。
if a.configManager != nil {
a.configManager.StopWatcher()
}
return errors.Join(errs...)
}
func (a *App) rollbackReplacedResources() error {
var errs []error
if a.initializedMySQL {
if a.previousDB != nil {
database.SwapDefaultManager(a.previousDB)
}
if a.dbManager != nil {
if err := a.dbManager.Close(); err != nil {
errs = append(errs, err)
}
}
a.dbManager = nil
a.previousDB = nil
a.initializedMySQL = false
}
if a.initializedRedis {
if a.previousRedis != nil {
database.SwapDefaultRedisManager(a.previousRedis)
}
if a.redisManager != nil {
if err := a.redisManager.Close(); err != nil {
errs = append(errs, err)
}
}
a.redisManager = nil
a.previousRedis = nil
a.initializedRedis = false
}
if a.initializedLogger {
if err := logger.RestoreDefaultSnapshot(a.loggerSnapshot); err != nil {
errs = append(errs, err)
}
a.loggerManager = nil
a.loggerSnapshot = nil
a.initializedLogger = false
}
return errors.Join(errs...)
}
func (a *App) commitReplacedResources() {
if a.previousDB != nil {
if err := a.previousDB.Close(); err != nil {
logger.Warnf("关闭被替换的旧数据库 manager 失败: %v", err)
}
a.previousDB = nil
}
if a.previousRedis != nil {
if err := a.previousRedis.Close(); err != nil {
logger.Warnf("关闭被替换的旧 Redis manager 失败: %v", err)
}
a.previousRedis = nil
}
if a.loggerSnapshot != nil {
if err := logger.CloseDefaultSnapshot(a.loggerSnapshot); err != nil {
logger.Warnf("关闭被替换的旧 logger writer 失败: %v", err)
}
a.loggerSnapshot = nil
}
}
func (a *App) stopCron(timeout time.Duration) error {
if !a.initializedCron {
return nil
}
a.initializedCron = false
if !cron.StopGlobalWithTimeout(timeout) {
return fmt.Errorf("cron 调度器停止超时(%s", timeout)
}
return nil
}
func (a *App) shutdownAfterStartFailure(startErr error) error {
if startErr == nil {
return nil
}
if shutErr := a.Shutdown(); shutErr != nil {
return fmt.Errorf("%w (关闭错误: %v)", startErr, shutErr)
}
return startErr
}
func (a *App) runOnStopHook(ctx context.Context, h Hook) error {
if h.OnStop == nil {
return nil
}
done := make(chan error, 1)
go func() {
done <- h.OnStop(a)
}()
select {
case err := <-done:
if err != nil {
return fmt.Errorf("OnStop hook %q 失败: %w", h.Name, err)
}
return nil
case <-ctx.Done():
return fmt.Errorf("OnStop hook %q 超时: %w", h.Name, ctx.Err())
}
}
// doInit 执行实际初始化流程。仅在 Init 持有 initMu 并完成状态切换后调用,不可直接调用。
func (a *App) doInit() error {
cfg, err := a.resolveConfig()
if err != nil {
return err
@@ -365,33 +658,43 @@ func (a *App) Init() error {
}
if a.enableLogger {
if err := logger.Init(cfg); err != nil {
lm := logger.NewLogManager()
snapshot := logger.SnapshotDefault()
if err := lm.InitPreservingPrevious(cfg); err != nil {
return fmt.Errorf("初始化日志失败: %w", err)
}
logger.SetDefaultLogManager(lm)
a.loggerManager = lm
a.loggerSnapshot = snapshot
a.initializedLogger = true
}
if a.enableMySQL {
if err := database.InitDB(cfg); err != nil {
dbm := database.NewManager(cfg)
if err := dbm.InitDB(a.rootCtx, cfg); err != nil {
return fmt.Errorf("初始化数据库失败: %w", err)
}
a.previousDB = database.SwapDefaultManager(dbm)
a.dbManager = dbm
a.initializedMySQL = true
a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "mysql", Check: func(ctx context.Context) error {
// 优先读探活缓存标记(#21),避免每次探针都同步 ping
if !database.IsDBHealthy() {
if !dbm.IsHealthy() {
return errors.New("mysql 主库探活不健康")
}
status := database.HealthCheck()
if !status["master"] {
return errors.New("mysql 主库不可用")
}
return nil
return dbm.HealthCheck(ctx)
}})
}
if a.enableRedis {
if err := database.InitRedis(cfg); err != nil {
rm := database.NewRedisManager()
if err := rm.Init(cfg); err != nil {
return fmt.Errorf("初始化 Redis 失败: %w", err)
}
a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "redis", Check: database.HealthCheckRedis})
a.previousRedis = database.SwapDefaultRedisManager(rm)
a.redisManager = rm
a.initializedRedis = true
a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "redis", Check: rm.HealthCheck})
}
if a.enableStorage {
@@ -445,6 +748,9 @@ func (a *App) Init() error {
router.RegisterReadinessRoute(a.router, a.healthChecks...)
}
if a.enableMetrics {
// 采集中间件交给注册中心,Apply 时作为首个全局中间件装入(H8c),
// 覆盖所有经注册中心注册的业务路由,不依赖调用顺序。
a.registry.SetMetricsMiddleware(middleware.Metrics())
if a.metricsPath != "" {
router.RegisterMetricsRoute(a.router, a.metricsPath)
} else {
@@ -464,14 +770,22 @@ func (a *App) Init() error {
}
}
// 错误 Detail 暴露策略(P1 #15):仅开发环境向客户端输出 Error.Detail
// 生产环境隐藏,避免内部错误细节(SQL/堆栈等)经 Detail 泄露给客户端。
response.SetExposeDetail(cfg.IsDevelopment())
// in-flight goroutine 根 ctx 在 New() 时已初始化(#22
// 启动主库/从库探活后台循环(#21),ctx 在 Shutdown 时取消
if a.enableMySQL {
a.Go(database.StartDBProbing)
a.Go(a.dbManager.StartProbing)
}
a.initialized = true
// 启动 cron 全局调度器(P1 #10),Shutdown 时统一停止。任务须在此前经 cron.AddTask 注册。
if a.enableCron {
cron.Start()
a.initializedCron = true
}
// OnInit hooks:组件初始化完成后触发(#12)
for _, h := range a.hooks {
@@ -481,12 +795,18 @@ func (a *App) Init() error {
}
}
}
a.commitReplacedResources()
return nil
}
func (a *App) resolveConfig() (*config.Config, error) {
if a.config != nil {
return a.config, nil
cfg := a.config.Clone()
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("配置校验失败: %w", err)
}
a.config = cfg
return cfg, nil
}
if a.configManager == nil && a.configPath != "" {
a.configManager = config.NewManager(a.configPath)
@@ -515,7 +835,7 @@ func (a *App) Run() error {
if err := a.Init(); err != nil {
return err
}
return a.StartServer()
return a.shutdownAfterStartFailure(a.StartServer())
}
// StartServer 启动 HTTP 服务器(支持优雅关闭)
@@ -546,13 +866,37 @@ func (a *App) StartServer() error {
for _, h := range a.hooks {
if h.OnStart != nil {
if err := h.OnStart(a); err != nil {
return fmt.Errorf("OnStart hook %q 失败: %w", h.Name, err)
return a.shutdownAfterStartFailure(fmt.Errorf("OnStart hook %q 失败: %w", h.Name, err))
}
}
}
network := "tcp"
address := a.server.Addr
if useUnix {
network = "unix"
address = srvCfg.UnixSocket
}
ln, err := net.Listen(network, address)
if err != nil {
return a.shutdownAfterStartFailure(fmt.Errorf("服务器监听失败: %w", err))
}
if srvCfg.TLS.Enabled {
cert, err := tls.LoadX509KeyPair(srvCfg.TLS.CertFile, srvCfg.TLS.KeyFile)
if err != nil {
_ = ln.Close()
return a.shutdownAfterStartFailure(fmt.Errorf("服务器 TLS 配置无效: %w", err))
}
ln = tls.NewListener(ln, &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
}
serverErr := make(chan error, 1)
serveStarted := make(chan struct{})
go func() {
close(serveStarted)
if useUnix {
logger.Infof("服务器启动,监听 unix socket %s", srvCfg.UnixSocket)
} else if srvCfg.Host != "" {
@@ -560,23 +904,38 @@ func (a *App) StartServer() error {
} else {
logger.Infof("服务器启动,监听端口 %d(所有接口)", srvCfg.Port)
}
var err error
if srvCfg.TLS.Enabled {
err = a.server.ListenAndServeTLS(srvCfg.TLS.CertFile, srvCfg.TLS.KeyFile)
} else {
err = a.server.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
serveErr := a.server.Serve(ln)
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
serverErr <- serveErr
return
}
serverErr <- nil
}()
<-serveStarted
select {
case err := <-serverErr:
if err != nil {
return a.shutdownAfterStartFailure(fmt.Errorf("服务器启动失败: %w", err))
}
return nil
default:
}
// OnReady hooks端口就绪后
// OnReady hookslistener 已成功创建,Serve goroutine 已启动(M1)。
for _, h := range a.hooks {
if h.OnReady != nil {
h.OnReady(a)
if err := h.OnReady(a); err != nil {
logger.Errorf("OnReady hook %q 失败: %v", h.Name, err)
// H-1 修复:失败时走 Shutdown 释放 HTTP 端口、后台 goroutine 与已初始化资源,
// 避免监听 goroutine 永久阻塞在 Serve 致端口/goroutine 泄漏。
// 不再向 serverErr 写入——监听 goroutine 在 Shutdown 后会写 nil 到 serverErrcap=1),
// 若此处也写则第二次写阻塞致 goroutine 泄漏。
if shutErr := a.Shutdown(); shutErr != nil {
return fmt.Errorf("OnReady hook %q 失败: %w (关闭错误: %v)", h.Name, err, shutErr)
}
_ = ln.Close()
return fmt.Errorf("OnReady hook %q 失败: %w", h.Name, err)
}
}
}
@@ -587,7 +946,7 @@ func (a *App) StartServer() error {
select {
case err := <-serverErr:
if err != nil {
return fmt.Errorf("服务器启动失败: %w", err)
return a.shutdownAfterStartFailure(fmt.Errorf("服务器启动失败: %w", err))
}
return nil
case sig := <-quit:
@@ -596,8 +955,41 @@ func (a *App) StartServer() error {
}
}
// Shutdown 优雅关闭应用
// Shutdown 优雅关闭应用(M1:幂等 + 与 Init 互斥)。
// shutdownOnce 保证 doShutdown 单次执行,并发调用者阻塞至首个完成后返回同一 shutdownErr。
// initMu 串行化 doShutdown 与 doInit,避免并发改资源。
//
// 注意:生命周期 hook 不允许重入调用 Init/Shutdown/Run——initMu 非重入,hook 内调用会自锁。
func (a *App) Shutdown() error {
a.shutdownOnce.Do(func() {
a.initMu.Lock()
defer a.initMu.Unlock()
a.lifecycleMu.Lock()
if a.state >= stateStopping {
// shutdownOnce 已保证单次,此处不可达;防御性直接返回。
a.lifecycleMu.Unlock()
return
}
wasInitialized := a.state == stateInitialized
a.state = stateStopping
a.lifecycleMu.Unlock()
a.shutdownErr = a.doShutdown(wasInitialized)
a.lifecycleMu.Lock()
a.state = stateStopped
a.lifecycleMu.Unlock()
})
return a.shutdownErr
}
// doShutdown 执行实际关闭流程。仅在 Shutdown(持 initMu)中调用。
// 顺序(M1 调整):OnStop(仅 Init 曾成功时)→ cancel rootCtx → server.Shutdown →
// wg.Wait → cron / 限流器 / db / redis / logger。OnStop 在 shutdown 开头、关 HTTP 之前,
// 保有 cancel 前协调资源的机会;state=Stopping 已阻新 Go,无需先 cancel/wait 再跑 hook。
// OnStop 也受同一个 shutdownTimeout 预算约束,避免 hook 卡死拖住整个进程退出。
func (a *App) doShutdown(wasInitialized bool) error {
shutdownTimeout := 30 * time.Second
if a.config != nil {
shutdownTimeout = a.config.Server.EffectiveShutdownTimeout()
@@ -607,11 +999,15 @@ func (a *App) Shutdown() error {
var errs []error
// OnStop hooks:关 HTTP 之前触发(#12
for _, h := range a.hooks {
if h.OnStop != nil {
if err := h.OnStop(a); err != nil {
errs = append(errs, fmt.Errorf("OnStop hook %q 失败: %w", h.Name, err))
// OnStop hooksShutdown 开头,关 HTTP 之前触发(#12。仅 Init 曾成功时执行(M1)——
// Init 失败/未 Init 时 HTTP 从未启动,OnStop 语义不适用。
if wasInitialized {
for _, h := range a.hooks {
if err := a.runOnStopHook(ctx, h); err != nil {
errs = append(errs, err)
if ctx.Err() != nil {
break
}
}
}
}
@@ -644,21 +1040,30 @@ func (a *App) Shutdown() error {
logger.Warnf("等待后台 goroutine 退出超时")
}
// 停止 cron 全局调度器(P1 #10),在剩余 shutdown 预算内等待在跑任务退出。
if a.initializedCron {
logger.Info("停止 cron 调度器...")
remaining := time.Second
if dl, ok := ctx.Deadline(); ok {
if r := time.Until(dl); r > 0 {
remaining = r
}
}
if err := a.stopCron(remaining); err != nil {
logger.Warnf("cron 调度器停止超时,可能有任务未响应 ctx 取消")
}
}
logger.Info("停止限流器...")
middleware.StopRateLimiters()
logger.Info("关闭数据库连接...")
if err := database.CloseAll(); err != nil {
errs = append(errs, err)
}
if err := database.CloseRedis(); err != nil {
errs = append(errs, err)
}
logger.Info("关闭日志...")
// 先记录最后一条 "应用已优雅关闭",再 Close(关闭后写日志会 fall back 到 nop
// db/redis/logger 经 closeResources 统一关闭(M1)。注意:closeResources 仅供
// Shutdown 路径使用;Init 失败的回滚走 failAfterInit 的 rollbackReplacedResources
// 两者不复用同一路径(Init 失败需恢复默认 managerShutdown 直接关闭)。
// 先记最后一条 "应用已优雅关闭",再 Close(关闭后写日志会 fall back 到 nop)。
logger.Info("关闭数据库连接/Redis/日志...")
logger.Info("应用已优雅关闭")
if err := logger.Close(); err != nil {
if err := a.closeResources(); err != nil {
errs = append(errs, err)
}
return errors.Join(errs...)
@@ -666,16 +1071,23 @@ func (a *App) Shutdown() error {
// Go 启动一个受 App 生命周期管理的后台 goroutine(#22)。
// fn 收到的 ctx 在 Shutdown 时被 cancelfn 应在 ctx.Done() 时及时退出。
// Shutdown 会等待所有 App.Go 启动的 goroutine 退出(带 ShutdownTimeout 超时)。
// Shutdown/Init 失败后调用为 no-opM1state>=stateStopping 拒绝 wg.Add,避免与
// Shutdown 的 wg.Wait 竞争 WaitGroup 契约——Add 由 lifecycleMu.RLock 保护 happen-before Wait)。
func (a *App) Go(fn func(ctx context.Context)) {
if fn == nil {
return
}
a.lifecycleMu.RLock()
if a.state >= stateStopping {
a.lifecycleMu.RUnlock()
return
}
ctx := a.rootCtx
if ctx == nil {
ctx = context.Background()
}
a.wg.Add(1)
a.lifecycleMu.RUnlock()
go func() {
defer a.wg.Done()
fn(ctx)
+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)
}
}
+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()
}
+464
View File
@@ -0,0 +1,464 @@
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
cron.AddTask("canary-m1", cron.Every(time.Millisecond), func(context.Context) error {
ran.Add(1)
return nil
})
app := xlgo.New(
xlgo.WithConfig(testConfig(18104)),
xlgo.WithCron(),
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)
}
}
+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)")
}
}
+90 -46
View File
@@ -4,68 +4,75 @@ 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 缓存实现
type redisCache struct {
client *redis.Client
// redisCache Redis 缓存实现
//
// 不在构造时快照 redis.ClientM12 修复:原 NewRedisCache 构造时取 database.GetRedis()
// 若在 database.InitRedis 之前构造则永久 nil、即使后续 Redis 就绪也是 no-op)。
// 改为每次操作实时取 database.GetRedis(),使"先构造后 Init Redis"的顺序也能正确工作。
type redisCache struct{}
// client 返回当前 Redis 客户端(实时取,未初始化则 nil)。
func (c *redisCache) client() *redis.Client {
return database.GetRedis()
}
// NewRedisCache 创建 Redis 缓存实例
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
// 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 +81,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 +91,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 +106,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 +116,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 +124,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 +147,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,16 +169,25 @@ type CacheManager struct {
svc CacheService
}
// DefaultCache 默认缓存管理器,包级 facade 代理到它
var DefaultCache = NewCacheManager()
// defaultCachePtr 全局默认缓存管理器CK3 修复:atomic.Pointer 替代裸指针)
var defaultCachePtr atomic.Pointer[CacheManager]
func init() {
defaultCachePtr.Store(NewCacheManager())
}
// NewCacheManager 创建缓存管理器实例。
func NewCacheManager() *CacheManager { return &CacheManager{} }
// SetDefaultCacheManager 提升指定 CacheManager 为全局默认
// GetDefaultCache 返回全局默认缓存管理器(并发安全,CK3 修复)
func GetDefaultCache() *CacheManager {
return defaultCachePtr.Load()
}
// SetDefaultCacheManager 提升指定 CacheManager 为全局默认(atomic 置换,并发安全)。
func SetDefaultCacheManager(m *CacheManager) {
if m != nil {
DefaultCache = m
defaultCachePtr.Store(m)
}
}
@@ -191,14 +215,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)
}
}
+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)
+6 -7
View File
@@ -491,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)
}
}
+537 -107
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,89 @@ 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"`
}
// 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,7 +166,7 @@ type TLSConfig struct {
// ServerConfig 服务配置
type ServerConfig struct {
Host string `mapstructure:"host"` // 绑定地址,空=监听所有接口(0.0.0.0)"127.0.0.1"=仅本机;内网IP=绑定指定网卡
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"
@@ -100,7 +175,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
}
@@ -159,6 +234,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
@@ -225,6 +306,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 最大空闲连接数
@@ -237,32 +321,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 配置
@@ -275,6 +429,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)
}
@@ -284,7 +441,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 短信配置
@@ -304,10 +461,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 存储配置
@@ -317,6 +490,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 日志配置
@@ -394,9 +571,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 {
@@ -411,6 +603,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 {
@@ -441,7 +676,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 加载配置文件并启用热更新
@@ -469,204 +708,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")
}
}
}
+37 -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 时校验
@@ -67,3 +90,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 -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))
+479 -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,38 @@ 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
// 全局调度器。用 atomic.Pointer 懒初始化,使 StopGlobalWithTimeout 能安全 peek
// 是否已创建而不触发创建,也消除原 once+裸指针读写的竞态。
var globalScheduler atomic.Pointer[Scheduler]
// GetScheduler 获取全局调度器
// GetScheduler 获取全局调度器(懒初始化,并发安全)。
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()
}
// AddTask 添加任务到全局调度器
@@ -460,7 +810,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("数据库主库未初始化")
}
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` 包完善入参校验、密码强度策略与用户注册流程。
+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)
},
+306 -56
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: 配置为空")
}
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")
}
}
+104 -39
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()),
@@ -135,21 +132,58 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
}
}
// 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
}
@@ -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")
}
}
+1 -1
View File
@@ -45,11 +45,11 @@ var (
func Metrics() gin.HandlerFunc {
return func(c *gin.Context) {
httpRequestsInFlight.Inc()
defer httpRequestsInFlight.Dec() // M2 修复:defer 保证 panic 时也递减,不依赖 Recover 中间件顺序
start := time.Now()
c.Next()
httpRequestsInFlight.Dec()
elapsed := time.Since(start).Seconds()
status := strconv.Itoa(c.Writer.Status())
route := c.FullPath()
File diff suppressed because it is too large Load Diff
+230 -94
View File
@@ -2,8 +2,11 @@ package middleware
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
@@ -20,15 +23,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 +46,7 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
window: window,
ctx: ctx,
cancel: cancel,
nowFunc: time.Now,
}
limiter.wg.Add(1)
@@ -44,32 +55,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 +133,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 +151,22 @@ 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 并发安全切换。
}
// slidingWindowLua 滑动窗口限流 Lua 脚本
@@ -135,19 +193,59 @@ 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 检查是否允许请求
// 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)
}
// 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 未启用(database.GetRedis() == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable)
// fail-open 返 (true, nil)(兼容旧行为)。安全场景必须确保 Redis 已启用。
func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) {
if database.RedisClient == nil {
// Redis 未启用,默认允许
// M-C 修复:GetRedis() 仅取一次复用。原实现 nil 检查与 Eval 各调一次 GetRedis()
// 两次之间若 CloseRedis,第二次返回 nil → nil.Eval panic。
rdb := database.GetRedis()
if rdb == nil {
if rl.failClosed.Load() {
return false, ErrRedisRateLimiterUnavailable
}
return true, nil
}
@@ -155,18 +253,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 修复:GetRedis() 取一次复用(原三次调用存在 nil-deref 窗口)。
rdb := database.GetRedis()
if rdb == nil {
return 0, nil
}
@@ -175,32 +285,37 @@ 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 修复:GetRedis() 取一次复用(原两次调用存在 nil-deref 窗口)。
rdb := database.GetRedis()
if rdb == nil {
return nil
}
key := rl.keyPrefix + ":" + identifier
return database.RedisClient.Del(ctx, key).Err()
return rdb.Del(ctx, key).Err()
}
// ===== 全局限速器 =====
var (
loginLimiter *RateLimiter
apiLimiter *RateLimiter
uploadLimiter *RateLimiter
redisLimiters map[string]*RedisRateLimiter
limitersMu sync.Mutex
loginLimiter *RateLimiter
apiLimiter *RateLimiter
uploadLimiter *RateLimiter
customLimiters []*RateLimiter // H4b: CustomRateLimit 创建的限流器登记表,供 StopRateLimiters/InitRateLimiters 统一停止,避免 cleanup goroutine 泄漏
limitersMu sync.Mutex
)
func init() {
redisLimiters = make(map[string]*RedisRateLimiter)
// drainCustomLimiters 取出已登记的自定义限流器并清空登记表。调用方须持有 limitersMu。
func drainCustomLimiters() []*RateLimiter {
drained := customLimiters
customLimiters = nil
return drained
}
// InitRateLimiters 初始化限速器
@@ -208,7 +323,9 @@ func InitRateLimiters() {
limitersMu.Lock()
defer limitersMu.Unlock()
// 先停止旧的限流器
// 先停止旧的限流器(含自定义),释放 cleanup goroutine。
// 持锁期间调 Stop() 安全:Stop→wg.Wait 等待的 cleanupVisitors 取的是 limiter 自身的 rl.mu
// 非 limitersMu,无死锁;全程持锁避免与 LoginRateLimit 等懒初始化路径交错致覆盖泄漏。
if loginLimiter != nil {
loginLimiter.Stop()
}
@@ -218,6 +335,9 @@ func InitRateLimiters() {
if uploadLimiter != nil {
uploadLimiter.Stop()
}
for _, l := range drainCustomLimiters() {
l.Stop()
}
// 内存限流器(单实例)
loginLimiter = NewRateLimiter(10, time.Minute)
@@ -242,11 +362,20 @@ func StopRateLimiters() {
uploadLimiter.Stop()
uploadLimiter = nil
}
for _, l := range drainCustomLimiters() {
l.Stop()
}
}
// RateLimit 通用速率限制中间件(内存版)
func RateLimit(limiter *RateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
if limiter == nil {
response.Custom(c, http.StatusServiceUnavailable, response.CodeServiceUnavailable,
"限流器未初始化", nil)
c.Abort()
return
}
ip := c.ClientIP()
if !limiter.Allow(ip) {
response.RateLimit(c)
@@ -294,17 +423,50 @@ func UploadRateLimit() gin.HandlerFunc {
}
// CustomRateLimit 自定义速率限制(内存版)
//
// 创建的限流器登记入包级 customLimiters 表,StopRateLimiters / InitRateLimiters
// 会统一停止其 cleanup goroutineH4b 修复:原实现每次路由构造创建 limiter 无句柄,
// StopRateLimiters 不感知 → cleanup goroutine 泄漏)。
func CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
limiter := NewRateLimiter(rate, window)
limitersMu.Lock()
customLimiters = append(customLimiters, limiter)
limitersMu.Unlock()
return RateLimit(limiter)
}
// ===== 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 +478,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)
}
}
}
+31 -6
View File
@@ -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()
@@ -53,9 +70,17 @@ func RecoverWithDetail() gin.HandlerFunc {
zap.String("stack", string(debug.Stack())),
)
// 开发环境返回详细错误
response.FailWithCode(c, response.CodeServerError, fmt.Sprintf("服务器内部错误: %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)
+77 -1
View File
@@ -1,7 +1,9 @@
package response_test
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/response"
@@ -85,6 +87,34 @@ func TestFailWithCode(t *testing.T) {
}
}
func TestFailWithErrorNilDoesNotPanic_M6(t *testing.T) {
r := setupTestRouter()
r.Use(func(c *gin.Context) {
c.Set("request_id", "req-m6")
c.Next()
})
r.GET("/test", func(c *gin.Context) {
response.FailWithError(c, nil)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
if w.Code != 200 {
t.Fatalf("FailWithError(nil) status = %d, want 200 in business mode", w.Code)
}
var body response.Response
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Code != response.CodeServerError {
t.Fatalf("code = %d, want %d", body.Code, response.CodeServerError)
}
if body.RequestID != "req-m6" {
t.Fatalf("request_id = %q, want req-m6", body.RequestID)
}
}
func TestUnauthorized(t *testing.T) {
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
@@ -198,6 +228,29 @@ func TestDownload(t *testing.T) {
}
}
// TestDownloadChineseFilename_M6:中文文件名经 RFC 5987 编码(filename*),不乱码。
func TestDownloadChineseFilename_M6(t *testing.T) {
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
response.Download(c, "报告.txt", []byte("x"))
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
disposition := w.Header().Get("Content-Disposition")
// 应含 RFC 5987 编码的 filename*(百分号编码),且无原始 UTF-8 字节直接拼入 filename= 段
if !contains(disposition, "filename*=UTF-8''") {
t.Fatalf("Content-Disposition should contain filename*=UTF-8''..., got %s", disposition)
}
if !contains(disposition, "%E6%8A%A5%E5%91%8A") { // "报告" 的百分号编码
t.Fatalf("Content-Disposition should contain percent-encoded 报告, got %s", disposition)
}
// ASCII 回退名不应为空
if !contains(disposition, `filename="`) {
t.Fatalf("Content-Disposition should contain ASCII fallback filename, got %s", disposition)
}
}
func TestDownloadWithContentType(t *testing.T) {
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
@@ -219,6 +272,29 @@ func TestDownloadWithContentType(t *testing.T) {
}
}
func TestDownloadReaderStreamsContent_M6(t *testing.T) {
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
response.DownloadReader(c, "big.txt", "text/plain", 11, strings.NewReader("hello world"))
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
if w.Code != 200 {
t.Fatalf("DownloadReader status = %d, want 200", w.Code)
}
if got := w.Header().Get("Content-Type"); !contains(got, "text/plain") {
t.Fatalf("Content-Type = %q, want text/plain", got)
}
if got := w.Header().Get("Content-Length"); got != "11" {
t.Fatalf("Content-Length = %q, want 11", got)
}
if got := w.Body.String(); got != "hello world" {
t.Fatalf("body = %q, want hello world", got)
}
}
func TestHTML(t *testing.T) {
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
@@ -320,4 +396,4 @@ func contains(s, sub string) bool {
}
}
return false
}
}
+77
View File
@@ -6,7 +6,9 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/router"
)
@@ -60,6 +62,81 @@ func TestRegisterHealthRouteWithFailingCheck(t *testing.T) {
}
}
func TestRegisterHealthRouteTimesOutStuckCheck_M7(t *testing.T) {
r := setupTestRouter()
router.RegisterHealthRoute(r, router.HealthCheck{
Name: "stuck",
Timeout: 10 * time.Millisecond,
Check: func(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
},
})
w := httptest.NewRecorder()
start := time.Now()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", w.Code)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("health check took %s, want bounded timeout", elapsed)
}
if !strings.Contains(w.Body.String(), `"stuck":"timeout"`) {
t.Fatalf("body = %s, want timeout status", w.Body.String())
}
}
func TestRegisterHealthRouteRecoversCheckPanic_M7(t *testing.T) {
r := setupTestRouter()
router.RegisterHealthRoute(r, router.HealthCheck{
Name: "panic",
Check: func(context.Context) error {
panic("boom")
},
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", w.Code)
}
if !strings.Contains(w.Body.String(), `"panic":"error"`) {
t.Fatalf("body = %s, want panic check error status", w.Body.String())
}
}
func TestRegisterHealthRouteLimitsNonCooperativeCheckConcurrency_M7(t *testing.T) {
var started atomic.Int32
release := make(chan struct{})
defer close(release)
r := setupTestRouter()
router.RegisterHealthRoute(r, router.HealthCheck{
Name: "stuck",
Timeout: 10 * time.Millisecond,
Check: func(context.Context) error {
started.Add(1)
<-release
return nil
},
})
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("request %d status = %d, want 503", i+1, w.Code)
}
}
if got := started.Load(); got != 1 {
t.Fatalf("check started %d times, want 1 while first non-cooperative check is still running", got)
}
}
func TestRegisterDefaultRoutes(t *testing.T) {
r := setupTestRouter()
router.RegisterDefaultRoutes(r)
+7 -8
View File
@@ -1,16 +1,16 @@
package router
import (
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// RegisterMetricsRoute 注册 Prometheus 指标暴露端点与采集中间件#18)。
// RegisterMetricsRoute 注册 Prometheus 指标暴露端点(#18H8c 修正)。
//
// 默认路径 /metrics。传入 path 可自定义。同时把 Metrics() 中间件挂到该组,
// 这样只有业务路由被统计,/metrics 自身与 /health 等基础路由不计入。
// 默认路径 /metrics。传入 path 可自定义。本函数仅注册暴露端点,不再用 r.Use
// 挂采集中间件——采集中间件改由 Registry.SetMetricsMiddleware 在 Apply 内作为
// 首个全局中间件装入,使所有经注册中心注册的业务路由都被采集,且不依赖本函数
// 相对其他路由注册的调用顺序。/metrics 端点自身与 /health 等基础路由不经采集中间件。
//
// 用法:
//
@@ -21,7 +21,6 @@ func RegisterMetricsRoute(r *gin.Engine, path ...string) {
if len(path) > 0 && path[0] != "" {
p = path[0]
}
// 指标采集中间件挂在根引擎,统计所有业务请求
r.Use(middleware.Metrics())
r.GET(p, gin.WrapH(promhttp.Handler()))
// 幂等注册(H8d 收尾):重复调用跳过,避免重复路由 panic。
registerGETOnce(r, p, gin.WrapH(promhttp.Handler()))
}
+480 -73
View File
@@ -2,30 +2,187 @@ package router
import (
"context"
"errors"
"fmt"
"net/http"
"runtime/debug"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
var routerWarnf atomic.Value // stores func(string, ...any)
func warnf(format string, args ...any) {
if fn, ok := routerWarnf.Load().(func(string, ...any)); ok && fn != nil {
fn(format, args...)
return
}
logger.Warnf(format, args...)
}
func isDuplicateRoutePanic(msg, path string) bool {
if strings.Contains(msg, "already registered") {
return true
}
if !strings.Contains(msg, "conflicts with existing wildcard") {
return false
}
return strings.Contains(msg, fmt.Sprintf("new path '%s'", path)) &&
strings.Contains(msg, fmt.Sprintf("existing prefix '%s'", path))
}
// HealthCheck 健康检查项
type HealthCheck struct {
Name string
Check func(context.Context) error
Disabled bool
Timeout time.Duration
}
// DefaultHealthCheckTimeout bounds each dependency check so /health and /readyz
// do not hang behind a stuck database/client call.
const DefaultHealthCheckTimeout = 2 * time.Second
type healthCheckRunner struct {
check HealthCheck
running chan struct{}
}
func newHealthCheckRunners(checks []HealthCheck) []healthCheckRunner {
runners := make([]healthCheckRunner, len(checks))
for i, check := range checks {
runners[i] = healthCheckRunner{
check: check,
running: make(chan struct{}, 1),
}
}
return runners
}
func healthCheckTimeout(check HealthCheck) time.Duration {
if check.Timeout > 0 {
return check.Timeout
}
return DefaultHealthCheckTimeout
}
func healthCheckStatusFromContext(err error) string {
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
return "error"
}
func (r healthCheckRunner) run(ctx context.Context) (string, error) {
check := r.check
checkCtx, cancel := context.WithTimeout(ctx, healthCheckTimeout(check))
defer cancel()
select {
case r.running <- struct{}{}:
case <-checkCtx.Done():
return healthCheckStatusFromContext(checkCtx.Err()), checkCtx.Err()
}
errCh := make(chan error, 1)
go func() {
defer func() {
<-r.running
}()
defer func() {
if rec := recover(); rec != nil {
errCh <- fmt.Errorf("health check %q panic recovered: %v\n%s", check.Name, rec, debug.Stack())
}
}()
errCh <- check.Check(checkCtx)
}()
select {
case err := <-errCh:
if err == nil {
return "ok", nil
}
return healthCheckStatusFromContext(err), err
case <-checkCtx.Done():
return healthCheckStatusFromContext(checkCtx.Err()), checkCtx.Err()
}
}
// registerGETOnce 幂等注册 GET 路由(H8d 收尾:消除 defaultModule 与 Register* 系列
// 并存时 Gin 重复路由 panic 的 footgun)。若 (GET, path) 已存在则静默跳过——首次注册胜出。
//
// 实现分两条路径:
// - *gin.Engine:经 Routes() 精确预检(method+path),命中即跳过;未命中则直接注册,
// 不吞 panic——真正不同的路由冲突仍按 gin 原语义 panic,不被掩盖。
// - *gin.RouterGroupgin 未暴露其 engine,无法预检):用 recover 兜底,仅吞 gin 的
// 重复路由 panic"already registered" / "conflicts with existing wildcard"
// 两者覆盖 gin 对重复注册的两类 panic),其余 panic 原样抛出。defaultModule 的
// /swagger/*any 与 /health 重复注册即走此路径被吞。最坏情况(gin 改动文本)退化为
// 当前行为(panic),不引入新风险。
//
// 注册期单线程调用,无并发问题。
func registerGETOnce(r gin.IRoutes, path string, h gin.HandlerFunc) {
if r == nil {
warnf("router: skip GET %q because routes is nil", path)
return
}
if h == nil {
warnf("router: skip GET %q because handler is nil", path)
return
}
switch route := r.(type) {
case *gin.Engine:
eng := route
if eng == nil {
warnf("router: skip GET %q because engine is nil", path)
return
}
for _, ri := range eng.Routes() {
if ri.Method == http.MethodGet && ri.Path == path {
warnf("router: duplicate GET %q skipped", path)
return
}
}
eng.GET(path, h)
return
case *gin.RouterGroup:
if route == nil {
warnf("router: skip GET %q because router group is nil", path)
return
}
}
defer func() {
if rec := recover(); rec != nil {
msg := fmt.Sprint(rec)
if isDuplicateRoutePanic(msg, path) {
warnf("router: duplicate GET %q skipped: %v", path, rec)
return // 重复路由,静默跳过
}
panic(rec) // 非重复路由 panic,原样抛出
}
}()
r.GET(path, h)
}
// runHealthChecks 执行所有检查项,返回总体状态、HTTP code 与逐项结果。
// 无检查项时视为健康(用于 /livez 与无依赖场景)。
func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, map[string]string) {
if len(checks) == 0 {
func runHealthChecks(ctx context.Context, runners []healthCheckRunner) (string, int, map[string]string) {
if len(runners) == 0 {
return "ok", http.StatusOK, nil
}
status := "ok"
code := http.StatusOK
result := make(map[string]string, len(checks))
for _, check := range checks {
result := make(map[string]string, len(runners))
for _, runner := range runners {
check := runner.check
if check.Name == "" {
continue
}
@@ -33,34 +190,45 @@ func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, ma
result[check.Name] = "disabled"
continue
}
if err := check.Check(ctx); err != nil {
result[check.Name] = "error"
checkStatus, err := runner.run(ctx)
result[check.Name] = checkStatus
if err != nil {
status = "error"
code = http.StatusServiceUnavailable
continue
}
result[check.Name] = "ok"
}
return status, code, result
}
// RegisterHealthRoute 注册健康检查路由(兼容端点,等价于 readiness)。
func RegisterHealthRoute(r *gin.Engine, checks ...HealthCheck) {
r.GET("/health", func(c *gin.Context) {
status, code, result := runHealthChecks(c.Request.Context(), checks)
// healthHandler 返回统一的 /health 风格响应(H8d 收敛)。
// 无检查项时视为健康(200 + {"status":"ok"});有检查项时任一失败返回 503
// 并附逐项结果。RegisterHealthRoute / RegisterReadinessRoute / defaultModule
// 均委托此实现,避免三个 /health 行为与响应体不一致。
func healthHandler(checks []HealthCheck) gin.HandlerFunc {
runners := newHealthCheckRunners(checks)
return func(c *gin.Context) {
status, code, result := runHealthChecks(c.Request.Context(), runners)
if result == nil {
c.JSON(http.StatusOK, gin.H{"status": status})
return
}
c.JSON(code, gin.H{"status": status, "checks": result})
})
}
}
// RegisterHealthRoute 注册健康检查路由(兼容端点,等价于 readiness)。
// 幂等:若 /health 已注册则跳过(首次注册胜出),避免与 defaultModule 等并存时重复路由 panic。
func RegisterHealthRoute(r *gin.Engine, checks ...HealthCheck) {
registerGETOnce(r, "/health", healthHandler(checks))
}
// RegisterLivenessRoute 注册存活性探针(#17)。
// GET /livez 永不依赖外部,仅表示进程存活,始终返回 200。
// 供 K8s livenessProbe 使用:失败由进程崩溃体现,而非端点返回 503。
// 幂等:重复注册跳过。
func RegisterLivenessRoute(r *gin.Engine) {
r.GET("/livez", func(c *gin.Context) {
registerGETOnce(r, "/livez", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
}
@@ -68,20 +236,14 @@ func RegisterLivenessRoute(r *gin.Engine) {
// RegisterReadinessRoute 注册就绪性探针(#17)。
// GET /readyz 复用 HealthCheck 检查依赖(mysql/redis...),任一失败返回 503。
// 供 K8s readinessProbe 使用:未就绪时不接流量。
// 幂等:重复注册跳过。
func RegisterReadinessRoute(r *gin.Engine, checks ...HealthCheck) {
r.GET("/readyz", func(c *gin.Context) {
status, code, result := runHealthChecks(c.Request.Context(), checks)
if result == nil {
c.JSON(http.StatusOK, gin.H{"status": status})
return
}
c.JSON(code, gin.H{"status": status, "checks": result})
})
registerGETOnce(r, "/readyz", healthHandler(checks))
}
// RegisterSwaggerRoutes 注册 Swagger 文档路由
// RegisterSwaggerRoutes 注册 Swagger 文档路由。幂等:重复注册跳过。
func RegisterSwaggerRoutes(r *gin.Engine) {
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
registerGETOnce(r, "/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
// RegisterDefaultRoutes 注册框架默认路由(健康检查、Swagger)
@@ -98,11 +260,11 @@ type defaultModule struct{}
func (m *defaultModule) Name() string { return "default" }
func (m *defaultModule) Register(r *gin.RouterGroup) {
// 作为模块注册时,路由在根路径
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// 作为模块注册时,路由在根路径。经 registerGETOnce 幂等注册(H8d 收尾):
// 若用户已通过 RegisterSwaggerRoutes / RegisterHealthRoute 注册过同名路由,
// 此处静默跳过,避免 Gin 重复路由 panic。首次注册胜出。
registerGETOnce(r, "/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
registerGETOnce(r, "/health", healthHandler(nil))
}
// Module 路由模块接口
@@ -143,37 +305,94 @@ type MiddlewareGroup struct {
// Registry 路由注册中心
type Registry struct {
engine *gin.Engine
modules []Module
versions map[string]*VersionedAPI
middlewareGroups map[string]*MiddlewareGroup
mu sync.Mutex
engine *gin.Engine
modules []Module
versions map[string]*VersionedAPI
middlewareGroups map[string]*MiddlewareGroup
globalMiddlewares []gin.HandlerFunc
// metricsMiddleware 在 Apply 时作为首个全局中间件装入(H8c),
// 使所有经注册中心注册的路由都被采集,不依赖 RegisterMetricsRoute 的调用顺序。
// 为 nil 时不装入。/metrics 端点本身与 /health 等基础路由直接挂在 engine 上,
// 不经此中间件,故不被自采集。
metricsMiddleware gin.HandlerFunc
// applyOnce 保证 Apply 仅执行一次(H8b + P1 #13)。
// 原实现用裸 bool 无同步,并发 Apply 会竞态并可能重复 engine.Use/重复注册致 gin panic
// 改用 sync.Once,二次/并发 Apply 均安全幂等。
applyOnce sync.Once
applied atomic.Bool
}
// NewRegistry 创建路由注册中心
func NewRegistry(engine *gin.Engine) *Registry {
return &Registry{
engine: engine,
modules: make([]Module, 0),
versions: make(map[string]*VersionedAPI),
engine: engine,
modules: make([]Module, 0),
versions: make(map[string]*VersionedAPI),
middlewareGroups: make(map[string]*MiddlewareGroup),
}
}
func (r *Registry) lockForMutation(action string) (func(), bool) {
if r == nil {
warnf("router: skip %s because registry is nil", action)
return nil, false
}
r.mu.Lock()
if r.applied.Load() {
r.mu.Unlock()
warnf("router: %s called after Apply; registration will not take effect", action)
return nil, false
}
return r.mu.Unlock, true
}
func filterNilHandlers(action string, handlers []gin.HandlerFunc) []gin.HandlerFunc {
out := make([]gin.HandlerFunc, 0, len(handlers))
for _, h := range handlers {
if h == nil {
warnf("router: nil middleware skipped in %s", action)
continue
}
out = append(out, h)
}
return out
}
// Use 注册全局中间件
func (r *Registry) Use(middlewares ...gin.HandlerFunc) *Registry {
r.globalMiddlewares = append(r.globalMiddlewares, middlewares...)
unlock, ok := r.lockForMutation("Use")
if !ok {
return r
}
defer unlock()
r.globalMiddlewares = append(r.globalMiddlewares, filterNilHandlers("Use", middlewares)...)
return r
}
// RegisterModule 注册模块(无版本)
func (r *Registry) RegisterModule(module Module) *Registry {
unlock, ok := r.lockForMutation("RegisterModule")
if !ok {
return r
}
defer unlock()
if module == nil {
warnf("router: nil module skipped")
return r
}
r.modules = append(r.modules, module)
return r
}
// RegisterModuleFunc 注册函数式模块
func (r *Registry) RegisterModuleFunc(name string, fn func(r *gin.RouterGroup)) *Registry {
if fn == nil {
warnf("router: module func %q skipped because function is nil", name)
return r
}
return r.RegisterModule(&namedModule{name: name, fn: fn})
}
@@ -183,89 +402,212 @@ type namedModule struct {
fn func(r *gin.RouterGroup)
}
func (m *namedModule) Name() string { return m.name }
func (m *namedModule) Name() string { return m.name }
func (m *namedModule) Register(r *gin.RouterGroup) { m.fn(r) }
// RegisterVersion 注册版本化 API
func (r *Registry) RegisterVersion(version *VersionedAPI) *Registry {
unlock, ok := r.lockForMutation("RegisterVersion")
if !ok {
return r
}
defer unlock()
if version == nil {
warnf("router: nil version skipped")
return r
}
version.Middlewares = filterNilHandlers("RegisterVersion "+version.Version, version.Middlewares)
if _, exists := r.versions[version.Version]; exists {
warnf("router: duplicate version %q overwritten", version.Version)
}
r.versions[version.Version] = version
return r
}
// RegisterMiddlewareGroup 注册中间件分组
func (r *Registry) RegisterMiddlewareGroup(group *MiddlewareGroup) *Registry {
unlock, ok := r.lockForMutation("RegisterMiddlewareGroup")
if !ok {
return r
}
defer unlock()
if group == nil {
warnf("router: nil middleware group skipped")
return r
}
group.Middlewares = filterNilHandlers("RegisterMiddlewareGroup "+group.Name, group.Middlewares)
if _, exists := r.middlewareGroups[group.Name]; exists {
warnf("router: duplicate middleware group %q overwritten", group.Name)
}
r.middlewareGroups[group.Name] = group
return r
}
// GetMiddlewareGroup 获取中间件分组
func (r *Registry) GetMiddlewareGroup(name string) []gin.HandlerFunc {
if r == nil {
warnf("router: GetMiddlewareGroup(%q) on nil registry", name)
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
if group, ok := r.middlewareGroups[name]; ok {
return group.Middlewares
return append([]gin.HandlerFunc(nil), group.Middlewares...)
}
return nil
}
// Apply 应用所有路由注册
// SetMetricsMiddleware 设置指标采集中间件(H8c)。Apply 时它会作为首个全局中间件
// 装入 engine,使所有经注册中心注册的路由都被采集,不再依赖注册顺序。
// 传入 nil 清除。须在 Apply 之前调用。
func (r *Registry) SetMetricsMiddleware(mw gin.HandlerFunc) {
unlock, ok := r.lockForMutation("SetMetricsMiddleware")
if !ok {
return
}
defer unlock()
r.metricsMiddleware = mw
}
type versionApplySnapshot struct {
isNil bool
version string
basePath string
middlewares []gin.HandlerFunc
modules []Module
}
// Apply 应用所有路由注册。
//
// 幂等(H8b):二次调用直接返回,避免重复 engine.Use 与 Gin 重复路由 panic。
// 装入顺序:metrics 中间件(若有)→ 用户全局中间件 → 模块/版本路由。
// metrics 置于首位保证全量业务路由被采集,且不依赖 RegisterMetricsRoute 调用顺序。
func (r *Registry) Apply() {
// 应用全局中间件
r.engine.Use(r.globalMiddlewares...)
// 注册无版本模块
for _, module := range r.modules {
module.Register(r.engine.Group(""))
if r == nil {
warnf("router: Apply called on nil registry")
return
}
// 注册版本化 API
for _, v := range r.versions {
group := r.engine.Group(v.BasePath)
if len(v.Middlewares) > 0 {
group.Use(v.Middlewares...)
}
for _, module := range v.Modules {
module.Register(group)
}
if r.engine == nil {
warnf("router: Apply skipped because engine is nil")
return
}
r.applyOnce.Do(func() {
r.mu.Lock()
r.applied.Store(true)
metricsMiddleware := r.metricsMiddleware
globalMiddlewares := append([]gin.HandlerFunc(nil), r.globalMiddlewares...)
modules := append([]Module(nil), r.modules...)
versions := make([]versionApplySnapshot, 0, len(r.versions))
versionKeys := make([]string, 0, len(r.versions))
for k := range r.versions {
versionKeys = append(versionKeys, k)
}
sort.Strings(versionKeys)
for _, k := range versionKeys {
v := r.versions[k]
if v == nil {
versions = append(versions, versionApplySnapshot{isNil: true, version: k})
continue
}
versions = append(versions, versionApplySnapshot{
version: v.Version,
basePath: v.BasePath,
middlewares: append([]gin.HandlerFunc(nil), v.Middlewares...),
modules: append([]Module(nil), v.Modules...),
})
}
r.mu.Unlock()
// 指标采集中间件首个装入,统计所有经注册中心注册的业务路由
if metricsMiddleware != nil {
r.engine.Use(metricsMiddleware)
}
// 应用全局中间件
r.engine.Use(globalMiddlewares...)
// 注册无版本模块
for _, module := range modules {
if module == nil {
warnf("router: nil module skipped during Apply")
continue
}
module.Register(r.engine.Group(""))
}
// 注册版本化 API。P1 #13:按 version 键排序遍历,使跨版本注册顺序确定,
// 避免 map 随机序导致重叠路径"谁先胜出"(配合 registerGETOnce)每次运行不一致。
for _, v := range versions {
if v.isNil {
warnf("router: nil version %q skipped during Apply", v.version)
continue
}
group := r.engine.Group(v.basePath)
if len(v.middlewares) > 0 {
group.Use(v.middlewares...)
}
for _, module := range v.modules {
if module == nil {
warnf("router: nil module skipped in version %q", v.version)
continue
}
module.Register(group)
}
}
})
}
// ===== 全局注册中心 =====
var globalRegistry *Registry
// globalRegistry 包级全局注册中心。用 atomic.Pointer 保护读写(H8a):
// Init 写入、各全局 helper 读取,避免裸指针与请求 goroutine 的无锁竞争。
var globalRegistry atomic.Pointer[Registry]
// Init 初始化全局注册中心
// Init 初始化全局注册中心。须在使用任何全局 helperUse/RegisterModule/Apply…)之前调用。
func Init(engine *gin.Engine) *Registry {
globalRegistry = NewRegistry(engine)
return globalRegistry
r := NewRegistry(engine)
globalRegistry.Store(r)
return r
}
// GetRegistry 获取全局注册中心
// GetRegistry 获取全局注册中心,未初始化时返回 nil。
func GetRegistry() *Registry {
return globalRegistry
return globalRegistry.Load()
}
// ensureRegistry 取全局注册中心,未初始化时以明确信息 panic(H8a)。
// 把晦涩的 nil 解引用 panic 转成可定位的初始化顺序错误。
func ensureRegistry() *Registry {
r := globalRegistry.Load()
if r == nil {
panic("router: 全局注册中心未初始化,请先调用 router.Init(engine) 再使用全局 helper")
}
return r
}
// Use 注册全局中间件(全局方式)
func Use(middlewares ...gin.HandlerFunc) *Registry {
return globalRegistry.Use(middlewares...)
return ensureRegistry().Use(middlewares...)
}
// RegisterModule 注册模块(全局方式)
func RegisterModule(module Module) *Registry {
return globalRegistry.RegisterModule(module)
return ensureRegistry().RegisterModule(module)
}
// RegisterModuleFunc 注册函数式模块(全局方式)
func RegisterModuleFunc(name string, fn func(r *gin.RouterGroup)) *Registry {
return globalRegistry.RegisterModuleFunc(name, fn)
return ensureRegistry().RegisterModuleFunc(name, fn)
}
// RegisterVersion 注册版本化 API(全局方式)
func RegisterVersion(version *VersionedAPI) *Registry {
return globalRegistry.RegisterVersion(version)
return ensureRegistry().RegisterVersion(version)
}
// Apply 应用路由注册(全局方式)
func Apply() {
globalRegistry.Apply()
ensureRegistry().Apply()
}
// ===== 快捷构建函数 =====
@@ -275,19 +617,31 @@ func NewVersion(version, basePath string, middlewares ...gin.HandlerFunc) *Versi
return &VersionedAPI{
Version: version,
BasePath: basePath,
Middlewares: middlewares,
Middlewares: filterNilHandlers("NewVersion "+version, middlewares),
Modules: make([]Module, 0),
}
}
// AddModule 为版本添加模块
func (v *VersionedAPI) AddModule(module Module) *VersionedAPI {
if v == nil {
warnf("router: AddModule called on nil version")
return v
}
if module == nil {
warnf("router: nil module skipped in version %q", v.Version)
return v
}
v.Modules = append(v.Modules, module)
return v
}
// AddModuleFunc 为版本添加函数式模块
func (v *VersionedAPI) AddModuleFunc(name string, fn func(r *gin.RouterGroup)) *VersionedAPI {
if fn == nil {
warnf("router: module func %q skipped because function is nil", name)
return v
}
return v.AddModule(&namedModule{name: name, fn: fn})
}
@@ -295,7 +649,7 @@ func (v *VersionedAPI) AddModuleFunc(name string, fn func(r *gin.RouterGroup)) *
func NewMiddlewareGroup(name string, middlewares ...gin.HandlerFunc) *MiddlewareGroup {
return &MiddlewareGroup{
Name: name,
Middlewares: middlewares,
Middlewares: filterNilHandlers("NewMiddlewareGroup "+name, middlewares),
}
}
@@ -303,13 +657,29 @@ func NewMiddlewareGroup(name string, middlewares ...gin.HandlerFunc) *Middleware
// Group 创建路由组(带中间件分组)
func Group(engine *gin.Engine, path string, middlewares ...gin.HandlerFunc) *gin.RouterGroup {
return engine.Group(path, middlewares...)
if engine == nil {
warnf("router: Group(%q) skipped because engine is nil", path)
return nil
}
return engine.Group(path, filterNilHandlers("Group "+path, middlewares)...)
}
// GroupWithMiddlewareGroup 使用中间件分组创建路由组
// GroupWithMiddlewareGroup 使用中间件分组创建路由组
//
// H-B 修复:改走 ensureRegistry()(与 Use/RegisterModule/Apply 等所有全局 helper 一致),
// 把"未初始化"从 nil 解引用 panic 转成可定位的明确 panicH8a 目标)。原实现用 GetRegistry()
// 可返回 nilnil.GetMiddlewareGroup 即 panic,错误信息晦涩。
func (r *Registry) GroupWithMiddlewareGroup(engine *gin.Engine, path string, groupName string) *gin.RouterGroup {
if engine == nil {
warnf("router: GroupWithMiddlewareGroup(%q) skipped because engine is nil", path)
return nil
}
return engine.Group(path, r.GetMiddlewareGroup(groupName)...)
}
// GroupWithMiddlewareGroup 使用中间件分组创建路由组(全局方式,代理到默认 Registry)。
func GroupWithMiddlewareGroup(engine *gin.Engine, path string, groupName string) *gin.RouterGroup {
middlewares := GetRegistry().GetMiddlewareGroup(groupName)
return engine.Group(path, middlewares...)
return ensureRegistry().GroupWithMiddlewareGroup(engine, path, groupName)
}
// RESTfulRoute RESTful 路由快捷注册
@@ -323,28 +693,61 @@ func NewRESTful(group *gin.RouterGroup, path string) *RESTfulRoute {
return &RESTfulRoute{Group: group, Path: path}
}
func (r *RESTfulRoute) prepareHandlers(method string, handlers []gin.HandlerFunc) []gin.HandlerFunc {
if r == nil || r.Group == nil {
warnf("router: RESTful %s skipped because route/group is nil", method)
return nil
}
filtered := filterNilHandlers("RESTful "+method+" "+r.Path, handlers)
if len(filtered) == 0 {
warnf("router: RESTful %s %q skipped because handlers are empty", method, r.Path)
return nil
}
return filtered
}
// GET 注册 GET 路由
func (r *RESTfulRoute) GET(handlers ...gin.HandlerFunc) {
handlers = r.prepareHandlers(http.MethodGet, handlers)
if handlers == nil {
return
}
r.Group.GET(r.Path, handlers...)
}
// POST 注册 POST 路由
func (r *RESTfulRoute) POST(handlers ...gin.HandlerFunc) {
handlers = r.prepareHandlers(http.MethodPost, handlers)
if handlers == nil {
return
}
r.Group.POST(r.Path, handlers...)
}
// PUT 注册 PUT 路由
func (r *RESTfulRoute) PUT(handlers ...gin.HandlerFunc) {
handlers = r.prepareHandlers(http.MethodPut, handlers)
if handlers == nil {
return
}
r.Group.PUT(r.Path, handlers...)
}
// DELETE 注册 DELETE 路由
func (r *RESTfulRoute) DELETE(handlers ...gin.HandlerFunc) {
handlers = r.prepareHandlers(http.MethodDelete, handlers)
if handlers == nil {
return
}
r.Group.DELETE(r.Path, handlers...)
}
// PATCH 注册 PATCH 路由
func (r *RESTfulRoute) PATCH(handlers ...gin.HandlerFunc) {
handlers = r.prepareHandlers(http.MethodPatch, handlers)
if handlers == nil {
return
}
r.Group.PATCH(r.Path, handlers...)
}
@@ -355,6 +758,10 @@ func (r *RESTfulRoute) PATCH(handlers ...gin.HandlerFunc) {
// PUT /path/:id - 更新
// DELETE /path/:id - 删除
func (r *RESTfulRoute) CRUD(list, detail, create, update, delete gin.HandlerFunc) {
if r == nil || r.Group == nil {
warnf("router: RESTful CRUD skipped because route/group is nil")
return
}
if list != nil {
r.Group.GET(r.Path, list)
}
@@ -370,4 +777,4 @@ func (r *RESTfulRoute) CRUD(list, detail, create, update, delete gin.HandlerFunc
if delete != nil {
r.Group.DELETE(r.Path+"/:id", delete)
}
}
}
+271
View File
@@ -0,0 +1,271 @@
package router
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/gin-gonic/gin"
)
// TestApplyIdempotent_H8b 复现 H8b:修复前二次 Apply 会重复 engine.Use 并触发
// Gin 重复路由 panic;修复后二次 Apply 直接返回,无 panic、无重复中间件。
func TestApplyIdempotent_H8b(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
r := NewRegistry(engine)
runs := 0
r.Use(func(c *gin.Context) { runs++; c.Next() })
r.RegisterModuleFunc("test", func(g *gin.RouterGroup) {
g.GET("/h8b", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
})
r.Apply()
r.Apply() // 二次 Apply 必须无 panic
r.Apply() // 三次同样
// 幂等性由下方 runs==1 断言验证(P1 #13applied bool 已改为 sync.Once
// 无导出标志可查;若 Apply 非幂等则中间件会被重复装入致 runs>1)。
// 中间件只应被装入一次:请求一次,runs 应为 1(若重复装入则 >1)。
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8b", nil))
if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
if runs != 1 {
t.Fatalf("global middleware ran %d times, want 1 (Apply not idempotent)", runs)
}
}
// TestApplyConcurrent_P1_13 验证并发 Apply 无 data race 且仅生效一次(sync.Once)。
// 修复前 applied 为裸 bool,多 goroutine 并发 Apply 竞态可重复 engine.Use/重复注册致 panic。
// 须配合 -race 运行。
func TestApplyConcurrent_P1_13(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
r := NewRegistry(engine)
runs := 0
r.Use(func(c *gin.Context) { runs++; c.Next() })
r.RegisterModuleFunc("p113", func(g *gin.RouterGroup) {
g.GET("/p113", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
})
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() { defer wg.Done(); r.Apply() }()
}
wg.Wait()
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/p113", nil))
if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
if runs != 1 {
t.Fatalf("global middleware ran %d times, want 1 (concurrent Apply not once)", runs)
}
}
// TestMetricsMiddlewareFirstInApply_H8c 验证 metrics 中间件经 SetMetricsMiddleware
// 在 Apply 内装入,覆盖所有经注册中心注册的路由,且不依赖 RegisterMetricsRoute
// 的调用顺序。修复前 RegisterMetricsRoute 用 r.Use,先注册的路由不被采集。
func TestMetricsMiddlewareFirstInApply_H8c(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
r := NewRegistry(engine)
// 用一个计数中间件模拟 metrics 采集。
hits := 0
r.SetMetricsMiddleware(func(c *gin.Context) {
hits++
c.Next()
})
r.RegisterModuleFunc("biz", func(g *gin.RouterGroup) {
g.GET("/biz", func(c *gin.Context) { c.JSON(200, gin.H{}) })
})
// 注意:不调用 RegisterMetricsRoute,仅靠 SetMetricsMiddleware + Apply 装入。
r.Apply()
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/biz", nil))
if w.Code != 200 {
t.Fatalf("biz status = %d, want 200", w.Code)
}
if hits != 1 {
t.Fatalf("metrics middleware hits = %d, want 1 (route not instrumented)", hits)
}
}
// TestMetricsMiddlewareNilSkipped_H8c:未设置 metrics 中间件时 Apply 不应装入空壳。
func TestMetricsMiddlewareNilSkipped_H8c(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
r := NewRegistry(engine)
if r.metricsMiddleware != nil {
t.Fatal("default metricsMiddleware should be nil")
}
r.Apply() // 不应 panic
}
// TestEnsureRegistryPanicsBeforeInit_H8a 复现 H8a:修复前 Init 之前调全局 helper
// 触发 nil 解引用 panic;修复后为带明确信息的 panic。
func TestEnsureRegistryPanicsBeforeInit_H8a(t *testing.T) {
// 保存并清空全局注册中心,测试后恢复,避免污染其它测试。
prev := globalRegistry.Load()
globalRegistry.Store(nil)
t.Cleanup(func() { globalRegistry.Store(prev) })
var got any
func() {
defer func() { got = recover() }()
Apply()
}()
if got == nil {
t.Fatal("Apply before Init should panic, got nil")
}
msg, ok := got.(string)
if !ok {
t.Fatalf("panic value should be string, got %T: %v", got, got)
}
if !strings.Contains(msg, "router.Init") {
t.Fatalf("panic message should mention router.Init, got %q", msg)
}
}
// TestGlobalRegistryAtomicConcurrent_H8a:并发 Init/GetRegistry 不触发 data race
// atomic.Pointer 保护)。须配合 -race 运行。
func TestGlobalRegistryAtomicConcurrent_H8a(t *testing.T) {
prev := globalRegistry.Load()
t.Cleanup(func() { globalRegistry.Store(prev) })
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(2)
go func() { defer wg.Done(); Init(gin.New()) }()
go func() { defer wg.Done(); _ = GetRegistry() }()
}
wg.Wait()
}
// TestHealthHandlerConvergedSchema_H8d 验证 defaultModule / RegisterHealthRoute(无 checks)
// 与 handler 风格的 /health 同 schema200 + {"status":"ok"}。
func TestHealthHandlerConvergedSchema_H8d(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
r := NewRegistry(engine)
r.RegisterModule(&defaultModule{})
r.Apply()
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if w.Code != 200 {
t.Fatalf("defaultModule /health status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"status":"ok"`) {
t.Fatalf("defaultModule /health body = %s, want {\"status\":\"ok\"}", body)
}
// 不应携带 response 业务信封字段。
if strings.Contains(body, `"code"`) || strings.Contains(body, `"data"`) {
t.Fatalf("defaultModule /health should not use response envelope, got %s", body)
}
}
// 编译期保证 middleware 包仍可独立使用(H8c 回归用)。
var _ gin.HandlerFunc = middleware.Metrics()
// TestDefaultModuleAndRegisterHealthRouteCoexist_H8dfootgun 复现 H8d 收尾 footgun
// 修复前 WithDefaultRoutes()+WithModules(DefaultModule) 并存会触发 Gin 重复路由 panic;
// 修复后 registerGETOnce 使二者幂等共存,/health 与 /swagger 均可访问。
// 此测试模拟 app.go 的真实顺序:Register* 先注册(带 checks),defaultModule 经 Apply 后注册。
func TestDefaultModuleAndRegisterHealthRouteCoexist_H8dfootgun(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
// 先经 Register* 注册(等价 app.go Init 中 enableHealth/enableSwagger 段)
RegisterHealthRoute(engine, HealthCheck{Name: "mysql", Check: func(context.Context) error { return nil }})
RegisterSwaggerRoutes(engine)
// 再经注册中心注册 DefaultModule(等价 app.go registry.Apply()
r := NewRegistry(engine)
r.RegisterModule(&defaultModule{})
r.Apply() // 修复前在此 panic: handlers are already registered for path '/health'
// /health 仍可访问,且首次注册(带 checks)胜出——响应含 checks 字段。
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if w.Code != 200 {
t.Fatalf("/health status = %d, want 200", w.Code)
}
if !strings.Contains(w.Body.String(), `"mysql":"ok"`) {
t.Fatalf("first registration (with checks) should win, got %s", w.Body.String())
}
// /swagger/*any 注册存在(非 404 即说明路由已注册)
w2 := httptest.NewRecorder()
engine.ServeHTTP(w2, httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil))
if w2.Code == http.StatusNotFound {
t.Fatal("/swagger/*any should be registered, got 404")
}
}
// TestRegisterHealthRouteIdempotent_H8dfootgunRegisterHealthRoute 重复调用不 panic。
func TestRegisterHealthRouteIdempotent_H8dfootgun(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
RegisterHealthRoute(engine, HealthCheck{Name: "a", Check: func(context.Context) error { return nil }})
RegisterHealthRoute(engine) // 重复,不 panic
RegisterHealthRoute(engine) // 三次,不 panic
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if w.Code != 200 {
t.Fatalf("/health status = %d, want 200", w.Code)
}
}
// TestDefaultModuleOnly_H8dfootgun:仅用 defaultModule(不预先 Register*)时,
// /health 与 /swagger 仍正常注册(recover 兜底路径不影响首次注册)。
func TestDefaultModuleOnly_H8dfootgun(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
r := NewRegistry(engine)
r.RegisterModule(&defaultModule{})
r.Apply()
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if w.Code != 200 {
t.Fatalf("/health status = %d, want 200", w.Code)
}
if !strings.Contains(w.Body.String(), `"status":"ok"`) {
t.Fatalf("/health body = %s", w.Body.String())
}
}
// TestRegisterGETOnceEngineDoesNotSwallowRealConflict_H8dfootgunEngine 路径经 Routes()
// 精确预检,未命中即直接注册(无 recover)。真正不同的路由冲突(如 /foo/:id 已存在再注册
// /foo/*any)仍按 gin 原语义 panic,不被掩盖——证明幂等只吞"同一 path 重复",不掩盖真实冲突。
func TestRegisterGETOnceEngineDoesNotSwallowRealConflict_H8dfootgun(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/foo/:id", func(c *gin.Context) {})
var got any
func() {
defer func() { got = recover() }()
registerGETOnce(engine, "/foo/*any", func(c *gin.Context) {}) // 与 :id 真实冲突
}()
if got == nil {
t.Fatal("registerGETOnce (Engine) should panic on real (different-path) conflict, got nil")
}
}
+196
View File
@@ -0,0 +1,196 @@
package router
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
func captureRouterWarnings(t *testing.T) *[]string {
t.Helper()
var mu sync.Mutex
warnings := make([]string, 0)
prev, hadPrev := routerWarnf.Load().(func(string, ...any))
routerWarnf.Store(func(format string, args ...any) {
mu.Lock()
defer mu.Unlock()
warnings = append(warnings, format)
})
t.Cleanup(func() {
if hadPrev {
routerWarnf.Store(prev)
return
}
routerWarnf.Store(func(string, ...any) {})
})
return &warnings
}
func warningsContain(warnings *[]string, substr string) bool {
for _, warning := range *warnings {
if strings.Contains(warning, substr) {
return true
}
}
return false
}
func TestM7DuplicateGETWarnsAndSkips(t *testing.T) {
gin.SetMode(gin.TestMode)
warnings := captureRouterWarnings(t)
engine := gin.New()
RegisterHealthRoute(engine)
RegisterHealthRoute(engine)
if !warningsContain(warnings, "duplicate GET") {
t.Fatalf("duplicate registration should warn, got %#v", *warnings)
}
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if w.Code != http.StatusOK {
t.Fatalf("/health status = %d, want 200", w.Code)
}
}
func TestM7RouterGroupWildcardConflictPanics(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
group := engine.Group("")
group.GET("/files/:id", func(c *gin.Context) {})
defer func() {
if rec := recover(); rec == nil {
t.Fatal("wildcard conflict should panic, not be swallowed as duplicate")
}
}()
registerGETOnce(group, "/files/*path", func(c *gin.Context) {})
}
func TestM7RegistryGroupWithMiddlewareGroupUsesLocalRegistry(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registry := NewRegistry(engine)
registry.RegisterMiddlewareGroup(NewMiddlewareGroup("auth", func(c *gin.Context) {
c.Set("auth", true)
c.Next()
}))
group := registry.GroupWithMiddlewareGroup(engine, "/api", "auth")
if group == nil {
t.Fatal("GroupWithMiddlewareGroup returned nil")
}
group.GET("/check", func(c *gin.Context) {
v, _ := c.Get("auth")
c.JSON(http.StatusOK, gin.H{"auth": v})
})
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/check", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"auth":true`) {
t.Fatalf("local middleware group not applied: status=%d body=%s", w.Code, w.Body.String())
}
}
func TestM7NilGuardsNoPanic(t *testing.T) {
warnings := captureRouterWarnings(t)
var nilRegistry *Registry
var nilEngine *gin.Engine
var nilGroup *gin.RouterGroup
var nilRoute *RESTfulRoute
requireNoPanic := func(name string, fn func()) {
t.Helper()
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("%s panicked: %v", name, rec)
}
}()
fn()
}
requireNoPanic("RegisterHealthRoute nil engine", func() { RegisterHealthRoute(nilEngine) })
requireNoPanic("DefaultModule nil group", func() { DefaultModule.Register(nilGroup) })
requireNoPanic("nil registry Use", func() { nilRegistry.Use(nil) })
requireNoPanic("nil registry RegisterModule", func() { nilRegistry.RegisterModule(nil) })
requireNoPanic("nil registry RegisterVersion", func() { nilRegistry.RegisterVersion(nil) })
requireNoPanic("nil registry RegisterMiddlewareGroup", func() { nilRegistry.RegisterMiddlewareGroup(nil) })
requireNoPanic("nil registry GetMiddlewareGroup", func() { _ = nilRegistry.GetMiddlewareGroup("x") })
requireNoPanic("nil registry Apply", func() { nilRegistry.Apply() })
requireNoPanic("nil engine Group", func() { _ = Group(nil, "/x", nil) })
requireNoPanic("nil RESTful GET", func() { nilRoute.GET(nil) })
requireNoPanic("nil RESTful CRUD", func() { nilRoute.CRUD(nil, nil, nil, nil, nil) })
if len(*warnings) == 0 {
t.Fatal("nil guards should emit warnings")
}
}
func TestM7RegistryApplyConcurrentMutationNoRace(t *testing.T) {
gin.SetMode(gin.TestMode)
_ = captureRouterWarnings(t)
engine := gin.New()
registry := NewRegistry(engine)
const workers = 32
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(workers + 1)
for i := 0; i < workers; i++ {
idx := i
go func() {
defer wg.Done()
<-start
registry.Use(func(c *gin.Context) { c.Next() })
registry.RegisterMiddlewareGroup(NewMiddlewareGroup(
fmt.Sprintf("group-%d", idx),
func(c *gin.Context) { c.Next() },
))
registry.RegisterModuleFunc(fmt.Sprintf("module-%d", idx), func(g *gin.RouterGroup) {
g.GET(fmt.Sprintf("/m%d", idx), func(c *gin.Context) { c.Status(http.StatusOK) })
})
registry.RegisterVersion(NewVersion(
fmt.Sprintf("v%d", idx),
fmt.Sprintf("/v%d", idx),
).AddModuleFunc("ping", func(g *gin.RouterGroup) {
g.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
}))
}()
}
go func() {
defer wg.Done()
<-start
registry.Apply()
}()
close(start)
wg.Wait()
}
func TestM7RegisterAfterApplyWarnsAndDoesNotTakeEffect(t *testing.T) {
gin.SetMode(gin.TestMode)
warnings := captureRouterWarnings(t)
engine := gin.New()
registry := NewRegistry(engine)
registry.Apply()
registry.RegisterModuleFunc("late", func(g *gin.RouterGroup) {
g.GET("/late", func(c *gin.Context) { c.Status(http.StatusOK) })
})
if !warningsContain(warnings, "after Apply") {
t.Fatalf("late registration should warn, got %#v", *warnings)
}
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/late", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("late route status = %d, want 404", w.Code)
}
}
+2 -2
View File
@@ -430,7 +430,7 @@ func TestGroupWithMiddlewareGroup(t *testing.T) {
registry.RegisterMiddlewareGroup(router.NewMiddlewareGroup("auth", func(c *gin.Context) { c.Next() }))
group := router.GroupWithMiddlewareGroup(engine, "/api", "auth")
group := registry.GroupWithMiddlewareGroup(engine, "/api", "auth")
if group == nil {
t.Error("GroupWithMiddlewareGroup should not return nil")
}
@@ -456,4 +456,4 @@ type testModule struct {
func (m *testModule) Name() string { return m.name }
func (m *testModule) Register(r *gin.RouterGroup) {
r.GET("/"+m.name, func(c *gin.Context) { c.JSON(200, gin.H{"module": m.name}) })
}
}
+90
View File
@@ -0,0 +1,90 @@
# 扫描收敛协议(review discipline
> 本文件是 CLAUDE.md「开发纪律」的姊妹篇。开发纪律管"怎么写代码",本协议管"怎么审代码"。
> 起源:多轮大模型全局复审非收敛--每换一个 reviewer 就重新生成一批 nits/功能建议/风格偏好,finding 数永不归零,压迫感持续累积却无关真实质量。本协议把复审收敛到"只产出严重问题"。
## 0. 一句话原则
**只报可达、真实、严重的缺陷。功能正常、质量良好、无 bug 的代码不报。低于 HIGH 一律不报。**
## 1. 栅栏:只报 CRITICAL 与 HIGH
仅当缺陷**同时**满足以下三条才计入交付:
- **可达**:存在真实调用路径触发(必须给出路径:哪条调用链、什么输入、什么并发时序)。纸上推演"理论上如果有人这么调"不算,必须有真实调用方或合理下游用法。
- **是缺陷,非设计**:不是已文档化的设计取舍,不是功能缺口,不是已知 footgun。
- **严重**:达到 CRITICAL 或 HIGH。
级别定义:
- **CRITICAL**:正常使用下可触发的 数据损坏 / 安全突破 / 死锁 / panic / 资源泄漏。
- **HIGH**:真实(不必常见)路径下的 契约违反 / 并发竞态 / 生命周期泄漏 / 安全弱点,且无优雅降级。
低于 HIGH(MEDIUM/LOW)**默认不报**。若某条 MEDIUM 实际后果严重(如静默停摆),应在证伪后上调为 HIGH 再报,而不是以 MEDIUM 身份进清单。
## 2. 吹毛求疵禁区(明确不报)
以下一律**不作为 finding**。可在交付末尾"观察区(非 bug)"单列,但**不计入、不阻塞、不计数**:
1. **风格 / 格式 / 命名**:`fmt.Errorf` vs `errors.New`、变量命名、注释措辞、import 顺序。
2. **"无害冗余" / 微优化**:无热路径实测证据的重复调用、可选的零拷贝、可省的临时变量。
3. **功能缺口 / 未实现能力**:TLS 未加、交叉校验未加、某配置项未暴露--这些是 **roadmap**,不是 bug。归观察区。
4. **已文档化的设计决策 / footgun**:代码注释或 CLAUDE.md 已声明"刻意如此"的,不重挂。
5. **不可达路径的防御性补丁**:找不到真实 nil 调用方,就不报"缺 nil-check";找不到触发 panic 的输入,就不报"缺 recover"。防御性编码是好习惯,但不构成缺陷。
6. **与红线有张力但场景"可接受"**:逐条对照开发纪律判定,若该场景被纪律明文豁免(如 watcher 的 for-range 关闭语义),不报。
7. **维护性风险但当前无缺陷**:Clone 手动列举字段今天工作正确、某 switch 缺 default 今天不会命中--只有当"未来扩展会漏"已造成**当前**可证缺陷时才报,否则不报。
## 3. 证伪门(计入前强制)
每条 finding 在写入交付前,reviewer 必须主动执行证伪:
1. **写出触发路径**(具体到调用链 / 输入 / 并发时序)。
2. **尝试证伪**:路径上有无 guard?调用方是否真实存在?是否已文档化?是否已是已知设计?是否其实有优雅降级?
3. **裁定**:证伪成功 → 丢弃或降级;证伪失败 → 计入。
只有**证伪失败**的 finding 进入交付清单。证伪成功的进入"证伪台账"(见 §5),透明留痕但不制造压迫。
## 4. 范围规则(防重新生成噪声)
非收敛的头号成因是"对已加固模块反复全量重扫"--每个新 reviewer 都会重新生成一批 §2 禁区项。规则:
- **默认扫 diff**:自上次绿灯复审以来的变更,不扫全模块。
- **已加固模块不重扫**,除非本次改动了它。模块是否"已加固"以最近一份"达标"总评为准。
- **全模块扫描**只在"首次复审"或"大重构后"做,且必须在报告头标注"首次 / 重构后重审"。
- **换 reviewer 不算重扫理由**:同一模块换模型复审前,先确认自上次复审有无改动;无改动则拒绝重扫。
## 5. 输出格式
交付物只含三段,严格物理分隔:
### 5.1 真 bug 清单(仅 CRITICAL/HIGH,证伪失败)
每条:
- 标题
- `file:line`(源码唯一证据)
- 触发路径(可达性证明)
- 为何是缺陷(违反哪条契约 / 红线)
- 修复方向(一两句,不展开实现)
### 5.2 观察区(非 bug,不计入,不阻塞)
功能缺口 / roadmap 建议 / 可选增强。明确标注"非 bug"。用户可忽略。
### 5.3 证伪台账(透明留痕)
列出本轮考虑过但丢弃的条目及丢弃理由(落入 §2 哪一条 / 证伪成功)。让用户看到"审过了但拒绝计入",避免"是不是漏审了"的疑虑。
**明确不输出**:风格 nits、"无害冗余"、防御性补丁建议、纯维护性担忧。
## 6. 成功度量(避免把不可达目标绑在自己身上)
-**证伪后剩余的真实 CRITICAL/HIGH 数** -- 可归零,这是唯一硬指标。
-**是否出现新 bug 类别** -- 已收敛的类别(多视图状态 / 公开生命周期 / 用户扩展点 / 间接层吞错)再出现同类的实例,属"扫尾",不属"新问题"。
-**原始 finding 条数** -- 永不归零,不作度量。任何用"finding 条数下降"衡量进步的做法都是错的。
## 7. 给 reviewer 的硬约束(可直接喂入 prompt)
> 你在审查 xlgo 框架代码。遵守 `scan_protocol.md`:
> 1. 只报 CRITICAL/HIGH,且每条必须给出可达触发路径,否则不报。
> 2. 风格、冗余、功能缺口、已文档化设计、不可达防御、可接受场景、纯维护性风险--一律不报。
> 3. 每条 finding 先尝试证伪;证伪成功的丢弃。
> 4. 交付三段:真 bug 清单 / 观察区(非 bug)/ 证伪台账。
> 5. 不重扫已加固模块,只扫本次改动 diff(除非报告头标注"首次/重构后")。
> 6. 若你只能找到 MEDIUM/LOW,直接在证伪台账说明并结束,不要为凑数而上调。
+117 -26
View File
@@ -1,17 +1,34 @@
// Package sse 提供 Server-Sent Events 流式响应支持,典型用于 AI 对话/LLM 流式输出。
//
// 断连契约(C3 收尾,重要):
// 框架侧的消费循环(Stream/StreamText/StreamChunks/StreamWithID)已监听 c.Request.Context()
// 客户端断连即退出,消费端不泄漏 goroutine。但框架无法单方面停止上游生产者(LLM 流)——
// 生产者(向 ch 发送数据的一方)必须自行监听同一 ctx 并在取消时停止生产,否则:
// - 上游 LLM 流在客户端已断开后仍持续运行(浪费算力/费用);
// - 生产者向已无人消费的 ch 发送可能阻塞(无界 ch)或丢弃(有界 ch)。
//
// 推荐写法:生产者 goroutine 内 select { case <-ctx.Done(): return; case ...: ch <- token }
// 其中 ctx 取自请求的 c.Request.Context()Stream 系列内部用的就是它)。
package sse
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
var ErrInvalidEventName = errors.New("sse: event name must not contain CR or LF")
// SSEWriter SSE 写入器
type SSEWriter struct {
writer gin.ResponseWriter
flusher http.Flusher
ctx context.Context // 请求上下文,用于 Stream 系列监听断连(C3a)
}
// NewSSEWriter 创建 SSE 写入器
@@ -20,7 +37,8 @@ func NewSSEWriter(c *gin.Context) (*SSEWriter, error) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Transfer-Encoding", "chunked")
// 不手设 Transfer-Encoding: chunked——HTTP/1.1 下由 server 自动分帧,
// HTTP/2 下该头非法会致协议错误(C3c 修复)。
flusher, ok := c.Writer.(http.Flusher)
if !ok {
@@ -30,14 +48,25 @@ func NewSSEWriter(c *gin.Context) (*SSEWriter, error) {
return &SSEWriter{
writer: c.Writer,
flusher: flusher,
ctx: c.Request.Context(),
}, nil
}
// WriteEvent 写入 SSE 事件
// 格式: event: <event>\ndata: <data>\n\n
//
// 写错误向上传播(C3b 修复):旧实现丢弃 fmt.Fprintf 错误且恒 return nil
// 导致客户端断连后 Stream 守卫永不触发、消费循环不退出、上游 LLM 流持续运行。
func (w *SSEWriter) WriteEvent(event, data string) error {
fmt.Fprintf(w.writer, "event: %s\n", event)
fmt.Fprintf(w.writer, "data: %s\n\n", data)
if strings.ContainsAny(event, "\r\n") {
return ErrInvalidEventName
}
if _, err := fmt.Fprintf(w.writer, "event: %s\n", event); err != nil {
return err
}
if err := w.writeDataLines(data); err != nil {
return err
}
w.flusher.Flush()
return nil
}
@@ -45,11 +74,25 @@ func (w *SSEWriter) WriteEvent(event, data string) error {
// WriteMessage 写入消息(无事件类型)
// 格式: data: <data>\n\n
func (w *SSEWriter) WriteMessage(data string) error {
fmt.Fprintf(w.writer, "data: %s\n\n", data)
if err := w.writeDataLines(data); err != nil {
return err
}
w.flusher.Flush()
return nil
}
func (w *SSEWriter) writeDataLines(data string) error {
data = strings.ReplaceAll(data, "\r\n", "\n")
data = strings.ReplaceAll(data, "\r", "\n")
for _, line := range strings.Split(data, "\n") {
if _, err := fmt.Fprintf(w.writer, "data: %s\n", line); err != nil {
return err
}
}
_, err := fmt.Fprint(w.writer, "\n")
return err
}
// WriteJSON 写入 JSON 数据
func (w *SSEWriter) WriteJSON(event string, data any) error {
jsonData, err := json.Marshal(data)
@@ -69,19 +112,41 @@ func (w *SSEWriter) WriteDone() error {
return w.WriteEvent("done", "")
}
// KeepAlive 发送保持连接的心跳
// KeepAlive 发送保持连接的心跳
//
// 用 SSE 注释行(": ping\n\n")而非 data 行(N6 修复):data 行(含空 data)会触发
// 客户端 onmessage 回调,注释行仅维持连接不产生消息事件,更符合心跳语义。
func (w *SSEWriter) KeepAlive() error {
return w.WriteMessage("")
if _, err := fmt.Fprintf(w.writer, ": ping\n\n"); err != nil {
return err
}
w.flusher.Flush()
return nil
}
// Stream 流式发送数据
//
// 消费循环含 ctx.Done 分支(C3a 修复):客户端断连(c.Request.Context 取消)时立即退出,
// 不再仅靠 channel 关闭或写错误退出。配合 WriteJSON 的写错误传播(C3b),断连即停。
// ctx 为 nil 时回退到 context.Background()(防御外部未走 NewSSEWriter 构造的 nil panic)。
func (w *SSEWriter) Stream(event string, ch <-chan any) error {
for data := range ch {
if err := w.WriteJSON(event, data); err != nil {
return err
ctx := w.ctx
if ctx == nil {
ctx = context.Background()
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case data, ok := <-ch:
if !ok {
return w.WriteDone()
}
if err := w.WriteJSON(event, data); err != nil {
return err
}
}
}
return w.WriteDone()
}
// SSE 中间件,设置必要的响应头
@@ -95,19 +160,31 @@ func SSE() gin.HandlerFunc {
}
// StreamText 流式发送文本(适用于 AI 对话场景)
//
// 消费循环含 ctx.Done 分支(C3a 修复):客户端断连即退出。
// 生产者(ch 的发送方)也应监听 c.Request.Context(),以便消费端早退后停止上游 LLM 流;
// 若生产者忽略 ctx,本函数仍会因 ctx.Done 退出(消费端不泄漏),但上游生产者可能继续运行
// 直到其自身完成或 channel 阻塞——调用方有责任在 ctx 取消时停止生产。
func StreamText(c *gin.Context, ch <-chan string) error {
writer, err := NewSSEWriter(c)
if err != nil {
return err
}
for text := range ch {
if err := writer.WriteJSON("message", gin.H{"text": text}); err != nil {
return err
ctx := c.Request.Context()
for {
select {
case <-ctx.Done():
return ctx.Err()
case text, ok := <-ch:
if !ok {
return writer.WriteDone()
}
if err := writer.WriteJSON("message", gin.H{"text": text}); err != nil {
return err
}
}
}
return writer.WriteDone()
}
// StreamChunks 流式发送文本块(带增量标记)
@@ -117,13 +194,20 @@ func StreamChunks(c *gin.Context, ch <-chan string) error {
return err
}
for chunk := range ch {
if err := writer.WriteJSON("chunk", gin.H{"delta": chunk}); err != nil {
return err
ctx := c.Request.Context()
for {
select {
case <-ctx.Done():
return ctx.Err()
case chunk, ok := <-ch:
if !ok {
return writer.WriteJSON("done", gin.H{"finished": true})
}
if err := writer.WriteJSON("chunk", gin.H{"delta": chunk}); err != nil {
return err
}
}
}
return writer.WriteJSON("done", gin.H{"finished": true})
}
// StreamWithID 流式发送带消息 ID 的数据
@@ -138,13 +222,20 @@ func StreamWithID(c *gin.Context, messageID string, ch <-chan string) error {
return err
}
ctx := c.Request.Context()
// 发送内容块
for chunk := range ch {
if err := writer.WriteJSON("chunk", gin.H{"id": messageID, "delta": chunk}); err != nil {
return err
for {
select {
case <-ctx.Done():
return ctx.Err()
case chunk, ok := <-ch:
if !ok {
// 发送完成事件
return writer.WriteJSON("done", gin.H{"id": messageID, "finished": true})
}
if err := writer.WriteJSON("chunk", gin.H{"id": messageID, "delta": chunk}); err != nil {
return err
}
}
}
// 发送完成事件
return writer.WriteJSON("done", gin.H{"id": messageID, "finished": true})
}
+77
View File
@@ -0,0 +1,77 @@
package sse_test
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/sse"
"github.com/gin-gonic/gin"
)
// ===== C3a:断连即停(核心) =====
// 回归 C3a:断连即停由 internal testsse_stream_internal_test.goTestStreamStopsOnCtxCancelInternal
// 权威覆盖——直接构造 SSEWriter + 可控 ctx,验证 Stream 在 ctx.Done 时返回 ctx.Err。
// StreamText/StreamChunks/StreamWithID 用相同 select 模式(代码审查保证),外部网络断连测试
// 因 httptest loopback 下 c.Request.Context() 取消时序不可靠而省略,internal test 为权威。
// ===== C3a:正常完成路径仍工作 =====
// 回归:ch 正常关闭时 StreamText 写 done 并返回 nil。
func TestStreamTextNormalCompletion(t *testing.T) {
ch := make(chan string, 3)
ch <- "a"
ch <- "b"
close(ch)
r := gin.New()
r.GET("/sse", func(c *gin.Context) {
err := sse.StreamText(c, ch)
if err != nil {
t.Errorf("StreamText normal completion err: %v", err)
}
})
srv := httptest.NewServer(r)
defer srv.Close()
resp, err := http.Get(srv.URL + "/sse")
if err != nil {
t.Fatalf("connect: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), `"text":"a"`) || !strings.Contains(string(body), `"text":"b"`) {
t.Errorf("body missing chunks: %s", string(body))
}
if !strings.Contains(string(body), "event: done") {
t.Errorf("body missing done event: %s", string(body))
}
}
// ===== C3c:不手设 Transfer-Encoding: chunked =====
// 回归 C3c:响应头不应含 Transfer-Encoding: chunkedHTTP/2 下非法,HTTP/1.1 冗余)。
func TestNewSSEWriterNoChunkedHeader(t *testing.T) {
r := gin.New()
r.GET("/sse", func(c *gin.Context) {
_, _ = sse.NewSSEWriter(c)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil)
r.ServeHTTP(w, req)
if got := w.Header().Get("Transfer-Encoding"); got != "" {
t.Errorf("Transfer-Encoding = %q, want empty (C3c: should not hand-set chunked)", got)
}
}
// ===== C3b:上游生产者契约文档化 =====
// StreamText 在 ctx.Done 后返回 ctx.Err;生产者(往 ch 发送者)应监听同一 ctx,
// 在取消时停止上游 LLM 流。本框架无法单方面停止生产者,调用方契约见 StreamText 注释。
// 该契约由 TestStreamTextStopsOnContextCancel 间接验证(StreamText 确实因 ctx.Done 退出)。

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