29 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
101 changed files with 8692 additions and 1155 deletions
+7 -8
View File
@@ -1,17 +1,16 @@
# Claude Code 协作指引(本地,不入库)
CLAUDE.md
AGENTS.md
# 临时发版辅助文件
gitHub_release_*.md
# 构建产物
*.exe
bin/
claude_check_report_framework.md
claude_check_report_module.md
cross_review_assessment.md
deepseek_check_report_framework.md
deepseek_check_report_module.md
glm_check_report_framework.md
glm_check_report_modelue.md
last_report.md
md/
.gocache/
gpt_check_report_framework.md
gpt_check_report_review.md
gpt_check_report_module.md
+122 -5
View File
@@ -16,7 +16,124 @@ 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
@@ -38,7 +155,7 @@ _暂无未发布变更。_
- **`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)`atomic.Store并发安全)与 `database.GetDefaultManager()`(atomic 读取)。下游若直接调用 `DefaultManager.Init/Master` 等方法需改用 `InitDB`/`GetDB` 等 facade,或 `DefaultManager.Load().Init(...)`,或经 `GetDefaultManager()` 取实例;原 `database.DefaultManager = myDB` 直接赋值改为 `database.SetDefaultManager(myDB)`
- **`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 🐛
@@ -223,7 +340,7 @@ _暂无未发布变更。_
- **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)` 严格校验;`ParseCron` 保留原签名,非法回退默认全 `*`
- **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
@@ -667,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)
```
**为什么现在动手**
+48 -25
View File
@@ -334,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` 包:
@@ -527,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 {
// 缓存命中
}
@@ -539,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)
@@ -560,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 分布式锁(安全增强版)
@@ -582,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
})
@@ -772,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")
@@ -894,16 +906,20 @@ 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次
@@ -912,13 +928,13 @@ r.Use(middleware.CustomRateLimit(50, time.Minute)) // 每分钟50次
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-open
r.Use(middleware.UploadRedisRateLimit()) // 上传限流(fail-closed:资源敏感,Redis 故障时拒绝
// 自定义 Redis 限流
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open
r.Use(middleware.CustomRedisRateLimitFailClosed("sensitive", 50, time.Minute)) // fail-closed(安全场景)
// 自定义 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))
}))
@@ -928,15 +944,15 @@ defer middleware.StopRateLimiters()
```
> **fail-open vs fail-closedH4c**Redis 限流器在 Redis 故障时有两种策略——
> - **fail-open**`RedisRateLimit`/`APIRedisRateLimit`/`UploadRedisRateLimit`/`CustomRedisRateLimit`默认):Redis 故障时放行,避免影响业务,但限流静默失效。
> - **fail-closed**`RedisRateLimitFailClosed`/`LoginRedisRateLimit`/`CustomRedisRateLimitFailClosed`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、敏感操作)必须用 fail-closed**。
> `RedisRateLimiter` 可经 `NewRedisRateLimiterFailClosed` 构造或 `SetFailClosed(true)` 切换策略。
> - **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 和中间件分组。
@@ -1107,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 字节)
@@ -1221,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 {
// 文件存在
}
```
@@ -1539,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 启动与停止
@@ -1660,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))
}
```
+32 -10
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 脚手架的几点
@@ -276,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()` 也会识别新驱动:
@@ -349,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")
@@ -369,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)
@@ -394,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
})
@@ -474,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 流式响应
@@ -529,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()
@@ -604,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))
+433 -90
View File
@@ -2,8 +2,10 @@ package xlgo
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
@@ -28,7 +30,7 @@ import (
// Version 框架版本号。发版时只改这一处,避免版本字面量散落各处。
// CLIxlgo version)、脚手架生成的 go.mod 等均引用此常量。
const Version = "1.2.0"
const Version = "1.3.0"
// HealthCheckFunc 健康检查函数
type HealthCheckFunc func(context.Context) error
@@ -40,7 +42,7 @@ type Migrator func(*gorm.DB) error
// - OnInit: Init() 内组件初始化完成后、路由注册前
// - OnStart: StartServer() 监听端口前
// - OnReady: 端口就绪后(已开始接受连接)
// - OnStop: Shutdown() 开头,关 HTTP 之前
// - OnStop: Shutdown() 开头,关 HTTP 之前,受 server.shutdown_timeout 约束
//
// OnInit/OnStart/OnReady/OnStop 返回 error 会中断流程并向上返回。
// A2 修复:OnReady 改为返回 error,失败时触发 shutdown。
@@ -57,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
@@ -66,27 +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
enableCron 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
// 初始化同步守卫(A1 修复:sync.Once 替代裸 bool,防止并发 Init 竞态)
initOnce sync.Once
initErr error
// 生命周期状态机(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
@@ -100,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
@@ -109,11 +158,14 @@ func WithConfigPath(path string) Option {
}
// WithConfig 设置配置对象。
// A3 修复:不再调用 config.Set(cfg),配置仅保留在 App 实例内,不污染全局状态。
// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath
// 配置会在传入时深拷贝成 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
a.config = cfg.Clone()
}
}
@@ -208,8 +260,6 @@ func WithoutWire() Option {
return func(a *App) {}
}
// WithAutoMigrate 启用数据库迁移(需配合 WithMigrator/WithModels 注册迁移逻辑)
func WithAutoMigrate() Option {
return func(a *App) { a.enableAutoMigrate = true }
@@ -365,31 +415,239 @@ func RunFullStack(opts ...Option) error {
return NewFullStack(opts...).Run()
}
// Init 初始化应用,不启动 HTTP 监听(A1 修复:sync.Once 保证单次执行并发安全)。
// 多次调用返回首次执行的结果
// 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 {
a.initOnce.Do(func() {
a.initErr = a.doInit()
if a.initErr != nil {
// H-2 修复:Init 失败时 cancel rootCtx 并等待已通过 App.Go 启动的后台 goroutine 退出。
// App.Go 不要求 Init 先成功(注释 app.go:337),若不清理,调用方在 Init 失败后
// 不调 Run/Shutdown,则 rootCtx 永不 cancel、wg 永不归零,后台 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")
}
}
})
return a.initErr
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
}
// doInit 执行实际初始化流程。仅在 sync.Once 中调用,不可直接调用
// 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 {
@@ -400,33 +658,43 @@ func (a *App) doInit() 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 {
@@ -510,12 +778,13 @@ func (a *App) doInit() error {
// 启动主库/从库探活后台循环(#21),ctx 在 Shutdown 时取消
if a.enableMySQL {
a.Go(database.StartDBProbing)
a.Go(a.dbManager.StartProbing)
}
// 启动 cron 全局调度器(P1 #10),Shutdown 时统一停止。任务须在此前经 cron.AddTask 注册。
if a.enableCron {
cron.Start()
a.initializedCron = true
}
// OnInit hooks:组件初始化完成后触发(#12)
@@ -526,12 +795,18 @@ func (a *App) doInit() 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)
@@ -560,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 服务器(支持优雅关闭)
@@ -591,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 != "" {
@@ -605,31 +904,36 @@ 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端口就绪后(A2 修复:失败时触发 shutdown
// OnReady hookslistener 已成功创建,Serve goroutine 已启动(M1)。
for _, h := range a.hooks {
if h.OnReady != nil {
if err := h.OnReady(a); err != nil {
logger.Errorf("OnReady hook %q 失败: %v", h.Name, err)
// H-1 修复:失败时走 Shutdown 释放 HTTP 端口、后台 goroutine 与已初始化资源,
// 避免监听 goroutine 永久阻塞在 ListenAndServe 致端口/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)
}
}
@@ -642,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:
@@ -651,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()
@@ -662,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
}
}
}
}
@@ -700,7 +1041,7 @@ func (a *App) Shutdown() error {
}
// 停止 cron 全局调度器(P1 #10),在剩余 shutdown 预算内等待在跑任务退出。
if a.enableCron {
if a.initializedCron {
logger.Info("停止 cron 调度器...")
remaining := time.Second
if dl, ok := ctx.Deadline(); ok {
@@ -708,7 +1049,7 @@ func (a *App) Shutdown() error {
remaining = r
}
}
if !cron.StopGlobalWithTimeout(remaining) {
if err := a.stopCron(remaining); err != nil {
logger.Warnf("cron 调度器停止超时,可能有任务未响应 ctx 取消")
}
}
@@ -716,18 +1057,13 @@ func (a *App) Shutdown() error {
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...)
@@ -735,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)
}
}
+47 -23
View File
@@ -7,26 +7,25 @@ import (
"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 缓存实现。
@@ -46,34 +45,34 @@ func NewRedisCache() CacheService {
return &redisCache{}
}
// Get 获取缓存值
func (c *redisCache) Get(ctx context.Context, key string, dest any) bool {
// 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
return false, ErrRedisNotReady
}
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 {
cli := c.client()
if cli == nil {
return nil // Redis 未启用,跳过缓存
return ErrRedisNotReady
}
data, err := json.Marshal(value)
@@ -94,7 +93,7 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du
func (c *redisCache) Delete(ctx context.Context, key string) error {
cli := c.client()
if cli == nil {
return nil
return ErrRedisNotReady
}
if err := cli.Del(ctx, key).Err(); err != nil {
@@ -109,7 +108,7 @@ func (c *redisCache) Delete(ctx context.Context, key string) error {
func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error {
cli := c.client()
if cli == nil {
return nil
return ErrRedisNotReady
}
var cursor uint64
@@ -148,14 +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 {
// 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
return false, ErrRedisNotReady
}
return cli.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 模式:
@@ -222,3 +226,23 @@ func Init() {
func GetCache() CacheService {
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)
}
}
+18 -5
View File
@@ -25,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
}
}
@@ -33,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
}
}
@@ -53,6 +62,9 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
_cacheType: "cache",
}
for _, opt := range opts {
if opt == nil {
continue
}
opt(kb)
}
return kb
@@ -60,7 +72,7 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
// 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()
@@ -141,7 +153,7 @@ func (kb *KeyBuilder) BuildSession(key string) string {
}
// BuildPattern 构建匹配模式(用于 SCAN/Keys)(CK2 修复:mu 读保护)。
// 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*"
// 示例: kb.BuildPattern("user:*") -> "cache:site_a:user:*"
func (kb *KeyBuilder) BuildPattern(pattern string) string {
return kb.Build(pattern)
}
@@ -258,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)
}
+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
}
}
+52 -19
View File
@@ -15,8 +15,16 @@ var (
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("锁业务函数不能为空")
)
// toInt64 将 Lua 脚本返回值安全断言为 int64(C1b:禁止裸断言 panic)。
@@ -29,6 +37,13 @@ func toInt64(v any) (int64, error) {
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)。
@@ -88,7 +103,10 @@ func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, er
}
token := utils.UUID()
ttlMs := int64(ttl / time.Millisecond)
ttlMs, err := ttlMillis(ttl)
if err != nil {
return nil, err
}
result, err := rdb.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
if err != nil {
@@ -148,7 +166,7 @@ func Unlock(ctx context.Context, token *LockToken) error {
func UnlockByKey(ctx context.Context, key string) error {
rdb := database.GetRedis()
if rdb == nil {
return nil
return ErrRedisNotReady
}
return rdb.Del(ctx, key).Err()
}
@@ -165,7 +183,10 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
return ErrLockNotHeld
}
ttlMs := int64(ttl / time.Millisecond)
ttlMs, err := ttlMillis(ttl)
if err != nil {
return err
}
result, err := rdb.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
if err != nil {
@@ -185,6 +206,9 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
// 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 {
@@ -200,7 +224,7 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t
case <-time.After(retryInterval):
}
}
return nil, nil
return nil, ErrLockNotAcquired
}
// WithLock 使用分布式锁执行函数(自动管理锁)。
@@ -209,13 +233,16 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t
//
// 解锁用独立 Background ctxC1a 一致性修复):fn 返回或 panic 后,原 ctx 可能已被
// 调用方取消,用其解锁会失败导致锁泄漏到 TTL。fn panic 时 defer 也保证解锁执行。
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() error) error {
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 func() {
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -223,7 +250,7 @@ func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() erro
_ = Unlock(unlockCtx, token)
}()
return fn()
return fn(ctx)
}
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)。
@@ -234,13 +261,19 @@ func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() erro
// 避免旧实现 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() error) error {
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
}
// 父关停信号(仅父 close)与子 ack 信号(仅子 close)。
@@ -277,8 +310,8 @@ func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duratio
_ = Unlock(unlockCtx, token)
}()
// 执行业务函数。
err = fn()
// 执行业务函数。fn 必须接收 ctx,避免请求取消后业务 loader/DB/HTTP 调用继续运行。
err = fn(ctx)
return err
}
@@ -335,7 +368,7 @@ func ForceUnlock(ctx context.Context, key string) error {
func Incr(ctx context.Context, key string) (int64, error) {
rdb := database.GetRedis()
if rdb == nil {
return 0, nil
return 0, ErrRedisNotReady
}
return rdb.Incr(ctx, key).Result()
}
@@ -344,7 +377,7 @@ func Incr(ctx context.Context, key string) (int64, error) {
func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
rdb := database.GetRedis()
if rdb == nil {
return 0, nil
return 0, ErrRedisNotReady
}
return rdb.IncrBy(ctx, key, value).Result()
}
@@ -353,7 +386,7 @@ func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
func Decr(ctx context.Context, key string) (int64, error) {
rdb := database.GetRedis()
if rdb == nil {
return 0, nil
return 0, ErrRedisNotReady
}
return rdb.Decr(ctx, key).Result()
}
@@ -362,7 +395,7 @@ func Decr(ctx context.Context, key string) (int64, error) {
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
rdb := database.GetRedis()
if rdb == nil {
return 0, nil
return 0, ErrRedisNotReady
}
return rdb.TTL(ctx, key).Result()
}
@@ -371,7 +404,7 @@ func GetTTL(ctx context.Context, key string) (time.Duration, error) {
func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
rdb := database.GetRedis()
if rdb == nil {
return false, nil
return false, ErrRedisNotReady
}
return rdb.Expire(ctx, key, ttl).Result()
}
@@ -380,7 +413,7 @@ func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error)
func GetRaw(ctx context.Context, key string) (string, error) {
rdb := database.GetRedis()
if rdb == nil {
return "", nil
return "", ErrRedisNotReady
}
return rdb.Get(ctx, key).Result()
}
@@ -389,7 +422,7 @@ func GetRaw(ctx context.Context, key string) (string, error) {
func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error {
rdb := database.GetRedis()
if rdb == nil {
return nil
return ErrRedisNotReady
}
return rdb.Set(ctx, key, value, ttl).Err()
}
}
+122 -10
View File
@@ -45,11 +45,11 @@ func TestWithLockAutoExtendCtxCancelNoPanic(t *testing.T) {
go func() {
defer wg.Done()
defer func() { panicked = recover() }()
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func() error {
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(runCtx context.Context) error {
close(ran)
// 阻塞直到 ctx 被取消,模拟长任务。
<-ctx.Done()
return ctx.Err()
<-runCtx.Done()
return runCtx.Err()
})
// WithLockAutoExtend 返回 fn 的错误(ctx.Err),不应 panic。
if err != nil && !errors.Is(err, context.Canceled) {
@@ -83,7 +83,7 @@ func TestWithLockAutoExtendNormalRelease(t *testing.T) {
key := "c1a:normal"
called := false
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func() error {
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
called = true
return nil
})
@@ -107,7 +107,7 @@ func TestWithLockAutoExtendExtendsLock(t *testing.T) {
// initialTTL=500msextendInterval=100ms。fn 执行 800ms,期间应多次续期。
// 若续期失效,锁会在 500ms 过期,另一 worker 可获取。
err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func() error {
err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func(context.Context) error {
time.Sleep(800 * time.Millisecond)
return nil
})
@@ -137,7 +137,7 @@ func TestWithLockAutoExtendBlocksContender(t *testing.T) {
go func() {
defer close(lockDone)
cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func() error {
cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func(context.Context) error {
close(fnStarted)
time.Sleep(600 * time.Millisecond)
close(fnDone)
@@ -173,7 +173,7 @@ func TestWithLockAutoExtendFnPanicReleasesLock(t *testing.T) {
var panicked any
func() {
defer func() { panicked = recover() }()
_ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func() error {
_ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
time.Sleep(150 * time.Millisecond) // 让续期 ticker 至少触发一次
panic("boom")
})
@@ -201,10 +201,10 @@ func TestWithLockCtxCancelReleasesLock(t *testing.T) {
done := make(chan struct{})
go func() {
defer close(done)
_ = cache.WithLock(ctx, key, 10*time.Second, func() error {
_ = cache.WithLock(ctx, key, 10*time.Second, func(runCtx context.Context) error {
close(fnStarted)
<-ctx.Done()
return ctx.Err()
<-runCtx.Done()
return runCtx.Err()
})
}()
@@ -218,6 +218,39 @@ func TestWithLockCtxCancelReleasesLock(t *testing.T) {
}
}
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)。
@@ -299,3 +332,82 @@ func TestTryLockRespectsCtxCancel(t *testing.T) {
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)
}
}
+18 -16
View File
@@ -51,33 +51,35 @@ func TestLockErrors(t *testing.T) {
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")
@@ -88,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")
@@ -101,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")
@@ -146,4 +148,4 @@ func TestKFunctions(t *testing.T) {
if sessionKey == "" {
t.Error("KSession should not return empty string")
}
}
}
+32 -29
View File
@@ -18,6 +18,7 @@ func createProject(name string) error {
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) {
return fmt.Errorf("目录 %s 已存在", name)
}
@@ -74,7 +75,9 @@ func createProject(name string) error {
}
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 {
// #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)
}
@@ -115,6 +118,7 @@ func createProject(name string) error {
for path, content := range files {
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
}
@@ -135,6 +139,7 @@ func renderTemplateFile(path, content string, data TemplateData) (retErr error)
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)
@@ -168,19 +173,19 @@ func validateProjectName(name string) error {
return nil
}
// isValidGoIdentifier 判断 s 是否为合法 Go 标识符(ASCII 范围)
// 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') || r == '_'
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 {
} else if !isLetter && !isNum && r != '_' {
return false
}
}
@@ -202,17 +207,11 @@ func validateModulePath(module string) error {
}
func makeFile(fileType, name string) error {
// 文件名小写,但保留原分隔用于多词;标识符须为合法 Go 标识符(仅字母数字下划线)。
// 将连字符/空格等转为下划线后再 Title,避免 "my-thing" → "My-Thing" 生成非法标识符(M20)。
name = strings.ToLower(name)
// P1 #21:拒绝含路径分隔符的名称,避免生成到目标目录之外。
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name)
if err := validateMakeName(name); err != nil {
return err
}
identBase := sanitizeIdent(name)
caser := cases.Title(language.English)
nameTitle := caser.String(strings.ReplaceAll(identBase, "_", " "))
nameTitle = strings.ReplaceAll(nameTitle, " ", "") // 拼回 CamelCase
name = strings.ToLower(name)
nameTitle := makeNameTitle(name)
switch fileType {
case "handler":
@@ -228,24 +227,28 @@ func makeFile(fileType, name string) error {
}
}
// sanitizeIdent 把 name 中的非字母数字字符替换为下划线,生成合法 Go 标识符基串(M20)
// 如 "my-thing" → "my_thing",后续 Title 后得到 "MyThing"。
func sanitizeIdent(name string) string {
var b strings.Builder
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else if r >= 'A' && r <= 'Z' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
// validateMakeName 校验 xlgo make 的资源名,避免路径穿越或生成不可编译的 Go 代码
func validateMakeName(name string) error {
if name == "" {
return fmt.Errorf("名称不能为空")
}
s := strings.Trim(b.String(), "_")
if s == "" {
return "xlgo"
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name)
}
return s
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 {
+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)
}
}
+3
View File
@@ -9,10 +9,12 @@ import (
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 {
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 {
return fmt.Errorf("写入文件失败: %w", err)
}
@@ -20,6 +22,7 @@ func writeFile(path, content string) error {
}
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
+8 -4
View File
@@ -176,7 +176,9 @@ func GzipDecompressFileWithOptions(src, dst string, opts DecompressOptions) (err
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 dst,避免被拒炸弹遗留残片;
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
defer func() {
dstFile.Close()
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
_ = os.Remove(dst)
}
@@ -252,7 +254,7 @@ func Zip(zipPath string, paths []string) error {
return nil
}
// #nosec G304 -- path 为 Walk 遍历调用方源路径产生,非不可信输入
// #nosec G304,G122 -- path 为 Walk 遍历调用方源路径产生,非不可信输入;压缩场景接受 symlink 跟随语义。
file, err := os.Open(path)
if err != nil {
return err
@@ -298,7 +300,7 @@ func UnzipWithOptions(zipPath, dstDir string, opts DecompressOptions) error {
// 用绝对路径作目标锚定根,避免相对路径 + `..` 组合绕过前缀校验(C5a)。
absDst, err := filepath.Abs(dstDir)
if err != nil {
absDst = dstDir
return fmt.Errorf("resolve unzip destination failed: %w", err)
}
absDst = filepath.Clean(absDst)
@@ -365,7 +367,9 @@ func unzipFile(file *zip.File, absDst string, entryLimit, totalLimit, accrued in
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 target,避免被拒炸弹遗留残片;
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
defer func() {
dstFile.Close()
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
_ = os.Remove(target)
}
+308 -90
View File
@@ -1,9 +1,13 @@
package config
import (
"context"
"errors"
"fmt"
"log"
"net/url"
"path/filepath"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
@@ -16,7 +20,9 @@ import (
// 配置错误
var (
ErrConfigNotLoaded = fmt.Errorf("配置未加载")
// L-config-4:无格式化的哨兵错误用 errors.Newfmt.Errorf 无动词等价但语义上后者暗示格式化)。
ErrConfigNotLoaded = errors.New("配置未加载")
ErrInvalidConfig = errors.New("配置非法")
)
// Config 全局配置结构体
@@ -39,8 +45,7 @@ type Config struct {
// 类型白名单、Storage.Local/OSS 上传策略的扩展名/MIME 白名单)深拷贝底层数组,使返回值
// 可被调用方安全修改(含 append/sort/改元素)而不污染框架内部配置、不与其他读者竞态。
//
// 用于需要可变配置副本的场景。热路径的 Get() 为零分配仍返回内部只读指针——需要改配置
// 用 Clone() 或 Load()Load 内部已返回 Clone)。
// 用于需要可变配置副本的场景。Load/Get/回调均返回 Clone,避免调用方误改全局配置
func (c *Config) Clone() *Config {
if c == nil {
return nil
@@ -72,6 +77,34 @@ func cloneStrings(s []string) []string {
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 应用配置
// 使用场景:
// - 缓存键名前缀: cache:{site_name}:user:1
@@ -133,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"
@@ -142,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
}
@@ -201,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
@@ -282,44 +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 ""
}
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 {
if c == nil { // L-config-6
return ""
}
loc := c.Timezone
if loc == "" {
loc = "Local"
}
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s",
c.User, url.QueryEscape(c.Password), c.Host, c.Port, c.Name, url.QueryEscape(loc))
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 连接字符串。
// 密码经单引号转义(内嵌单引号翻倍),避免含空格/引号/反斜杠破坏 key=value DSNM9
// 字符串字段统一用单引号包裹并转义,避免含空格/引号/反斜杠破坏 key=value DSN。
// TimeZone 由 Timezone 配置,空则默认 "Asia/Shanghai"(向后兼容)。
// sslmode 由 SSLMode 配置,空则默认 "prefer"M-config-2:原硬编码 disable 改为 prefer,优先加密)。
func (c *DatabaseConfig) PostgresDSN() string {
if c == nil { // L-config-6
return ""
}
tz := c.Timezone
if tz == "" {
tz = "Asia/Shanghai"
}
return fmt.Sprintf("host=%s port=%d user=%s password='%s' dbname=%s sslmode=disable TimeZone=%s",
c.Host, c.Port, c.User, strings.ReplaceAll(c.Password, "'", "''"), c.Name, tz)
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 配置
@@ -332,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)
}
@@ -476,6 +576,10 @@ type Manager struct {
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
}
// defaultManager 是包级默认管理器(C10a)。改用 atomic.Pointer 保护读写,
@@ -499,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 {
@@ -594,31 +741,34 @@ func (m *Manager) StartWatcher() error {
}
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()
go m.watchLoop(w, target, done)
go m.watchLoop(ctx, w, target, done)
return nil
}
// watchLoop 是文件监听 goroutine 主体。文件变更经去抖后调用 reload;
// watcher 被 CloseEvents 通道关闭)时退出并 close done。
// done 由 StartWatcher 在锁内捕获传入,避免本 goroutine 读取 m.watchDone 字段
// 与 StopWatcher 写入竞争。
func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct{}) {
// 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
var timer *time.Timer
// P1 #16:退出时停掉未触发的去抖 timer,避免 StopWatcher 之后 AfterFunc 仍
// reload() 一个调用方认为已停止的 manager。
defer func() {
if timer != nil {
timer.Stop()
}
}()
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
@@ -631,25 +781,30 @@ func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct
continue
}
// 去抖:合并编辑器/工具的连续写事件,仅最后一次触发重载。
if timer != nil {
timer.Stop()
if !timer.Stop() && timerC != nil {
select {
case <-timer.C:
default:
}
}
timer = time.AfterFunc(debounce, func() {
// reload 内部对非法配置保留旧配置(C10b),错误被忽略——
// 监听路径无法向上传播错误,保留旧配置即正确语义。
_ = m.reload()
})
timer.Reset(debounce)
timerC = timer.C
case _, ok := <-w.Errors:
if !ok {
return
}
// 非致命错误:继续监听。
case <-timerC:
timerC = nil
// reload 内部对非法配置保留旧配置(C10b),错误被忽略——
// 监听路径无法向上传播错误,保留旧配置即正确语义。
_ = m.reload()
}
}
}
// StopWatcher 停止配置文件监听并释放 watcher(C10d)。幂等。
// 关闭 fsnotify watcher → Events 通道关闭 → watchLoop 退出 → 等待 watchDone
// cancel ctx 与关闭 fsnotify watcher 双重退出(L-config-2),等待 watchDone 确认 goroutine 退出
func (m *Manager) StopWatcher() {
if m == nil {
return
@@ -657,57 +812,120 @@ func (m *Manager) StopWatcher() {
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()
_ = w.Close() // 再 Close watcher 让 w.Events 关闭,双重保障
if done != nil {
<-done
}
}
// Get 获取配置。
//
// 返回的是 Manager 内部持有的配置指针(共享),调用方**必须视为只读**:
// 修改返回值的标量或切片元素会污染全局配置并与其他读取 goroutine 竞态。需要可变副本时
// 用 Clone()(返回深拷贝)或 Load()(重载并返回深拷贝)。
//
// M-G:热路径(jwt 鉴权等每请求调用)保持返回内部指针以零分配,可变副本走 Clone()。
// 框架自身的 reload 创建新 Config 并替换 m.cfg 指针,不改写旧 Config 对象,故 Get()
// 返回的旧指针在 reload 后仍指向一致的(旧)快照,reload 不引入对旧快照的竞态。
// 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 重新加载配置文件。读取、解析、校验(C10b)任一步失败均保留旧配置并返回错误;
@@ -749,8 +967,17 @@ func (m *Manager) reload() error {
// 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.Clone())
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
}
@@ -760,39 +987,42 @@ func (m *Manager) reload() error {
// 都 Store,遗留一个已 StartWatcher 的 manager 无引用可停(goroutine 泄漏)。
var pkgLoadMu sync.Mutex
// Load 加载配置文件(C3.5 修复:替换前停止旧 Manager 的 watcher,防止 goroutine 泄漏)
// P1 #8:全程持 pkgLoadMu 串行化,消除与并发 Load/LoadWithWatch 的 TOCTOU。
// Load 加载配置文件。
// P1 #8:全程持 pkgLoadMu 串行化。新配置加载成功后才替换默认 Manager 并停止旧 watcher
// 加载失败会保留旧 Manager 与旧 watcher,避免一次错误配置导致热更新链路断掉。
func Load(configPath string) (*Config, error) {
pkgLoadMu.Lock()
defer pkgLoadMu.Unlock()
if old := defaultManager.Load(); old != nil {
old.StopWatcher()
}
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 加载配置文件并启用热更新(C3.5 修复:替换前停止旧 watcher)
// LoadWithWatch 加载配置文件并启用热更新。
// P1 #8:全程持 pkgLoadMu 串行化,且新 manager 在其 watcher 成功启动后才置换为默认;
// 启动失败则停掉半启动的 watcher 并不置换,避免遗留孤儿 watcher。
// 启动失败则保留旧 Manager 与旧 watcher,并停掉新 manager 可能半启动的 watcher。
func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) {
pkgLoadMu.Lock()
defer pkgLoadMu.Unlock()
if old := defaultManager.Load(); old != nil {
old.StopWatcher()
}
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
}
@@ -821,9 +1051,9 @@ func GetViper() *viper.Viper {
return defaultManager.Load().GetViper()
}
// Set 手动设置配置(用于测试或动态修改)
func Set(cfg *Config) {
defaultManager.Load().Set(cfg)
// Set 手动设置配置(用于测试或动态修改)。非 nil 配置会先校验并复制。
func Set(cfg *Config) error {
return defaultManager.Load().Set(cfg)
}
// Reload 重新加载配置文件
@@ -837,48 +1067,36 @@ func Reload() error {
// 传入 nil 表示重置为空管理器。
//
// C10a:经 atomic.Pointer.Store 原子置换,消除与并发读取(Get 等)的数据竞争。
// 置换后会停止旧 Manager 的 watcher,避免全局默认 manager 切换后遗留热更新 goroutine。
func SetDefaultManager(m *Manager) {
old := defaultManager.Load()
if m == nil {
defaultManager.Store(NewManager(""))
return
m = NewManager("")
}
defaultManager.Store(m)
if old != nil && old != m {
old.StopWatcher()
}
}
// 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 是否开发环境
+168
View File
@@ -329,3 +329,171 @@ func TestStartWatcherIdempotent(t *testing.T) {
}
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"},
},
},
},
}
}
+25 -5
View File
@@ -102,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)
}
@@ -113,6 +113,26 @@ 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
@@ -140,13 +160,13 @@ func TestDatabaseConfigDSNPasswordEscape_M9(t *testing.T) {
Name: "testdb",
}
pdsn := pg.PostgresDSN()
if !strings.Contains(pdsn, "password='p''ord'") {
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") {
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"}
@@ -350,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",
},
}
@@ -418,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
}
+5
View File
@@ -21,10 +21,15 @@ var writeMu sync.Mutex
type Level int32
const (
// LevelDebug 调试级别(最低)。
LevelDebug Level = iota
// LevelInfo 普通信息。
LevelInfo
// LevelSuccess 成功信息。
LevelSuccess
// LevelWarn 警告。
LevelWarn
// LevelError 错误(最高常规级别)。
LevelError
// LevelSilent 完全静默:所有调用都不输出
+129 -17
View File
@@ -3,6 +3,7 @@ package cron
import (
"context"
"fmt"
"runtime/debug"
"strconv"
"strings"
"sync"
@@ -39,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,
@@ -61,6 +100,14 @@ 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,
@@ -139,6 +186,27 @@ func (s *Scheduler) ListTasks() []*Task {
return tasks
}
// 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()
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)。
@@ -166,11 +234,12 @@ func (s *Scheduler) RunTask(name string) error {
}
}()
err := task.Handler(s.ctx)
err := s.executeTask(task)
s.mu.Lock()
task.LastRun = time.Now()
task.RunCount++
task.LastError = err // M13: 手动路径也记 LastError(与调度路径对齐)
s.mu.Unlock()
return err
@@ -178,16 +247,25 @@ func (s *Scheduler) RunTask(name string) 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 停止调度器并无限等待在跑任务退出(要求 handler 尊重 ctx.Done)。
@@ -200,15 +278,19 @@ func (s *Scheduler) Stop() {
// 返回 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 true
}
s.running = false
cancel := s.cancel
s.mu.Unlock()
s.cancel()
cancel()
if timeout <= 0 {
s.wg.Wait()
return true
@@ -224,7 +306,7 @@ func (s *Scheduler) StopWithTimeout(timeout time.Duration) bool {
}
// run 运行调度循环
func (s *Scheduler) run() {
func (s *Scheduler) run(ctx context.Context) {
defer s.wg.Done()
ticker := time.NewTicker(1 * time.Second)
@@ -232,10 +314,10 @@ func (s *Scheduler) run() {
for {
select {
case <-s.ctx.Done():
case <-ctx.Done():
return
case <-ticker.C:
s.checkAndRun()
s.checkAndRun(ctx)
}
}
}
@@ -256,10 +338,23 @@ func (s *Scheduler) run() {
// 一定能等到本批全部 goroutine。收集阶段已 CAS 占用守卫并推进 NextRun,故 spawn 必须
// 执行(否则守卫不释放、NextRun 已推进却未跑)。spawn 用 s.ctx——Stop 后 ctx 已取消,
// handler 收到 canceled ctx 应及时退出。
func (s *Scheduler) checkAndRun() {
func (s *Scheduler) checkAndRun(ctxs ...context.Context) {
now := time.Now()
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) {
@@ -285,7 +380,7 @@ func (s *Scheduler) checkAndRun() {
}
}()
err := t.Handler(s.ctx)
err := s.executeTaskWithContext(ctx, t)
s.mu.Lock()
t.LastRun = time.Now()
@@ -313,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}
}
@@ -333,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}
}
@@ -361,6 +458,8 @@ func (s *WeeklySchedule) Next(now time.Time) time.Time {
// Weekly 每周指定时间运行
func Weekly(day time.Weekday, hour, minute int) *WeeklySchedule {
validateWeekday(day)
validateClock(hour, minute, "Weekly")
return &WeeklySchedule{Day: day, Hour: hour, Minute: minute}
}
@@ -522,8 +621,21 @@ func parseCronRange(s string, min, max int) (int, int, error) {
// "0 0 1 * *" - 每月1号凌晨
// "0 0 * * 0" - 每周日凌晨
//
// 非法表达式回退默认全 "*"(每分钟执行)。需要严格校验请用 ParseCronStrict。
// 非法表达式会 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
}
+18 -9
View File
@@ -264,15 +264,15 @@ func TestC12eParseCronStrict(t *testing.T) {
{"0 12 * * *", true},
{"*/15 * * * *", true},
{"0 9-17 * * 1-5", true},
{"0 0 1 * 7", true}, // 周日 7 合法
{"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}, // 周越界
{"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 非法
}
@@ -287,11 +287,20 @@ func TestC12eParseCronStrict(t *testing.T) {
}
}
// TestC12eParseCronFallback 验证 ParseCron 非法回退默认全 *(保持原行为)。
// TestC12eParseCronFallback verifies ParseCron now fails fast on invalid input.
func TestC12eParseCronFallback(t *testing.T) {
s := cron.ParseCron("invalid")
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("ParseCron(invalid) should fall back to all-*, got %+v", s)
t.Errorf("ParseCronOrDefault(invalid) should fall back to all-*, got %+v", s)
}
// 合法表达式不回退。
s = cron.ParseCron("1-5,8 0 * * *")
+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)
}
}
}
+51 -11
View File
@@ -1,6 +1,7 @@
package database
import (
"errors"
"fmt"
"strings"
"sync"
@@ -10,6 +11,8 @@ import (
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// 内置驱动常量(更多驱动可通过 RegisterDialect 扩展)
@@ -29,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
}
@@ -92,24 +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() 的回退保持一致。
// 拼写错误的驱动名(如 "mysq"/"postgrs")会静默回退 MySQL,导致连接错误难排查(M10),
// 故在此告警一次,提示用户驱动名未注册。
logger.Warnf("database: 驱动 %q 未注册,回退到 MySQL(已注册: %s);若是拼写错误请在配置中修正 driver",
normalizeDriver(driver), strings.Join(RegisteredDialects(), ", "))
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))
@@ -124,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() {
+161 -36
View File
@@ -2,10 +2,11 @@ package database
import (
"context"
cryptorand "crypto/rand"
"database/sql"
"errors"
"fmt"
"math/rand"
"math/big"
"strings"
"sync"
"sync/atomic"
@@ -30,6 +31,13 @@ const (
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
@@ -37,7 +45,8 @@ type ReplicaPicker interface {
// RoundRobinPicker 轮询选择从库
type RoundRobinPicker struct {
counter uint64
mu sync.Mutex
counter int
}
// Pick 轮询选择一个从库
@@ -45,14 +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 随机选择从库。
//
// D2 注释:rand.Intn 自 Go 1.20 起(go.dev/issue/54899)内部使用 per-goroutine
// 随机源,并发安全无需额外同步。本模块 go.mod 声明 go 1.25.0,满足此要求
// 使用 crypto/rand 生成随机索引,并发安全且不可预测。len(replicas)<=0 返回 nil
// crypto/rand 失败(极罕见,如熵池耗尽)时回退到 replicas[0],保证可用性
type RandomPicker struct{}
// Pick 随机选择一个从库
@@ -60,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 数据库管理器,持有主库与从库连接实例
@@ -70,6 +86,9 @@ type Manager struct {
replicas []*gorm.DB
picker ReplicaPicker
mu sync.Mutex
// opMu 串行化 InitDB / InitDBWithReplicas / Close 这类资源生命周期操作。
// 避免关闭已开始后初始化又发布新连接,或初始化发布后被并发 Close 置空。
opMu sync.Mutex
// #21 健康自愈
healthy atomic.Bool // 主库是否健康
@@ -190,6 +209,15 @@ 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。
@@ -203,6 +231,7 @@ func (m *Manager) resetReplicaHealth() {
// 周期 ping 主库,连续失败达阈值后标记不健康(IsHealthy=false);
// 同时 ping 各从库,失败则从读流量剔除,恢复后自动重新纳入。
func (m *Manager) StartProbing(ctx context.Context) {
ctx = normalizeContext(ctx)
m.initReplicaHealth()
cfg := m.getCfg() // P1 #11:锁内快照,避免与 InitDB 写 m.cfg 竞态
@@ -255,6 +284,9 @@ 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()
@@ -272,7 +304,8 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) {
}
continue
}
if err := sqlDB.PingContext(ctx); err != nil {
// 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)
}
@@ -289,6 +322,7 @@ 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()
@@ -309,7 +343,7 @@ func (m *Manager) Open(ctx context.Context) error {
if cfg == nil {
return errors.New("数据库配置未设置")
}
return m.InitDB(cfg)
return m.InitDB(ctx, cfg)
}
// OpenWithReplicas 打开主库与从库连接
@@ -318,7 +352,7 @@ func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) er
if cfg == nil {
return errors.New("数据库配置未设置")
}
return m.InitDBWithReplicas(cfg, replicaDSNs)
return m.InitDBWithReplicas(ctx, cfg, replicaDSNs)
}
// closeDB 关闭 gorm.DB 底层连接池。nil 或未初始化(无 ConnPool)时返回 nil,不 panic。
@@ -334,9 +368,17 @@ func closeDB(db *gorm.DB) error {
return sqlDB.Close()
}
func warnCloseDB(db *gorm.DB, context string) {
if err := closeDB(db); err != nil {
logger.Warnf("%s: %v", context, err)
}
}
// Close 关闭主库与全部从库连接,并重置从库健康状态。
// 字段置空在锁内完成(保证新读取得到 nil),实际关闭在锁外执行避免持锁阻塞。
func (m *Manager) Close() error {
m.opMu.Lock()
defer m.opMu.Unlock()
m.mu.Lock()
master := m.master
replicas := m.replicas
@@ -358,8 +400,13 @@ func (m *Manager) Close() error {
return errors.Join(errs...)
}
// HealthCheck 健康检查,主库不可达时返回错误
// 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()
@@ -370,7 +417,7 @@ func (m *Manager) HealthCheck(ctx context.Context) error {
if err != nil {
return err
}
return sqlDB.PingContext(ctx)
return pingWithTimeout(sqlDB, ctx)
}
// DefaultManager 默认数据库管理器,包级 facade 代理到它。
@@ -389,11 +436,28 @@ func init() {
DefaultManager.Store(NewManager(nil))
}
// SetDefaultManager 提升指定 Manager 为全局默认(主线A 修复:atomic.Store,并发安全)
// 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 {
DefaultManager.Store(m)
if m == nil {
return
}
old := SwapDefaultManager(m)
if old != nil && old != m {
if err := old.Close(); err != nil {
logger.Warnf("关闭被替换的旧数据库 manager 失败: %v", err)
}
}
}
@@ -404,8 +468,22 @@ func GetDefaultManager() *Manager {
return DefaultManager.Load()
}
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
func (m *Manager) InitDB(cfg *config.Config) error {
// 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 日志配置
@@ -418,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
}
// 重试配置
@@ -426,6 +515,9 @@ func (m *Manager) InitDB(cfg *config.Config) error {
var lastErr error
for i := range maxRetries {
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)
@@ -439,7 +531,7 @@ func (m *Manager) InitDB(cfg *config.Config) error {
sqlDB, err := db.DB()
if err != nil {
lastErr = err
_ = closeDB(db) // C11b: 关闭刚打开的池,避免下轮泄漏
warnCloseDB(db, "关闭刚打开的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮泄漏
} else {
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
@@ -448,14 +540,15 @@ func (m *Manager) InitDB(cfg *config.Config) error {
sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime)
}
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)
_ = closeDB(old)
warnCloseDB(old, "关闭旧数据库主库连接池失败")
logger.Info("数据库主库连接成功",
zap.String("driver", driverDescription(cfg.Database.Driver)),
zap.String("host", cfg.Database.Host),
@@ -464,13 +557,20 @@ func (m *Manager) InitDB(cfg *config.Config) error {
} else {
// Ping 失败(如服务端暂时不可达)视作可重试
lastErr = err
_ = closeDB(db) // C11b: 关闭刚打开的池,避免下轮覆盖泄漏
warnCloseDB(db, "关闭 Ping 失败的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮覆盖泄漏
}
}
}
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
@@ -527,9 +627,15 @@ func replicaMaxOpenConns(masterMax int) int {
// 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
}
@@ -540,7 +646,7 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
m.resetReplicaHealth()
m.mu.Unlock()
for _, r := range oldReplicas {
_ = closeDB(r)
warnCloseDB(r, "关闭旧数据库从库连接池失败")
}
// 初始化从库
@@ -554,11 +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)
@@ -568,7 +682,7 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
sqlDB, err := replicaDB.DB()
if err != nil {
logger.Warnf("数据库从库 %d 获取连接池失败: %v", i+1, err)
_ = closeDB(replicaDB) // C11c: 关闭刚打开的池避免泄漏
warnCloseDB(replicaDB, "关闭刚打开的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
continue
}
@@ -581,9 +695,10 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
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)
_ = closeDB(replicaDB) // C11c: 关闭刚打开的池避免泄漏
warnCloseDB(replicaDB, "关闭 Ping 失败的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
continue
}
@@ -593,20 +708,21 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
m.mu.Lock()
m.replicas = newReplicas
m.ensureReplicaHealthLocked()
m.mu.Unlock()
}
return nil
}
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
func InitDB(cfg *config.Config) error {
return DefaultManager.Load().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.Load().InitDBWithReplicas(cfg, replicaDSNs)
func InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error {
return DefaultManager.Load().InitDBWithReplicas(ctx, cfg, replicaDSNs)
}
// GetReadDB 获取读库实例(按策略选择从库)
@@ -636,11 +752,13 @@ func SetReplicaPicker(p ReplicaPicker) {
// 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)
}
@@ -662,6 +780,7 @@ func GetDBFromContext(ctx context.Context) *gorm.DB {
//
// 注意:tx 仅在该 ctx 的生命周期内有效;事务提交/回滚后不得再用该 ctx 携带的 tx。
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
ctx = normalizeContext(ctx)
if tx == nil {
return ctx
}
@@ -670,6 +789,7 @@ func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
// TxFromContext 取出 ctx 携带的外层事务;无则返回 nil。
func TxFromContext(ctx context.Context) *gorm.DB {
ctx = normalizeContext(ctx)
if tx, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
return tx
}
@@ -704,6 +824,7 @@ func Transaction(fn func(tx *gorm.DB) error) error {
// TransactionWithContext 带上下文的事务操作
func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error {
ctx = normalizeContext(ctx)
db := DefaultManager.Load().Master()
if db == nil {
return errors.New("数据库未初始化")
@@ -711,8 +832,9 @@ func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) err
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("数据库未初始化")
@@ -724,6 +846,7 @@ func ReadQuery(ctx context.Context, model any, query string, args ...any) error
// 注意:命名沿用历史,实际用 .Find() 扫描结果集(读取语义),并非写操作——
// 强制主库是为了读到刚写入的最新数据(read-your-writes)。命名误导见 M11。
func WriteQuery(ctx context.Context, model any, query string, args ...any) error {
ctx = normalizeContext(ctx)
db := DefaultManager.Load().Master()
if db == nil {
return errors.New("数据库未初始化")
@@ -736,6 +859,7 @@ 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)
@@ -745,9 +869,10 @@ func pingWithTimeout(sqlDB *sql.DB, parent context.Context) error {
func HealthCheck() map[string]bool {
result := make(map[string]bool)
ctx := context.Background()
m := DefaultManager.Load()
// 检查主库
if master := DefaultManager.Load().Master(); master != nil {
if master := m.Master(); master != nil {
sqlDB, err := master.DB()
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
result["master"] = true
@@ -759,7 +884,7 @@ func HealthCheck() map[string]bool {
}
// 检查从库
for i, replica := range DefaultManager.Load().Replicas() {
for i, replica := range m.Replicas() {
if replica != nil {
sqlDB, err := replica.DB()
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
+161
View File
@@ -2,13 +2,17 @@ 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() {
@@ -131,6 +135,25 @@ func TestC11ReplicaHealthResetOnRebuild(t *testing.T) {
}
}
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) {
@@ -241,3 +264,141 @@ func TestC11HealthCheckLockedRead(t *testing.T) {
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")
}
}
+71 -10
View File
@@ -2,6 +2,7 @@ package database_test
import (
"context"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -111,6 +112,62 @@ func TestDefaultManagerHealthCheckWithoutInit(t *testing.T) {
}
}
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)
}
}
+55 -14
View File
@@ -2,6 +2,7 @@ package database
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
@@ -37,37 +38,76 @@ func init() {
// NewRedisManager 创建 Redis 管理器实例。
func NewRedisManager() *RedisManager { return &RedisManager{} }
// SetDefaultRedisManager 提升指定 RedisManager 为全局默认,后续包级 facade 走它
// 用于多实例场景或测试注入 mock。并发安全(atomic.Store
func SetDefaultRedisManager(m *RedisManager) {
if m != nil {
DefaultRedis.Store(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,
DialTimeout: 5 * time.Second, // D7 修复:连接超时
ReadTimeout: 3 * time.Second, // D7 修复:读超时
WriteTimeout: 3 * time.Second, // D7 修复:写超时
})
client := newRedisClient(cfg.Redis.Addr(), cfg.Redis.Password, cfg.Redis.DB)
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(pingCtx).Err(); err != nil {
client.Close()
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
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
}
@@ -104,6 +144,7 @@ func (m *RedisManager) setClientForTest(c *redis.Client) *redis.Client {
// HealthCheck Redis 健康检查。
func (m *RedisManager) HealthCheck(ctx context.Context) error {
ctx = normalizeContext(ctx)
m.mu.Lock()
client := m.client
m.mu.Unlock()
+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")
}
}
+15
View File
@@ -23,6 +23,21 @@ func TestHealthCheckRedisWithoutInit(t *testing.T) {
}
}
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 无锁写、
+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)
}
}
-20
View File
@@ -1,20 +0,0 @@
# xlgo 文档
| 文档 | 位置 | 说明 |
|---|---|---|
| README.md | 仓库根 | 快速开始、功能概览 |
| GUIDE.md | 仓库根 | 完整使用指南 |
| CHANGELOG.md | 仓库根 | 变更日志(遵循 Keep a Changelog |
| 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+ 个覆盖接口密集/注释最详尽/中间件/示例/工具各类型,初筛"无问题"裁定整体可信。
+16
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
@@ -10,6 +11,11 @@ import (
"github.com/gin-gonic/gin"
)
// DefaultMaxJSONBodyBytes 是 BindJSON 的默认请求体上限。
// 入口层无上限读 JSON 会让任意 handler 暴露 OOM 面;需要更大请求体的业务可改用
// BindJSONWithMaxBytes 显式声明上限。
const DefaultMaxJSONBodyBytes int64 = 1 << 20 // 1 MiB
// HealthCheck 健康检查
// @Summary 健康检查
// @Description 检查服务是否正常运行
@@ -28,6 +34,16 @@ func HealthCheck(c *gin.Context) {
// 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)
}
+29 -5
View File
@@ -62,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 {
@@ -185,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 {
@@ -387,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)
}
}
+147 -67
View File
@@ -53,6 +53,10 @@ var (
// 本实现仅支持 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 固定算法,
@@ -114,6 +118,18 @@ func (tb *TokenBlacklist) redisClient() *redis.Client {
// 注: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)
@@ -125,6 +141,10 @@ func blacklistCtx() (context.Context, context.CancelFunc) {
// 无 Redis 时返回 ErrBlacklistUnavailableC9a 修复):让调用方(RefreshToken/InvalidateToken
// 感知黑名单不可用并 fail-closed,避免无 Redis 时静默成功致撤销失效。
func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
if strings.TrimSpace(jti) == "" {
return ErrEmptyJTI
}
client := tb.redisClient()
if client == nil {
// Redis 未启用,黑名单不可用——fail-closed 让调用方决策。
@@ -144,26 +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, cancel := blacklistCtx()
defer cancel()
key := fmt.Sprintf("jwt_bl:%s", jti)
// M-A 修复:显式处理 Redis 错误(原 .Val() 吞错致故障被静默当"未拉黑")。
// 错误时保持 fail-open(返 false),与"无 Redis 部署可用"的固有局限一致,但记录告警
// 便于运维感知 Redis 故障。安全敏感场景必须启用 Redis(见 ErrBlacklistUnavailable 注释)。
n, err := client.Exists(ctx, key).Result()
if err != nil {
logger.Warn("jwt 黑名单检查失败,fail-open 放行", zap.String("jti", jti), zap.Error(err))
return false
return false, err
}
return n > 0
return n > 0, nil
}
// Manager JWT 管理器(#10)。持有独立的 TokenBlacklist
@@ -230,7 +247,17 @@ func (m *Manager) Blacklist() *TokenBlacklist {
// 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)
@@ -255,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),
@@ -270,39 +297,10 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
// GenerateTokenWithCustomExpiry 生成带自定义过期时间的 Token
func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) {
cfg := config.Get()
// P0:先校验密钥非空与算法受支持(fail-closed,见 GenerateToken)。
key, err := secretKey(cfg)
if err != nil {
return "", err
if expireSeconds <= 0 {
return "", ErrInvalidExpiry
}
method, err := signingMethod(cfg.JWT.Algorithm)
if err != nil {
return "", err
}
jti, err := generateJTI()
if err != nil {
return "", err
}
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(method, claims)
return token.SignedString(key)
return generateTokenWithExpiry(cfg, userID, username, role, userType, time.Duration(expireSeconds)*time.Second)
}
// issuerOrDefault 返回配置的 issuer,未配置时回退 "xlgo"。
@@ -329,29 +327,86 @@ func signingMethod(algorithm string) (jwt.SigningMethod, error) {
}
}
// 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{}, hmacKeyfunc(cfg), jwt.WithValidMethods(validMethods))
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 != "" && currentBlacklist().IsBlacklisted(claims.JTI) {
return nil, ErrTokenRevoked
if err := checkTokenBlacklist(claims, policy); err != nil {
return nil, err
}
return claims, nil
}
@@ -363,15 +418,22 @@ func ParseToken(tokenString string) (*Claims, error) {
func InvalidateToken(tokenString string) error {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), jwt.WithValidMethods(validMethods))
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 {
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)
}
}
@@ -382,6 +444,9 @@ func InvalidateToken(tokenString string) error {
// InvalidateTokenByID 直接通过 JTI 使 Token 失效
// 参数: jti JWT IDexpiry 过期时间
func InvalidateTokenByID(jti string, expiry time.Time) error {
if strings.TrimSpace(jti) == "" {
return ErrEmptyJTI
}
return currentBlacklist().Add(jti, expiry)
}
@@ -397,13 +462,24 @@ func RefreshToken(tokenString string) (string, error) {
}
// 将旧 Token 加入黑名单;失败则不签发新 token(C9b:禁止吞 Add 错误)。
if claims.JTI != "" && claims.ExpiresAt != nil {
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(不验证签名)
@@ -421,8 +497,9 @@ func GetJTI(tokenString string) (string, error) {
return "", ErrTokenInvalid
}
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
func IsTokenRevoked(jti string) bool {
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
// 返回 (是否撤销, 错误);黑名单后端不可用时返回 (false, ErrBlacklistUnavailable)。
func IsTokenRevoked(jti string) (bool, error) {
return currentBlacklist().IsBlacklisted(jti)
}
@@ -431,16 +508,19 @@ func IsTokenRevoked(jti string) bool {
func GetClaimsFromToken(tokenString string) (*Claims, error) {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg),
jwt.WithValidMethods(validMethods), 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
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ func TestC9cConcurrentSetDefaultAndRead(t *testing.T) {
default:
}
_, _ = ParseToken(token)
_ = IsTokenRevoked("some-jti")
_, _ = IsTokenRevoked("some-jti")
_ = InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
_ = currentManager()
}
+198 -6
View File
@@ -17,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)
@@ -44,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")
@@ -93,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)
@@ -181,9 +184,10 @@ func TestTokenBlacklist(t *testing.T) {
t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err)
}
// 无 Redis 时,IsBlacklisted 返回 false(验证侧 fail-open 是无 Redis 部署的固有局限
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)
}
}
@@ -374,6 +378,7 @@ func TestSupportedAlgorithmHS384(t *testing.T) {
Expire: time.Hour,
}})
t.Cleanup(setupTestConfig)
setupMiniRedis(t)
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
if err != nil {
@@ -384,3 +389,190 @@ func TestSupportedAlgorithmHS384(t *testing.T) {
}
}
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
}
+193 -36
View File
@@ -26,9 +26,20 @@ var (
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 在 m.mu 下同步维护,但直接读它在 re-Init/Close
// Deprecated: 此导出变量由 Init/Close 在 globalMu 下同步维护,但直接读它在 re-Init/Close
// 期间非并发安全(L-E)。请改用包级函数 Info/Debug/Warn/Error/...(经 atomic 读取,
// 并发安全)。保留仅为向后兼容,未来大版本可能移除。
Logger = zap.NewNop()
@@ -37,7 +48,7 @@ var (
// Close() 调用时显式释放文件句柄。
// 必要性:lumberjack 不依赖 GC 关闭文件,进程长跑或测试场景下
// 不显式关闭会持有句柄导致 Windows 上无法删除日志目录。
// 仅在 m.mu 下访问(Init/Close/closeFileWriters),无读侧竞争。
// 仅在 globalMu 下访问(Init/Close),无读侧竞争。
fileWriters []*lumberjack.Logger
)
@@ -48,6 +59,7 @@ func init() {
sugarPtr.Store(nop.Sugar())
apiLogPtr.Store(nop)
dbLogPtr.Store(nop)
defaultLoggerPtr.Store(DefaultLogger)
}
// currentLogger 返回通用 logger 的 atomic 快照(永不 nil)。
@@ -90,15 +102,48 @@ type LogManager struct {
cfg *config.Config
// level 是所有 core 共享的 AtomicLevel,支持运行期 SetLevel 热切换(M19)。
// Init 前为 nilSetLevel 守卫之。
level zap.AtomicLevel
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{} }
// 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 {
@@ -122,18 +167,66 @@ func (m *LogManager) GetLevel() zapcore.Level {
}
// SetLevel 包级 facade:运行期热切换默认日志级别(M19)。未 Init 时无操作返回 false。
func SetLevel(l zapcore.Level) bool { return DefaultLogger.SetLevel(l) }
func SetLevel(l zapcore.Level) bool { return GetDefaultLogManager().SetLevel(l) }
// GetLevel 包级 facade:返回默认日志当前级别。
func GetLevel() zapcore.Level { return DefaultLogger.GetLevel() }
func GetLevel() zapcore.Level { return GetDefaultLogManager().GetLevel() }
// SetDefaultLogManager 提升指定 LogManager 为全局默认。
// 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 的分流策略:
@@ -145,9 +238,25 @@ 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
}
m.mu.Lock()
defer m.mu.Unlock()
// 确保日志目录存在(0750owner+group 可访问,与 storage 目录权限一致)
if err := os.MkdirAll(cfg.Log.Dir, 0o750); err != nil {
@@ -175,8 +284,6 @@ func (m *LogManager) Init(cfg *config.Config) error {
if cfg.IsProduction() {
level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
}
// 记录到 manager,供 SetLevel/GetLevel 热切换(H7:在 m.mu 下写入)。
m.level = level
jsonEncoder := zapcore.NewJSONEncoder(encoderConfig)
consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig)
@@ -205,27 +312,51 @@ func (m *LogManager) Init(cfg *config.Config) error {
zap.AddCaller(), zap.AddCallerSkip(1),
)
m.mu.Lock()
defer m.mu.Unlock()
// 全部构造成功后再原子替换全局变量,避免半初始化状态。
// 同时关闭旧 writer 释放句柄(重复 Init 场景,主要服务于测试)。
// 先发布新 logger,再关闭旧 writer,避免请求 goroutine 在替换窗口内
// 通过包级函数拿到仍指向已关闭 writer 的旧 logger。
// H7:四个 logger 经 atomic.Pointer Store,读侧(请求 goroutine)无锁原子 load
// fileWriters 仅在 m.mu 下访问。Logger 兼容别名同步维护。
closeFileWriters()
// 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
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
@@ -239,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{currentLogger(), currentAPILog(), currentDBLog()} {
for _, l := range loggers {
if l == nil {
continue
}
@@ -267,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。
@@ -278,10 +416,20 @@ 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
// H7:重置为 Nop 经 atomic Store,与读侧一致;Logger 兼容别名同步。
nop := zap.NewNop()
@@ -290,14 +438,23 @@ func (m *LogManager) Close() error {
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 在不同平台返回的预期错误。
@@ -308,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
+241 -18
View File
@@ -42,9 +42,9 @@ func TestH7ConcurrentInitCloseAndRead(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
// 确保 DefaultLogger 起点干净。
_ = DefaultLogger.Close()
t.Cleanup(func() { _ = DefaultLogger.Close() })
// 确保默认 LogManager 起点干净。
_ = GetDefaultLogManager().Close()
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
stop := make(chan struct{})
var wg sync.WaitGroup
@@ -59,8 +59,8 @@ func TestH7ConcurrentInitCloseAndRead(t *testing.T) {
return
default:
}
_ = DefaultLogger.Init(cfg)
_ = DefaultLogger.Close()
_ = GetDefaultLogManager().Init(cfg)
_ = GetDefaultLogManager().Close()
}
}()
@@ -96,7 +96,7 @@ func TestH7ConcurrentInitCloseAndRead(t *testing.T) {
wg.Wait()
// 收尾到 Nop,避免后续测试持有临时目录句柄。
_ = DefaultLogger.Close()
_ = GetDefaultLogManager().Close()
}
// TestH7CurrentLoggerReflectsInitAndClose 验证 atomic 快照随 Init/Close 正确切换:
@@ -105,8 +105,8 @@ func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
_ = DefaultLogger.Close()
t.Cleanup(func() { _ = DefaultLogger.Close() })
_ = GetDefaultLogManager().Close()
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
// Init 前:Nop,调用安全且不写文件。
before := currentLogger()
@@ -114,7 +114,7 @@ func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
t.Fatal("currentLogger nil before Init")
}
if err := DefaultLogger.Init(cfg); err != nil {
if err := GetDefaultLogManager().Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
@@ -130,7 +130,7 @@ func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
// 写一条日志并 flush,验证落到文件(说明拿到的是真实 logger,非 Nop)。
const mark = "H7_INIT_REFLECT_xyz"
after.Info(mark)
_ = DefaultLogger.Sync()
_ = GetDefaultLogManager().Sync()
data, err := readFile(t, filepath.Join(dir, "app.log"))
if err != nil {
@@ -141,7 +141,7 @@ func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
}
// Close 后:currentLogger 回到 Nop(与 Init 前实例不同,但同为 Nop 行为)。
if err := DefaultLogger.Close(); err != nil {
if err := GetDefaultLogManager().Close(); err != nil {
t.Fatalf("Close: %v", err)
}
closed := currentLogger()
@@ -150,7 +150,7 @@ func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
}
// Nop logger 写不报错也不落盘;验证 Close 后再写不新增 mark。
closed.Info("H7_SHOULD_NOT_APPEAR_xyz")
_ = DefaultLogger.Sync()
_ = 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)")
@@ -183,6 +183,57 @@ func TestH7AtomicPointersNonNil(t *testing.T) {
}
}
// 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 不再被丢弃。
//
@@ -232,22 +283,22 @@ func readFile(t *testing.T, path string) (string, error) {
func TestSetLevelHotSwap_M19(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
_ = DefaultLogger.Close()
t.Cleanup(func() { _ = DefaultLogger.Close() })
_ = GetDefaultLogManager().Close()
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
if err := DefaultLogger.Init(cfg); err != nil {
if err := GetDefaultLogManager().Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
// Init 后(tmpCfg 为 production 模式,默认 InfoLevel)可热切换到 ErrorLevel。
before := DefaultLogger.GetLevel()
before := GetDefaultLogManager().GetLevel()
if before != zapcore.InfoLevel {
t.Errorf("default level = %v, want InfoLevel (production)", before)
}
if ok := DefaultLogger.SetLevel(zapcore.ErrorLevel); !ok {
if ok := GetDefaultLogManager().SetLevel(zapcore.ErrorLevel); !ok {
t.Fatal("SetLevel after Init should return true")
}
if got := DefaultLogger.GetLevel(); got != zapcore.ErrorLevel {
if got := GetDefaultLogManager().GetLevel(); got != zapcore.ErrorLevel {
t.Errorf("after SetLevel, level = %v, want ErrorLevel", got)
}
@@ -257,3 +308,175 @@ func TestSetLevelHotSwap_M19(t *testing.T) {
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")
}
}
+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 {
+89 -21
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"net/http"
"strings"
"sync"
@@ -23,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 配置
@@ -47,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 跳过检查函数
@@ -55,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
@@ -83,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 == "" {
@@ -100,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) {
// 检查是否跳过
@@ -123,6 +170,7 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
cookieToken = token
// 设置 Cookie
c.SetSameSite(cfg.SameSite)
c.SetCookie(
cfg.CookieName,
token,
@@ -160,10 +208,17 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
// body 缓存进 gin context,下游可重复读取。
if clientToken == "" {
var body map[string]any
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
}
}
}
@@ -202,7 +257,9 @@ func GetCSRFToken(c *gin.Context) string {
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 == "" {
@@ -225,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
}
}
@@ -295,6 +352,7 @@ func GenerateAPIToken(c *gin.Context) {
}
apiTokensMu.Lock()
cleanupExpiredAPITokensLocked(time.Now())
apiTokens[token] = time.Now()
apiTokensMu.Unlock()
@@ -313,6 +371,14 @@ var (
// 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 {
@@ -328,6 +394,7 @@ 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 {
@@ -360,6 +427,7 @@ func DoubleSubmitCookie() gin.HandlerFunc {
token, _ := generateCSRFToken(CSRFTokenLength)
// 双重提交模式要求前端 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 {
+145
View File
@@ -87,3 +87,148 @@ func TestCSRFJSONBodyPreservedForDownstream(t *testing.T) {
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")
}
}
+14 -3
View File
@@ -198,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
}
}
@@ -206,6 +206,17 @@ func shouldSkipPath(path string, cfg LoggerConfig) bool {
return false
}
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+"/")
}
// filterSensitiveFields 过滤敏感字段(密码、token等)
// sensitiveFieldsRE M1 修复:编译期正则,匹配 "key":"value" 整体并将 value 替换为 [FILTERED]。
// 支持 JSON 字符串中标准转义(\" \\ \/ \b \f \n \r \t \uXXXX)。
@@ -278,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
}
@@ -294,4 +305,4 @@ func LoggerMinimal() gin.HandlerFunc {
zap.Duration("latency", time.Since(start)),
)
}
}
}
+13
View File
@@ -260,3 +260,16 @@ func TestLoggerWithConfig_NormalizesMaxBodyLength(t *testing.T) {
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")
}
}
+79 -4
View File
@@ -1094,6 +1094,55 @@ func TestRateLimiterBurstCappedAtRate(t *testing.T) {
}
}
func assertPanic(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if r := recover(); r == nil {
t.Fatalf("%s did not panic", name)
}
}()
fn()
}
func TestRateLimiterRejectsInvalidConfig(t *testing.T) {
assertPanic(t, "memory limiter zero rate", func() {
_ = middleware.NewRateLimiter(0, time.Minute)
})
assertPanic(t, "memory limiter zero window", func() {
_ = middleware.NewRateLimiter(1, 0)
})
assertPanic(t, "redis limiter zero rate", func() {
_ = middleware.NewRedisRateLimiter("bad", 0, time.Minute)
})
assertPanic(t, "redis fail-closed limiter zero window", func() {
_ = middleware.NewRedisRateLimiter("bad", 1, 0, middleware.WithFailClosed(true))
})
}
func TestRateLimiterStopIdempotent(t *testing.T) {
limiter := middleware.NewRateLimiter(1, time.Minute)
limiter.Stop()
limiter.Stop()
}
func TestRateLimitNilLimiterFailsClosed(t *testing.T) {
r := setupTestRouter()
r.Use(middleware.RateLimit(nil))
r.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("RateLimit(nil) status = %d, want 503; body=%s", w.Code, w.Body.String())
}
if got := respCode(t, w); got != response.CodeServiceUnavailable {
t.Fatalf("RateLimit(nil) code = %d, want %d", got, response.CodeServiceUnavailable)
}
}
func TestLoginRateLimit(t *testing.T) {
middleware.InitRateLimiters()
defer middleware.StopRateLimiters()
@@ -1238,6 +1287,32 @@ func TestCustomRedisRateLimit(t *testing.T) {
}
}
func TestRedisRateLimitWithIdentifierNilFuncUsesClientIP(t *testing.T) {
setupMiddlewareMiniRedis(t)
r := setupTestRouter()
r.Use(middleware.RedisRateLimitWithIdentifier("nil_identifier", 1, nil))
r.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
w1 := httptest.NewRecorder()
req1 := httptest.NewRequest("GET", "/test", nil)
req1.RemoteAddr = "192.0.2.10:12345"
r.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("first request status = %d, want 200; body=%s", w1.Code, w1.Body.String())
}
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest("GET", "/test", nil)
req2.RemoteAddr = "192.0.2.10:12345"
r.ServeHTTP(w2, req2)
if got := respCode(t, w2); got != response.CodeRateLimit {
t.Fatalf("second request code = %d, want %d; status=%d body=%s", got, response.CodeRateLimit, w2.Code, w2.Body.String())
}
}
func TestRedisRateLimiterGetCount(t *testing.T) {
limiter := middleware.NewRedisRateLimiter("test_count", 10, time.Minute)
@@ -1296,7 +1371,7 @@ func TestRedisRateLimiterFailClosedNoRedis(t *testing.T) {
prev := database.SetTestRedisClient(nil)
defer func() { database.SetTestRedisClient(prev) }()
limiter := middleware.NewRedisRateLimiterFailClosed("test", 10, time.Minute)
limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute, middleware.WithFailClosed(true))
allowed, err := limiter.Allow(context.Background(), "1.2.3.4")
if allowed {
t.Error("fail-closed no-redis should deny")
@@ -1322,7 +1397,7 @@ func TestRedisRateLimiterFailClosedOnRedisError(t *testing.T) {
}
// fail-closed:正常时放行。
closedLimiter := middleware.NewRedisRateLimiterFailClosed("test_closed", 10, time.Minute)
closedLimiter := middleware.NewRedisRateLimiter("test_closed", 10, time.Minute, middleware.WithFailClosed(true))
allowed, err = closedLimiter.Allow(context.Background(), "1.2.3.4")
if err != nil {
t.Fatalf("fail-closed normal err = %v", err)
@@ -1356,7 +1431,7 @@ func TestRedisRateLimitFailClosedMiddlewareReturns503(t *testing.T) {
defer func() { database.SetTestRedisClient(prev) }()
r := setupTestRouter()
r.Use(middleware.RedisRateLimitFailClosed("login_limit", 10))
r.Use(middleware.RedisRateLimit("login_limit", 10, middleware.WithFailClosed(true)))
r.GET("/login", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
@@ -1470,4 +1545,4 @@ func TestRedisRateLimiterSetFailClosed(t *testing.T) {
} else if !errors.Is(err, middleware.ErrRedisRateLimiterUnavailable) {
t.Errorf("after SetFailClosed err = %v, want ErrRedisRateLimiterUnavailable", err)
}
}
}
+66 -59
View File
@@ -34,8 +34,11 @@ type visitor struct {
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),
@@ -52,6 +55,15 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
return limiter
}
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 {
@@ -151,10 +163,10 @@ var (
// RedisRateLimiter Redis 分布式限流器
type RedisRateLimiter struct {
keyPrefix string // 键名前缀
rate int // 每分钟允许的请求数
window time.Duration // 时间窗口
failClosed atomic.Bool // H4c/H-6: Redis 错误/断言失败时是否拒绝(true=安全型 fail-closed)。atomic 以支持运行期 SetFailClosed 并发安全切换。
keyPrefix string // 键名前缀
rate int // 每分钟允许的请求数
window time.Duration // 时间窗口
failClosed atomic.Bool // H4c/H-6: Redis 错误/断言失败时是否拒绝(true=安全型 fail-closed)。atomic 以支持运行期 SetFailClosed 并发安全切换。
}
// slidingWindowLua 滑动窗口限流 Lua 脚本
@@ -181,26 +193,32 @@ else
end
`
// NewRedisRateLimiter 创建 Redis 分布式限流器(默认 fail-open:Redis 错误时放行,避免影响业务)
// 安全敏感场景(如登录防爆破)应使用 NewRedisRateLimiterFailClosedRedis 故障时拒绝以防限流失效。
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
return &RedisRateLimiter{
// 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)
}
}
// 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,兼容默认)
}
}
// NewRedisRateLimiterFailClosed 创建安全型 Redis 分布式限流器(fail-closed):
// Redis 不可用/错误/返回非预期类型时拒绝请求,避免限流静默失效(防爆破场景必备)。
func NewRedisRateLimiterFailClosed(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
rl := &RedisRateLimiter{
keyPrefix: keyPrefix,
rate: rate,
window: window,
for _, opt := range opts {
if opt != nil {
opt(rl)
}
}
rl.failClosed.Store(true)
return rl
}
@@ -352,6 +370,12 @@ func StopRateLimiters() {
// 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)
@@ -438,11 +462,11 @@ func redisLimitDecision(c *gin.Context, allowed bool, err error) {
c.Next()
}
// RedisRateLimit Redis 分布式限流中间件fail-open:Redis 故障时放行,避免影响业务)
// 参数: keyPrefix 键名前缀(如 "login_limit"),rate 每分钟请求数
// 安全敏感场景(登录防爆破等)应使用 RedisRateLimitFailClosedRedis 故障时拒绝以防限流失效。
func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
// 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()
@@ -458,25 +482,17 @@ func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
}
}
// RedisRateLimitFailClosed 安全型 Redis 分布式限流中间件(fail-closed):
// Redis 故障时拒绝(503),避免限流静默失效。用于登录防爆破等安全场景
func RedisRateLimitFailClosed(keyPrefix string, rate int) gin.HandlerFunc {
limiter := NewRedisRateLimiterFailClosed(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 := c.ClientIP()
allowed, err := limiter.Allow(c.Request.Context(), identifier)
redisLimitDecision(c, allowed, err)
}
}
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流(fail-open)。
// 参数: keyPrefix 键名前缀,rate 每分钟请求数,identifierFunc 标识获取函数
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
return func(c *gin.Context) {
identifier := identifierFunc(c)
identifier := ""
if identifierFunc != nil {
identifier = identifierFunc(c)
}
if identifier == "" {
identifier = c.ClientIP()
}
@@ -488,10 +504,10 @@ func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc fun
// LoginRedisRateLimit 登录接口 Redis 分布式限流(fail-closed)。
//
// H4c: 登录防爆破场景必须 fail-closed——Redis 故障时若 fail-open 则限流失效、
// H4c: 登录防爆破场景必须 fail-closed--Redis 故障时若 fail-open 则限流失效、
// 攻击者可借 Redis 抖动窗口无限爆破。改为 fail-closedRedis 故障时返 503 拒绝。
func LoginRedisRateLimit() gin.HandlerFunc {
return RedisRateLimitFailClosed("login_limit", 10)
return RedisRateLimit("login_limit", 10, WithFailClosed(true))
}
// APIRedisRateLimit API Redis 分布式限流(fail-open,避免影响业务)。
@@ -499,14 +515,16 @@ func APIRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("api_limit", 100)
}
// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-open)。
// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-closed)。
// 上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效。
func UploadRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("upload_limit", 20)
return RedisRateLimit("upload_limit", 20, WithFailClosed(true))
}
// CustomRedisRateLimit 自定义 Redis 分布式限流fail-open
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()
@@ -514,14 +532,3 @@ func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration) gin.
redisLimitDecision(c, allowed, err)
}
}
// CustomRedisRateLimitFailClosed 自定义安全型 Redis 分布式限流(fail-closed)。
func CustomRedisRateLimitFailClosed(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc {
limiter := NewRedisRateLimiterFailClosed(keyPrefix, rate, window)
return func(c *gin.Context) {
identifier := c.ClientIP()
allowed, err := limiter.Allow(c.Request.Context(), identifier)
redisLimitDecision(c, allowed, err)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
// BaseModel 基础模型。CreatedAt/UpdatedAt 不显式指定 type,由 GORM 按驱动选默认
// 时间列类型(MySQL 通常为 datetime(0) 或 timestamp,保留亚秒精度)。
// 时间列类型(MySQL 通常为 datetime(6),保留亚秒精度)。
type BaseModel struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
+212 -15
View File
@@ -2,11 +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 查询
@@ -15,7 +34,7 @@ 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)
@@ -43,12 +62,26 @@ func NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T] {
return &BaseRepo[T]{db: db}
}
// readConn 返回读连接:外层 ctx 事务 > 本 repo 事务 > 读写分离路由 > r.db 回退。
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息。
func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.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)
}
@@ -58,16 +91,16 @@ func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB {
if gdb := database.GetDBFromContext(ctx); gdb != nil {
return gdb.WithContext(ctx)
}
return r.db.WithContext(ctx)
return base.WithContext(ctx)
}
// writeConn 返回写连接:外层 ctx 事务 > 本 repo 事务 > 主库 > r.db 回退。
// writeConn 返回写连接:先校验 repo 构造时注入的 r.db 非 nil,再按
// 外层 ctx 事务 > 本 repo 事务 > 主库 > r.db 回退。
// 写操作始终走主库,不路由到只读从库。
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息。
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息,即使 ctx 携带外层事务也要求 repo 构造合法
func (r *BaseRepo[T]) writeConn(ctx context.Context) *gorm.DB {
if r.db == nil {
panic("repository.BaseRepo: db is nil. Use NewBaseRepo with a valid *gorm.DB")
}
ctx = normalizeContext(ctx)
base := r.requireDB()
if tx := database.TxFromContext(ctx); tx != nil {
return tx.WithContext(ctx)
}
@@ -77,7 +110,7 @@ func (r *BaseRepo[T]) writeConn(ctx context.Context) *gorm.DB {
if mdb := database.GetWriteDB(); mdb != nil {
return mdb.WithContext(ctx)
}
return r.db.WithContext(ctx)
return base.WithContext(ctx)
}
// FindByID 根据 ID 查询
@@ -110,8 +143,8 @@ func (r *BaseRepo[T]) Update(ctx context.Context, model *T) error {
//
// 示例:
//
// repo.UpdateFields(ctx, &User{Name: "new"}, "name") // 仅更新 name
// repo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // 显式置零
// 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 {
@@ -135,13 +168,23 @@ func (r *BaseRepo[T]) HardDelete(ctx context.Context, id uint) 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.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.readConn(ctx).Find(&models).Error
return models, err
@@ -196,6 +239,9 @@ func (r *BaseRepo[T]) FindWhere(ctx context.Context, query string, args ...any)
// (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.readConn(ctx).Where(query, args...).Order(order).Find(&models).Error
return models, err
@@ -203,6 +249,9 @@ func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, order, query string,
// 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.readConn(ctx).Order(order)
if limit > 0 {
@@ -214,6 +263,9 @@ 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.readConn(ctx).Limit(limit).Find(&models).Error
return models, err
@@ -229,8 +281,25 @@ type PageResult[T any] struct {
PageSize int `json:"page_size"`
}
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
@@ -248,6 +317,7 @@ func pageOffset(page, pageSize int) int {
func (r *BaseRepo[T]) FindPage(ctx context.Context, page, pageSize int) (*PageResult[T], error) {
var models []T
var total int64
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 {
@@ -263,8 +333,12 @@ func (r *BaseRepo[T]) FindPage(ctx context.Context, page, pageSize int) (*PageRe
// 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
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 {
@@ -282,6 +356,7 @@ func (r *BaseRepo[T]) FindPageOrdered(ctx context.Context, page, pageSize int, o
func (r *BaseRepo[T]) FindPageWhere(ctx context.Context, page, pageSize int, query string, args ...any) (*PageResult[T], error) {
var models []T
var total int64
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 {
@@ -301,8 +376,12 @@ func (r *BaseRepo[T]) FindPageWhere(ctx context.Context, page, pageSize int, que
// (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
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 {
@@ -325,16 +404,28 @@ func (r *BaseRepo[T]) CreateBatch(ctx context.Context, models []T) error {
// UpdateBatch 批量更新(指定字段)
func (r *BaseRepo[T]) UpdateBatch(ctx context.Context, ids []uint, field string, value any) 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 {
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 {
if len(ids) == 0 {
return nil
}
return r.writeConn(ctx).Unscoped().Delete(new(T), ids).Error
}
@@ -364,16 +455,28 @@ func (r *BaseRepo[T]) SoftDeleteColumn() string {
// Restore 恢复软删除记录
func (r *BaseRepo[T]) Restore(ctx context.Context, id uint) 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 {
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.readConn(ctx).Unscoped().Where(r.SoftDeleteColumn() + " IS NOT NULL").Find(&models).Error
return models, err
@@ -425,11 +528,12 @@ func (r *BaseRepo[T]) WithTransaction(ctx context.Context, fn func(txRepo *BaseR
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 添加条件
@@ -446,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
@@ -470,6 +584,10 @@ func (qb *QueryBuilder[T]) clone() *gorm.DB {
// 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.clone().WithContext(ctx).Find(&models).Error
return models, err
@@ -477,6 +595,10 @@ func (qb *QueryBuilder[T]) Find(ctx context.Context) ([]T, error) {
// 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.clone().WithContext(ctx).First(&model).Error
if err != nil {
@@ -488,6 +610,10 @@ func (qb *QueryBuilder[T]) First(ctx context.Context) (*T, error) {
// 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.clone().Limit(-1).Offset(-1).WithContext(ctx).Count(&count).Error
return count, err
@@ -498,8 +624,13 @@ func (qb *QueryBuilder[T]) Count(ctx context.Context) (int64, error) {
// 注意:与 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)
// count 克隆并剥离 Limit/Offset
countDB := qb.clone().Limit(-1).Offset(-1)
@@ -519,3 +650,69 @@ func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageR
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
}
+1 -1
View File
@@ -403,7 +403,7 @@ func setupH6Manager(t *testing.T) (masterFile, replicaFile string) {
cfg.Database.Driver = "sqlite"
cfg.Database.CustomDSN = masterFile
if err := database.InitDBWithReplicas(cfg, []string{replicaFile}); err != nil {
if err := database.InitDBWithReplicas(context.Background(), cfg, []string{replicaFile}); err != nil {
t.Fatalf("InitDBWithReplicas: %v", err)
}
if err := database.GetWriteDB().AutoMigrate(&h6User{}); err != nil {
+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
View File
@@ -91,6 +91,9 @@ func (e *Error) Error() string {
//
// 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 {
if e == nil {
return &Error{Detail: detail}
@@ -107,6 +110,13 @@ func (e *Error) WithDetail(detail string) *Error {
// 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,
@@ -211,6 +221,10 @@ func FailWithError(c *gin.Context, err *Error) {
// FailWithDetail 使用预定义错误并添加详细信息。
// P1 #15detail 仅在 detailExposed()(默认 trueApp 生产环境置 false)时输出给客户端。
func FailWithDetail(c *gin.Context, err *Error, detail string) {
if err == nil {
FailWithError(c, nil)
return
}
if detailExposed() {
writeResp(c, err.Code, err.Message, gin.H{"detail": detail})
return
+26 -9
View File
@@ -1,10 +1,11 @@
package response
import (
"bytes"
"io"
"net/http"
"net/url"
"github.com/EthanCodeCraft/xlgo-core/utils"
"github.com/gin-gonic/gin"
)
@@ -126,22 +127,38 @@ func asciiFallbackName(filename string) string {
}
// 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", contentDisposition(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", contentDisposition(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)
+54 -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) {
@@ -242,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) {
@@ -343,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)
+355 -46
View File
@@ -2,23 +2,118 @@ 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* 系列
@@ -35,20 +130,40 @@ type HealthCheck struct {
//
// 注册期单线程调用,无并发问题。
func registerGETOnce(r gin.IRoutes, path string, h gin.HandlerFunc) {
if eng, ok := r.(*gin.Engine); ok {
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 strings.Contains(msg, "already registered") ||
strings.Contains(msg, "conflicts with existing wildcard") {
if isDuplicateRoutePanic(msg, path) {
warnf("router: duplicate GET %q skipped: %v", path, rec)
return // 重复路由,静默跳过
}
panic(rec) // 非重复路由 panic,原样抛出
@@ -59,14 +174,15 @@ func registerGETOnce(r gin.IRoutes, path string, h gin.HandlerFunc) {
// 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
}
@@ -74,13 +190,13 @@ 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
}
@@ -90,8 +206,9 @@ func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, ma
// 并附逐项结果。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(), checks)
status, code, result := runHealthChecks(c.Request.Context(), runners)
if result == nil {
c.JSON(http.StatusOK, gin.H{"status": status})
return
@@ -188,10 +305,11 @@ 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),
@@ -204,32 +322,77 @@ type Registry struct {
// 原实现用裸 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})
}
@@ -239,25 +402,57 @@ 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
}
@@ -266,31 +461,43 @@ func (r *Registry) GetMiddlewareGroup(name string) []gin.HandlerFunc {
// 装入 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() {
if r == nil {
warnf("router: Apply called on nil registry")
return
}
if r.engine == nil {
warnf("router: Apply skipped because engine is nil")
return
}
r.applyOnce.Do(func() {
// 指标采集中间件首个装入,统计所有经注册中心注册的业务路由
if r.metricsMiddleware != nil {
r.engine.Use(r.metricsMiddleware)
}
// 应用全局中间件
r.engine.Use(r.globalMiddlewares...)
// 注册无版本模块
for _, module := range r.modules {
module.Register(r.engine.Group(""))
}
// 注册版本化 API。P1 #13:按 version 键排序遍历,使跨版本注册顺序确定,
// 避免 map 随机序导致重叠路径"谁先胜出"(配合 registerGETOnce)每次运行不一致。
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)
@@ -298,11 +505,52 @@ func (r *Registry) Apply() {
sort.Strings(versionKeys)
for _, k := range versionKeys {
v := r.versions[k]
group := r.engine.Group(v.BasePath)
if len(v.Middlewares) > 0 {
group.Use(v.Middlewares...)
if v == nil {
versions = append(versions, versionApplySnapshot{isNil: true, version: k})
continue
}
for _, module := range v.Modules {
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)
}
}
@@ -369,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})
}
@@ -389,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),
}
}
@@ -397,7 +657,11 @@ 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 使用中间件分组创建路由组。
@@ -405,9 +669,17 @@ func Group(engine *gin.Engine, path string, middlewares ...gin.HandlerFunc) *gin
// 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 := ensureRegistry().GetMiddlewareGroup(groupName)
return engine.Group(path, middlewares...)
return ensureRegistry().GroupWithMiddlewareGroup(engine, path, groupName)
}
// RESTfulRoute RESTful 路由快捷注册
@@ -421,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...)
}
@@ -453,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)
}
@@ -468,4 +777,4 @@ func (r *RESTfulRoute) CRUD(list, detail, create, update, delete gin.HandlerFunc
if delete != nil {
r.Group.DELETE(r.Path+"/:id", delete)
}
}
}
+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,直接在证伪台账说明并结束,不要为凑数而上调。
+21 -2
View File
@@ -14,12 +14,16 @@ 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
@@ -54,10 +58,13 @@ func NewSSEWriter(c *gin.Context) (*SSEWriter, error) {
// 写错误向上传播(C3b 修复):旧实现丢弃 fmt.Fprintf 错误且恒 return nil
// 导致客户端断连后 Stream 守卫永不触发、消费循环不退出、上游 LLM 流持续运行。
func (w *SSEWriter) WriteEvent(event, data string) error {
if strings.ContainsAny(event, "\r\n") {
return ErrInvalidEventName
}
if _, err := fmt.Fprintf(w.writer, "event: %s\n", event); err != nil {
return err
}
if _, err := fmt.Fprintf(w.writer, "data: %s\n\n", data); err != nil {
if err := w.writeDataLines(data); err != nil {
return err
}
w.flusher.Flush()
@@ -67,13 +74,25 @@ func (w *SSEWriter) WriteEvent(event, data string) error {
// WriteMessage 写入消息(无事件类型)
// 格式: data: <data>\n\n
func (w *SSEWriter) WriteMessage(data string) error {
if _, err := fmt.Fprintf(w.writer, "data: %s\n\n", data); err != nil {
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)
+46 -1
View File
@@ -1,6 +1,7 @@
package sse_test
import (
"errors"
"net/http/httptest"
"strings"
"testing"
@@ -139,4 +140,48 @@ func TestSSEHeaders(t *testing.T) {
if w.Header().Get("Connection") != "keep-alive" {
t.Error("Connection should be keep-alive")
}
}
}
func TestSSEWriterRejectsEventNewlineInjection(t *testing.T) {
r := setupTestRouter()
var writeErr error
r.GET("/sse", func(c *gin.Context) {
writer, _ := sse.NewSSEWriter(c)
writeErr = writer.WriteEvent("message\nevent: hacked", "safe")
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil)
r.ServeHTTP(w, req)
if !errors.Is(writeErr, sse.ErrInvalidEventName) {
t.Fatalf("WriteEvent newline err = %v, want ErrInvalidEventName", writeErr)
}
if strings.Contains(w.Body.String(), "event: hacked") {
t.Fatalf("response contains injected event: %q", w.Body.String())
}
}
func TestSSEWriterSplitsMultilineData(t *testing.T) {
r := setupTestRouter()
r.GET("/sse", func(c *gin.Context) {
writer, _ := sse.NewSSEWriter(c)
if err := writer.WriteMessage("first\nevent: injected\r\nsecond"); err != nil {
t.Errorf("WriteMessage: %v", err)
}
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil)
r.ServeHTTP(w, req)
body := w.Body.String()
if strings.Contains(body, "\nevent: injected") {
t.Fatalf("data newline escaped into event field: %q", body)
}
for _, want := range []string{"data: first", "data: event: injected", "data: second"} {
if !strings.Contains(body, want) {
t.Fatalf("body = %q, want %q", body, want)
}
}
}
+105 -31
View File
@@ -30,7 +30,9 @@ type Storage interface {
GetURL(path string) string
Delete(path string) error
Get(path string) ([]byte, error)
Exists(path string) bool
// Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil)
// 未初始化、路径非法/穿越、后端错误返回 (false, err)。
Exists(path string) (bool, error)
}
var (
@@ -41,9 +43,14 @@ var (
ErrPathTraversal = errors.New("path traversal detected")
// ErrInvalidPath 路径非法(空、含 NUL 等)。
ErrInvalidPath = errors.New("invalid path")
// ErrUploadTooLarge 上传实际字节数超过 MaxSizeBytesP0)。客户端声明的 file.Size
// 不可信,故除前置校验外,拷贝阶段按实际字节封顶,防止声明小体积却流式发送大 body 撑爆磁盘/OSS。
// ErrInvalidFile 上传文件参数非法,例如 nil *multipart.FileHeader。
ErrInvalidFile = errors.New("invalid file")
// ErrUploadTooLarge 上传声明大小或实际字节数超过 MaxSizeBytesP0)。客户端声明的 file.Size
// 不可信,故除前置校验外,拷贝阶段也按实际字节封顶,防止声明小体积却流式发送大 body 撑爆磁盘/OSS。
ErrUploadTooLarge = errors.New("upload exceeds max size")
// ErrReadTooLarge Get 读取内容超过 maxReadBytes 上限(C4c)。与 ErrUploadTooLarge 区分:
// 前者是读操作的超限,后者是上传操作的超限。
ErrReadTooLarge = errors.New("read exceeds max size")
)
const (
@@ -71,7 +78,7 @@ func resolveMaxRead(n int64) int64 {
// 在拷贝阶段按实际字节数二次把关(P0)。
func validateUploadSize(p config.UploadPolicy, size int64) error {
if p.MaxSizeBytes > 0 && size > p.MaxSizeBytes {
return fmt.Errorf("文件大小 %d 超过上限 %d: %w", size, p.MaxSizeBytes, ErrInvalidPath)
return fmt.Errorf("文件大小 %d 超过上限 %d: %w", size, p.MaxSizeBytes, ErrUploadTooLarge)
}
return nil
}
@@ -186,6 +193,14 @@ func sanitizeObjectKey(key string) (string, error) {
return path.Clean(key), nil
}
func publicURL(baseURL, key string) string {
cleanKey, err := sanitizeObjectKey(key)
if err != nil {
return ""
}
return strings.TrimRight(baseURL, "/") + "/" + cleanKey
}
// LocalStorage 本地存储
type LocalStorage struct {
rootAbs string // 绝对根路径(已 Clean),前缀锚定用(C4a)
@@ -199,7 +214,12 @@ type LocalStorage struct {
// 安全约束:Path 指向的根目录应为框架独占目录,不与用户可控内容混用。
// safeJoin 已防 `..` 路径穿越,但若根目录内已存在指向外部的符号链接,
// Get 会跟随 symlink 读到根外内容——需保证攻击者无法在根目录内创建 symlink。
// cfg 为 nil、Path 无法解析或根目录本身是 symlink 时,实例 fail-closed
// 后续文件操作返回 ErrStorageNotInitialized。
func NewLocalStorage(cfg *config.LocalStorageConfig) *LocalStorage {
if cfg == nil {
return &LocalStorage{maxReadBytes: resolveMaxRead(0)}
}
// 用绝对路径作根锚定,避免相对路径 + `..` 组合绕过前缀校验(C4a)。
var rootAbs string
abs, err := filepath.Abs(cfg.Path)
@@ -213,6 +233,12 @@ func NewLocalStorage(cfg *config.LocalStorageConfig) *LocalStorage {
} else {
rootAbs = filepath.Clean(abs)
}
if rootAbs != "" {
if info, err := os.Lstat(rootAbs); err == nil && info.Mode()&os.ModeSymlink != 0 {
logger.Error("storage: local storage root must not be a symlink", zap.String("path", rootAbs))
rootAbs = ""
}
}
return &LocalStorage{
rootAbs: rootAbs,
baseURL: cfg.BaseURL,
@@ -244,7 +270,10 @@ func (s *LocalStorage) safeJoin(parts ...string) (string, error) {
}
// Upload 上传文件
func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (ret string, err error) {
if file == nil {
return "", ErrInvalidFile
}
// 上传安全策略校验(大小 / 扩展名);file.Size 由 multipart 解析时填,无需打开文件(C4b)。
if err := validateUploadSize(s.policy, file.Size); err != nil {
return "", err
@@ -294,13 +323,18 @@ func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string
if err != nil {
return "", fmt.Errorf("创建文件失败: %w", err)
}
defer dstFile.Close()
defer func() {
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
ret = ""
_ = os.Remove(dst)
}
}()
// 复制文件内容。按策略实测封顶(P0):超限即报错并清理已落盘的部分文件。
if _, err := io.Copy(dstFile, enforceUploadSize(s.policy, srcReader)); err != nil {
// 先关闭再删除(Windows 下删除打开中的文件会失败);defer 的 Close 随后成为无害 no-op。
_ = dstFile.Close()
_ = os.Remove(dst)
return "", fmt.Errorf("保存文件失败: %w", err)
}
@@ -314,7 +348,7 @@ func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string
}
// UploadFromBytes 从字节数组上传文件
func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) {
func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (ret string, err error) {
// 上传安全策略校验(C4b
if err := validateUploadSize(s.policy, int64(len(data))); err != nil {
return "", err
@@ -360,7 +394,15 @@ func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (st
if err != nil {
return "", fmt.Errorf("创建文件失败: %w", err)
}
defer dstFile.Close()
defer func() {
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
ret = ""
_ = os.Remove(dst)
}
}()
// 写入文件内容
if _, err := io.Copy(dstFile, srcReader); err != nil {
@@ -378,7 +420,7 @@ func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (st
// GetURL 获取文件访问 URL
func (s *LocalStorage) GetURL(path string) string {
return fmt.Sprintf("%s/%s", s.baseURL, path)
return publicURL(s.baseURL, path)
}
// Delete 删除文件
@@ -421,19 +463,25 @@ func (s *LocalStorage) Get(p string) ([]byte, error) {
return nil, fmt.Errorf("读取文件内容失败: %w", err)
}
if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes {
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrInvalidPath)
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge)
}
return data, nil
}
// Exists 检查文件是否存在
func (s *LocalStorage) Exists(p string) bool {
// Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil)
// 路径非法/穿越返回 (false, err);其他 Stat 错误返回 (false, err)。
func (s *LocalStorage) Exists(p string) (bool, error) {
fullPath, err := s.safeJoin(p)
if err != nil {
return false
return false, err
}
_, err = os.Stat(fullPath)
return err == nil
if _, err := os.Stat(fullPath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
// OSSStorage OSS 存储
@@ -449,6 +497,9 @@ type OSSStorage struct {
// NewOSSStorage 创建 OSS 存储实例
func NewOSSStorage(cfg *config.OSSStorageConfig) (*OSSStorage, error) {
if cfg == nil {
return nil, ErrStorageNotInitialized
}
client, err := oss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
if err != nil {
return nil, fmt.Errorf("创建 OSS 客户端失败: %w", err)
@@ -472,6 +523,9 @@ func NewOSSStorage(cfg *config.OSSStorageConfig) (*OSSStorage, error) {
// Upload 上传文件到 OSS
func (s *OSSStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
if file == nil {
return "", ErrInvalidFile
}
// 上传安全策略校验(C4b
if err := validateUploadSize(s.policy, file.Size); err != nil {
return "", err
@@ -563,15 +617,23 @@ func (s *OSSStorage) UploadFromBytes(data []byte, filename, subdir string) (stri
// GetURL 获取文件访问 URL
func (s *OSSStorage) GetURL(path string) string {
if s.baseURL != "" {
return fmt.Sprintf("%s/%s", s.baseURL, path)
cleanKey, err := sanitizeObjectKey(path)
if err != nil {
return ""
}
return fmt.Sprintf("https://%s.%s/%s", s.bucketName, s.endpoint, path)
if s.baseURL != "" {
return strings.TrimRight(s.baseURL, "/") + "/" + cleanKey
}
return fmt.Sprintf("https://%s.%s/%s", s.bucketName, s.endpoint, cleanKey)
}
// GetSignedURL 获取带签名的临时访问 URL(用于私有文件)
func (s *OSSStorage) GetSignedURL(path string, expire time.Duration) (string, error) {
return s.bucket.SignURL(path, oss.HTTPGet, int64(expire.Seconds()))
key, err := sanitizeObjectKey(path)
if err != nil {
return "", err
}
return s.bucket.SignURL(key, oss.HTTPGet, int64(expire.Seconds()))
}
// Delete 删除 OSS 文件
@@ -610,19 +672,27 @@ func (s *OSSStorage) Get(p string) ([]byte, error) {
return nil, fmt.Errorf("读取 OSS 文件内容失败: %w", err)
}
if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes {
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrInvalidPath)
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge)
}
return data, nil
}
// Exists 检查 OSS 文件是否存在
func (s *OSSStorage) Exists(p string) bool {
// Exists 检查 OSS 文件是否存在。存在返回 (true, nil)object 不存在返回 (false, nil)
// key 非法/穿越返回 (false, err);鉴权/网络/服务端错误返回 (false, err)。
func (s *OSSStorage) Exists(p string) (bool, error) {
key, err := sanitizeObjectKey(p)
if err != nil {
return false
return false, err
}
_, err = s.bucket.GetObjectMeta(key)
return err == nil
if _, err := s.bucket.GetObjectMeta(key); err != nil {
// OSS object 不存在(404 / NoSuchKey)不是错误,返回 (false, nil)。
var se *oss.ServiceError
if errors.As(err, &se) && (se.StatusCode == http.StatusNotFound || se.Code == "NoSuchKey") {
return false, nil
}
return false, err
}
return true, nil
}
// StorageManager 存储管理器(#10)。照 database.Manager 模式:
@@ -657,6 +727,9 @@ func SetDefaultStorageManager(m *StorageManager) {
// Init 初始化存储
func (m *StorageManager) Init(cfg *config.StorageConfig) error {
if cfg == nil {
return ErrStorageNotInitialized
}
m.mu.Lock()
defer m.mu.Unlock()
@@ -757,11 +830,12 @@ func Get(path string) ([]byte, error) {
return s.Get(path)
}
// Exists 检查文件是否存在
func Exists(path string) bool {
// Exists 检查文件是否存在。未初始化返回 (false, ErrStorageNotInitialized)
// 其余语义同 Storage.Exists。
func Exists(path string) (bool, error) {
s := GetStorage()
if s == nil {
return false
return false, ErrStorageNotInitialized
}
return s.Exists(path)
}
+26
View File
@@ -1,6 +1,7 @@
package storage
import (
"errors"
"strings"
"testing"
)
@@ -64,3 +65,28 @@ func TestResolveMaxRead(t *testing.T) {
t.Errorf("resolveMaxRead(2048) = %d, want 2048", got)
}
}
func TestNewOSSStorageNilConfig(t *testing.T) {
if _, err := NewOSSStorage(nil); !errors.Is(err, ErrStorageNotInitialized) {
t.Fatalf("NewOSSStorage(nil) err = %v, want ErrStorageNotInitialized", err)
}
}
func TestOSSStorageGetURLSanitizesPath(t *testing.T) {
s := &OSSStorage{
baseURL: "https://cdn.example.com/",
bucketName: "bucket",
endpoint: "oss.example.com",
}
if got := s.GetURL("docs//a.txt"); got != "https://cdn.example.com/docs/a.txt" {
t.Fatalf("GetURL clean path = %q", got)
}
if got := s.GetURL("../secret.txt"); got != "" {
t.Fatalf("GetURL traversal = %q, want empty", got)
}
s.baseURL = ""
if got := s.GetURL("docs/a.txt"); got != "https://bucket.oss.example.com/docs/a.txt" {
t.Fatalf("GetURL endpoint fallback = %q", got)
}
}
+61 -14
View File
@@ -84,9 +84,9 @@ func TestLocalStoragePathTraversal(t *testing.T) {
t.Errorf("Get(%q) err = %v, want ErrPathTraversal", escapeRel, err)
}
// Exists 必须返回 false(而非穿越探测到 canary
if s.Exists(escapeRel) {
t.Errorf("Exists(%q) = true, want false (traversal must not probe outside root)", escapeRel)
// Exists 必须返回 (false, ErrPathTraversal)(而非穿越探测到 canary
if ok, err := s.Exists(escapeRel); ok || !errors.Is(err, storage.ErrPathTraversal) {
t.Errorf("Exists(%q) = (%v, %v), want (false, ErrPathTraversal)", escapeRel, ok, err)
}
// 绝对路径也必须拒绝
@@ -111,8 +111,8 @@ func TestLocalStorageNormalPathStillWorks(t *testing.T) {
}
rel := filepath.ToSlash(filepath.Join("sub", "ok.txt"))
if !s.Exists(rel) {
t.Error("Exists(normal) = false, want true")
if ok, err := s.Exists(rel); err != nil || !ok {
t.Errorf("Exists(normal) = (%v, %v), want (true, nil)", ok, err)
}
data, err := s.Get(rel)
if err != nil {
@@ -170,8 +170,8 @@ func TestLocalStorageGetReadLimit(t *testing.T) {
if err != nil {
t.Fatalf("Upload big file: %v", err)
}
if _, err := s.Get(rel); !errors.Is(err, storage.ErrInvalidPath) {
t.Errorf("Get over-limit err = %v, want ErrInvalidPath", err)
if _, err := s.Get(rel); !errors.Is(err, storage.ErrReadTooLarge) {
t.Errorf("Get over-limit err = %v, want ErrReadTooLarge", err)
}
// 小文件应正常读取。
@@ -196,8 +196,8 @@ func TestLocalStorageGetReadLimit(t *testing.T) {
func TestLocalStorageUploadSizeLimit(t *testing.T) {
s := newLocalStorageWithPolicy(t, config.UploadPolicy{MaxSizeBytes: 5}, 0)
fh := makeFileHeader(t, "file", "big.txt", "text/plain", []byte("0123456789")) // 10 字节
if _, err := s.Upload(fh, "docs"); !errors.Is(err, storage.ErrInvalidPath) {
t.Errorf("Upload over size err = %v, want ErrInvalidPath", err)
if _, err := s.Upload(fh, "docs"); !errors.Is(err, storage.ErrUploadTooLarge) {
t.Errorf("Upload over size err = %v, want ErrUploadTooLarge", err)
}
}
@@ -239,14 +239,14 @@ func TestLocalStorageUploadMIMESniff(t *testing.T) {
// 回归 C4bUploadFromBytes 同样受策略约束(大小 / 扩展名 / MIME)。
func TestLocalStorageUploadFromBytesPolicy(t *testing.T) {
s := newLocalStorageWithPolicy(t, config.UploadPolicy{
MaxSizeBytes: 100,
AllowedExts: []string{".txt"},
AllowedMIMEs: []string{"text/plain"},
MaxSizeBytes: 100,
AllowedExts: []string{".txt"},
AllowedMIMEs: []string{"text/plain"},
}, 0)
// 超限
if _, err := s.UploadFromBytes(make([]byte, 200), "ok.txt", "docs"); !errors.Is(err, storage.ErrInvalidPath) {
t.Errorf("UploadFromBytes over size err = %v, want ErrInvalidPath", err)
if _, err := s.UploadFromBytes(make([]byte, 200), "ok.txt", "docs"); !errors.Is(err, storage.ErrUploadTooLarge) {
t.Errorf("UploadFromBytes over size err = %v, want ErrUploadTooLarge", err)
}
// 扩展名不符
if _, err := s.UploadFromBytes([]byte("hi"), "evil.php", "docs"); !errors.Is(err, storage.ErrInvalidPath) {
@@ -295,3 +295,50 @@ func TestLocalStorageZeroPolicyAllowsAll(t *testing.T) {
t.Errorf("Upload with zero policy err = %v (must remain unrestricted for backward compat)", err)
}
}
func TestLocalStorageNilConfigFailsClosed(t *testing.T) {
s := storage.NewLocalStorage(nil)
if _, err := s.Get("x.txt"); !errors.Is(err, storage.ErrStorageNotInitialized) {
t.Fatalf("Get with nil config err = %v, want ErrStorageNotInitialized", err)
}
}
func TestStorageInitNilConfigNoPanic(t *testing.T) {
if err := storage.NewStorageManager().Init(nil); !errors.Is(err, storage.ErrStorageNotInitialized) {
t.Fatalf("Init(nil) err = %v, want ErrStorageNotInitialized", err)
}
}
func TestLocalStorageUploadNilFile(t *testing.T) {
s := newLocalStorageWithPolicy(t, config.UploadPolicy{}, 0)
if _, err := s.Upload(nil, "docs"); !errors.Is(err, storage.ErrInvalidFile) {
t.Fatalf("Upload(nil) err = %v, want ErrInvalidFile", err)
}
}
func TestLocalStorageRejectsSymlinkRoot(t *testing.T) {
parent := t.TempDir()
target := filepath.Join(parent, "target")
if err := os.Mkdir(target, 0755); err != nil {
t.Fatalf("mkdir target: %v", err)
}
link := filepath.Join(parent, "link")
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink not available on this system: %v", err)
}
s := storage.NewLocalStorage(&config.LocalStorageConfig{Path: link, BaseURL: "http://localhost/uploads"})
if _, err := s.Get("x.txt"); !errors.Is(err, storage.ErrStorageNotInitialized) {
t.Fatalf("Get with symlink root err = %v, want ErrStorageNotInitialized", err)
}
}
func TestLocalStorageGetURLSanitizesPath(t *testing.T) {
s := newLocalStorageWithPolicy(t, config.UploadPolicy{}, 0)
if got := s.GetURL("docs//a.txt"); got != "http://localhost/uploads/docs/a.txt" {
t.Fatalf("GetURL clean path = %q", got)
}
if got := s.GetURL("../secret.txt"); got != "" {
t.Fatalf("GetURL traversal = %q, want empty", got)
}
}
+8 -6
View File
@@ -40,8 +40,8 @@ func TestStorageNotInitialized(t *testing.T) {
if url := storage.GetURL("missing"); url != "" {
t.Fatalf("expected empty URL, got %q", url)
}
if storage.Exists("missing") {
t.Fatal("expected Exists false without storage")
if ok, err := storage.Exists("missing"); ok || !errors.Is(err, storage.ErrStorageNotInitialized) {
t.Fatalf("expected (false, ErrStorageNotInitialized) without storage, got (%v, %v)", ok, err)
}
}
@@ -107,8 +107,9 @@ func TestLocalStorageDeleteGetExists(t *testing.T) {
}
// 测试 Exists
if !local.Exists("test.txt") {
t.Error("Exists should return true for existing file")
ok, err := local.Exists("test.txt")
if err != nil || !ok {
t.Errorf("Exists should return (true, nil) for existing file, got (%v, %v)", ok, err)
}
// 测试 Get
@@ -127,8 +128,9 @@ func TestLocalStorageDeleteGetExists(t *testing.T) {
}
// 验证删除后不存在
if local.Exists("test.txt") {
t.Error("Exists should return false after delete")
ok, err = local.Exists("test.txt")
if err != nil || ok {
t.Errorf("Exists should return (false, nil) after delete, got (%v, %v)", ok, err)
}
// 删除不存在的文件应该失败
+114 -77
View File
@@ -3,25 +3,27 @@ package test
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
// SetupRouter 创建测试路由。
// SetupRouter 创建不带框架默认中间件的测试路由。
//
// 刻意返回裸 gin.New()(N3 文档化):不含框架中间件(RequestID/Recover/超时等),
// 让测试方完全控制中间件链。需要框架中间件的集成测试应直接构造 xlgo.App 或手动 Use。
// 需要 RequestIDRecover超时等框架中间件的集成测试,应直接构造 xlgo.App
// 或手动 Use 对应中间件
func SetupRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
return gin.New()
}
// Request 测试请求辅助函数
// Request 测试请求构造器。
type Request struct {
router *gin.Engine
method string
@@ -30,7 +32,7 @@ type Request struct {
headers map[string]string
}
// NewRequest 创建测试请求
// NewRequest 创建测试请求
func NewRequest(router *gin.Engine, method, path string) *Request {
return &Request{
router: router,
@@ -40,90 +42,92 @@ func NewRequest(router *gin.Engine, method, path string) *Request {
}
}
// GET 创建 GET 请求
// GET 创建 GET 请求
func GET(router *gin.Engine, path string) *Request {
return NewRequest(router, http.MethodGet, path)
}
// POST 创建 POST 请求
// POST 创建 POST 请求
func POST(router *gin.Engine, path string) *Request {
return NewRequest(router, http.MethodPost, path)
}
// PUT 创建 PUT 请求
// PUT 创建 PUT 请求
func PUT(router *gin.Engine, path string) *Request {
return NewRequest(router, http.MethodPut, path)
}
// DELETE 创建 DELETE 请求
// DELETE 创建 DELETE 请求
func DELETE(router *gin.Engine, path string) *Request {
return NewRequest(router, http.MethodDelete, path)
}
// PATCH 创建 PATCH 请求
// PATCH 创建 PATCH 请求
func PATCH(router *gin.Engine, path string) *Request {
return NewRequest(router, http.MethodPatch, path)
}
// WithBody 设置请求体
// WithBody 设置请求体
func (r *Request) WithBody(body any) *Request {
r.body = body
return r
}
// WithJSON 设置 JSON 请求体
// WithJSON 设置 JSON 请求体并写入 Content-Type。
func (r *Request) WithJSON(body any) *Request {
r.body = body
r.headers["Content-Type"] = "application/json"
return r
}
// WithHeader 设置请求头
// WithHeader 设置请求头
func (r *Request) WithHeader(key, value string) *Request {
r.headers[key] = value
return r
}
// WithToken 设置 Authorization
// WithToken 设置 Authorization Bearer token。
func (r *Request) WithToken(token string) *Request {
r.headers["Authorization"] = "Bearer " + token
return r
}
// Execute 执行请求
func (r *Request) Execute() *httptest.ResponseRecorder {
var bodyReader *bytes.Reader
// Execute 执行请求并返回带断言方法的响应包装。
func (r *Request) Execute() *Response {
return &Response{ResponseRecorder: r.ExecuteRecorder()}
}
// ExecuteRecorder 执行请求并返回原始 httptest.ResponseRecorder。
func (r *Request) ExecuteRecorder() *httptest.ResponseRecorder {
var bodyReader *bytes.Reader
if r.body != nil {
bodyBytes, _ := json.Marshal(r.body)
bodyReader = bytes.NewReader(bodyBytes)
} else {
bodyReader = bytes.NewReader([]byte{})
bodyReader = bytes.NewReader(nil)
}
req := httptest.NewRequest(r.method, r.path, bodyReader)
for key, value := range r.headers {
req.Header.Set(key, value)
}
recorder := httptest.NewRecorder()
r.router.ServeHTTP(recorder, req)
return recorder
}
// Response 响应解析辅助
// Response 是测试响应辅助包装。
type Response struct {
*httptest.ResponseRecorder
}
// ParseJSON 解析 JSON 响应
// ParseJSON 解析 JSON 响应
func (r *Response) ParseJSON(v any) error {
return json.Unmarshal(r.Body.Bytes(), v)
}
// AssertStatus 断言状态码
// AssertStatus 断言 HTTP 状态码
func (r *Response) AssertStatus(t *testing.T, expected int) {
t.Helper()
if r.Code != expected {
@@ -131,43 +135,43 @@ func (r *Response) AssertStatus(t *testing.T, expected int) {
}
}
// AssertOK 断言状态码为 200
// AssertOK 断言状态码为 200
func (r *Response) AssertOK(t *testing.T) {
t.Helper()
r.AssertStatus(t, http.StatusOK)
}
// AssertCreated 断言状态码为 201
// AssertCreated 断言状态码为 201
func (r *Response) AssertCreated(t *testing.T) {
t.Helper()
r.AssertStatus(t, http.StatusCreated)
}
// AssertBadRequest 断言状态码为 400
// AssertBadRequest 断言状态码为 400
func (r *Response) AssertBadRequest(t *testing.T) {
t.Helper()
r.AssertStatus(t, http.StatusBadRequest)
}
// AssertUnauthorized 断言状态码为 401
// AssertUnauthorized 断言状态码为 401
func (r *Response) AssertUnauthorized(t *testing.T) {
t.Helper()
r.AssertStatus(t, http.StatusUnauthorized)
}
// AssertForbidden 断言状态码为 403
// AssertForbidden 断言状态码为 403
func (r *Response) AssertForbidden(t *testing.T) {
t.Helper()
r.AssertStatus(t, http.StatusForbidden)
}
// AssertNotFound 断言状态码为 404
// AssertNotFound 断言状态码为 404
func (r *Response) AssertNotFound(t *testing.T) {
t.Helper()
r.AssertStatus(t, http.StatusNotFound)
}
// AssertJSONContains 断言 JSON 包含指定字段
// AssertJSONContains 断言 JSON 响应包含指定字段和值,支持 data.user.id 形式的嵌套键。
func (r *Response) AssertJSONContains(t *testing.T, key string, expected any) {
t.Helper()
@@ -177,10 +181,8 @@ func (r *Response) AssertJSONContains(t *testing.T, key string, expected any) {
return
}
// 支持嵌套键(如 "data.user.id"
keys := splitKey(key)
current := result
for i, k := range keys {
if i == len(keys)-1 {
if current[k] != expected {
@@ -211,92 +213,107 @@ func splitKey(key string) []string {
return keys
}
// Mock 模拟工具
// MockDB 模拟数据库(用于简单测试)
// MockDB 是并发安全的简单内存数据库 mock。
type MockDB struct {
mu sync.RWMutex
data map[any]any
}
// NewMockDB 创建模拟数据库
// NewMockDB 创建模拟数据库
func NewMockDB() *MockDB {
return &MockDB{
data: make(map[any]any),
}
return &MockDB{data: make(map[any]any)}
}
// Set 设置数据
// Set 设置数据
func (m *MockDB) Set(key, value any) {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = value
}
// Get 获取数据
// Get 获取数据
func (m *MockDB) Get(key any) (any, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
v, ok := m.data[key]
return v, ok
}
// Delete 删除数据
// Delete 删除数据
func (m *MockDB) Delete(key any) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, key)
}
// Clear 清空数据
// Clear 清空数据
func (m *MockDB) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.data = make(map[any]any)
}
// MockCache 模拟缓存
// MockCache 是并发安全的简单内存缓存 mock。
type MockCache struct {
mu sync.RWMutex
data map[string][]byte
}
// NewMockCache 创建模拟缓存
// NewMockCache 创建模拟缓存
func NewMockCache() *MockCache {
return &MockCache{
data: make(map[string][]byte),
}
return &MockCache{data: make(map[string][]byte)}
}
// Set 设置缓存
// Set 设置缓存,内部会复制 value,避免调用方后续修改污染 mock。
func (m *MockCache) Set(key string, value []byte) {
m.data[key] = value
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = append([]byte(nil), value...)
}
// Get 获取缓存
// Get 获取缓存,返回值会复制一份,避免调用方修改内部状态。
func (m *MockCache) Get(key string) ([]byte, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
v, ok := m.data[key]
return v, ok
return append([]byte(nil), v...), ok
}
// Delete 删除缓存
// Delete 删除缓存
func (m *MockCache) Delete(key string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, key)
}
// Exists 检查缓存是否存在
// Exists 检查缓存是否存在
func (m *MockCache) Exists(key string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.data[key]
return ok
}
// Clear 清空缓存
// Clear 清空缓存
func (m *MockCache) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.data = make(map[string][]byte)
}
// MockStorage 模拟存储
//
// 注意(N3):本 mock 与真实 storage 的签名已对齐——
// - Upload(file *multipart.FileHeader, subdir string) 对应 storage.Upload
// - UploadFromBytes(data []byte, filename, subdir string) 对应 storage.UploadFromBytes
// 真实 storage 无导出接口,此 mock 仅供测试手写注入,不强制实现接口
// ErrMockStorageTooLarge 表示 mock storage 收到超过内存保护上限的内容
var ErrMockStorageTooLarge = errors.New("mock storage: 文件超过大小限制")
const mockStorageMaxBytes int64 = 32 << 20
// MockStorage 是并发安全的简单内存存储 mock
type MockStorage struct {
mu sync.RWMutex
files map[string][]byte
urls map[string]string
}
// NewMockStorage 创建模拟存储
// NewMockStorage 创建模拟存储
func NewMockStorage() *MockStorage {
return &MockStorage{
files: make(map[string][]byte),
@@ -304,35 +321,55 @@ func NewMockStorage() *MockStorage {
}
}
// Upload 模拟上传签名对齐 storage.Upload
// Upload 模拟上传签名对齐 storage.Upload。
func (m *MockStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
if file == nil {
return "", errors.New("mock storage: 文件不能为空")
}
if file.Size > mockStorageMaxBytes {
return "", ErrMockStorageTooLarge
}
data, err := file.Open()
if err != nil {
return "", err
}
defer data.Close()
b, err := io.ReadAll(data)
b, err := io.ReadAll(io.LimitReader(data, mockStorageMaxBytes+1))
if cerr := data.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
return "", err
}
if int64(len(b)) > mockStorageMaxBytes {
return "", ErrMockStorageTooLarge
}
path := "/mock/" + subdir + "/" + file.Filename
m.mu.Lock()
defer m.mu.Unlock()
m.files[path] = b
return path, nil
}
// UploadFromBytes 模拟字节上传签名对齐 storage.UploadFromBytes
// UploadFromBytes 模拟字节上传签名对齐 storage.UploadFromBytes。
func (m *MockStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) {
if int64(len(data)) > mockStorageMaxBytes {
return "", ErrMockStorageTooLarge
}
path := "/mock/" + subdir + "/" + filename
m.files[path] = data
m.mu.Lock()
defer m.mu.Unlock()
m.files[path] = append([]byte(nil), data...)
return path, nil
}
// GetURL 获取 URL
// GetURL 获取 mock URL
func (m *MockStorage) GetURL(path string) string {
return "http://mock.test" + path
}
// AssertEqual 通用相等断言
// AssertEqual 断言相等。
func AssertEqual(t *testing.T, expected, actual any) {
t.Helper()
if expected != actual {
@@ -340,7 +377,7 @@ func AssertEqual(t *testing.T, expected, actual any) {
}
}
// AssertNotNil 断言不为空
// AssertNotNil 断言非空。
func AssertNotNil(t *testing.T, value any) {
t.Helper()
if value == nil {
@@ -348,7 +385,7 @@ func AssertNotNil(t *testing.T, value any) {
}
}
// AssertNil 断言为空
// AssertNil 断言为空
func AssertNil(t *testing.T, value any) {
t.Helper()
if value != nil {
@@ -356,23 +393,23 @@ func AssertNil(t *testing.T, value any) {
}
}
// AssertTrue 断言为
// AssertTrue 断言为 true。
func AssertTrue(t *testing.T, value bool) {
t.Helper()
if !value {
t.Error("期望为真,实际为假")
t.Error("期望为 true,实际为 false")
}
}
// AssertFalse 断言为
// AssertFalse 断言为 false。
func AssertFalse(t *testing.T, value bool) {
t.Helper()
if value {
t.Error("期望为假,实际为真")
t.Error("期望为 false,实际为 true")
}
}
// AssertError 断言有错误
// AssertError 断言有错误
func AssertError(t *testing.T, err error) {
t.Helper()
if err == nil {
@@ -380,7 +417,7 @@ func AssertError(t *testing.T, err error) {
}
}
// AssertNoError 断言无错误
// AssertNoError 断言无错误
func AssertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
+89
View File
@@ -0,0 +1,89 @@
package test
import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
func TestRequestExecuteReturnsResponse(t *testing.T) {
router := SetupRouter()
router.POST("/users", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"name": "张三"}})
})
resp := POST(router, "/users").WithJSON(gin.H{"name": "张三"}).Execute()
resp.AssertOK(t)
resp.AssertJSONContains(t, "data.name", "张三")
}
func TestRequestExecuteRecorderKeepsRawRecorder(t *testing.T) {
router := SetupRouter()
router.GET("/ping", func(c *gin.Context) {
c.String(http.StatusCreated, "pong")
})
recorder := GET(router, "/ping").ExecuteRecorder()
if _, ok := any(recorder).(*httptest.ResponseRecorder); !ok {
t.Fatalf("ExecuteRecorder 应返回原始 ResponseRecorder")
}
if recorder.Code != http.StatusCreated {
t.Fatalf("状态码错误: 期望 %d, 实际 %d", http.StatusCreated, recorder.Code)
}
}
func TestMockCacheCopiesBytes(t *testing.T) {
cache := NewMockCache()
input := []byte("abc")
cache.Set("k", input)
input[0] = 'x'
got, ok := cache.Get("k")
if !ok {
t.Fatalf("缓存不存在")
}
if string(got) != "abc" {
t.Fatalf("缓存被外部输入污染: %q", got)
}
got[0] = 'y'
gotAgain, _ := cache.Get("k")
if string(gotAgain) != "abc" {
t.Fatalf("缓存被返回值污染: %q", gotAgain)
}
}
func TestMocksConcurrentAccess(t *testing.T) {
db := NewMockDB()
cache := NewMockCache()
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
db.Set(i, i)
_, _ = db.Get(i)
cache.Set("k", []byte{byte(i)})
_, _ = cache.Get("k")
}(i)
}
wg.Wait()
}
func TestMockStorageRejectsNilAndLargeInput(t *testing.T) {
storage := NewMockStorage()
if _, err := storage.Upload(nil, "avatars"); err == nil {
t.Fatalf("nil 文件应返回错误")
}
large := bytes.Repeat([]byte{'x'}, int(mockStorageMaxBytes)+1)
if _, err := storage.UploadFromBytes(large, "large.bin", "files"); !errors.Is(err, ErrMockStorageTooLarge) {
t.Fatalf("超大文件错误不符合预期: %v", err)
}
}
+67 -16
View File
@@ -3,9 +3,11 @@ package trace
import (
"context"
"fmt"
"math"
"net/http"
"os"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/propagators/b3"
@@ -66,6 +68,10 @@ var tracerProviderPtr atomic.Pointer[sdktrace.TracerProvider]
// tracerPtr 全局 TraceratomicC13a)。Init 之前为 Noop,调用安全。
var tracerPtr atomic.Pointer[trace.Tracer]
const defaultOperationTimeout = 5 * time.Second
var operationTimeoutNanos atomic.Int64
func init() {
// 初始化为 Noop,保证包级函数在任何时刻(未 Init / 已 CloseLoad 均非 nilC13a)。
// P1 #19:用 noop.NewTracerProvider()(新 OTel API),替代已弃用的 trace.NewNoopTracerProvider()。
@@ -74,6 +80,7 @@ func init() {
tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample()))
tracerProviderPtr.Store(tp)
tracerPtr.Store(&noopTracer)
operationTimeoutNanos.Store(int64(defaultOperationTimeout))
}
// getTracer 返回全局 Tracer 的 atomic 快照(永不 nil)。
@@ -88,6 +95,10 @@ func TracerProvider() *sdktrace.TracerProvider {
// Init 初始化链路追踪
func Init(cfg Config) error {
if math.IsNaN(cfg.SampleRatio) || cfg.SampleRatio < 0 || cfg.SampleRatio > 1 {
return fmt.Errorf("trace SampleRatio must be between 0.0 and 1.0: %v", cfg.SampleRatio)
}
if !cfg.Enabled {
// 设置 Noop TracerP1 #19noop 包替代弃用 API
noopProvider := noop.NewTracerProvider()
@@ -98,7 +109,7 @@ func Init(cfg Config) error {
// 避免禁用 trace 后旧 exporter 后台 goroutine/连接持续占用。
fallback := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample()))
if old := tracerProviderPtr.Swap(fallback); old != nil {
_ = old.Shutdown(context.Background())
_ = shutdownProviderWithTimeout(context.Background(), old)
}
return nil
}
@@ -139,7 +150,7 @@ func Init(cfg Config) error {
if err != nil {
// M-65 修复:传播器非法时回滚已创建的 provider。此时尚未 otel.SetTracerProvider
// otel 全局仍指向旧 provider,无需恢复——仅 Shutdown 新 provider 避免泄漏。
_ = newProvider.Shutdown(context.Background())
_ = shutdownProviderWithTimeout(context.Background(), newProvider)
return err
}
@@ -152,12 +163,36 @@ func Init(cfg Config) error {
tracer := newProvider.Tracer(cfg.ServiceName)
tracerPtr.Store(&tracer)
if oldProvider != nil {
_ = oldProvider.Shutdown(context.Background())
_ = shutdownProviderWithTimeout(context.Background(), oldProvider)
}
return nil
}
func operationTimeout() time.Duration {
timeout := time.Duration(operationTimeoutNanos.Load())
if timeout <= 0 {
return defaultOperationTimeout
}
return timeout
}
func contextWithOperationTimeout(parent context.Context) (context.Context, context.CancelFunc) {
if parent == nil {
parent = context.Background()
}
return context.WithTimeout(parent, operationTimeout())
}
func shutdownProviderWithTimeout(parent context.Context, provider *sdktrace.TracerProvider) error {
if provider == nil {
return nil
}
ctx, cancel := contextWithOperationTimeout(parent)
defer cancel()
return provider.Shutdown(ctx)
}
// createExporter 创建导出器
func createExporter(cfg Config) (sdktrace.SpanExporter, error) {
switch cfg.ExporterType {
@@ -167,14 +202,18 @@ func createExporter(cfg Config) (sdktrace.SpanExporter, error) {
opts = append(opts, otlptracehttp.WithInsecure()) // C13c
}
client := otlptracehttp.NewClient(opts...)
return otlptrace.New(context.Background(), client)
ctx, cancel := contextWithOperationTimeout(context.Background())
defer cancel()
return otlptrace.New(ctx, client)
case "otlp-grpc":
opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.Endpoint)}
if cfg.Insecure {
opts = append(opts, otlptracegrpc.WithInsecure()) // C13c
}
client := otlptracegrpc.NewClient(opts...)
return otlptrace.New(context.Background(), client)
ctx, cancel := contextWithOperationTimeout(context.Background())
defer cancel()
return otlptrace.New(ctx, client)
case "stdout":
return stdouttrace.New(
stdouttrace.WithWriter(os.Stdout),
@@ -219,7 +258,7 @@ func Close(ctx context.Context) error {
noopTracer := noop.NewTracerProvider().Tracer("xlgo") // P1 #19noop 包替代弃用 API
tracerPtr.Store(&noopTracer)
if old != nil {
return old.Shutdown(ctx)
return shutdownProviderWithTimeout(ctx, old)
}
return nil
}
@@ -242,15 +281,20 @@ func Middleware(serviceName string) gin.HandlerFunc {
spanName = c.Request.Method + " " + route
}
attrs := []attribute.KeyValue{
semconv.HTTPRequestMethodKey.String(c.Request.Method),
semconv.URLPathKey.String(c.Request.URL.Path),
semconv.HTTPRouteKey.String(route),
attribute.String("http.user_agent", c.Request.UserAgent()),
attribute.String("http.host", c.Request.Host),
}
if serviceName != "" {
attrs = append(attrs, attribute.String("service.name", serviceName))
}
ctx, span := getTracer().Start(ctx, spanName,
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(
semconv.HTTPRequestMethodKey.String(c.Request.Method),
semconv.URLPathKey.String(c.Request.URL.Path),
semconv.HTTPRouteKey.String(route),
attribute.String("http.user_agent", c.Request.UserAgent()),
attribute.String("http.host", c.Request.Host),
),
trace.WithAttributes(attrs...),
)
// P1 #19defer 保证下游 handler panic(在上游 recovery 捕获前)时 span 也被结束,
// 不泄漏未结束的 span。
@@ -262,8 +306,9 @@ func Middleware(serviceName string) gin.HandlerFunc {
c.Set("otel_ctx", ctx)
// 将 TraceID 添加到响应头
traceID := span.SpanContext().TraceID().String()
c.Header("X-Trace-ID", traceID)
if span.SpanContext().HasTraceID() {
c.Header("X-Trace-ID", span.SpanContext().TraceID().String())
}
// 执行请求
c.Next()
@@ -321,6 +366,9 @@ func StartSpanFromContext(ctx context.Context, name string, attrs ...attribute.K
// RecordError 记录错误
func RecordError(c *gin.Context, err error) {
if c == nil || err == nil {
return
}
ctx := GetContext(c)
span := trace.SpanFromContext(ctx)
span.RecordError(err)
@@ -329,6 +377,9 @@ func RecordError(c *gin.Context, err error) {
// RecordErrorToSpan 记录错误到指定 Span
func RecordErrorToSpan(span trace.Span, err error) {
if span == nil || err == nil {
return
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
@@ -364,4 +415,4 @@ func SetAttribute(c *gin.Context, key string, value any) {
default:
span.SetAttributes(attribute.String(key, fmt.Sprintf("%v", v)))
}
}
}
+203 -5
View File
@@ -2,10 +2,14 @@ package trace
import (
"context"
"errors"
"math"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel"
@@ -39,6 +43,60 @@ func resetGlobal(t *testing.T) {
propagation.TraceContext{}, propagation.Baggage{}))
}
func setOperationTimeoutForTest(t *testing.T, timeout time.Duration) {
t.Helper()
old := operationTimeoutNanos.Load()
operationTimeoutNanos.Store(int64(timeout))
t.Cleanup(func() {
operationTimeoutNanos.Store(old)
})
}
type blockingExporter struct{}
func (blockingExporter) ExportSpans(context.Context, []sdktrace.ReadOnlySpan) error {
return nil
}
func (blockingExporter) Shutdown(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
type recordingExporter struct {
mu sync.Mutex
spans []sdktrace.ReadOnlySpan
}
func (e *recordingExporter) ExportSpans(_ context.Context, spans []sdktrace.ReadOnlySpan) error {
e.mu.Lock()
defer e.mu.Unlock()
e.spans = append(e.spans, spans...)
return nil
}
func (e *recordingExporter) Shutdown(context.Context) error {
return nil
}
func (e *recordingExporter) Spans() []sdktrace.ReadOnlySpan {
e.mu.Lock()
defer e.mu.Unlock()
spans := make([]sdktrace.ReadOnlySpan, len(e.spans))
copy(spans, e.spans)
return spans
}
func requireNoPanic(t *testing.T, fn func()) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Fatalf("unexpected panic: %v", r)
}
}()
fn()
}
// TestC13aInitNoopInvariant 锁定 init() 的 Noop 兜底不变式:
// 包加载后(任何 Init/Close/resetGlobal 之前)getTracer() 必须非 nil。
//
@@ -204,11 +262,11 @@ func TestC13dRequestContextContainsSpan(t *testing.T) {
// 用 stdout 导出器 + 全采样,使 span 真实生成(非 Noop)。
if err := Init(Config{
Enabled: true,
ServiceName: "test-svc",
ExporterType: "stdout",
SampleRatio: 1.0,
Propagator: "w3c",
Enabled: true,
ServiceName: "test-svc",
ExporterType: "stdout",
SampleRatio: 1.0,
Propagator: "w3c",
}); err != nil {
t.Fatalf("Init: %v", err)
}
@@ -432,6 +490,146 @@ func TestH14CloseIdempotent(t *testing.T) {
}
}
// ============================================================
// M14 trace residue: validation, timeout, nil guards, headers, service name
// ============================================================
func TestM14SampleRatioRange(t *testing.T) {
t.Cleanup(func() { resetGlobal(t) })
for _, ratio := range []float64{-0.01, 1.01, math.NaN()} {
err := Init(Config{
Enabled: true,
ExporterType: "stdout",
Propagator: "w3c",
SampleRatio: ratio,
})
if err == nil {
t.Fatalf("Init with SampleRatio=%v should fail", ratio)
}
}
for _, ratio := range []float64{0, 1} {
if err := Init(Config{
Enabled: true,
ServiceName: "svc",
ExporterType: "stdout",
Propagator: "w3c",
SampleRatio: ratio,
}); err != nil {
t.Fatalf("Init with boundary SampleRatio=%v failed: %v", ratio, err)
}
if err := Close(context.Background()); err != nil {
t.Fatalf("Close after SampleRatio=%v: %v", ratio, err)
}
}
}
func TestM14CloseUsesOperationTimeout(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { resetGlobal(t) })
setOperationTimeoutForTest(t, 20*time.Millisecond)
blockingProvider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(blockingExporter{}))
tracerProviderPtr.Store(blockingProvider)
start := time.Now()
err := Close(context.Background())
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Close error = %v, want context deadline exceeded", err)
}
if elapsed > 500*time.Millisecond {
t.Fatalf("Close took %s, expected operation timeout to bound shutdown", elapsed)
}
}
func TestM14InitDisabledShutdownUsesOperationTimeout(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { resetGlobal(t) })
setOperationTimeoutForTest(t, 20*time.Millisecond)
blockingProvider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(blockingExporter{}))
tracerProviderPtr.Store(blockingProvider)
start := time.Now()
if err := Init(Config{Enabled: false, ServiceName: "svc"}); err != nil {
t.Fatalf("Init disabled: %v", err)
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("Init disabled took %s, expected operation timeout to bound old provider shutdown", elapsed)
}
}
func TestM14RecordErrorNilInputsNoPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
requireNoPanic(t, func() { RecordError(nil, errors.New("boom")) })
requireNoPanic(t, func() { RecordError(c, nil) })
var nilSpan oteltrace.Span
requireNoPanic(t, func() { RecordErrorToSpan(nilSpan, errors.New("boom")) })
requireNoPanic(t, func() {
RecordErrorToSpan(oteltrace.SpanFromContext(context.Background()), nil)
})
}
func TestM14NoZeroTraceIDHeaderFromNoop(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { resetGlobal(t) })
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Middleware("svc"))
r.GET("/p", func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/p", nil)
r.ServeHTTP(w, req)
const zeroTraceID = "00000000000000000000000000000000"
if got := w.Header().Get("X-Trace-ID"); got == zeroTraceID {
t.Fatalf("X-Trace-ID should not expose all-zero TraceID, got %q", got)
}
}
func TestM14MiddlewareServiceNameAttribute(t *testing.T) {
resetGlobal(t)
t.Cleanup(func() { resetGlobal(t) })
exporter := &recordingExporter{}
provider := sdktrace.NewTracerProvider(
sdktrace.WithSyncer(exporter),
sdktrace.WithSampler(sdktrace.AlwaysSample()),
)
tracer := provider.Tracer("test")
tracerProviderPtr.Store(provider)
tracerPtr.Store(&tracer)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Middleware("middleware-svc"))
r.GET("/p", func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/p", nil)
r.ServeHTTP(w, req)
for _, span := range exporter.Spans() {
for _, attr := range span.Attributes() {
if string(attr.Key) == "service.name" && attr.Value.AsString() == "middleware-svc" {
return
}
}
}
t.Fatal("server span missing service.name attribute from Middleware(serviceName)")
}
// TestM65PropagatorFailureLeavesOtelUsable 验证传播器失败时 otel 全局未被切到
// 已 Shutdown 的 providerM-65)。失败后仍可正常 start span 不 panic。
func TestM65PropagatorFailureLeavesOtelUsable(t *testing.T) {
+14 -2
View File
@@ -10,6 +10,12 @@ func ToInt(s string) int {
return n
}
// ToIntE converts a string to int and returns parse errors. Prefer it when 0
// is a meaningful value and parse failure must be distinguishable.
func ToIntE(s string) (int, error) {
return strconv.Atoi(s)
}
// ToIntDefault 字符串转 int,失败返回默认值
func ToIntDefault(s string, def int) int {
n, err := strconv.Atoi(s)
@@ -25,6 +31,12 @@ func ToInt64(s string) int64 {
return n
}
// ToInt64E converts a string to int64 and returns parse errors. Prefer it when
// 0 is a meaningful value and parse failure must be distinguishable.
func ToInt64E(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
}
// ToInt64Default 字符串转 int64,失败返回默认值
func ToInt64Default(s string, def int64) int64 {
n, err := strconv.ParseInt(s, 10, 64)
@@ -74,7 +86,7 @@ func ToString64(n int64) string {
return strconv.FormatInt(n, 10)
}
// CalcPageCount 计算总页数
// CalcPageCount 计算总页数(向上取整)。total<=0 或 pageSize<=0 时返回 0。
func CalcPageCount(total, pageSize int64) int64 {
if total <= 0 || pageSize <= 0 {
return 0
@@ -82,7 +94,7 @@ func CalcPageCount(total, pageSize int64) int64 {
return (total + pageSize - 1) / pageSize
}
// CalcOffset 计算分页偏移量
// CalcOffset 计算分页偏移量 (page-1)*pageSize。page<=0 时按 1 处理,pageSize<=0 时按 20 处理。
func CalcOffset(page, pageSize int) int {
if page <= 0 {
page = 1
+10 -6
View File
@@ -2,8 +2,8 @@ package utils
import (
"bytes"
"crypto/md5"
"crypto/sha1"
"crypto/md5" // #nosec G501 -- exported checksum helper for non-password/non-security use; callers needing security should use SHA256/HMAC.
"crypto/sha1" // #nosec G505 -- exported checksum helper for legacy/non-security use; callers needing security should use SHA256/HMAC.
"crypto/sha256"
"encoding/base64"
"encoding/hex"
@@ -20,6 +20,7 @@ func MD5(s string) string {
// MD5Bytes 计算字节数组的 MD5 哈希值
func MD5Bytes(data []byte) string {
// #nosec G401 -- MD5 helper is retained for legacy checksums, not password storage or security decisions.
h := md5.New()
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
@@ -28,6 +29,7 @@ func MD5Bytes(data []byte) string {
// SHA1 计算字符串的 SHA1 哈希值
// 注意: 不应用于密码存储
func SHA1(s string) string {
// #nosec G401 -- SHA1 helper is retained for legacy checksums, not password storage or security decisions.
h := sha1.New()
h.Write([]byte(s))
return hex.EncodeToString(h.Sum(nil))
@@ -47,12 +49,14 @@ func SHA256Bytes(data []byte) string {
return hex.EncodeToString(h.Sum(nil))
}
// HashFile 计算文件的哈希值。
// HashFile 流式计算文件的哈希值,返回 hex 编码字符串
//
// M-F 修复:改为流式 io.Copy(h, f),不再经 ReadFile 把整文件读入内存——原实现大文件 OOM
// 流式哈希内存占用恒定、可处理任意大小文件,与 hash.Hash 的 io.Writer 接口契合。
// 调用方负责选择哈希算法(如 sha256.New)。
// newHash 须返回全新的 hash.Hash 实例(如 sha256.New),本函数不复用它
// Open 或读取失败时返回错误。M-F 修复:改为流式 io.Copy(h, f),不再经 ReadFile 把整文件
// 读入内存——原实现大文件 OOM。流式哈希内存占用恒定、可处理任意大小文件,与 hash.Hash
// 的 io.Writer 接口契合。调用方负责选择哈希算法(如 sha256.New)。
func HashFile(path string, newHash func() hash.Hash) (string, error) {
// #nosec G304 -- generic hashing helper intentionally opens caller-provided trusted local paths.
f, err := os.Open(path)
if err != nil {
return "", err
+2 -2
View File
@@ -73,12 +73,12 @@ func StartOfWeek(t time.Time) time.Time {
return StartOfDay(monday)
}
// StartOfMonth 返回指定时间当月的开始时间
// StartOfMonth 返回指定时间当月 1 日 00:00:00(保留原时区)。
func StartOfMonth(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
}
// EndOfMonth 返回指定时间当月的结束时间
// EndOfMonth 返回指定时间当月最后一天 23:59:59.999999999(保留原时区)。
func EndOfMonth(t time.Time) time.Time {
return time.Date(t.Year(), t.Month()+1, 0, 23, 59, 59, 999999999, t.Location())
}
+17 -4
View File
@@ -1,6 +1,7 @@
package utils
import (
"errors"
"io"
"os"
"path/filepath"
@@ -30,6 +31,7 @@ func DirExists(path string) bool {
// EnsureDir 确保目录存在,不存在则创建
func EnsureDir(path string) error {
// #nosec G301 -- generic filesystem helper intentionally uses conventional directory permissions; caller controls trusted path and policy.
return os.MkdirAll(path, 0755)
}
@@ -42,6 +44,7 @@ func EnsureDir(path string) error {
// 注意:本函数无大小上限(os.ReadFile 语义),读取不可信/超大文件有 OOM 风险——
// 需限长读取请用 io.ReadAll(io.LimitReader(...));流式哈希大文件请用 HashFile(已流式)。
func ReadFile(path string) ([]byte, error) {
// #nosec G304 -- generic filesystem helper intentionally reads caller-provided trusted paths; package docs require callers to sanitize untrusted input.
return os.ReadFile(path)
}
@@ -51,6 +54,7 @@ func WriteFile(path string, data []byte) error {
if err := EnsureDir(dir); err != nil {
return err
}
// #nosec G306 -- generic filesystem helper intentionally uses conventional file permissions; caller controls trusted path and policy.
return os.WriteFile(path, data, 0644)
}
@@ -60,22 +64,28 @@ func AppendFile(path string, data []byte) error {
if err := EnsureDir(dir); err != nil {
return err
}
// #nosec G302,G304 -- generic filesystem helper intentionally appends caller-provided trusted paths with conventional file permissions.
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(data)
if cerr := f.Close(); cerr != nil {
return errors.Join(err, cerr)
}
return err
}
// CopyFile 复制文件
// CopyFile 将 src 复制到 dst(覆盖),自动创建目标目录。src 不存在时返回其 Open 错误。
func CopyFile(dst, src string) error {
// #nosec G304 -- generic filesystem helper intentionally opens caller-provided trusted source paths.
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
defer func() {
_ = srcFile.Close()
}()
// 确保目标目录存在
dir := filepath.Dir(dst)
@@ -83,13 +93,16 @@ func CopyFile(dst, src string) error {
return err
}
// #nosec G304 -- generic filesystem helper intentionally creates caller-provided trusted destination paths.
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
if cerr := dstFile.Close(); cerr != nil {
return errors.Join(err, cerr)
}
return err
}
+130 -126
View File
@@ -19,54 +19,42 @@ import (
"time"
)
// HTTPClient HTTP 客户端封装
// HTTPClient 封装可运行期调整配置的 HTTP 客户端
type HTTPClient struct {
// mu 保护 client/transport/cfg/timeout/skipTLS 的并发读写。SetSkipTLS/SetTimeout
// 在写锁下重建 client/transportdo/DoWithResponse/Close 在读锁下快照后无锁调用
// http.Client.DoDo 自身并发安全),不持有锁发请求以避免序列化。
mu sync.RWMutex
client *http.Client
transport *http.Transport
cfg HTTPClientConfig // 保留配置以便 SetSkipTLS 重建 transport
cfg HTTPClientConfig
timeout time.Duration
headers map[string]string
cookies map[string]string
skipTLS bool
// maxRespBodySize 响应体读取上限(字节),0 表示用默认 32MB-1 表示不限。
// 防止恶意/异常服务端返回超大响应打爆内存(C5/N5)。
// maxRespBodySize 限制 do 读取的响应体大小;0 使用默认值-1 表示不限
maxRespBodySize int64
}
// UploadFile 上传文件信息
// UploadFile 描述 multipart 上传中的文件字段。
type UploadFile struct {
FieldName string // 表单字段名
FilePath string // 文件路径
FieldName string
FilePath string
}
// HTTPClientConfig HTTP 客户端配置
// HTTPClientConfig HTTPClient 的配置
type HTTPClientConfig struct {
Timeout time.Duration // 请求超时时间
MaxIdleConns int // 最大空闲连接数
IdleConnTimeout time.Duration // 空闲连接超时时间
MaxConnsPerHost int // 每个主机最大连接数
MaxIdleConnsPerHost int // 每个主机最大空闲连接数
SkipTLSVerify bool // 是否跳过 TLS 验证(默认 false 校验 TLS;自签证书场景需显式设 true)
// MaxResponseBodySize 响应体读取上限(字节)。0 = 默认 32MB,-1 = 不限制。
// 防止异常服务端返回超大响应打爆内存(C5/N5)。
MaxResponseBodySize int64
// BlockPrivateNetworks 启用 SSRF 防护(P0,默认 false 保持兼容)。启用后,连接建立时
// 校验解析出的目标 IP,拒绝回环/私有(RFC1918+ULA)/链路本地/元数据(169.254.169.254)/
// 未指定/多播等内网地址。校验在 DialContext.Control 中进行,对重定向的每一跳同样生效。
// 当 URL 可能来自用户输入(webhook、头像抓取等)时应开启。
Timeout time.Duration
MaxIdleConns int
IdleConnTimeout time.Duration
MaxConnsPerHost int
MaxIdleConnsPerHost int
SkipTLSVerify bool
MaxResponseBodySize int64
BlockPrivateNetworks bool
}
// ErrSSRFBlocked 目标 IP 落在被拦截网段(SSRF 防护,P0)
var ErrSSRFBlocked = errors.New("ssrf guard: 目标 IP 属被拦截网段(回环/私有/链路本地/元数据)")
// ErrSSRFBlocked 表示 SSRF 防护拦截了目标 IP。
var ErrSSRFBlocked = errors.New("ssrf guard: 目标 IP 属被拦截的网络范围")
// isBlockedIP 判断 IP 是否应被 SSRF 防护拦截。
// 覆盖:回环、私有(RFC1918 + ULA fc00::/7)、链路本地(含 169.254.169.254 云元数据 / fe80::)、
// 未指定(0.0.0.0/::)、多播。
// isBlockedIP 判断 ip 是否属于应被 SSRF 防护拦截的网络范围
func isBlockedIP(ip net.IP) bool {
if ip == nil {
return true
@@ -76,8 +64,7 @@ func isBlockedIP(ip net.IP) bool {
ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast()
}
// ssrfControl 是 net.Dialer.Control 回调:在 DNS 解析后、真正 dial 前校验目标 IP
// 命中内网段即拒绝。放在 Control 层可覆盖 DNS 重绑定与重定向的每一跳(P0)。
// ssrfControl 在拨号前拦截私有、回环、链路本地、未指定和多播 IP
func ssrfControl(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
@@ -93,32 +80,31 @@ func ssrfControl(network, address string, _ syscall.RawConn) error {
return nil
}
// DefaultHTTPClientConfig 默认配置
// DefaultHTTPClientConfig 是包级默认 HTTP 客户端配置
var DefaultHTTPClientConfig = HTTPClientConfig{
Timeout: 30 * time.Second,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
MaxConnsPerHost: 10,
MaxIdleConnsPerHost: 10,
SkipTLSVerify: false, // H2 修复:默认校验 TLS,防 MITM;自签证书需显式 SetSkipTLS(true)
SkipTLSVerify: false,
MaxResponseBodySize: 32 * 1024 * 1024,
}
// NewHTTPClient 创建 HTTP 客户端
// NewHTTPClient 使用默认配置创建 HTTP 客户端
func NewHTTPClient() *HTTPClient {
cfg := DefaultHTTPClientConfig
return NewHTTPClientWithConfig(cfg)
}
// NewSSRFSafeHTTPClient 创建启用 SSRF 防护的 HTTP 客户端P0):拒绝连接内网目标 IP
// 适用于目标 URL 可能来自用户输入的场景(webhook 回调、远程图片抓取等)。
// NewSSRFSafeHTTPClient 创建启用 SSRF 防护的 HTTP 客户端。
func NewSSRFSafeHTTPClient() *HTTPClient {
cfg := DefaultHTTPClientConfig
cfg.BlockPrivateNetworks = true
return NewHTTPClientWithConfig(cfg)
}
// NewHTTPClientWithConfig 使用自定义配置创建 HTTP 客户端
// NewHTTPClientWithConfig 使用 cfg 创建 HTTP 客户端
func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
transport, client := buildHTTPClientPair(cfg)
return &HTTPClient{
@@ -133,12 +119,10 @@ func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
}
}
// buildHTTPClientPair cfg 构造 (transport, client) 对。SetSkipTLS 重建时复用。
// buildHTTPClientPair 根据 cfg 构造 transport/client 对。
func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
// Transport 在初始化时创建,连接池可复用
transport := &http.Transport{
// #nosec G402 -- InsecureSkipVerify 仅在调用方显式设 cfg.SkipTLSVerify=true 时启用,
// 默认 false 校验 TLS(H2 修复)。自签证书场景需 opt-in。
// #nosec G402 -- SkipTLSVerify 仅在调用方显式配置时启用,默认校验 TLS。
TLSClientConfig: &tls.Config{
InsecureSkipVerify: cfg.SkipTLSVerify,
},
@@ -148,7 +132,6 @@ func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
DisableCompression: false,
}
// SSRF 防护(P0):启用后为拨号器装 Control 回调,连接建立时拦截内网目标 IP。
if cfg.BlockPrivateNetworks {
dialer := &net.Dialer{
Timeout: 30 * time.Second,
@@ -164,36 +147,33 @@ func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
return transport, client
}
// currentClient 在读锁下快照当前 *http.ClientH-12:与 SetSkipTLS/SetTimeout 重建无竞态)
// currentClient 返回当前 client 快照
func (c *HTTPClient) currentClient() *http.Client {
c.mu.RLock()
defer c.mu.RUnlock()
return c.client
}
// currentTransport 在读锁下快照当前 *http.Transport。
// currentTransport 返回当前 transport 快照
func (c *HTTPClient) currentTransport() *http.Transport {
c.mu.RLock()
defer c.mu.RUnlock()
return c.transport
}
// SetTimeout 设置超时时间
// H-12 修复:在写锁下重建 *http.Client(复用 transport 保留连接池),避免与并发 Do 对
// client.Timeout 字段的数据竞争。
// SetTimeout 更新请求超时时间,并保留当前 transport
func (c *HTTPClient) SetTimeout(timeout time.Duration) *HTTPClient {
c.mu.Lock()
defer c.mu.Unlock()
c.timeout = timeout
c.cfg.Timeout = timeout
_, client := buildHTTPClientPair(c.cfg)
// 复用旧 transport 保留连接池:新 client 用旧 transport + 新 timeout。
client.Transport = c.transport
c.client = client
return c
}
// SetHeader 设置请求头(P0:写锁保护,与并发 do/DoWithResponse 读 map 无竞态)
// SetHeader 设置后续请求使用的请求头。
func (c *HTTPClient) SetHeader(key, value string) *HTTPClient {
c.mu.Lock()
c.headers[key] = value
@@ -201,7 +181,7 @@ func (c *HTTPClient) SetHeader(key, value string) *HTTPClient {
return c
}
// SetHeaders 批量设置请求头P0:写锁保护)
// SetHeaders 批量设置后续请求使用的请求头。
func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient {
c.mu.Lock()
for k, v := range headers {
@@ -211,7 +191,7 @@ func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient {
return c
}
// SetCookie 设置 CookieP0:写锁保护)
// SetCookie 设置后续请求使用的 Cookie。
func (c *HTTPClient) SetCookie(key, value string) *HTTPClient {
c.mu.Lock()
c.cookies[key] = value
@@ -219,8 +199,7 @@ func (c *HTTPClient) SetCookie(key, value string) *HTTPClient {
return c
}
// snapshotHeadersCookies 在读锁下拷贝 headers/cookies,供 do/DoWithResponse 在锁外应用到请求,
// 消除与 SetHeader/SetHeaders/SetCookie 的 map 读写竞态(P0)。
// snapshotHeadersCookies 在锁内复制可变的 headers/cookies
func (c *HTTPClient) snapshotHeadersCookies() (headers, cookies map[string]string) {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -235,14 +214,7 @@ func (c *HTTPClient) snapshotHeadersCookies() (headers, cookies map[string]strin
return headers, cookies
}
// SetSkipTLS 设置是否跳过 TLS 证。
// 跳过 TLS 校验会暴露于 MITM 攻击,仅在受控环境(如自签证书的内网服务)且明确风险时启用,
// 生产环境应保持 false。
//
// H-12 修复:原实现直接覆盖 c.transport.TLSClientConfig 指针,与并发 Do 读取该字段
// 存在数据竞争(-race 必采),且注释自承"需重建 Transport"却未重建。改为在写锁下用
// 新配置重建 transport+client 并原子替换,旧 transport 释放空闲连接(保留旧 TLS 配置的
// idle 连接不再被复用)。支持运行期并发调用与并发请求无竞态。
// SetSkipTLS 切换后续请求是否校验 TLS 证
func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient {
c.mu.Lock()
c.skipTLS = skip
@@ -252,16 +224,14 @@ func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient {
c.transport = transport
c.client = client
c.mu.Unlock()
// 锁外释放旧 transport 的空闲连接CloseIdleConnections 仅关 idle 连接,不影响在途请求)
// 锁外释放旧 transport 的空闲连接。
if oldTransport != nil {
oldTransport.CloseIdleConnections()
}
return c
}
// SetBlockPrivateNetworks 运行期开关 SSRF 防护(P0)。开启后连接内网 IP(回环/私有/链路本地/
// 元数据等)会被拒绝并返回 ErrSSRFBlocked。写锁下重建 transport+client 并原子替换,
// 与并发请求无竞态;旧 transport 的空闲连接在锁外释放。
// SetBlockPrivateNetworks 切换后续请求的 SSRF 防护
func (c *HTTPClient) SetBlockPrivateNetworks(block bool) *HTTPClient {
c.mu.Lock()
c.cfg.BlockPrivateNetworks = block
@@ -276,7 +246,7 @@ func (c *HTTPClient) SetBlockPrivateNetworks(block bool) *HTTPClient {
return c
}
// Get 发送 GET 请求
// Get 发送 GET 请求
func (c *HTTPClient) Get(urlStr string, params map[string]string) ([]byte, error) {
if len(params) > 0 {
u, err := url.Parse(urlStr)
@@ -298,7 +268,7 @@ func (c *HTTPClient) Get(urlStr string, params map[string]string) ([]byte, error
return c.do(req)
}
// Post 发送 POST 请求form 表单格式)
// Post 发送表单编码的 POST 请求
func (c *HTTPClient) Post(urlStr string, params map[string]string) ([]byte, error) {
data := url.Values{}
for k, v := range params {
@@ -313,7 +283,7 @@ func (c *HTTPClient) Post(urlStr string, params map[string]string) ([]byte, erro
return c.do(req)
}
// PostJSON 发送 POST 请求JSON 格式)
// PostJSON 发送 JSON POST 请求
func (c *HTTPClient) PostJSON(urlStr string, data any) ([]byte, error) {
jsonData, err := json.Marshal(data)
if err != nil {
@@ -328,7 +298,7 @@ func (c *HTTPClient) PostJSON(urlStr string, data any) ([]byte, error) {
return c.do(req)
}
// Put 发送 PUT 请求JSON 格式)
// Put 发送 JSON PUT 请求
func (c *HTTPClient) Put(urlStr string, data any) ([]byte, error) {
jsonData, err := json.Marshal(data)
if err != nil {
@@ -343,7 +313,7 @@ func (c *HTTPClient) Put(urlStr string, data any) ([]byte, error) {
return c.do(req)
}
// Delete 发送 DELETE 请求
// Delete 发送 DELETE 请求
func (c *HTTPClient) Delete(urlStr string) ([]byte, error) {
req, err := http.NewRequest("DELETE", urlStr, nil)
if err != nil {
@@ -352,49 +322,85 @@ func (c *HTTPClient) Delete(urlStr string) ([]byte, error) {
return c.do(req)
}
// Upload 上传文件
// Upload 以流式 multipart 上传文件,避免把完整请求体缓存在内存中。
func (c *HTTPClient) Upload(urlStr string, files []UploadFile, params map[string]string) ([]byte, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
errCh := make(chan error, 1)
for _, f := range files {
file, err := os.Open(f.FilePath)
if err != nil {
return nil, err
}
go func() {
errCh <- writeMultipartUpload(pw, writer, files, params)
}()
part, err := writer.CreateFormFile(f.FieldName, filepath.Base(f.FilePath))
if err != nil {
file.Close()
return nil, err
}
if _, err = io.Copy(part, file); err != nil {
file.Close()
return nil, err
}
// 显式关闭,避免循环内 defer 累积 FD(N5/C5)。
file.Close()
}
for k, v := range params {
if err := writer.WriteField(k, v); err != nil {
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
req, err := http.NewRequest("POST", urlStr, &buf)
req, err := http.NewRequest("POST", urlStr, pr)
if err != nil {
_ = pr.CloseWithError(err)
_ = pw.CloseWithError(err)
<-errCh
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
return c.do(req)
resp, err := c.do(req)
if err != nil {
_ = pr.CloseWithError(err)
<-errCh
return nil, err
}
if err := <-errCh; err != nil {
return nil, err
}
return resp, nil
}
// UploadFromBytes 从字节数据上传文件
func writeMultipartUpload(pw *io.PipeWriter, writer *multipart.Writer, files []UploadFile, params map[string]string) (err error) {
defer func() {
if err != nil {
_ = pw.CloseWithError(err)
return
}
if closeErr := writer.Close(); closeErr != nil {
_ = pw.CloseWithError(closeErr)
err = closeErr
return
}
err = pw.Close()
}()
for _, f := range files {
if err = writeMultipartFile(writer, f); err != nil {
return err
}
}
for k, v := range params {
if err = writer.WriteField(k, v); err != nil {
return err
}
}
return nil
}
func writeMultipartFile(writer *multipart.Writer, f UploadFile) (err error) {
file, err := os.Open(f.FilePath)
if err != nil {
return err
}
defer func() {
if cerr := file.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
}()
part, err := writer.CreateFormFile(f.FieldName, filepath.Base(f.FilePath))
if err != nil {
return err
}
_, err = io.Copy(part, file)
return err
}
// UploadFromBytes 将内存中的字节切片作为 multipart 文件上传。
func (c *HTTPClient) UploadFromBytes(urlStr string, fieldName string, filename string, data []byte, params map[string]string) ([]byte, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
@@ -425,7 +431,7 @@ func (c *HTTPClient) UploadFromBytes(urlStr string, fieldName string, filename s
return c.do(req)
}
// Request 发送自定义请求
// Request 使用调用方指定的方法和 JSON 请求体发送请求
func (c *HTTPClient) Request(method, urlStr string, body []byte) ([]byte, error) {
req, err := http.NewRequest(method, urlStr, bytes.NewReader(body))
if err != nil {
@@ -437,38 +443,37 @@ func (c *HTTPClient) Request(method, urlStr string, body []byte) ([]byte, error)
return c.do(req)
}
// do 执行请求(使用共享的 client 和 transport
// do 执行 req,并按配置封顶读取响应体。
func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
headers, cookies := c.snapshotHeadersCookies()
for k, v := range headers {
req.Header.Set(k, v)
}
for k, v := range cookies {
// #nosec G124 -- 这里构造的是出站 Cookie 请求头;Secure/HttpOnly/SameSite 适用于响应 Set-Cookie。
req.AddCookie(&http.Cookie{Name: k, Value: v})
}
// 发送请求(H-12:快照 client,与 SetSkipTLS/SetTimeout 重建无竞态)
// #nosec G704 -- 默认客户端为兼容性允许调用方传入 URL;不可信 URL 请使用 NewSSRFSafeHTTPClient/BlockPrivateNetworks。
resp, err := c.currentClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 检查状态码
// 将 4xx/5xx 响应视为请求错误。
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("http error: %d %s", resp.StatusCode, resp.Status)
return nil, fmt.Errorf("HTTP 请求失败: %d %s", resp.StatusCode, resp.Status)
}
// 响应体读取封顶,防异常服务端返回超大响应打爆内存(C5/N5)
// maxRespBodySize: 0=默认 32MB-1=不限。
// maxRespBodySize: 0 使用默认 32MiB 上限;-1 表示不限制
limit := c.maxRespBodySize
if limit == 0 {
limit = 32 * 1024 * 1024
}
var reader io.Reader = resp.Body
if limit > 0 {
// 多读 1 字节用于判断是否超限。
// 多读 1 字节用于判断响应体是否超限。
reader = io.LimitReader(resp.Body, limit+1)
}
data, err := io.ReadAll(reader)
@@ -476,44 +481,43 @@ func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
return nil, err
}
if limit > 0 && int64(len(data)) > limit {
return nil, fmt.Errorf("response body exceeds limit %d bytes", limit)
return nil, fmt.Errorf("响应体超过限制 %d 字节", limit)
}
return data, nil
}
// DoWithResponse 执行请求并返回完整响应
//
// 注意:调用方负责关闭返回的 resp.Body;且此方法不套用 maxRespBodySize 读封顶(由调用方掌控)。
// DoWithResponse 执行 req 并返回原始响应;调用方必须关闭 resp.Body
func (c *HTTPClient) DoWithResponse(req *http.Request) (*http.Response, error) {
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
headers, cookies := c.snapshotHeadersCookies()
for k, v := range headers {
req.Header.Set(k, v)
}
for k, v := range cookies {
// #nosec G124 -- 这里构造的是出站 Cookie 请求头;Secure/HttpOnly/SameSite 适用于响应 Set-Cookie。
req.AddCookie(&http.Cookie{Name: k, Value: v})
}
// #nosec G704 -- 默认客户端为兼容性允许调用方传入 URL;不可信 URL 请使用 NewSSRFSafeHTTPClient/BlockPrivateNetworks。
return c.currentClient().Do(req)
}
// Close 关闭客户端(释放连接池资源)
// Close 释放客户端 transport 持有的空闲连接。
func (c *HTTPClient) Close() {
if t := c.currentTransport(); t != nil {
t.CloseIdleConnections()
}
}
// JSONMarshal 内部 JSON 序列化函数
// JSONMarshal 将 v 序列化为 JSON。
func JSONMarshal(v any) ([]byte, error) {
return json.Marshal(v)
}
// 全局默认 HTTP 客户端
// defaultClient 是包级共享 HTTP 客户端
var defaultClient *HTTPClient
var defaultClientOnce sync.Once
// DefaultHTTPClient 获取全局默认 HTTP 客户端
// DefaultHTTPClient 返回包级共享 HTTP 客户端
func DefaultHTTPClient() *HTTPClient {
defaultClientOnce.Do(func() {
defaultClient = NewHTTPClient()
@@ -521,17 +525,17 @@ func DefaultHTTPClient() *HTTPClient {
return defaultClient
}
// HTTPGet 使用默认客户端发送 GET 请求
// HTTPGet 使用默认客户端发送 GET 请求
func HTTPGet(url string, params map[string]string) ([]byte, error) {
return DefaultHTTPClient().Get(url, params)
}
// HTTPPost 使用默认客户端发送 POST 请求
// HTTPPost 使用默认客户端发送表单 POST 请求
func HTTPPost(url string, params map[string]string) ([]byte, error) {
return DefaultHTTPClient().Post(url, params)
}
// HTTPPostJSON 使用默认客户端发送 JSON POST 请求
// HTTPPostJSON 使用默认客户端发送 JSON POST 请求
func HTTPPostJSON(url string, data any) ([]byte, error) {
return DefaultHTTPClient().PostJSON(url, data)
}
}
+47
View File
@@ -3,8 +3,11 @@ package utils_test
import (
"crypto/tls"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
@@ -246,3 +249,47 @@ func TestHTTPClientSetBlockPrivateNetworks(t *testing.T) {
t.Errorf("after enabling guard, err = %v, want ErrSSRFBlocked", err)
}
}
func TestHTTPClientUploadStreamsMultipartBody(t *testing.T) {
tmp := t.TempDir()
filePath := filepath.Join(tmp, "payload.txt")
if err := os.WriteFile(filePath, []byte("streamed payload"), 0644); err != nil {
t.Fatalf("write temp payload: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength != -1 {
t.Errorf("ContentLength = %d, want -1 for streaming upload", r.ContentLength)
}
if err := r.ParseMultipartForm(1024); err != nil {
t.Fatalf("ParseMultipartForm: %v", err)
}
if got := r.FormValue("kind"); got != "test" {
t.Errorf("form field kind = %q, want test", got)
}
file, _, err := r.FormFile("file")
if err != nil {
t.Fatalf("FormFile: %v", err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
t.Fatalf("read uploaded file: %v", err)
}
if string(data) != "streamed payload" {
t.Errorf("uploaded data = %q", string(data))
}
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
c := utils.NewHTTPClient()
defer c.Close()
body, err := c.Upload(srv.URL, []utils.UploadFile{{FieldName: "file", FilePath: filePath}}, map[string]string{"kind": "test"})
if err != nil {
t.Fatalf("Upload: %v", err)
}
if string(body) != "ok" {
t.Errorf("body = %q, want ok", string(body))
}
}
+2 -2
View File
@@ -36,7 +36,7 @@ const (
// ErrRandInvalidLength 长度过大(超过 1<<20,保护熵池)。n<=0 时返回空串而非此错误。
var ErrRandInvalidLength = errors.New("invalid random length")
// RandInt 返回 [min, max) 范围内的随机整数。
// RandInt 返回 [min, max) 范围内的随机整数。min==max 时返回 minmax<min 自动交换。
//
// 非密码学安全(math/rand + sync.Pool),仅用于非安全场景(负载均衡、游戏、A/B 分桶等);
// 不适用于 token/OTP 等安全场景。
@@ -52,7 +52,7 @@ func RandInt(min, max int) int {
return min + r.Intn(max-min)
}
// RandInt64 返回 [min, max) 范围内的随机 int64(非密码学安全)。
// RandInt64 返回 [min, max) 范围内的随机 int64(非密码学安全)。min==max 返回 minmax<min 自动交换。
func RandInt64(min, max int64) int64 {
if min == max {
return min
+2 -1
View File
@@ -102,7 +102,8 @@ func trimRightFunc(s string, f func(rune) bool) string {
return ""
}
// Substr 截取子字符串(按 Unicode 字符计数)
// Substr 截取子字符串(按 Unicode 字符计数)
// start 支持负数(从末尾计算);start 越界或 length<=0 返回空串;length 超出末尾自动截断。
// 参数: start 起始位置(支持负数从末尾计算),length 截取长度
func Substr(s string, start, length int) string {
runes := []rune(s)
+52 -6
View File
@@ -1,6 +1,8 @@
package utils_test
import (
"os"
"path/filepath"
"testing"
"time"
@@ -92,9 +94,9 @@ func TestDefaultIfBlank(t *testing.T) {
func TestSubstr(t *testing.T) {
tests := []struct {
input string
start int
length int
input string
start int
length int
expected string
}{
{"hello世界", 0, 7, "hello世界"},
@@ -259,12 +261,32 @@ func TestToIntDefault(t *testing.T) {
}
}
func TestToIntE(t *testing.T) {
n, err := utils.ToIntE("123")
if err != nil || n != 123 {
t.Fatalf("ToIntE valid = %d, err=%v; want 123,nil", n, err)
}
if _, err := utils.ToIntE("abc"); err == nil {
t.Fatal("ToIntE invalid should return error")
}
}
func TestToInt64(t *testing.T) {
if utils.ToInt64("1234567890123") != 1234567890123 {
t.Error("ToInt64 failed")
}
}
func TestToInt64E(t *testing.T) {
n, err := utils.ToInt64E("1234567890123")
if err != nil || n != 1234567890123 {
t.Fatalf("ToInt64E valid = %d, err=%v; want 1234567890123,nil", n, err)
}
if _, err := utils.ToInt64E("abc"); err == nil {
t.Fatal("ToInt64E invalid should return error")
}
}
func TestToInt64Default(t *testing.T) {
if utils.ToInt64Default("abc", 999) != 999 {
t.Error("ToInt64Default failed")
@@ -334,8 +356,8 @@ func TestCalcOffset(t *testing.T) {
{1, 20, 0},
{2, 20, 20},
{3, 10, 20},
{0, 20, 0}, // page <= 0 自动修正为 1
{1, 0, 0}, // pageSize <= 0 自动修正为 20
{0, 20, 0}, // page <= 0 自动修正为 1
{1, 0, 0}, // pageSize <= 0 自动修正为 20
}
for _, tt := range tests {
@@ -346,6 +368,30 @@ func TestCalcOffset(t *testing.T) {
}
}
func TestAppendFileAndCopyFile(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src.txt")
dst := filepath.Join(tmp, "nested", "dst.txt")
if err := utils.AppendFile(src, []byte("hello")); err != nil {
t.Fatalf("AppendFile first write: %v", err)
}
if err := utils.AppendFile(src, []byte(" world")); err != nil {
t.Fatalf("AppendFile second write: %v", err)
}
if err := utils.CopyFile(dst, src); err != nil {
t.Fatalf("CopyFile: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile copied file: %v", err)
}
if string(data) != "hello world" {
t.Errorf("copied data = %q, want hello world", string(data))
}
}
// ===== Validator Tests =====
func TestIsPhone(t *testing.T) {
@@ -813,4 +859,4 @@ func BenchmarkCalcPageCount(b *testing.B) {
for i := 0; i < b.N; i++ {
utils.CalcPageCount(10000, 20)
}
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ func IsIDCard(id string) bool {
return idCardRE.MatchString(id)
}
// IsChinese 检查字符串是否全部为中文字符
// IsChinese 检查字符串是否非空且全部为中文字符CJK 基本区 U+4E00..U+9FFF)。空串返回 false。
func IsChinese(s string) bool {
for _, r := range s {
if r < 0x4E00 || r > 0x9FFF {
+31 -17
View File
@@ -4,23 +4,39 @@ import (
"golang.org/x/crypto/bcrypt"
)
// 默认加密成本(bcrypt 推荐值)
// defaultCost 是框架默认 bcrypt cost。
const defaultCost = 12
// HashPassword 对密码进行加密
// normalizeHashCost 将 bcrypt cost 归一化到 bcrypt 支持范围内。
func normalizeHashCost(cost int) int {
if cost < bcrypt.MinCost {
return bcrypt.MinCost
}
if cost > bcrypt.MaxCost {
return bcrypt.MaxCost
}
return cost
}
// normalizeUpgradeTargetCost 归一化升级目标 cost,异常值回退到框架默认值。
func normalizeUpgradeTargetCost(cost int) int {
if cost <= 0 || cost > bcrypt.MaxCost {
return defaultCost
}
return normalizeHashCost(cost)
}
// HashPassword 使用框架默认 cost 哈希明文密码。
func HashPassword(password string) (string, error) {
return HashPasswordWithCost(password, defaultCost)
}
// HashPasswordWithCost 使用指定成本对密码进行加密
// cost 范围: 4-31,值越大越安全但越慢
// HashPasswordWithCost 使用指定 cost 哈希明文密码。
//
// 低于 bcrypt.MinCost 的 cost 会提升到 bcrypt.MinCost,高于 bcrypt.MaxCost
// 的 cost 会降到 bcrypt.MaxCost。
func HashPasswordWithCost(password string, cost int) (string, error) {
if cost < bcrypt.MinCost {
cost = bcrypt.MinCost
}
if cost > bcrypt.MaxCost {
cost = bcrypt.MaxCost
}
cost = normalizeHashCost(cost)
bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
@@ -29,23 +45,21 @@ func HashPasswordWithCost(password string, cost int) (string, error) {
return string(bytes), nil
}
// CheckPassword 验证密码是否匹配
// hashedPassword 是加密后的密码,password 是明文密码
// CheckPassword 校验 hashedPassword 是否匹配明文密码。
func CheckPassword(hashedPassword, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
return err == nil
}
// CheckPasswordAndUpgrade 验密码并在需要时升级加密成本
// 返回:是否匹配、是否需要升级、升级后的密码、错误
// CheckPasswordAndUpgrade 验密码并在存量 hash 的 cost 低于归一化后的目标 cost 时重新哈希。
func CheckPasswordAndUpgrade(hashedPassword, password string, targetCost int) (match bool, needUpgrade bool, newHash string, err error) {
// 验证密码
targetCost = normalizeUpgradeTargetCost(targetCost)
err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
if err != nil {
return false, false, "", err
}
// 检查是否需要升级
cost, err := bcrypt.Cost([]byte(hashedPassword))
if err != nil {
return true, false, "", nil
@@ -62,7 +76,7 @@ func CheckPasswordAndUpgrade(hashedPassword, password string, targetCost int) (m
return true, false, "", nil
}
// GetPasswordCost 获取加密密码的成本
// GetPasswordCost 返回 hashedPassword 中编码的 bcrypt cost。
func GetPasswordCost(hashedPassword string) (int, error) {
return bcrypt.Cost([]byte(hashedPassword))
}
+32 -9
View File
@@ -15,10 +15,10 @@ func TestValidatePassword(t *testing.T) {
valid bool
msg string
}{
{"Abc12345", true, ""}, // 有效
{"Abc12345", true, ""}, // 有效
{"abc12345", false, "密码必须包含大写字母"}, // 缺大写
{"ABC12345", false, "密码必须包含小写字母"}, // 缺小写
{"Abcdefgh", false, "密码必须包含数字"}, // 缺数字
{"Abcdefgh", false, "密码必须包含数字"}, // 缺数字
{"Abc123", false, "密码长度不能少于8位"}, // 太短
{"", false, "密码长度不能少于8位"}, // 空
}
@@ -193,6 +193,25 @@ func TestCheckPasswordAndUpgrade(t *testing.T) {
}
}
func TestCheckPasswordAndUpgradeNormalizesInvalidTargetCost(t *testing.T) {
password := "testPassword123"
hash, err := validation.HashPasswordWithCost(password, 12)
if err != nil {
t.Fatalf("HashPasswordWithCost: %v", err)
}
match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, password, bcrypt.MaxCost+1)
if err != nil {
t.Fatalf("CheckPasswordAndUpgrade with high target cost: %v", err)
}
if !match {
t.Fatal("Password should match")
}
if needUpgrade || newHash != "" {
t.Fatalf("invalid high target cost should normalize to default and avoid upgrade, needUpgrade=%v newHash=%q", needUpgrade, newHash)
}
}
func TestGetPasswordCost(t *testing.T) {
password := "testPassword123"
hash, _ := validation.HashPasswordWithCost(password, 12)
@@ -311,6 +330,10 @@ func TestValidateStruct(t *testing.T) {
}
func TestValidateStructNil(t *testing.T) {
if errors := validation.ValidateStruct(nil); errors != nil {
t.Fatalf("ValidateStruct(nil) = %v, want nil", errors)
}
// 空结构体
emptyUser := TestUser{}
errors := validation.ValidateStruct(emptyUser)
@@ -365,12 +388,12 @@ func TestValidateUsername(t *testing.T) {
username string
valid bool
}{
{"abc123", true}, // 有效
{"Abc123", true}, // 有效(大写开头)
{"user_name", true}, // 有效(包含下划线)
{"123abc", false}, // 无效(数字开头)
{"ab", false}, // 无效(太短)
{"a!bc", false}, // 无效(特殊字符)
{"abc123", true}, // 有效
{"Abc123", true}, // 有效(大写开头)
{"user_name", true}, // 有效(包含下划线)
{"123abc", false}, // 无效(数字开头)
{"ab", false}, // 无效(太短)
{"a!bc", false}, // 无效(特殊字符)
}
for _, tt := range tests {
@@ -435,4 +458,4 @@ func BenchmarkValidatePassword(b *testing.B) {
for i := 0; i < b.N; i++ {
validation.ValidatePassword(password)
}
}
}
+5 -1
View File
@@ -250,6 +250,10 @@ func validateIDCardChecksum(id string) bool {
// ValidateStruct 验证结构体
func ValidateStruct(s any) ValidationErrors {
if s == nil {
return nil
}
v := Validator.Load()
if v == nil {
InitValidator()
@@ -450,4 +454,4 @@ func BindForm(c *gin.Context, req any) ValidationErrors {
return parseValidationErrors(err, req)
}
return ValidateStruct(req)
}
}
+181 -38
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
@@ -21,7 +22,16 @@ import (
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: sameOriginCheck,
CheckOrigin: currentCheckOrigin,
}
var checkOriginValue atomic.Value // stores func(*http.Request) bool
func currentCheckOrigin(r *http.Request) bool {
if fn, ok := checkOriginValue.Load().(func(*http.Request) bool); ok && fn != nil {
return fn(r)
}
return sameOriginCheck(r)
}
// sameOriginCheck 同源校验:Origin 为空(非浏览器/服务器内部)放行;否则要求 Origin 的
@@ -36,7 +46,20 @@ func sameOriginCheck(r *http.Request) bool {
if err != nil {
return false
}
return u.Host == r.Host
if u.Scheme == "" || u.Host == "" {
return false
}
return strings.EqualFold(u.Scheme, requestScheme(r)) && strings.EqualFold(u.Host, r.Host)
}
func requestScheme(r *http.Request) string {
if r.URL != nil && r.URL.Scheme != "" {
return r.URL.Scheme
}
if r.TLS != nil {
return "https"
}
return "http"
}
// AllowOrigins 返回一个 CheckOrigin 函数,仅放行给定 Origin 列表(含 scheme+host[:port])。
@@ -69,11 +92,11 @@ const (
type MessageType string
const (
TypeText MessageType = "text"
TypeBinary MessageType = "binary"
TypePing MessageType = "ping"
TypePong MessageType = "pong"
TypeClose MessageType = "close"
TypeText MessageType = "text" // 文本消息
TypeBinary MessageType = "binary" // 二进制消息
TypePing MessageType = "ping" // 心跳 ping
TypePong MessageType = "pong" // 心跳 pong
TypeClose MessageType = "close" // 关闭消息
)
// Message WebSocket 消息
@@ -151,7 +174,9 @@ func (c *Connection) Close() {
c.once.Do(func() {
close(c.closeChan)
// #nosec G104 -- 关闭底层连接的错误无意义(重复关闭返错属正常),忽略
_ = c.conn.Close()
if c.conn != nil {
_ = c.conn.Close()
}
})
}
@@ -229,6 +254,9 @@ func Upgrade(c *gin.Context) (*Connection, error) {
// Handle WebSocket 处理中间件
func Handle(handler Handler) gin.HandlerFunc {
if handler == nil {
handler = &DefaultHandler{}
}
return func(c *gin.Context) {
conn, err := Upgrade(c)
if err != nil {
@@ -312,15 +340,26 @@ func HandleFunc(fn HandlerFunc) gin.HandlerFunc {
})
}
// SetCheckOrigin 设置 Origin 检查函数。
//
// L-H:直接写包级 upgrader.CheckOrigin 无锁,与并发 Upgrade(读 upgrader.CheckOrigin
// 存在数据竞争。约定仅在 HTTP 服务启动前(init/main 阶段、路由注册前)调用一次,
// 不要在服务运行期与请求处理并发切换。运行期动态切换 Origin 策略请改用自有 upgrader 实例。
// SetCheckOrigin 设置 Origin 检查函数。传 nil 会恢复默认同源校验。
// 运行期并发调用安全:upgrader 持有固定 wrapper,实际检查函数经 atomic.Value 切换。
func SetCheckOrigin(fn func(r *http.Request) bool) {
upgrader.CheckOrigin = fn
if fn == nil {
fn = sameOriginCheck
}
checkOriginValue.Store(fn)
}
var (
// ErrHubNotRunning 在 Hub 未启动 Run 即调用 Try* 方法时返回。
ErrHubNotRunning = errors.New("websocket hub not running")
// ErrHubStopped 在 Hub 已 Stop 后调用 Try* 方法时返回。
ErrHubStopped = errors.New("websocket hub stopped")
// ErrHubQueueFull 在 register/unregister/broadcast channel 缓冲满时返回。
ErrHubQueueFull = errors.New("websocket hub queue full")
// ErrNilConnection 在向 Hub 注册/注销 nil 连接时返回。
ErrNilConnection = errors.New("websocket connection is nil")
)
// Hub 连接管理中心(用于广播)。
// W1 修复:添加 stop channel + Stop() 方法,支持优雅退出。
type Hub struct {
@@ -329,19 +368,21 @@ type Hub struct {
unregister chan *Connection
broadcast chan []byte
mu sync.RWMutex
lifecycleMu sync.RWMutex
stop chan struct{}
stopOnce sync.Once // H-8: 保证 close(stop) 仅一次,并发 Stop 不 double-close panic
runOnce sync.Once // H-9: 保证 Run 仅执行一次
runStarted atomic.Bool // H-9: 标记 Run 是否已启动,Stop 据此决定是否等待
runDone chan struct{} // H-9: Run 退出时 close,替代 WaitGroup 避免 Add/Wait 竞态
stopOnce sync.Once // H-8: 保证 close(stop) 仅一次,并发 Stop 不 double-close panic
runOnce sync.Once // H-9: 保证 Run 仅执行一次
runStarted atomic.Bool // H-9: 标记 Run 是否已启动,Stop 据此决定是否等待
stopped atomic.Bool
runDone chan struct{} // H-9: Run 退出时 close,替代 WaitGroup 避免 Add/Wait 竞态
}
// NewHub 创建 Hub
func NewHub() *Hub {
return &Hub{
connections: make(map[*Connection]bool),
register: make(chan *Connection),
unregister: make(chan *Connection),
register: make(chan *Connection, 256),
unregister: make(chan *Connection, 256),
broadcast: make(chan []byte, 256),
stop: make(chan struct{}),
runDone: make(chan struct{}),
@@ -365,21 +406,22 @@ func (h *Hub) Run() {
for {
select {
case <-h.stop:
// 关闭所有连接后退出
h.mu.Lock()
for conn := range h.connections {
delete(h.connections, conn)
conn.Close()
}
h.mu.Unlock()
h.drainPending()
h.closeAll()
return
case conn := <-h.register:
if conn == nil {
continue
}
h.mu.Lock()
h.connections[conn] = true
h.mu.Unlock()
case conn := <-h.unregister:
if conn == nil {
continue
}
h.mu.Lock()
if _, ok := h.connections[conn]; ok {
delete(h.connections, conn)
@@ -402,6 +444,33 @@ func (h *Hub) Run() {
})
}
func (h *Hub) drainPending() {
for {
select {
case conn := <-h.register:
if conn != nil {
conn.Close()
}
case conn := <-h.unregister:
if conn != nil {
conn.Close()
}
case <-h.broadcast:
default:
return
}
}
}
func (h *Hub) closeAll() {
h.mu.Lock()
defer h.mu.Unlock()
for conn := range h.connections {
delete(h.connections, conn)
conn.Close()
}
}
// Stop 停止 Hub(W1 修复:优雅退出机制)。
// close(stop) 通知 Run() 退出;若 Run 已启动则等待其完全结束(<-runDone)。
//
@@ -412,48 +481,122 @@ func (h *Hub) Run() {
// H-9 修复:仅在 runStarted 为 true 时等待 runDoneRun 未启动时直接返回,
// 避免 WaitGroup Add/Wait 竞态,且 Stop 先于 Run 调用后误再调 Run 也不会 panic。
// 幂等:重复/并发调用安全。
// Stop discards pending broadcast messages instead of guaranteeing delivery;
// it is a shutdown boundary. Pending register/unregister entries are drained
// and all live connections are closed before Stop returns.
func (h *Hub) Stop() {
h.stopOnce.Do(func() {
h.lifecycleMu.Lock()
defer h.lifecycleMu.Unlock()
h.stopped.Store(true)
close(h.stop)
if !h.runStarted.Load() {
h.drainPending()
h.closeAll()
}
})
if h.runStarted.Load() {
<-h.runDone
}
}
// Register 注册连接(W1 修复:Hub 已 stop 时 non-blocking 返回,避免永久阻塞)。
func (h *Hub) Register(conn *Connection) {
func (h *Hub) enqueueRegister(conn *Connection, requireStarted bool) error {
if conn == nil {
return ErrNilConnection
}
h.lifecycleMu.RLock()
defer h.lifecycleMu.RUnlock()
if h.stopped.Load() {
conn.Close()
return ErrHubStopped
}
if requireStarted && !h.runStarted.Load() {
conn.Close()
return ErrHubNotRunning
}
select {
case h.register <- conn:
case <-h.stop:
return nil
default:
conn.Close()
return ErrHubQueueFull
}
}
// Unregister 注连接W1 修复:Hub 已 stop 时 non-blocking 返回
func (h *Hub) Unregister(conn *Connection) {
// Register 注连接。调用兼容旧 API:失败时关闭连接并直接返回。
func (h *Hub) Register(conn *Connection) {
_ = h.enqueueRegister(conn, false)
}
// TryRegister 注册连接并返回失败原因。Hub 未 Run、已 Stop 或队列满时立即返回错误。
func (h *Hub) TryRegister(conn *Connection) error {
return h.enqueueRegister(conn, true)
}
func (h *Hub) enqueueUnregister(conn *Connection, requireStarted bool) error {
if conn == nil {
return ErrNilConnection
}
h.lifecycleMu.RLock()
defer h.lifecycleMu.RUnlock()
if h.stopped.Load() {
return ErrHubStopped
}
if requireStarted && !h.runStarted.Load() {
return ErrHubNotRunning
}
select {
case h.unregister <- conn:
case <-h.stop:
return nil
default:
return ErrHubQueueFull
}
}
// Broadcast 广播消息(W1 修复:Hub 已 stop 时 non-blocking 返回
func (h *Hub) Broadcast(message []byte) {
// Unregister 注销连接。调用兼容旧 API:失败时直接返回。
func (h *Hub) Unregister(conn *Connection) {
_ = h.enqueueUnregister(conn, false)
}
// TryUnregister 注销连接并返回失败原因。
func (h *Hub) TryUnregister(conn *Connection) error {
return h.enqueueUnregister(conn, true)
}
func (h *Hub) enqueueBroadcast(message []byte, requireStarted bool) error {
h.lifecycleMu.RLock()
defer h.lifecycleMu.RUnlock()
if h.stopped.Load() {
return ErrHubStopped
}
if requireStarted && !h.runStarted.Load() {
return ErrHubNotRunning
}
select {
case h.broadcast <- message:
case <-h.stop:
return nil
default:
return ErrHubQueueFull
}
}
// Broadcast 广播消息。调用兼容旧 API:失败时直接返回。
func (h *Hub) Broadcast(message []byte) {
_ = h.enqueueBroadcast(message, false)
}
// TryBroadcast 广播消息并返回失败原因。
func (h *Hub) TryBroadcast(message []byte) error {
return h.enqueueBroadcast(message, true)
}
// BroadcastJSON 广播 JSON 消息
func (h *Hub) BroadcastJSON(v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
h.Broadcast(data)
return nil
return h.TryBroadcast(data)
}
// Count 获取连接数
+199
View File
@@ -0,0 +1,199 @@
package ws
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func TestM11SetCheckOriginConcurrentWithUpgradeNoRace(t *testing.T) {
SetCheckOrigin(nil)
t.Cleanup(func() { SetCheckOrigin(nil) })
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/ws", Handle(nil))
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
done := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
allowA := func(*http.Request) bool { return true }
allowB := func(*http.Request) bool { return true }
for {
select {
case <-done:
return
default:
SetCheckOrigin(allowA)
SetCheckOrigin(allowB)
}
}
}()
dialer := &websocket.Dialer{HandshakeTimeout: 2 * time.Second}
url := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
for i := 0; i < 50; i++ {
conn, _, err := dialer.Dial(url, nil)
if err != nil {
t.Fatalf("dial[%d]: %v", i, err)
}
_ = conn.Close()
}
close(done)
wg.Wait()
}
func TestM11HandleNilUsesDefaultHandler(t *testing.T) {
SetCheckOrigin(func(*http.Request) bool { return true })
t.Cleanup(func() { SetCheckOrigin(nil) })
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/ws", Handle(nil))
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", nil)
if err != nil {
t.Fatalf("dial Handle(nil): %v", err)
}
_ = conn.Close()
}
func TestM11HubNotRunFastFail(t *testing.T) {
hub := NewHub()
conn := NewConnection(nil)
if err := hub.TryRegister(conn); !errors.Is(err, ErrHubNotRunning) {
t.Fatalf("TryRegister before Run error = %v, want ErrHubNotRunning", err)
}
if !conn.IsClosed() {
t.Fatal("TryRegister before Run should close rejected connection")
}
if err := hub.TryUnregister(NewConnection(nil)); !errors.Is(err, ErrHubNotRunning) {
t.Fatalf("TryUnregister before Run error = %v, want ErrHubNotRunning", err)
}
if err := hub.TryBroadcast([]byte("x")); !errors.Is(err, ErrHubNotRunning) {
t.Fatalf("TryBroadcast before Run error = %v, want ErrHubNotRunning", err)
}
if err := hub.BroadcastJSON(map[string]string{"x": "y"}); !errors.Is(err, ErrHubNotRunning) {
t.Fatalf("BroadcastJSON before Run error = %v, want ErrHubNotRunning", err)
}
}
func TestM11PublicRegisterToleratesRunStartupWindow(t *testing.T) {
hub := NewHub()
conn := NewConnection(nil)
hub.Register(conn)
go hub.Run()
t.Cleanup(hub.Stop)
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if hub.Count() == 1 {
if conn.IsClosed() {
t.Fatal("public Register should not close connection during Run startup window")
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("Hub Count = %d, want 1", hub.Count())
}
func TestM11HubStopDrainsAndCloses(t *testing.T) {
hub := NewHub()
go hub.Run()
for !hub.runStarted.Load() {
time.Sleep(time.Millisecond)
}
conn := NewConnection(nil)
hub.register <- conn
hub.broadcast <- []byte("discarded")
hub.Stop()
if !conn.IsClosed() {
t.Fatal("Stop should close pending registered connection")
}
if got := hub.Count(); got != 0 {
t.Fatalf("Hub Count after Stop = %d, want 0", got)
}
if got := len(hub.register); got != 0 {
t.Fatalf("register queue length after Stop = %d, want 0", got)
}
if got := len(hub.broadcast); got != 0 {
t.Fatalf("broadcast queue length after Stop = %d, want 0", got)
}
if err := hub.TryRegister(NewConnection(nil)); !errors.Is(err, ErrHubStopped) {
t.Fatalf("TryRegister after Stop error = %v, want ErrHubStopped", err)
}
if err := hub.TryBroadcast([]byte("x")); !errors.Is(err, ErrHubStopped) {
t.Fatalf("TryBroadcast after Stop error = %v, want ErrHubStopped", err)
}
}
func TestM11HubStopConcurrentTryRegisterClosesAll(t *testing.T) {
hub := NewHub()
go hub.Run()
for !hub.runStarted.Load() {
time.Sleep(time.Millisecond)
}
var connsMu sync.Mutex
var conns []*Connection
var wg sync.WaitGroup
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
conn := NewConnection(nil)
connsMu.Lock()
conns = append(conns, conn)
connsMu.Unlock()
_ = hub.TryRegister(conn)
}()
}
time.Sleep(time.Millisecond)
hub.Stop()
wg.Wait()
connsMu.Lock()
defer connsMu.Unlock()
for i, conn := range conns {
if !conn.IsClosed() {
t.Fatalf("connection %d left open after concurrent Stop/TryRegister", i)
}
}
if got := hub.Count(); got != 0 {
t.Fatalf("Hub Count after concurrent Stop/TryRegister = %d, want 0", got)
}
if got := len(hub.register); got != 0 {
t.Fatalf("register queue length after concurrent Stop/TryRegister = %d, want 0", got)
}
}
func TestM11HubNilGuards(t *testing.T) {
hub := NewHub()
go hub.Run()
t.Cleanup(hub.Stop)
if err := hub.TryRegister(nil); !errors.Is(err, ErrNilConnection) {
t.Fatalf("TryRegister(nil) error = %v, want ErrNilConnection", err)
}
if err := hub.TryUnregister(nil); !errors.Is(err, ErrNilConnection) {
t.Fatalf("TryUnregister(nil) error = %v, want ErrNilConnection", err)
}
}
+20 -5
View File
@@ -1,6 +1,7 @@
package ws
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
@@ -16,7 +17,8 @@ func TestSameOriginCheck_C7(t *testing.T) {
want bool
}{
{"empty origin (non-browser)", "", "example.com", true},
{"same origin", "https://example.com", "example.com", true},
{"same origin", "http://example.com", "example.com", true},
{"scheme mismatch", "https://example.com", "example.com", false},
{"cross origin", "https://evil.com", "example.com", false},
{"origin with port same", "http://example.com:8080", "example.com:8080", true},
{"origin with port diff", "http://example.com:8080", "example.com:9090", false},
@@ -37,6 +39,19 @@ func TestSameOriginCheck_C7(t *testing.T) {
}
}
func TestSameOriginCheckHTTPS_C7(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://example.com/ws", nil)
req.TLS = &tls.ConnectionState{}
req.Header.Set("Origin", "https://example.com")
if !sameOriginCheck(req) {
t.Fatal("sameOriginCheck should allow matching https origin for TLS request")
}
req.Header.Set("Origin", "http://example.com")
if sameOriginCheck(req) {
t.Fatal("sameOriginCheck should reject origin with mismatched scheme")
}
}
// TestAllowOrigins_C7AllowOrigins 仅放行白名单 Origin。
func TestAllowOrigins_C7(t *testing.T) {
check := AllowOrigins("https://a.com", "https://b.com")
@@ -44,11 +59,11 @@ func TestAllowOrigins_C7(t *testing.T) {
origin string
want bool
}{
{"", true}, // 非浏览器放行
{"https://a.com", true}, // 白名单
{"https://b.com", true}, // 白名单
{"", true}, // 非浏览器放行
{"https://a.com", true}, // 白名单
{"https://b.com", true}, // 白名单
{"https://evil.com", false},
{"http://a.com", false}, // scheme 不符
{"http://a.com", false}, // scheme 不符
}
for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, "/", nil)

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