43 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:12:40 +08:00
杭州明婳科技 0c9f256dfc feat(v1.1.0): HA & Manager 化
对应体检报告 #10-#24,一次性完成 v1.1.0 全部 13 项:

- #10 storage/cache/redis/jwt/logger 五组件 Manager 化(XxxManager + DefaultXxx + SetDefaultXxxManager,包级 facade 保留兼容)
- #11 删除 wire 包 + 清理 StartServerWithPort/GracefulShutdown 双轨
- #12 Lifecycle Hooks(OnInit/OnStart/OnReady/OnStop)
- #13 Server 参数配置化(timeout/TLS/unix_socket/response_mode)
- #14 JWTConfig.Expire 改 time.Duration + 删 AppConfig.TokenExpire
- #15 response Mode 开关(ModeBusiness/ModeREST)
- #16 Config.Validate 启动期校验
- #17 livez/readyz 探针路由
- #18 Prometheus metrics 中间件 + /metrics
- #19 请求级 Timeout 中间件
- #21 主库探活 + replica 健康剔除
- #22 App.Go + in-flight goroutine 管理
- #24 RequestID 默认装入 + #23 核对

Breaking:删 wire、删 TokenExpire、JWTConfig.Expire 类型变更、删双轨函数。
详见 CHANGELOG 升级说明。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:41:05 +08:00
杭州明婳科技 cd3d629282 release: v1.0.4 DX & Docs release
v1.0.4 定位为 DX & Docs release,开发体验与文档改进,无破坏性 API 变更。
对应体检报告 #25/#27/#28/#29/#30(代码改动已在 deb3cc7 提交)。

本 release commit:
- app.go const Version bump 1.0.3 → 1.0.4
- CHANGELOG 新增 [1.0.4] 章节(Added/Changed + 升级说明)
- CHANGELOG 底部链接补 [1.0.4],修正 [1.0.2] 指向(无 v1.0.2 tag,改 compare 链接)
- README 更新日志补 v1.0.4 (2026-06-22) 摘要

v1.0.4 内容:
- #28 CLI 多模板(minimal/api/fullstack)
- #29 examples/ 目录(minimal + full 可运行示例)
- #25 模块路径文档改进(保留 -core,明确 module path vs package name)
- #27 Without* Option 定位文档化
- #30 文档结构(规划文档归档 docs/plans/)

build/vet/test 全绿,xlgo version → v1.0.4。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:28:46 +08:00
杭州明婳科技 deb3cc71bf feat(v1.0.4): DX & Docs 改进
按报告 v1.0.4 路线图完成 5 项开发体验与文档改进:

#25 模块路径文档改进:
- 保留 -core 后缀(xlgo 是多产品系列:xlgo-core/xlgo-orm/xlgo-ai)
- README 快速开始补完整 import 示例 + 新增「模块路径与包名」小节
- CLAUDE.md Import Path Note 措辞明确化

#27 Without* Option 定位文档化:
- 调研确认 Without* 有真实用例(测试覆盖 + NewFullStack 后排除单项)
- 不删不标 Deprecated,在 app.go 注释 + README 说明其定位

#28 CLI 多模板:
- xlgo new --template minimal/api/fullstack
- minimal 轻量HTTP无依赖,api 标准业务分层(默认),fullstack 全组件
- 三模板实测生成正确

#29 examples/ 目录:
- examples/minimal(50行可跑,无外部依赖)
- examples/full(mysql+redis+jwt+user CRUD)
- examples/README.md

#30 文档结构:
- Version_*.md + report.md 移到 docs/plans/(v2.0-review.md)
- 新增 docs/README.md 索引
- CHANGELOG/GUIDE 留根目录(按惯例)
- .gitignore 整理

build/vet/test + examples 编译全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:21:59 +08:00
杭州明婳科技 dcfd24b624 release: v1.0.3 bug fix release
v1.0.3 定位为 bug fix release,收口 v1.0.2 引入的破坏性清理并修复
4 个轻量 bug + 依赖复查 + 版本号治理 + 文档对齐。同时包含此前未提交
的 v1.0.2 全部工作。

v1.0.3 变更:

Fixed:
- generateJTI 忽略 rand.Read 错误(jwt)— 改为 (string, error) 并传播
- QueryBuilder.Page Count 受残留 Limit 截断(repository)— countDB 加 Limit(-1).Offset(-1)
- OSS/本地存储文件名冲突(storage)— 新增 uniqueFilename 加 8 字节随机后缀
- 数据库重试策略对不可恢复错误无效(database)— 新增 isTransientDBError

Dependencies:
- go mod tidy 补全 postgres 方言传递依赖;安全补丁升级
  x/crypto v0.49→v0.53、golang-jwt/jwt/v5 v5.2.1→v5.3.1、gorilla/websocket v1.5.1→v1.5.3

Removed:
- Breaking: 清理 v1.0.2 兼容别名(InitMySQL* / driverName)
- 死代码 database.DBResolver
- 代码中 292 行"评分/理由"自夸注释(#26,23 个文件)

Changed:
- Breaking: 错误码体系重构 CodeSuccess 1→0、CodeFail 0→1,删除 CodeInvalidParams,加编译期防撞码
- database/mysql.go → manager.go;Logger 拆分三独立 core 修复 Tee 重复写入
- 版本号常量化:app.go 新增 const Version 作为唯一来源,CLI/脚手架模板引用之,不再散落字面量

Security:
- CORS 中间件按 W3C 规范修复 Allow-Credentials / Vary: Origin / 非白名单不回显

Added:
- console 包显式 level 控制(SetLevel/WithLevel/LevelSilent,atomic.Int32 并发安全)
- 新增测试 jwt/repository/storage/database/router/middleware/app
- 文档:新增 CHANGELOG.md、Version_v1.0.2_report.md;更新 README/GUIDE 顶部更新日志
- 文档对齐:删除对不存在的 config.DefaultManager() getter 的描述(保持 API 与文档一致)

详见 CHANGELOG.md 与 README.md 更新日志。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:40:10 +08:00
Ethan xlp 2cc8c70960 chore: change license to MIT 2026-04-30 23:40:59 +08:00
158 changed files with 29861 additions and 3409 deletions
+15 -1
View File
@@ -1,2 +1,16 @@
report.md
# Claude Code 协作指引(本地,不入库)
CLAUDE.md
AGENTS.md
# 临时发版辅助文件
gitHub_release_*.md
# 构建产物
*.exe
bin/
md/
.gocache/
gpt_check_report_framework.md
gpt_check_report_review.md
gpt_check_report_module.md
+1141
View File
File diff suppressed because it is too large Load Diff
+352 -90
View File
@@ -25,6 +25,8 @@
## 1. 快速开始
> **环境要求**xlgo 需要 **Go 1.25+**。本项目作为新框架不背负旧版本兼容包袱,可直接使用 Go 1.25 的新特性。
### 1.1 安装框架
```bash
@@ -57,37 +59,38 @@ myproject/
package main
import (
"github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
)
func main() {
// 1. 加载配置
cfg, err := config.Load("./config.yaml")
if err != nil {
panic(err)
}
// 2. 创建应用
// v1.0.2 起 xlgo.New 默认是轻量应用,不会自动初始化 MySQL/Redis/Storage
// 也不会自动注册 /health 或 /swagger/* 路由。
// 通过 Option 显式启用所需组件即可。
app := xlgo.New(
xlgo.WithConfigPath("./config.yaml"),
xlgo.WithLogger(),
xlgo.WithDefaultRoutes(), // 同时启用 /health 与 /swagger/*
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
)
// 3. 注册路由
// 通过 Engine 直接挂路由,或使用 xlgo.WithModules(...) 注册模块
app.GetRouter().GET("/hello", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Hello World"})
})
// 4. 启动应用(支持优雅关闭)
if err := app.Run(); err != nil {
panic(err)
}
}
```
> 想要 batteries-included 体验,可改用 `xlgo.NewFullStack(...)` 或
> `xlgo.RunFullStack(...)`,它会一次性启用 Logger / MySQL / Redis / Storage /
> Wire / Health / Swagger / AutoMigrate 等组件。
**优雅关闭特性:**
- 监听系统信号(SIGINT、SIGTERM
- 等待请求处理完成(最多 30 秒)
@@ -108,7 +111,6 @@ app:
env: "dev" # dev/test/prod
debug: true
base_url: "http://localhost:8080"
token_expire: 86400 # Token过期时间(秒)
server:
port: 8080
@@ -131,7 +133,7 @@ redis:
jwt:
secret: your_jwt_secret_key
expire: 86400
expire: "24h" # time.Duration,如 "24h"/"30m"
log:
dir: ./logs
@@ -183,7 +185,8 @@ import "github.com/EthanCodeCraft/xlgo-core/logger"
logger.Init(cfg)
// 程序退出前同步
defer logger.Sync()
// v1.0.2 起 Sync 返回 error,可按需处理
_ = logger.Sync()
```
### 3.2 日志级别
@@ -199,7 +202,8 @@ logger.Error("错误信息")
logger.Infof("用户 %s 登录成功", username)
logger.Warnf("请求失败: %v", err)
// 致命错误(会终止程序)
// 致命错误(会终止程序)—— 仅在业务进程入口(main 等)使用
// v1.0.2 起,框架内部已禁止使用 Fatal/Fatalf,初始化错误改为 error 返回
logger.Fatalf("数据库连接失败: %v", err)
// 结构化日志
@@ -211,7 +215,7 @@ logger.Info("用户登录",
### 3.3 彩色控制台输出
开发调试时使用彩色输出,一眼识别日志级别:
`console` 包定位是**开发期彩色 stdout 工具**——跟 `fmt.Println` 同级,不写文件、不感知运行环境、不做任何隐式行为。
```go
import "github.com/EthanCodeCraft/xlgo-core/console"
@@ -226,30 +230,144 @@ console.Error("错误信息") // 红色
c := console.New(
console.WithColor(true),
console.WithTime(true),
console.WithCaller(true, 2),
console.WithCaller(true), // skip 可选,默认 2
console.WithLevel(console.LevelInfo),
)
c.Debug("自定义输出")
c.Debug("此条不会输出,已被 LevelInfo 过滤")
c.Info("自定义输出")
```
#### console vs logger:怎么选?
| 维度 | `console` | `logger` |
|---|---|---|
| 定位 | 开发期肉眼调试 | 业务可观测性记录 |
| 输出目标 | stdout(彩色) | 文件 + stdout |
| 持久化 | ❌ | ✅ 滚动归档 |
| 结构化 | 文本 | JSON 字段 |
| 性能 | 一般 | zap 高性能 |
| 默认级别 | `LevelDebug`(全开) | dev=Debug / prod=Info |
| 适用场景 | 临时打印、开发联调 | 用户登录、订单事件、审计日志 |
**简单判断**
- 这条信息上线后想留 → 用 `logger`
- 这条信息上线就该消失 → 用 `console`,并在 main 中显式切到高级别
#### 上线前显式收紧 console 输出
```go
func main() {
cfg, _ := config.Load("./config.yaml")
// 显式选择:生产期 console 只看 Warn / Error
if cfg.IsProduction() {
console.SetLevel(console.LevelWarn)
}
// 或者完全静默 console(业务全靠 logger
// console.SetLevel(console.LevelSilent)
app := xlgo.New(...)
app.Run()
}
```
> **注意**xlgo 不会自动根据 `app.env` 切换 console 级别——选择权完全在调用方。
> 我们认为隐式切换会带来"开发期看到的 / 生产期看到的"行为不一致,调试体验更糟。
---
## 4. 数据库操作
xlgo 基于 GORM,驱动由配置 `database.driver` 决定。v1.0.2 起 GORM 方言通过 **可插拔注册表** 管理:内置 `mysql`(默认)与 `postgres`(含 `postgresql``pg` 别名),应用可通过 `database.RegisterDialect` 接入任意 GORM 驱动;`database.dsn` 字段始终可用于手写连接串。
### 4.1 初始化数据库
```go
import "github.com/EthanCodeCraft/xlgo-core/database"
// 初始化 MySQL
database.InitMySQL(cfg)
// 初始化数据库(驱动由配置决定,等价于 database.DefaultManager.Load().InitDB(cfg)
database.InitDB(cfg)
// 关闭连接
defer database.Close()
// 关闭全部连接(含从库)
defer database.CloseAll()
// 获取数据库实例
db := database.GetDB()
db := database.GetDB() // 主库
read := database.GetReadDB() // 从库(无从库时回退主库)
```
### 4.1.1 主从读写分离
```go
// 从库 DSN 列表需与主库驱动匹配
database.InitDBWithReplicas(cfg, []string{
"root:pass@tcp(slave1:3306)/db",
"root:pass@tcp(slave2:3306)/db",
})
// 选择策略:默认 Random,可换成 RoundRobin
database.SetReplicaPicker(&database.RoundRobinPicker{})
// 强制使用主库(事务、需要实时数据的场景)
ctx := database.UseMaster(context.Background())
database.GetDBFromContext(ctx).Find(&users)
// 强制使用从库(报表查询)
ctx = database.UseReplica(context.Background())
database.GetDBFromContext(ctx).Find(&reports)
```
### 4.1.2 实例化 Manager
需要多套数据库连接(如平台库 + 租户库)时,`database.NewManager(cfg)` 创建独立管理器,互不影响:
```go
mgr := database.NewManager(cfg)
if err := mgr.Open(context.Background()); err != nil {
return err
}
defer mgr.Close()
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` 包:
```go
import (
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func init() {
database.RegisterDialect(database.DialectSpec{
Name: "sqlite",
Aliases: []string{"sqlite3"},
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
DSN: func(c *config.DatabaseConfig) string { return c.Name }, // Name 当作文件路径
})
}
```
之后只需把配置改成 `database.driver: sqlite` 即可。SQL Server / ClickHouse / TiDB 等同理。
诊断接口:
- `database.RegisteredDialects()` - 列出已注册驱动(含别名)
- `database.LookupDialect(name)` - 检查某驱动是否可用
- `config.RegisteredDrivers()` - 列出已登记的 DSN 构建器
未注册的驱动会回退到 MySQL 以保持向后兼容。
### 4.2 定义模型
```go
@@ -276,13 +394,23 @@ import "github.com/EthanCodeCraft/xlgo-core/repository"
// 创建仓库
userRepo := repository.NewBaseRepo[model.User](database.GetDB())
// 连接路由(v1.1.1+ 修复 H6c):
// - 读操作(FindByID/FindAll/FindPage/FindWhere/Count/Exists/...)默认路由到从库,
// 支持 database.UseMaster(ctx)/UseReplica(ctx) 显式路由;未配置从库时回退主库。
// - 写操作(Create/Update/Delete/*Batch/Restore/...)始终走主库,不路由到只读从库。
// - DefaultManager 未初始化(如单测注入 sqlite)时回退到 NewBaseRepo 传入的 db。
// 基础 CRUD
user, err := userRepo.FindByID(ctx, 1)
users, err := userRepo.FindAll(ctx)
users, err := userRepo.FindByIDs(ctx, []uint{1, 2, 3})
err := userRepo.Create(ctx, &user)
err := userRepo.Update(ctx, &user)
err := userRepo.Delete(ctx, 1)
err := userRepo.Update(ctx, &user) // Save 全列覆写(含零值),见下方 UpdateFields
err := userRepo.Delete(ctx, 1) // T 含 gorm.Model 时软删除,否则硬删除
// 局部更新(推荐,H6a):避免 Save 全列覆写丢失更新/零值不可辨
err := userRepo.UpdateFields(ctx, &model.User{Name: "new"}, "id = ?", id) // struct 仅更新非零字段
err := userRepo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map 可显式置零
// 统计数量
count, err := userRepo.Count(ctx)
@@ -304,7 +432,7 @@ result, err := userRepo.FindPageOrdered(ctx, 1, 20, "created_at DESC")
// 条件查询
user, err := userRepo.FindOne(ctx, "email = ?", "test@example.com")
users, err := userRepo.FindWhere(ctx, "status = ?", 1)
users, err := userRepo.FindWhereOrdered(ctx, "status = ?", 1, []any{}, "created_at DESC")
users, err := userRepo.FindWhereOrdered(ctx, "created_at DESC", "status = ?", 1)
// 排序查询
users, err := userRepo.FindOrdered(ctx, "created_at DESC", 10)
@@ -325,7 +453,7 @@ err := userRepo.RestoreBatch(ctx, []uint{1, 2})
// 查询已删除的记录
deletedUsers, err := userRepo.FindDeleted(ctx)
// 链式查询(灵活构建)
// 链式查询(灵活构建QueryBuilder 为单次使用、非并发安全
users, err := userRepo.NewQueryBuilder().
Where("status = ?", 1).
Where("role = ?", "admin").
@@ -348,6 +476,15 @@ err := userRepo.WithTransaction(ctx, func(txRepo *repository.BaseRepo[model.User
// 更新关联数据
return txRepo.Update(ctx, &profile)
})
// 跨层/跨 repo 事务 join(H6c):把外层事务注入 ctx 后传给任意 repo 方法
err := database.TransactionWithContext(ctx, func(tx *gorm.DB) error {
ctx2 := database.WithTx(ctx, tx)
if err := userRepo.Create(ctx2, &user); err != nil {
return err
}
return otherRepo.Update(ctx2, &other) // 同一事务
})
```
### 4.4 Model 基础模型
@@ -376,6 +513,9 @@ type User struct {
### 5.1 初始化缓存
> **v1.4.0**App 用户经 `WithRedis()` 启用时,`Init` 自动用 `app.redisManager.Client()` 初始化缓存
> (走 App 自己的 Redis,多 App 隔离,不串越)。下方 `cache.Init()`(包级)用于 standalone 或回退全局 Redis。
```go
import "github.com/EthanCodeCraft/xlgo-core/cache"
@@ -392,9 +532,11 @@ c := cache.GetCache()
// 设置缓存
c.Set(ctx, "user:1", userData, 30*time.Minute)
// 获取缓存
// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err
var user User
if c.Get(ctx, "user:1", &user) {
if hit, err := c.Get(ctx, "user:1", &user); err != nil {
return err
} else if hit {
// 缓存命中
}
@@ -404,8 +546,11 @@ c.Delete(ctx, "user:1")
// 批量删除(按模式)
c.DeleteByPattern(ctx, "user:*")
// 检查是否存在
exists := c.Exists(ctx, "user:1")
// 检查是否存在(未命中 false,nil;后端错误返回 err
exists, err := c.Exists(ctx, "user:1")
if err != nil {
return err
}
```
### 5.3 键名前缀管理(多站点共用 Redis)
@@ -425,7 +570,10 @@ cache.KSession("sid") // → "session:my_app:sid"
// 使用带前缀的缓存
c.Set(ctx, cache.K("user:1"), userData, ttl)
c.Get(ctx, cache.K("user:1"), &user)
hit, err := c.Get(ctx, cache.K("user:1"), &user)
if err != nil {
return err
}
```
### 5.4 分布式锁(安全增强版)
@@ -447,12 +595,12 @@ if token != nil {
}
// 自动管理锁(简单场景)
err := cache.WithLock(ctx, key, ttl, func() error {
err := cache.WithLock(ctx, key, ttl, func(ctx context.Context) error {
return nil
})
// 自动续期锁(长任务场景)
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func() error {
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func(ctx context.Context) error {
// 执行时间超过 TTL 时自动续期
return nil
})
@@ -460,11 +608,14 @@ err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func()
// 续期锁(手动续期)
cache.ExtendLock(ctx, token, 30*time.Second)
// 检查锁是否被占用
// 检查锁是否被占用Redis 不可用时返回 ErrRedisNotReady,可经
// errors.Is(err, cache.ErrRedisNotReady) 与"锁未占用"区分)
locked, err := cache.IsLocked(ctx, key)
// 强制释放锁(管理场景)
cache.ForceUnlock(ctx, key)
// 强制释放锁(管理场景Redis 不可用时返回 ErrRedisNotReady
if err := cache.ForceUnlock(ctx, key); err != nil {
// 处理 Redis 不可用
}
```
**安全特性:**
@@ -562,7 +713,9 @@ if !ok {
| `max=20` | 最大长度 |
| `email` | 邮箱格式 |
| `phone` | 手机号格式 |
| `password` | 密码强度(至少 8 位,包含字母和数字) |
| `phone_strict` | 手机号严格校验(验证运营商号段) |
| `password` | 密码强度(8-72 字节,需含大写+小写+数字) |
| `username` | 字母开头,3-20 位字母/数字/下划线 |
| `url` | URL 格式 |
| `ip` | IP 地址格式 |
@@ -614,23 +767,29 @@ response.FailWithDetail(c, response.ErrPasswordWrong, "连续错误3次将锁定
### 7.4 预定义错误码
> 注:参数错误等业务化错误码请由业务项目自行定义,框架不再内置 `ErrInvalidParams`。
> 推荐使用 `response.NewError(40001, "参数错误")` 在业务侧统一管理。
| 错误 | 码 | 说明 |
| ------------------ | ------ | ---------- |
| `ErrInvalidParams` | 000001 | 参数错误 |
| `ErrUnauthorized` | 000401 | 未授权 |
| `ErrNotFound` | 000404 | 资源不存在 |
| `ErrServerError` | 000500 | 服务器错误 |
| `ErrUserNotFound` | 010001 | 用户不存在 |
| `ErrPasswordWrong` | 010004 | 密码错误 |
| `ErrUnauthorized` | 401 | 未授权 |
| `ErrForbidden` | 403 | 无权限访问 |
| `ErrNotFound` | 404 | 资源不存在 |
| `ErrRateLimit` | 429 | 请求过于频繁 |
| `ErrServerError` | 500 | 服务器错误 |
| `ErrUserNotFound` | 10001 | 用户不存在 |
| `ErrPasswordWrong` | 10004 | 密码错误 |
### 7.5 特殊响应
```go
// 文件下载
response.Download(c, "report.xlsx", fileData)
// 大文件/对象存储流式下载优先使用 DownloadReader
// HTML响应
response.HTML(c, "<html>...</html>")
// HTML 会原样输出;只传入可信或已清洗的 markup
// 页面跳转
response.Redirect(c, 302, "https://example.com")
@@ -678,7 +837,13 @@ r.Use(middleware.Recover())
// 认证中间件
r.Use(middleware.AuthRequired())
// 角色检查
// 自定义用户类型权限
r.Use(middleware.RequireUserTypes("tenant_admin", "platform_admin"))
// 自定义角色权限
r.Use(middleware.RequireRoles("owner", "manager"))
// 默认快捷权限(super_admin/admin/staff 只是默认常量)
r.Use(middleware.AdminRequired())
// CSRF防护
@@ -744,30 +909,41 @@ r.Use(middleware.CSRFForAPI())
token := middleware.GetCSRFToken(c)
```
### 8.3 限流(支持 Redis 分布式限流)
### 8.4 限流(支持 Redis 分布式限流)
> **v1.4.0**App 持专属 `RateLimitRegistry``Shutdown` 调 `registry.Stop()`(不再调全局
> `StopRateLimiters`,避免误停其他 App 的限流器)。下方包级 `LoginRateLimit`/`APIRateLimit`/等仍可用
> (请求时解析全局默认 Registry,单 App 场景无变化)。**多 App per-App 计数隔离**用
> `app.RateLimitRegistry().LoginRateLimit()` 等 App-bound 中间件(捕获 App 自己的 Registry,不查全局)。
> **多 App 走各自 Redis** 用 `middleware.NewRedisRateLimiter(..., middleware.WithRedisClient(app.RedisClient()))`。
```go
// 初始化限流器
middleware.InitRateLimiters()
// 内存限流(单实例)
r.POST("/login", middleware.LoginRateLimit(), handler.Login) // 每分钟10次
r.POST("/upload", middleware.UploadRateLimit(), handler.Upload) // 每分钟20次
r.Use(middleware.APIRateLimit()) // 每分钟100
r.POST("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
response.Success(c, gin.H{"token": "..."})
}) // 每分钟10次
r.POST("/upload", middleware.UploadRateLimit(), func(c *gin.Context) {
response.Success(c, gin.H{"file": "..."})
}) // 每分钟20次
r.Use(middleware.APIRateLimit()) // 每分钟100次
// 自定义内存限流
r.Use(middleware.CustomRateLimit(50, time.Minute)) // 每分钟50次
// Redis 分布式限流(多实例共享)
r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次
r.Use(middleware.LoginRedisRateLimit()) // 登录限流
r.Use(middleware.APIRedisRateLimit()) // API限流
r.Use(middleware.UploadRedisRateLimit()) // 上传限流
r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次fail-openRedis 故障时放行)
r.Use(middleware.LoginRedisRateLimit()) // 登录限流fail-closedRedis 故障时拒绝,防爆破)
r.Use(middleware.APIRedisRateLimit()) // API限流fail-open
r.Use(middleware.UploadRedisRateLimit()) // 上传限流fail-closed:资源敏感,Redis 故障时拒绝)
// 自定义 Redis 限流
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute))
// 自定义 Redis 限流(默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open
r.Use(middleware.CustomRedisRateLimit("sensitive", 50, time.Minute, middleware.WithFailClosed(true))) // fail-closed(安全场景)
// 自定义标识限流(如按用户ID
// 自定义标识限流(如按用户ID,同样支持 WithFailClosed
r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Context) string {
return fmt.Sprintf("user:%d", middleware.GetUserID(c))
}))
@@ -776,11 +952,16 @@ r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Con
defer middleware.StopRateLimiters()
```
> **fail-open vs fail-closedH4c**Redis 限流器在 Redis 故障时有两种策略——
> - **fail-open**(默认):Redis 故障时放行,避免影响业务,但限流静默失效。
> - **fail-closed**`WithFailClosed(true)`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、上传、敏感操作)必须用 fail-closed**。
> `RedisRateLimiter` 可经 `NewRedisRateLimiter(..., WithFailClosed(true))` 构造或 `SetFailClosed(true)` 切换策略。
**内存限流 vs Redis 限流:**
- 内存限流:单实例使用,简单高效
- Redis 限流:多实例共享,滑动窗口算法,分布式场景必需
### 8.4 路由架构
### 8.5 路由架构
框架提供灵活的路由系统,支持模块化、版本化 API 和中间件分组。
@@ -856,7 +1037,7 @@ v1.AddModuleFunc("user", userRoutes)
// 创建分组
authGroup := router.NewMiddlewareGroup("auth",
middleware.AuthRequired(),
middleware.AdminRequired(),
middleware.RequireUserTypes("tenant_admin", "platform_admin"),
)
publicGroup := router.NewMiddlewareGroup("public",
@@ -915,19 +1096,27 @@ router.RegisterVersion(router.NewVersion("v1", "/api/v1"))
// 应用路由
router.Apply()
xlgo.StartServer(engine, 8080)
// 启动服务(如需优雅关闭与生命周期管理,改用 app := xlgo.New(...); app.Run()
engine.Run(":8080")
```
#### 8.4.7 默认路由
框架自动注册以下默认路由
v1.0.2 起,`xlgo.New()` 默认是 **轻量应用**,不会自动注册任何默认路由
按需通过下列 Option 显式启用:
```
/health -> 健康检查
/swagger/*any -> Swagger 文档
```go
xlgo.New(
xlgo.WithHealthRoutes(), // 注册 /health
xlgo.WithSwaggerRoutes(), // 注册 /swagger/*any
// 或一步到位:
xlgo.WithDefaultRoutes(), // 同时注册 /health 与 /swagger/*any
)
```
如需禁用,创建应用时不传入默认模块即可
也可以使用 `xlgo.NewFullStack(...)` / `xlgo.RunFullStack(...)` 启用全部默认组件
> 生产环境建议关闭 Swagger,仅保留 `WithHealthRoutes()`,避免文档接口意外暴露。
---
@@ -943,7 +1132,7 @@ import "github.com/EthanCodeCraft/xlgo-core/jwt"
// 生成Token(自动包含唯一 JTI)
token, err := jwt.GenerateToken(userID, username, "admin", "admin")
// 解析Token
// 解析Token(默认 fail-closed:黑名单后端不可检查时拒绝 Token,需 Redis
claims, err := jwt.ParseToken(tokenString)
// 使Token失效(使用 JTI,内存占用约 30 字节)
@@ -973,14 +1162,18 @@ claims, err := jwt.GetClaimsFromToken(tokenString)
### 9.2 获取用户信息
```go
// 从上下文获取用户ID
// 一次性取出全部认证信息(v1.0.2 推荐)
if user, ok := middleware.GetAuthUser(c); ok {
_ = user.UserID
_ = user.Username
_ = user.Role
_ = user.UserType
}
// 单字段便捷函数(与旧版兼容)
userID := middleware.GetUserID(c)
// 获取用户名
username := middleware.GetUsername(c)
// 获取用户类型(super_admin/admin/staff
userType := middleware.GetUserType(c)
userType := middleware.GetUserType(c) // super_admin/admin/staff 等,由业务定义
```
### 9.3 密码加密
@@ -1029,11 +1222,17 @@ storage:
### 10.3 使用存储
> **v1.4.0**App 用户经 `WithStorage()` 启用时,`Init` 自动初始化存储并提升为全局默认;`Shutdown`
> 经 `StorageManager.Close()` 收口(当前 LocalStorage/OSSStorage 无 closeable 资源为 no-op,为未来
> 驱动预留)。下方 `storage.Init()`(包级)用于 standalone。
```go
import "github.com/EthanCodeCraft/xlgo-core/storage"
// 初始化
storage.Init(&cfg.Storage)
if err := storage.Init(&cfg.Storage); err != nil {
return err
}
// 上传文件(从请求中获取)
file, _ := c.FormFile("file")
@@ -1051,8 +1250,12 @@ err := storage.Delete(path)
// 获取文件内容
data, err := storage.Get(path)
// 检查文件是否存在
if storage.Exists(path) {
// 检查文件是否存在(不存在 false,nil;后端错误返回 err
ok, err := storage.Exists(path)
if err != nil {
return err
}
if ok {
// 文件存在
}
```
@@ -1063,19 +1266,32 @@ if storage.Exists(path) {
### 11.1 随机数生成
**安全选型**:字符串随机(token/OTP/验证码/会话 ID/nonce)用 `RandStringSecure`/`RandDigitSecure`
(基于 `crypto/rand`,不可预测)。`RandString`/`RandDigit`math/rand 版本)已移除——
字符串随机的用途几乎都是安全场景,保留 math/rand 版本会诱导误用。
```go
import "github.com/EthanCodeCraft/xlgo-core/utils"
// 随机字符串(16位
token := utils.RandString(16)
// 安全场景:token、会话 ID、API keycrypto/rand,不可预测
token, err := utils.RandStringSecure(32)
if err != nil {
// 处理 crypto/rand 失败(极罕见,通常仅系统熵池耗尽)
}
// 随机数字(6位验证码
code := utils.RandDigit(6)
// 安全场景:6 位 OTP 验证码、密码重置码(crypto/rand
code, err := utils.RandDigitSecure(6)
// 范围随机数
// 安全场景:安全 nonce 范围、防猜抽奖、密钥分桶(crypto/rand 无偏)
idx, err := utils.RandIntSecure(0, 1000)
// 范围随机数(非密码学安全,仅用于负载均衡/游戏/A-B 分桶等非安全场景)
n := utils.RandInt(1, 100)
```
> 非安全场景需要高性能随机串时,直接用标准库 `math/rand` 即可,框架不再提供
> 易误用的 `RandString`/`RandDigit`。
### 11.2 字符串处理
```go
@@ -1256,7 +1472,8 @@ if utils.UUIDValid(uuid) { }
| 分类 | 高分函数(⭐⭐⭐⭐⭐) | 说明 |
|------|---------------------|------|
| **随机** | `RandString/RandDigit` | sync.Pool 优化性能 |
| **随机(安全)** | `RandStringSecure/RandDigitSecure/RandIntSecure/RandInt64Secure` | crypto/randtoken/OTP/nonce 用 |
| **随机(范围)** | `RandInt/RandInt64` | math/rand,负载均衡/游戏等非安全场景 |
| **字符串** | `IsBlank/DefaultIfBlank/StrLen` | 空值处理、Unicode支持 |
| **时间** | `FormatDateTime/StartOfDay/EndOfMonth` | 标准格式、边界计算 |
| **转换** | `ToIntDefault/CalcPageCount/CalcOffset` | 安全转换、分页计算 |
@@ -1271,7 +1488,7 @@ if utils.UUIDValid(uuid) { }
| 改进 | 说明 |
|------|------|
| 性能优化 | `RandString/RandDigit` 使用 sync.Pool 复用随机源 |
| 性能优化 | 非安全随机用 `RandInt/RandInt64`math/randsync.Pool 复用源);安全场景用 `RandStringSecure/RandDigitSecure/RandIntSecure`crypto/rand + big.Int 拒绝采样无偏) |
| 类型安全 | 移除使用反射的函数,保持类型安全 |
| 式调用 | `HTTPClient``URLBuilder` 支持链式调用 |
| 零依赖 | 仅依赖 `google/uuid`,其余使用标准库 |
@@ -1332,6 +1549,24 @@ count := hub.Count()
## 13. 定时任务
> **v1.4.0**:App 持专属调度器(实例隔离,多 App 互不污染)。**App 管理的任务用
> `xlgo.WithCronTask(name, schedule, handler)` 注册**(蕴含启用 cron`Init` 时注册+启动,
> `Shutdown` 时停止并等待在跑任务退出)。下方包级 `cron.AddTask`/`cron.Start`/`cron.Stop` 仍可用于
> standalone(无 App)或 `Init` 之后;**`Init` 之前**调包级 `cron.AddTask` 会注册到 init 默认
> 调度器、被 App swap 丢弃。`app.Scheduler()` 返回 App 调度器,供动态注册与管理
> `AddTask`/`RemoveTask`/`RunTask`/`ListTasks`)。
>
> ```go
> app := xlgo.New(xlgo.WithConfig(cfg),
> xlgo.WithCronTask("cleanup", cron.Every(5*time.Minute), func(ctx context.Context) error {
> return cleanupOldData()
> }),
> xlgo.WithCronTask("daily_report", cron.Daily(2, 0), generateReport),
> )
> // 动态注册(Init 后):app.Scheduler().AddTask(...)
> if err := app.Run(); err != nil { /* ... */ }
> ```
### 13.1 添加任务
```go
@@ -1355,6 +1590,9 @@ cron.AddTask("noon", cron.Cron("0", "12"), doSomething) // 每天12:00
cron.AddTask("complex", cron.ParseCron("*/15 * * * *"), doSomething) // 每15分钟
cron.AddTask("monthly", cron.ParseCron("0 0 1 * *"), doSomething) // 每月1号凌晨
cron.AddTask("workday", cron.ParseCron("0 9-17 * * 1-5"), doSomething) // 工作日9-17点
// ParseCron 对非法表达式 fail-fast panic;动态输入请用 ParseCronStrict 处理 error。
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
```
### 13.2 启动与停止
@@ -1379,6 +1617,17 @@ scheduler.EnableTask("cleanup") // 启用任务
## 14. 链路追踪
> **v1.4.0**`xlgo.WithTrace()` 把 trace 纳入 App 生命周期--`Init` 按 `config.Trace` 调
> `trace.Init`、装入 `trace.Middleware`、`Shutdown` 调 `trace.Close` 刷出 span 并释放 exporter
> 后台 goroutine。下方手动 `trace.Init`/`trace.Close`/`trace.Middleware` 仍可用于 standalone
> (无 App)。**trace 不做实例隔离**--OTel `TracerProvider` 是进程级全局单例,多 App 进程共享,
> 任一 `Shutdown` 会关全局导出;多 App 不要同时开 `WithTrace`。
>
> ```go
> app := xlgo.New(xlgo.WithConfigPath("config.yaml"), xlgo.WithTrace())
> // config.yaml 配置 trace.enabled / trace.endpoint / trace.exporter_type / trace.sample_ratio ...
> ```
### 14.1 初始化
```go
@@ -1389,11 +1638,18 @@ trace.Init(trace.Config{
ServiceName: "my-service",
Endpoint: "localhost:4318",
ExporterType: "otlp-http",
// Insecure: true, // 明文 collectorlocalhost:4318 等本地 collector 默认无 TLS,需显式开启)
SampleRatio: 1.0,
// Propagator: "w3c", // 可选 "w3c"(默认) / "b3" / "jaeger"(映射 W3C)
})
defer trace.Close(ctx)
```
> **导出器类型**`otlp-http` / `otlp-grpc` / `stdout`(写标准输出,便于调试)。未知类型 `Init` 返错。
> **Insecure**:默认 `false`TLS);对无 TLS 的本地 collector(如 `localhost:4318`)需显式置 `true`,否则握手失败。
> **传播器**`w3c`(默认,W3C TraceContext + Baggage/ `b3`(同时支持单头与多头,兼容旧 B3 客户端)/ `jaeger`(映射为 W3C TraceContext——现代 Jaeger agent 透传 W3C;纯 Jaeger thrift 头协议请用 `b3`)。未知类型 `Init` 返错。
> **未 Init 也安全**:未调用 `Init` 或 `Init(Enabled:false)` 时为 Noop tracer`Middleware`/`StartSpan` 等不 panic。
### 14.2 使用中间件
```go
@@ -1469,12 +1725,12 @@ func TestUserAPI(t *testing.T) {
WithJSON(map[string]any{"name": "test"}).
Execute()
resp.AssertOK(t)
resp.AssertCode(t, 1)
resp.AssertJSONContains(t, "code", float64(1))
// GET请求
resp = test.GET(router, "/api/users/1").Execute()
resp.AssertOK(t)
resp.AssertJSONKeyExists(t, "data.id")
resp.AssertJSONContains(t, "data.id", float64(1))
}
```
@@ -1527,7 +1783,8 @@ model/ → 数据模型定义
func GetUser(c *gin.Context) {
id := handler.PathInt64(c, "id", 0)
if id == 0 {
response.FailWithError(c, response.ErrInvalidParams)
// 参数错误码由业务侧定义;这里直接返回通用失败 + 自定义消息
response.Fail(c, "参数错误")
return
}
@@ -1564,8 +1821,9 @@ app:
```go
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
// 生产环境禁用彩色输出
console.Default = console.New(console.WithColor(false))
// 生产环境收紧 console 输出(仅保留 Warn / Error
// 业务事件请使用 logger 包记录
console.SetLevel(console.LevelWarn)
}
```
@@ -1610,13 +1868,17 @@ func Login(c *gin.Context) {
}
// 生成Token
token, _ := jwt.GenerateToken(user.ID, user.Username, "admin", "admin")
token, err := jwt.GenerateToken(user.ID, user.Username, "admin", "admin")
if err != nil {
response.FailWithError(c, response.ErrServerError)
return
}
// 缓存用户信息
cache.GetCache().Set(c.Request.Context(),
cache.KSession(token),
user,
time.Duration(cfg.JWT.Expire)*time.Second)
cfg.JWT.Expire)
response.Success(c, gin.H{
"token": token,
@@ -1628,5 +1890,5 @@ func Login(c *gin.Context) {
---
_文档版本: v2.1_
_最后更新: 2026-04-30_
_文档版本: v1.2.0_
_最后更新: 2026-07-04_
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ethan xlp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+497 -81
View File
@@ -6,25 +6,89 @@
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
</p>
xlgo 是一个基于 Go + Gin 的轻量级 Web 开发框架,提供了完整的后端开发基础设施,包括配置管理、数据库访问、缓存、认证、日志、文件存储等常用功能
> 基于 Go + Gin 的后端开发框架。**组件全部 Manager 化**——既能像脚本一样用包级函数简单调用,又能像正经框架一样注入实例、跑多套、塞 mock 做单测
## 为什么是 xlgo
多数 Gin 脚手架把数据库、Redis、缓存、JWT、日志做成**包级全局单例**:写小项目顺手,但一旦要做单元测试、同进程跑多套实例、或替换实现,就动弹不得。xlgo 把这些组件全部抽象成 `XxxManager`,同时保留包级便捷函数做 facade——**简单用法零成本,复杂场景不封顶**:
```go
// 简单用法:包级函数,跟单例脚手架一样直接
database.InitDB(cfg)
db := database.GetDB()
// 同一份代码,也能注入实例 / 跑多套 / 塞 mock
myDB := database.NewManager(cfg)
myDB.Open(ctx) // 独立实例,不受全局影响
database.SetDefaultManager(myDB) // 提升为全局默认,并关闭旧默认 manager
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入自定义 CacheManager
```
### 区别于一般 Gin 脚手架的几点
- **组件可注入** — database / redis / cache / jwt / storage / logger 均为 `Manager` + 全局 facade 双轨,支持多实例与 mock 注入,而非写死的包级单例
- **生产就绪内置** — `/livez` `/readyz`、Prometheus `/metrics`、主库探活自愈、请求级超时、优雅关闭(等待 in-flight goroutine)开箱即用,不用自己搭
- **框架内零 Fatal** — 错误一律返回由调用方处理,框架代码不 `panic` 杀进程
- **默认轻量、按需启用** — `New()` 什么都不开,`NewFullStack()` 一键全开,中间任意组合;`Without*` 精细排除
- **可插拔数据库方言** — GORM 方言注册表,内置 MySQL / PostgreSQL,可扩展 SQLite / ClickHouse 等
### 30 秒上手
```go
package main
import (
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
)
func main() {
app := xlgo.New(
xlgo.WithConfigPath("./config.yaml"),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
)
app.GetRouter().GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"hello": "xlgo"})
})
_ = app.Run()
}
```
## 框架特性
- **配置管理** - 支持 YAML 配置文件,环境变量覆盖,配置热更新
- **数据库** - MySQL + GORM,支持自动迁移、重试机制、连接池、读写分离
- **缓存** - Redis 缓存,支持分布式缓存、键前缀、TTL,SCAN 优化
- **认证** - JWT 认证,支持 Token 黑名单、刷新机制
- **日志** - 分级日志(API、数据库),日志轮转
- **中间件** - CORS、限流、日志、认证、CSRF 防护
- **文件存储** - 本地存储 + 阿里云 OSS 支持
- **实时通信** - SSE 流式响应 + WebSocket 支持
- **定时任务** - 内置任务调度器
- **验证器** - 请求参数验证,支持自定义错误消息
- **错误处理** - 统一错误码体系
- **CLI 工具** - 脚手架工具,快速创建项目和代码
**架构与可注入性**
- **组件 Manager 化** - database / redis / cache / jwt / storage / logger 均为 `XxxManager` + 全局 facade 双轨,支持多实例与测试注入 mock
- **可插拔数据库方言** - GORM 方言注册表,内置 MySQL / PostgreSQL,可注册 SQLite / ClickHouse 等任意驱动
- **读写分离** - 主库 + 多副本,`ReplicaPicker` 策略可插拔(轮询 / 随机 / 自定义),context 路由强制主库
- **Lifecycle Hooks** - `OnInit / OnStart / OnReady / OnStop` 生命周期钩子,可插拔启动/关闭逻辑
**生产就绪**
- **健康探针** - `/livez`(存活)+ `/readyz`(就绪)+ `/health`(兼容),K8s probe 友好
- **Prometheus 指标** - `/metrics` 端点 + 请求计数 / 耗时 / 在飞数采集中间件
- **依赖健康自愈** - 主库后台探活,连续失败标记不健康联动 503;从库失败临时剔除、恢复自动纳入
- **请求级超时** - `middleware.Timeout` 级联取消下游 GORM/Redis
- **优雅关闭** - `App.Go` 管理后台 goroutineShutdown 时 cancel + 等待退出(带超时)
- **配置校验** - 启动期 `Validate` 拦截非法配置(端口/密钥/必填字段),错误前置
**基础功能**
- **配置管理** - YAML + 环境变量覆盖 + 热更新,`time.Duration` 字符串解析(`"24h"`
- **缓存** - Redis 缓存,键前缀、TTL、SCAN 优化、分布式锁
- **认证** - JWT 认证,Token 黑名单、刷新机制,HMAC 算法可选
- **日志** - 分级日志(通用 / API / DB 三分流),lumberjack 轮转
- **中间件** - CORS、限流(内存 + Redis 版)、请求日志、认证、CSRF、RequestID、Recover
- **文件存储** - 本地 + 阿里云 OSS
- **实时通信** - SSE 流式响应 + WebSocket
- **定时任务** - 内置调度器(interval / daily / weekly / cron
- **验证器** - 请求参数验证,自定义错误消息
- **响应模式** - `ModeBusiness`(业务码,默认)/ `ModeREST`(按错误码映射 HTTP status)一键切换
- **CLI 工具** - 脚手架 + 代码生成(handler/repository/model/service
## 快速开始
> **环境要求**xlgo 基于 Go 1.25+ 构建,请确保本地已安装 Go 1.25 或更高版本。
### 1. 安装
```bash
@@ -45,10 +109,17 @@ go mod tidy
```yaml
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
port: 8080
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business # business(默认,全200+业务码) 或 rest(按错误码映射HTTP status)
database:
driver: mysql # mysql(默认)或 postgres
host: localhost
port: 3306
user: root
@@ -56,6 +127,7 @@ database:
name: your_database
max_idle_conns: 10
max_open_conns: 100
# dsn: "自定义连接字符串,设置后优先于上面的字段"
redis:
host: localhost
@@ -64,8 +136,11 @@ redis:
db: 0
jwt:
secret: your_jwt_secret_key
expire: 86400
secret: your_jwt_secret_key_at_least_32_bytes # ≥32 字节,否则启动期被 Validate 拦截
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
refresh_expire: "168h" # 刷新 token 过期时间
issuer: xlgo
algorithm: HS256 # HS256(默认)/HS384/HS512
storage:
driver: local
@@ -89,6 +164,43 @@ go run main.go
访问 http://localhost:8080/health 检查服务状态。
> v1.0.2 起,`xlgo.New()` 默认是轻量应用,不会自动初始化 MySQL、Redis、Storage 或 Swagger。需要完整基础设施时请显式使用 `WithMySQL()`、`WithRedis()`、`WithStorage()`、`WithSwaggerRoutes()`,或直接使用 `xlgo.NewFullStack()`。
>
> `Without*` 系列 Option(如 `WithoutSwaggerRoutes`)主要用于 `NewFullStack` / `RunFullStack` 启用全部组件后排除个别项,例如 `xlgo.NewFullStack(xlgo.WithoutSwaggerRoutes())` 全组件但关闭 Swagger。`xlgo.New()` 本身已全关,无需再 `Without`。
### 4. 模块路径与包名
xlgo 的 **模块路径**`github.com/EthanCodeCraft/xlgo-core`**包名** 是 `xlgo`(二者不同是 Go 惯例,如 `github.com/gin-gonic/gin` → 包名 `gin`)。import 时用别名 `xlgo` 即可:
```go
package main
import (
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
)
func main() {
app := xlgo.New(
xlgo.WithConfigPath("./config.yaml"),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
)
// 注册一个示例路由
app.GetRouter().GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Hello xlgo!"})
})
// 启用 JWT 认证示例路由(需配合 WithMySQL/WithRedis 生成 token
_ = middleware.RequireUserTypes("admin")
_ = app.Run()
}
```
> 子包(`config` / `database` / `logger` / `middleware` ...)的包名与目录名一致,无需别名。
---
## 核心功能
@@ -119,19 +231,22 @@ config.Reload()
### 数据库操作
xlgo 基于 GORM,主库与从库的驱动由配置 `database.driver` 决定,内置支持 `mysql`(默认)与 `postgres`,也可通过 `database.dsn` 使用任意自定义连接字符串。v1.0.2 起 GORM 方言通过 **可插拔注册表** 管理,应用可自行接入 SQLite、SQL Server、ClickHouse 等任意 GORM 驱动而无需修改框架。
```go
// 初始化 MySQL
database.InitMySQL(cfg)
// 初始化数据库(驱动由配置决定)
database.InitDB(cfg)
defer database.Close()
// 主从读写分离
database.InitMySQLWithReplicas(cfg, []string{
// 主从读写分离(从库 DSN 需与主库驱动匹配)
database.InitDBWithReplicas(cfg, []string{
"root:pass@tcp(slave1:3306)/db",
"root:pass@tcp(slave2:3306)/db",
})
// 读操作自动路由到从库
users := database.GetReadDB().Find(&users)
var users []model.User
database.GetReadDB().Find(&users)
// 强制使用主库
ctx := database.UseMaster(context.Background())
@@ -146,6 +261,46 @@ database.Transaction(func(tx *gorm.DB) error {
status := database.HealthCheck()
```
v1.0.2 起数据库状态由实例化的 `database.Manager` 管理,全局函数(`GetDB``GetReadDB``CloseAll` 等)作为默认 `DefaultManager` 的 facade 保留。可通过 `database.NewManager(cfg)` 创建独立管理器,并通过 `ReplicaPicker` 自定义从库选择策略:
```go
// 独立管理器(不影响全局 DefaultManager
mgr := database.NewManager(cfg)
if err := mgr.Open(context.Background()); err != nil {
return err
}
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()` 也会识别新驱动:
```go
import (
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func init() {
database.RegisterDialect(database.DialectSpec{
Name: "sqlite",
Aliases: []string{"sqlite3"},
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
DSN: func(c *config.DatabaseConfig) string { return c.Name }, // Name 当作文件路径
})
}
```
诊断接口:`database.RegisteredDialects()` 返回当前已注册的驱动名(含别名),`database.LookupDialect(name)` 用于检查某个驱动是否已就绪。未注册的驱动会回退到 MySQL 以保持向后兼容。
### Repository 泛型 CRUD
```go
@@ -184,6 +339,8 @@ err := userRepo.WithTransaction(ctx, func(txRepo *repository.BaseRepo[model.User
### Redis 缓存
> **v1.4.0**App 经 `WithRedis()` 启用时自动用 App 的 Redis 客户端初始化缓存(多 App 隔离,不串越)。下方 `cache.Init()` 用于 standalone。
```go
// 初始化缓存
cache.Init()
@@ -195,11 +352,18 @@ cacheService := cache.GetCache()
// 设置缓存
cacheService.Set(ctx, "user:1", user, 10*time.Minute)
// 获取缓存
// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err
var user User
if cacheService.Get(ctx, "user:1", &user) {
if hit, err := cacheService.Get(ctx, "user:1", &user); err != nil {
return err
} else if hit {
// 缓存命中
}
if exists, err := cacheService.Exists(ctx, "user:1"); err != nil {
return err
} else if exists {
// key 存在
}
// 删除缓存
cacheService.Delete(ctx, "user:1")
@@ -215,8 +379,12 @@ cacheService.DeleteByPattern(ctx, "user:*")
token, err := jwt.GenerateToken(userID, username, "admin", "admin")
// 解析 Token
// ParseToken 默认 fail-closed:黑名单后端不可检查时拒绝 Token(需 Redis
claims, err := jwt.ParseToken(tokenString)
// 需显式 fail-open(仅无 Redis 或低安全场景)用:
claims, err = jwt.ParseTokenWithBlacklistPolicy(tokenString, jwt.BlacklistFailOpen)
// 使 Token 失效(使用 JTI,高效)
jwt.InvalidateToken(tokenString)
@@ -240,7 +408,7 @@ if token != nil {
cache.ExtendLock(ctx, token, 30*time.Second)
// 自动续期执行
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func() error {
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func(ctx context.Context) error {
// 长任务自动续期
return nil
})
@@ -250,6 +418,8 @@ err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func()
### Redis 分布式限流
> **v1.4.0**:多 App 走各自 Redis 用 `middleware.NewRedisRateLimiter(..., middleware.WithRedisClient(app.RedisClient()))`。
```go
// 内存限流(单实例)
r.Use(middleware.CustomRateLimit(100, time.Minute))
@@ -320,8 +490,11 @@ err := storage.Delete(path)
// 获取文件内容
data, err := storage.Get(path)
// 检查文件是否存在
exists := storage.Exists(path)
// 检查文件是否存在(不存在 false,nil;后端错误返回 err
ok, err := storage.Exists(path)
if err != nil {
return err
}
```
### SSE 流式响应
@@ -359,6 +532,11 @@ hub.Broadcast([]byte("广播消息"))
### 定时任务
> **v1.4.0**App 持专属调度器(实例隔离)。**App 管理的任务用 `xlgo.WithCronTask(name, schedule, handler)`
> 注册**(蕴含启用 cron`Init` 时注册+启动,`Shutdown` 时停止)。下方包级 `cron.AddTask`/`cron.Start`
> 仅用于 standalone(无 App)或 `Init` 之后;**`Init` 之前**调包级 `cron.AddTask` 会注册到 init 默认
> 调度器、被 App swap 丢弃。`app.Scheduler()` 供动态注册与管理。
```go
// 每隔 5 分钟执行
cron.AddTask("cleanup", cron.Every(5*time.Minute), func(ctx context.Context) error {
@@ -375,6 +553,9 @@ cron.AddTask("weekly", cron.Weekly(time.Monday, 9, 0), weeklyTask)
cron.AddTask("every15min", cron.ParseCron("*/15 * * * *"), doSomething)
cron.AddTask("monthly", cron.ParseCron("0 0 1 * *"), doSomething) // 每月1号
// ParseCron 对非法表达式 fail-fast panic;动态输入请用 ParseCronStrict 处理 error。
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
// 启动调度器
cron.Start()
defer cron.Stop()
@@ -421,32 +602,45 @@ match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, pas
// JWT 认证(必须登录)
r.Use(middleware.AuthRequired())
// 管理员权限
// 自定义用户类型权限
r.Use(middleware.RequireUserTypes("tenant_admin", "platform_admin"))
// 自定义角色权限
r.Use(middleware.RequireRoles("owner", "manager"))
// 自定义复杂权限判断
r.Use(middleware.RequireAuth(func(user middleware.AuthUser, c *gin.Context) bool {
return user.UserType == "merchant" && user.Role == "owner"
}))
// 默认快捷方法(super_admin/admin/staff 只是框架默认常量)
r.Use(middleware.AdminRequired())
// 超级管理员权限
r.Use(middleware.SuperAdminRequired())
// 员工权限
r.Use(middleware.StaffRequired())
// 任意用户(管理员或员工)
r.Use(middleware.AnyUserRequired())
// 获取用户信息
user, ok := middleware.GetAuthUser(c)
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
role := middleware.GetRole(c)
userType := middleware.GetUserType(c)
```
### 限流中间件
> **v1.4.0**App `Shutdown` 自动 `registry.Stop()`(不再调全局 `StopRateLimiters`,避免误停其他 App)。多 App per-App 计数隔离用 `app.RateLimitRegistry().LoginRateLimit()` 等 App-bound 中间件。
```go
// 登录限流(每分钟 10 次)
r.POST("/login", middleware.LoginRateLimit(), handler.Login)
r.POST("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
response.Success(c, gin.H{"token": "..."})
})
// 上传限流(每分钟 20 次)
r.POST("/upload", middleware.UploadRateLimit(), handler.Upload)
r.POST("/upload", middleware.UploadRateLimit(), func(c *gin.Context) {
response.Success(c, gin.H{"file": "..."})
})
// 自定义限流
r.Use(middleware.CustomRateLimit(50, time.Minute))
@@ -486,6 +680,16 @@ response.Unauthorized(c, "请先登录")
response.NotFound(c, "资源不存在")
response.ServerError(c, "服务器错误")
response.RateLimit(c)
// 显式指定 HTTP status(不受 Mode 影响)
response.Custom(c, http.StatusBadRequest, response.CodeFail, "参数错误", nil)
```
**响应模式**v1.1.0):默认 `ModeBusiness`,所有响应 HTTP 200 + 业务码(兼容存量)。切换 `ModeREST` 后,失败响应按错误码映射对应 HTTP status401/404/429/500...),body 仍带业务码,便于 APM / Prometheus / 网关按 status 区分异常:
```go
response.SetMode(response.ModeREST) // 代码切换
// 或在 config.yaml 配置:server.response_mode: rest
```
---
@@ -550,14 +754,16 @@ xlgo/
├── compress/
│ └── compress.go # Gzip/Zip 压缩解压
├── config/
── config.go # 配置管理(支持热更新)
── config.go # 配置管理(支持热更新、time.Duration 解析
│ └── validate.go # 配置校验(启动期拦截非法配置)
├── console/
│ └── console.go # 彩色控制台输出
├── cron/
│ └── cron.go # 定时任务调度
├── database/
│ ├── mysql.go # MySQL 连接(支持读写分离
── redis.go # Redis 连接
│ ├── manager.go # 数据库管理器(主从、Picker、探活自愈、Init/Close/HealthCheck
── dialect.go # GORM 方言注册表(mysql / postgres,可扩展)
│ └── redis.go # Redis 连接管理器(RedisManager
├── handler/
│ └── handler.go # 基础处理器(类型安全参数获取)
├── jwt/
@@ -570,18 +776,22 @@ xlgo/
│ ├── cors.go # CORS 跨域中间件
│ ├── csrf.go # CSRF 防护中间件
│ ├── logger.go # 请求日志中间件
│ ├── metrics.go # Prometheus 指标中间件
│ ├── ratelimit.go # 限流中间件
│ ├── requestid.go # 请求ID中间件
── recover.go # Panic恢复中间件
── recover.go # Panic恢复中间件
│ └── timeout.go # 请求级超时中间件
├── model/
│ └── base.go # 基础模型
├── repository/
│ └── repository.go # 基础仓库(泛型CRUD
├── response/
│ ├── response.go # 统一响应格式
│ ├── mode.go # 响应模式开关(business / REST
│ └── error.go # 统一错误码
├── router/
── router.go # 路由注册中心(模块化/版本化)
── router.go # 路由注册中心(模块化/版本化、livez/readyz/health
│ └── metrics.go # Prometheus /metrics 端点
├── sse/
│ └── sse.go # SSE 流式响应
├── storage/
@@ -605,8 +815,6 @@ xlgo/
│ ├── validator.go # 请求验证器
│ ├── password.go # 密码强度验证
│ └── hash.go # 密码加密
├── wire/
│ └── wire.go # 依赖注入
└── ws/
└── ws.go # WebSocket 支持
```
@@ -618,7 +826,7 @@ xlgo/
### Docker 部署
```dockerfile
FROM golang:1.22-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY . .
@@ -643,50 +851,258 @@ docker run -d -p 8080:8080 xlgo-app:latest
## 更新日志
### v2.1.0 (2026-04-30)
> 完整变更历史见 [CHANGELOG.md](./CHANGELOG.md)。
- **分布式锁安全增强** - Lua 脚本 + UUID Token,只有持有者能释放锁
- **JWT 黑名单优化** - 使用 JTI 替代完整 Token,大幅节省 Redis 内存
- **HTTP Client 连接池** - Transport 初始化时创建,连接可复用
- **优雅关闭机制** - 监听系统信号,等待请求处理完成
- **Redis 分布式限流** - 滑动窗口算法,多实例共享限流状态
- **Repository 扩展** - 分页查询、链式查询、批量操作、事务支持
- **CORS 配置完善** - 支持配置文件、通配符域名
- **日志中间件增强** - 可记录请求体、慢请求警告、敏感字段过滤
- **新增测试** - cache、middleware 新增多项测试用例
### v1.4.0 (2026-07-12)
### v2.0.0 (2026-04-30)
> **instance+facade 一致性收尾**cron/storage/cache/ratelimit 实例化(App 持 per-App 实例 + `SwapDefault*` + 包级 facade 代理,照 `database.Manager`/`jwt.Manager` 模式),消除单进程多 App 互相污染;trace 纳入 App 生命周期(`WithTrace`);删除 `WithoutWire` 空函数。`go vet` + `go build` + `go test -race ./...` 全绿。完整变更见 [CHANGELOG.md](./CHANGELOG.md)。
- **新增工具函数库** - 111 个实用函数(随机数、字符串、时间、转换、文件、URL、验证、加密、HTTP 客户端、UUID)
- **新增彩色控制台输出** - console 包支持 Debug/Info/Success/Warn/Error 五级彩色输出
- **新增压缩解压** - compress 包支持 Gzip/Zip 压缩解压
- **新增键名前缀管理** - cache.K() 自动添加站点前缀,解决多项目共用 Redis 冲突
- **新增分布式锁** - cache.Lock/TryLock/WithLock 完整实现
- **新增计数器** - cache.Incr/Decr/IncrBy 支持
- **新增 RequestID 中间件** - 请求追踪支持
- **新增 Recover 中间件** - Panic 恢复
- **新增类型安全参数获取** - handler.QueryInt/PathInt/FormInt 等
- **新增路由架构** - router 包支持模块化、版本化 API、中间件分组、RESTful CRUD
- **新增 AppConfig** - 站点别名、环境判断
- **CLI 工具重构** - 模块化结构,模板分离
- **单元测试覆盖** - 17 个包有测试(68%覆盖)
主要破坏性变更(升级前必读):
### v1.1.0 (2026-04-29)
- **`xlgo.WithoutWire()` 删除**v1.1.0 wire 包删除后遗留的空 no-op,直接删除调用即可(无行为变化)。
- **`cron.AddTask``App.Init` 之前注册失效**:App 持专属调度器,pre-Init 包级 `cron.AddTask` 注册到 init 默认调度器、被 App swap 丢弃。改用 `xlgo.WithCronTask(name, schedule, handler)`(或 Init 后 `app.Scheduler().AddTask`)。
- **`middleware.StopRateLimiters` 语义收窄**:仅停默认 RegistryApp `Shutdown``app` 自己的 `registry.Stop()`,不再误停其他 App 限流器。
- **`commitReplacedResources` 不再关闭 `previousDB`/`previousRedis`/`loggerSnapshot`**:多 App 下 previous 可能是另一 App 的活跃资源(致 `redis: client is closed` / logger writer 误杀)。单 App 无影响(init 默认无资源)。
- **cache/ratelimit redis 注入**jwt 模型):`cache.NewRedisCacheWithRedis`/`CacheManager.InitWithRedis``middleware.WithRedisClient`
- 新增配置热更新支持
- 新增数据库读写分离
- 新增 CSRF 防护中间件
- 新增 SSE 流式响应支持
- 新增 WebSocket 支持
- 新增定时任务调度器
- 新增 CLI 脚手架工具
- 新增测试工具包
- 新增统一错误码体系
- 新增密码加密工具
- 新增请求验证器(支持自定义错误消息)
- 实现 OSS 存储上传
- 优化缓存 DeleteByPattern 使用 SCAN
- 修复限流器 goroutine 泄漏问题
新增:`WithTrace`/`WithCronTask``app.Scheduler()`/`Cache()`/`RedisClient()`/`RateLimitRegistry()``cron/storage/cache/middleware``SwapDefault*``config.TraceConfig``middleware.RateLimitRegistry` + App-bound 限流中间件;`storage.StorageManager.Close()`
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.4.0`(⚠️ 含破坏性变更,详见 CHANGELOG 迁移说明)
### v1.2.0 (2026-07-04)
> **破坏性版本**4 轮对抗性评审(deepseek / GLM / Claude / 终审)收口的全部 CRITICAL/HIGH/MEDIUM 修复 + 主线A 包级可变全局并发治理统一。`go vet` + `go build` + `go test -race ./...` 全绿。完整变更见 [CHANGELOG.md](./CHANGELOG.md)。
主要破坏性变更(升级前必读):
- **包级可变全局一律 `atomic.Pointer`**(主线A):`database.DefaultRedis`/`database.DefaultManager`/`storage.DefaultStorage`/`validation.Validator` 由裸指针改为 `atomic.Pointer[T]`。直接读字段调方法需改用 facade(`InitRedis`/`GetDB`/`ValidateStruct` 等)或 `.Load()``database.DefaultManager = x``database.SetDefaultManager(x)`
- **`jwt.DefaultJWT` 包级变量删除** → `GetDefaultJWT()` / `SetDefaultJWTManager()`
- **`repository.FindWhereOrdered`/`FindPageWhereOrdered` 签名**`args []any``args ...any``order` 前置于 `query`
- **`response.Response``Data`/`RequestID``omitempty`**:两字段在所有响应中恒存在。
- **`cache.IsLocked`/`GetLockTTL`/`ForceUnlock`** Redis 不可用改返 `ErrRedisNotReady`(锁操作正确性相关)。
- **`config.Load()` 返回深拷贝**:新增 `(*Config).Clone()``Get()` 仍返只读指针(热路径零分配)。
- **`handler.GetPage``page` 上限 10000**`utils.EqualsIgnoreCase``strings.EqualFold``utils.ReadFile` 去 TOCTOU 前置检查(错误改 `errors.Is(err, os.ErrNotExist)`)。
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.2.0`(⚠️ 含破坏性变更,详见 CHANGELOG 迁移说明)
### v1.1.1 (2026-06-23)
> v1.1.0 的补丁发布:补 `ServerConfig.Host` 绑定地址、面向用户文案统一中文、修正 README 过时/错误描述(含会致启动失败的配置示例)。
- **ServerConfig.Host** - `server.host` 控制监听地址,空=所有接口(兼容),`127.0.0.1`=仅本机,内网 IP=绑定指定网卡
- **文案统一中文** - recover/logger/metrics 等面向用户文案统一中文(保留协议字段名、Prometheus metric Name、MySQL 错误串匹配等英文)
- **README 修正** - 删 wire 段、修配置示例(secret≥32 字节 + expire 改 Duration)、补 v1.1.0 新文件、重写首段介绍
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.1.1`(无破坏性变更)
### v1.1.0 (2026-06-23)
> 本版本定位为 **HA & Manager 化 release**:高可用与生产就绪改进 + 组件 Manager 化。含少量破坏性变更,升级前请阅读 [CHANGELOG 升级说明](./CHANGELOG.md#升级说明)。
- **组件 Manager 化** - storage/cache/redis/jwt/logger 五组件新增 `XxxManager` + `SetDefaultXxxManager`,包级 facade 保留兼容,支持多实例与测试注入
- **Lifecycle Hooks** - `WithHook(Hook{OnInit/OnStart/OnReady/OnStop})` 生命周期钩子
- **App.Go** - 受管理的后台 goroutineShutdown 时 cancel + 等待退出
- **Server 配置化** - `server` 新增 timeout/TLS/unix_socket/response_mode 等字段
- **JWTConfig time.Duration** - `jwt.expire``"24h"`,新增 issuer/algorithm
- **Config Validate** - 启动期校验配置(端口/密钥/必填字段),错误前置
- **response REST 模式** - `SetMode(ModeREST)` 按错误码映射 HTTP status
- **livez/readyz** - K8s 友好的存活性/就绪性探针
- **Prometheus metrics** - `/metrics` + `middleware.Metrics()` 采集请求指标
- **请求级 Timeout** - `middleware.Timeout(d)` 级联取消下游
- **依赖健康自愈** - 主库探活 + replica 健康剔除,`/readyz` 联动 503
- **RequestID 默认装入** - 每个响应/panic 日志都带 request_id
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.1.0`(⚠️ 含破坏性变更,见升级说明)
### v1.0.4 (2026-06-22)
> 本版本定位为 **DX & Docs release**:开发体验与文档改进,无破坏性 API 变更。
- **CLI 多模板** - `xlgo new --template minimal/api/fullstack`,支持轻量 HTTP / 标准业务 / 全组件三种脚手架
- **examples/ 目录** - 新增 `examples/minimal`(无依赖可跑)与 `examples/full`mysql+redis+jwt+user CRUD)可运行示例
- **模块路径文档** - 明确说明 module path `xlgo-core` 与 package name `xlgo` 的关系,补完整 import 示例(保留 `-core`xlgo 多产品系列)
- **Without* 定位** - 文档化 `Without*` Option 主要用于 `NewFullStack` 后排除单项
- **文档结构** - 规划文档归档到 `docs/plans/`,新增 `docs/README.md` 索引
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.0.4`(无破坏性变更)
### v1.0.3 (2026-06-22)
> 本版本定位为 **bug fix release**:收口 v1.0.2 引入的破坏性清理,并修复 4 个轻量 bug + 依赖复查。完整说明见 [CHANGELOG.md#unreleased](./CHANGELOG.md#unreleased)。
#### 🐛 Fixed — JWT JTI 生成忽略 `rand.Read` 错误
`generateJTI()` 丢弃 `crypto/rand.Read` 的 error,失败时会基于全零字节生成 JTI,导致所有 token 的 JTI 相同、黑名单机制失效。改为 `(string, error)` 并在 `GenerateToken` / `GenerateTokenWithCustomExpiry` 传播错误。
#### 🐛 Fixed — `QueryBuilder.Page` 统计行数被残留 Limit 截断
`Page()` 复制查询做 Count 时未清除残留 `Limit`/`Offset`,调用方先 `.Limit(n)``.Page(...)` 会让 Count 被包成子查询,返回 `total` 被截断为 ≤ n。countDB 改为 `.Limit(-1).Offset(-1)` 清除残留条件。
#### 🐛 Fixed — OSS / 本地存储文件名冲突
4 处上传路径仅用 `time.Now().UnixNano()` 命名,同纳秒并发上传会撞名覆盖。新增 `uniqueFilename(now, ext)``<unixNano>-<8字节crypto/rand hex>.<ext>`),4 处统一改用。
#### 🐛 Fixed — 数据库重试策略对不可恢复错误无效
`InitDB` 对认证失败(`Access denied`)、未知数据库(`Unknown database`)、非法 DSN、未注册驱动等配置类错误也退避重试 5 次,白白延迟 31 秒。新增 `isTransientDBError`,这类错误首次出现即返回;网络类错误仍正常重试。
#### 📦 Dependencies — `go mod tidy` + 安全补丁升级
- `go mod tidy` 补全 postgres 方言传递依赖(`jackc/pgx` 家族、`golang.org/x/sync`),`gorm.io/driver/postgres` 提升为直接依赖
- 安全补丁升级(无 API 变更):`golang.org/x/crypto` v0.49→v0.53、`golang-jwt/jwt/v5` v5.2.1→v5.3.1、`gorilla/websocket` v1.5.1→v1.5.3
- 暂缓升级(留待 v1.0.4 / v1.1):`gin` / `validator` / `gorm` / `aliyun-oss-go-sdk` v2→v3(major 破坏性)等跨版本升级
#### ⚠️ Breaking — 清理 v1.0.2 兼容别名(database 包)
```go
// ❌ 移除
database.InitMySQL(cfg)
database.InitMySQLWithReplicas(cfg, replicas)
(*Manager).InitMySQL / InitMySQLWithReplicas
// ✅ 改用(v1.0.2 起就是正式 API,驱动由 cfg.Database.Driver 决定)
database.InitDB(cfg)
database.InitDBWithReplicas(cfg, replicas)
```
xlgo 仍是早期框架,趁此一次彻底清理 v1.0.2 临时保留的别名,避免长期累积技术债。
#### 🗑️ Removed — 删除死代码 `database.DBResolver`
`database.DBResolver.BeforeQuery` 从未被注册到 GORM callback chain,属于纯死代码。文档曾暗示的"自动读写分离"实际从未生效——读写分离一直依赖业务侧显式调用 `database.UseMaster(ctx)` / `database.UseReplica(ctx)`
需要 callback 级自动路由的用户请直接接入官方 [`gorm.io/plugin/dbresolver`](https://github.com/go-gorm/dbresolver)。
#### 🔄 Changed — 文件重命名 `database/mysql.go → database/manager.go`
文件内容已与 MySQL 解耦(v1.0.2 引入可插拔方言注册表后),继续叫 `mysql.go` 误导。**导入路径无变化**,公开 API 全部保留。
#### ✨ Added — console 包显式 level 控制
`console` 包新增显式级别屏蔽能力:
```go
console.SetLevel(console.LevelWarn) // 只看 Warn / Error
console.SetLevel(console.LevelSilent) // 完全静默
```
**定位明确**console 是**开发期彩色 stdout 工具**(跟 `fmt.Println` 同级),**不写文件、不感知环境**。业务可观测信息请使用 `logger`。框架不会自动根据 `app.env` 切换级别——选择权完全在调用方,避免"dev / prod 行为不一致"的隐式陷阱。
完整对比表见 [GUIDE.md §3.3](./GUIDE.md#33-彩色控制台输出)。
#### 🐛 Fixed — Logger 重复写入修复
修复通用 `Logger``apiCore``dbCore` 都 Tee 进来导致**每条日志写三份**的 bug。
- `Logger`(通用)→ `logs/app.log` + console
- `APILog()``logs/api.log` + console
- `DBLog()``logs/database.log` + console
- 互不串扰,磁盘体积砍掉 2/3
**新增**`logger.Close()` 显式关闭文件句柄,`App.Shutdown` 已自动调用;`Init(nil)` 改为返回 error 而非 panic;生产默认级别从 `Warn` 调整为 `Info`
**升级注意**:日志目录会新增 `logs/app.log` 文件(之前通用日志被串写进了 `api.log`/`database.log`),运维采集脚本如有需要请补上。
#### 🔒 Security — CORS 中间件修复
修复 CORS 中间件多个安全与规范遵守问题:
- **`Access-Control-Allow-Credentials` 永远是 `true`** — 旧实现 `if/else` 两个分支都设了 `"true"`,导致即使配置 `AllowCredentials=false` 也会发送凭证头
- **`*` + `credentials: true` 的规范违规** — 同时发送会被浏览器拒绝;修复后此场景回显具体 Origin
- **缺失 `Vary: Origin`** — 防止 CDN / 网关把 A 用户的 CORS 响应缓存给 B 用户
- 非白名单 Origin 不再被回显,防反射型 CORS 漏洞
**升级影响**:如果你之前依赖"默认允许凭证"的隐式行为,需要在配置里显式启用:
```yaml
cors:
allowed_origins: ["https://your-frontend.example"]
allow_credentials: true
```
#### ⚠️ Breaking — 错误码体系重构
修复 `CodeSuccess``CodeInvalidParams` 撞码的生产级 bug(两者都等于 `1`,导致业务错误响应被前端误判为成功)。
**数值变更**
| 常量 | 旧值 | 新值 |
|---|---|---|
| `response.CodeSuccess` | `1` | **`0`** |
| `response.CodeFail` | `0` | **`1`** |
**移除**
- `response.CodeInvalidParams`(与 `CodeSuccess` 撞码,且参数错误码应由业务自定义)
- `response.ErrInvalidParams`
**迁移指南**
```go
// ❌ 旧代码(编译失败)
response.FailWithError(c, response.ErrInvalidParams)
// ✅ 推荐:业务侧自定义错误码
var ErrInvalidParams = response.NewError(40001, "参数错误")
response.FailWithError(c, ErrInvalidParams)
// ✅ 或直接使用通用失败响应
response.Fail(c, "用户名格式错误")
```
**前端**`if (resp.code === 1)``if (resp.code === 0)`
新增 `_errorCodeUniquenessGuard` 编译期防撞码 map,任何后续 `Code*` 常量重复都会在 `go build` 阶段直接报错。
详细迁移说明见 [CHANGELOG.md](./CHANGELOG.md)。
### v1.0.2 (2026-06-20)
#### 数据库
- **可插拔方言注册表** - 新增 `database.RegisterDialect` / `LookupDialect` / `RegisteredDialects`,应用可一次注册即让 `database.driver: <name>` 生效,DSN 构建器同步登记到 `config` 包;内置 `mysql``postgres`(含 `postgresql``pg` 别名),未注册驱动回退 MySQL
- **多数据库驱动** - `database.driver` 支持 `mysql`(默认)与 `postgres`,新增 `database.InitDB` / `InitDBWithReplicas`v1.0.3 起原 `InitMySQL*` 别名已移除)
- **数据库管理器** - 引入实例化 `database.Manager` 持有主从连接,提供 `Master/Replica/FromContext/HealthCheck` 等方法
- **从库选择策略** - 新增 `ReplicaPicker` 接口与 `RoundRobinPicker` / `RandomPicker` 实现,可通过 `SetReplicaPicker` 自定义
- **私有上下文键** - `db_mode` 字符串键替换为私有 `dbModeContextKey{}` 类型,避免上下文键名冲突
- **DSN 构建器注册表** - `config` 包新增 `RegisterDSNBuilder` / `LookupDSNBuilder` / `RegisteredDrivers``DatabaseConfig.DSN()` 改为查注册表,自定义 `CustomDSN` 优先
#### App 启动流程
- **轻量默认** - `xlgo.New()` 默认不再初始化 MySQL / Redis / Storage,也不注册 `/health``/swagger/*`;通过 Option 显式启用
- **组件 Option** - 新增 `WithLogger / WithMySQL / WithRedis / WithStorage / WithHealthRoutes / WithSwaggerRoutes / WithDefaultRoutes / WithAutoMigrate` 及对应 `WithoutXxx` 关闭项(注:`WithWire` 曾在此版本新增,已于 v1.1.0 随 wire 包删除而移除)
- **迁移控制** - 新增 `Migrator` 类型与 `WithMigrator / WithModels`,注册时自动开启 `WithAutoMigrate``WithoutAutoMigrate` 可显式关闭;不再强制调用空的 `database.AutoMigrate()`
- **batteries-included** - 新增 `WithFullStack` / `NewFullStack` / `RunFullStack`,一键启用全部默认组件
- **错误传播** - 框架初始化失败一律 `return error`,不再在框架内部直接 `Fatalf` 退出进程
#### 权限中间件
- **去业务化** - `super_admin/admin/staff` 调整为默认常量而非固定业务模型
- **通用能力** - 新增 `AuthUser` 结构体、`GetAuthUser``RequireUserTypes / RequireRoles / RequireAuth`,旧的 `AdminRequired/SuperAdminRequired/StaffRequired/AnyUserRequired` 改为基于通用能力实现
#### 配置管理
- **实例化 Manager** - 新增 `config.Manager`,提供 `Load / LoadWithWatch / Reload / RegisterCallback / Get / GetViper / Set` 等方法,支持多实例与重复加载
- **App 持有 Manager** - `WithConfigPath` 创建 App 私有 manager 并通过新增的 `config.SetDefaultManager` 推为全局默认,使 `config.Get / GetString` 等便捷函数仍然可用
- **修复** - 修复 `WithConfigPath` 此前的空实现问题
#### 健康检查与默认路由
- **拆分注册** - `router` 包新增 `RegisterHealthRoute` / `RegisterSwaggerRoutes`,原 `RegisterDefaultRoutes` 改为组合调用
- **检查项与状态** - `/health` 支持注册 `HealthCheck`,失败返回 HTTP 503 与 `{"status":"error", "checks":{...}}`
- **App 集成** - 启用 MySQL / Redis 时自动追加对应健康检查项,可通过 `WithHealthCheck` 注册自定义检查
#### 资源关闭
- **Shutdown 修复** - 改为 `database.CloseAll()` 关闭主从连接,并使用 `errors.Join` 聚合限流器、日志、Redis 等组件的关闭错误
- **Storage 可选化** - 未初始化 Storage 时返回 `ErrStorageNotInitialized`,不再 nil panic
#### 文档与 CLI
- **CLI 修复** - `xlgo version` 更新为 v1.0.2,脚手架模板改为依赖 `github.com/EthanCodeCraft/xlgo-core` 并在注释中提示 Swagger / MySQL / Redis / FullStack 的开启方式
- **GUIDE 同步** - 1.3 最简示例、3.2 日志注释、8.4.7 默认路由、9.2 用户信息获取均更新为 v1.0.2 行为
- **历史日志修正** - 修复此前 README 中错误的 v2.0.0 / v2.1.0 更新日志表述
### v1.0.1 (2026-04-30)
- 新增工具函数库、彩色控制台输出、压缩解压、RequestID、Recover 中间件
- 新增缓存键名前缀、分布式锁、计数器、Redis 分布式限流
- 增强 JWT 黑名单、Repository、CORS、日志中间件和优雅关闭能力
- 新增路由架构,支持模块化、版本化 API、中间件分组和 RESTful CRUD
- 完善配置热更新、数据库读写分离、CSRF、SSE、WebSocket、定时任务、CLI、测试工具和统一错误码
### v1.0.0 (2024-04)
+1218 -119
View File
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
package xlgo_test
import (
"context"
"errors"
"net"
"strconv"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/cache"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/alicebob/miniredis/v2"
)
func splitAddr(t *testing.T, addr string) (string, int) {
t.Helper()
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("SplitHostPort %q: %v", addr, err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi port %q: %v", portStr, err)
}
if host == "" {
host = "127.0.0.1"
}
return host, port
}
// TestAppCacheMultiAppRedisIsolation 固化 Phase 4 核心契约:单进程多 App 时,每个 App 的
// 缓存走自己的 redisManager.Client(),互不串越。修复前 cache 硬编码 database.GetRedis()
// (全局),App B swap 后 App A 的缓存会读到 App B 的 redis。
//
// 用两个 miniredis 实例分别作 App A/B 的 Redis 后端,断言各自 cache 只写自己的实例、
// 互读对方的 key 为 miss。appA.Cache()/appB.Cache() 绕过全局 facade 直取 App 实例。
func TestAppCacheMultiAppRedisIsolation(t *testing.T) {
mrA := miniredis.RunT(t)
mrB := miniredis.RunT(t)
hostA, portA := splitAddr(t, mrA.Addr())
hostB, portB := splitAddr(t, mrB.Addr())
cfgA := testConfig(18120)
cfgA.Redis = config.RedisConfig{Host: hostA, Port: portA}
cfgB := testConfig(18121)
cfgB.Redis = config.RedisConfig{Host: hostB, Port: portB}
appA := xlgo.New(xlgo.WithConfig(cfgA), xlgo.WithRedis())
if err := appA.Init(); err != nil {
t.Fatalf("appA Init: %v", err)
}
defer appA.Shutdown()
appB := xlgo.New(xlgo.WithConfig(cfgB), xlgo.WithRedis())
if err := appB.Init(); err != nil {
t.Fatalf("appB Init: %v", err)
}
defer appB.Shutdown()
if appA.Cache() == nil || appB.Cache() == nil {
t.Fatal("app.Cache() = nil after Init with WithRedis")
}
ctx := context.Background()
// A 写 keyA -> 仅落 miniredisA
if err := appA.Cache().Set(ctx, "keyA", "valA", time.Minute); err != nil {
t.Fatalf("appA cache Set: %v", err)
}
if !mrA.Exists("keyA") {
t.Error("keyA not in miniredisA (appA redis)")
}
if mrB.Exists("keyA") {
t.Error("keyA leaked into miniredisB (appB redis) -- cache not isolated")
}
// B 写 keyB -> 仅落 miniredisB
if err := appB.Cache().Set(ctx, "keyB", "valB", time.Minute); err != nil {
t.Fatalf("appB cache Set: %v", err)
}
if !mrB.Exists("keyB") {
t.Error("keyB not in miniredisB (appB redis)")
}
if mrA.Exists("keyB") {
t.Error("keyB leaked into miniredisA (appA redis) -- cache not isolated")
}
// 互读对方 key -> miss(各自走自己的 redis
var got string
if ok, err := appA.Cache().Get(ctx, "keyB", &got); ok || err != nil {
t.Errorf("appA cache should miss keyB (different redis): ok=%v err=%v", ok, err)
}
if ok, err := appB.Cache().Get(ctx, "keyA", &got); ok || err != nil {
t.Errorf("appB cache should miss keyA (different redis): ok=%v err=%v", ok, err)
}
// 各自读自己的 key -> hit
if ok, err := appA.Cache().Get(ctx, "keyA", &got); err != nil || !ok || got != "valA" {
t.Errorf("appA cache Get keyA = %v %q err=%v (want true valA)", ok, got, err)
}
if ok, err := appB.Cache().Get(ctx, "keyB", &got); err != nil || !ok || got != "valB" {
t.Errorf("appB cache Get keyB = %v %q err=%v (want true valB)", ok, got, err)
}
}
// TestAppCacheRollbackOnInitFailure 固化 Phase 4 回滚:Init 失败后全局默认 CacheManager
// 必须恢复为 Init 前的实例。
func TestAppCacheRollbackOnInitFailure(t *testing.T) {
preInit := cache.NewCacheManager()
saved := cache.SwapDefaultCacheManager(preInit) // saved = init 默认;preInit 现为全局
defer cache.SwapDefaultCacheManager(saved)
mr := miniredis.RunT(t)
host, port := splitAddr(t, mr.Addr())
cfg := testConfig(18122)
cfg.Redis = config.RedisConfig{Host: host, Port: port}
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithRedis(),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
)
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
if cache.GetDefaultCache() != preInit {
t.Fatal("rollback did not restore preInit cache manager as global default")
}
}
+60
View File
@@ -0,0 +1,60 @@
package xlgo_test
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
)
// TestAppShutdownStopsConfigWatcher_Mconfig3 固化 M-config-3App.Shutdown 须停止其
// configManager 的热重载 watcher,避免关闭后遗留监听 goroutine(违反 C7 生命周期契约)。
//
// Init 后 resolveConfig 把 a.configManager 提升为全局默认,故经包级 config.StartWatcher
// 在其上启动 watcher(等价于用户对 configManager 调 LoadWithWatch/StartWatcher);
// Shutdown 后断言 watchLoop goroutine 已退出。
func TestAppShutdownStopsConfigWatcher_Mconfig3(t *testing.T) {
dir := filepath.Join(os.TempDir(), "xlgo_app_mconfig3")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
defer os.RemoveAll(dir)
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte("app:\n name: m3\n env: dev\nserver:\n port: 18080\n"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
app := xlgo.New(xlgo.WithConfigPath(cfgPath))
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
// 经全局默认(=a.configManager)启动 watcher,模拟用户开启热重载
if err := config.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
defer config.SetDefaultManager(nil)
time.Sleep(120 * time.Millisecond) // 等 watchLoop 就绪
before := runtime.NumGoroutine()
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
// watchLoop goroutine 应随 Shutdown 退出
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() < before {
break
}
time.Sleep(20 * time.Millisecond)
}
if after := runtime.NumGoroutine(); after >= before {
t.Errorf("App.Shutdown 未停止 config watcherM-config-3: before=%d after=%d", before, after)
}
}
+135
View File
@@ -0,0 +1,135 @@
package xlgo_test
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/cron"
)
// TestAppCronSchedulerInstance 固化 Phase 3WithCronTask 把任务注册到 App 专属调度器,
// app.Scheduler() 返回该实例且含注册的任务。
func TestAppCronSchedulerInstance(t *testing.T) {
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
defer cron.SwapDefaultScheduler(saved)
app := xlgo.New(
xlgo.WithConfig(testConfig(18112)),
xlgo.WithCronTask("my-task", cron.Every(time.Hour), func(context.Context) error { return nil }),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
defer app.Shutdown()
s := app.Scheduler()
if s == nil {
t.Fatal("app.Scheduler() = nil after Init with WithCronTask")
}
found := false
for _, tk := range s.ListTasks() {
if tk.Name == "my-task" {
found = true
break
}
}
if !found {
t.Fatal("WithCronTask task not registered on App scheduler")
}
}
// TestAppCronRollbackRestoresDefault 固化 Phase 3 回滚:Init 失败后全局默认调度器
// 必须恢复为 Init 前的实例(不含 App 注册的任务),而非残留 App 的调度器。
func TestAppCronRollbackRestoresDefault(t *testing.T) {
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
defer cron.SwapDefaultScheduler(saved)
app := xlgo.New(
xlgo.WithConfig(testConfig(18113)),
xlgo.WithCronTask("rollback-task", cron.Every(time.Hour), func(context.Context) error { return nil }),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
)
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
// 回滚后全局默认不应含 App 的 "rollback-task"
for _, tk := range cron.GetScheduler().ListTasks() {
if tk.Name == "rollback-task" {
t.Fatal("rollback did not restore global default scheduler (App task still on global)")
}
}
}
// TestAppCronMultiAppIsolation 固化 Phase 3 核心契约:单进程多 App 时,App A 的 Shutdown
// 只停 A 自己的调度器,不影响 App B 的调度器继续运行(修复前 cron 是全局单例,A.Shutdown
// 会经 cron.StopGlobalWithTimeout 把 B 的调度器也停了)。
//
// 调度器内部 ticker 为 1s,故任务每 1s tick 触发一次(schedule=Every(1ms) 仅决定 due 判定,
// 实际 spawn 受 1s ticker 节拍)。测试因此需要跨 1s tick 观测。
func TestAppCronMultiAppIsolation(t *testing.T) {
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
defer cron.SwapDefaultScheduler(saved)
var ranA, ranB atomic.Int32
appA := xlgo.New(
xlgo.WithConfig(testConfig(18110)),
xlgo.WithCronTask("taskA", cron.Every(time.Millisecond), func(context.Context) error {
ranA.Add(1)
return nil
}),
)
appB := xlgo.New(
xlgo.WithConfig(testConfig(18111)),
xlgo.WithCronTask("taskB", cron.Every(time.Millisecond), func(context.Context) error {
ranB.Add(1)
return nil
}),
)
if err := appA.Init(); err != nil {
t.Fatalf("appA Init: %v", err)
}
if err := appB.Init(); err != nil {
t.Fatalf("appB Init: %v", err)
}
defer appB.Shutdown()
// 轮询等第一个 1s tick:两个调度器都应各跑至少一次(deadline 3s,容忍慢机/CI 漂移)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if ranA.Load() > 0 && ranB.Load() > 0 {
break
}
time.Sleep(50 * time.Millisecond)
}
aAtTick := ranA.Load()
bAtTick := ranB.Load()
if aAtTick == 0 || bAtTick == 0 {
t.Fatalf("expected both schedulers ran at least once: A=%d B=%d", aAtTick, bAtTick)
}
// 停 App A
if err := appA.Shutdown(); err != nil {
t.Fatalf("appA Shutdown: %v", err)
}
// 轮询等 B 再跑一次(deadline 3s),证明 B 调度器未被 A.Shutdown 停掉
deadline = time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if ranB.Load() > bAtTick {
break
}
time.Sleep(50 * time.Millisecond)
}
if ranA.Load() != aAtTick {
t.Errorf("App A scheduler ran after its Shutdown (not isolated): before=%d after=%d", aAtTick, ranA.Load())
}
if ranB.Load() <= bAtTick {
t.Errorf("App B scheduler did not continue after App A Shutdown (not isolated): before=%d after=%d", bAtTick, ranB.Load())
}
}
+43
View File
@@ -0,0 +1,43 @@
package xlgo_test
import (
"context"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
)
func TestAppGoWaitsOnShutdown(t *testing.T) {
app := xlgo.New()
started := make(chan struct{})
exited := make(chan struct{})
app.Go(func(ctx context.Context) {
close(started)
<-ctx.Done()
// 模拟收尾
time.Sleep(10 * time.Millisecond)
close(exited)
})
<-started
// Shutdown 应 cancel ctx 并等待 goroutine 退出
done := make(chan error, 1)
go func() { done <- app.Shutdown() }()
select {
case <-exited:
// goroutine 在 cancel 后退出,符合预期
case <-time.After(2 * time.Second):
t.Fatal("App.Go goroutine did not exit after Shutdown cancel")
}
select {
case err := <-done:
if err != nil {
t.Errorf("Shutdown returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Shutdown did not return")
}
}
+116
View File
@@ -0,0 +1,116 @@
package xlgo_test
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"sync"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// appHungDriver 是测试用 database/sql 驱动,Conn.Ping 阻塞到 ctx 取消,模拟挂起 DB。
// 与 database 包内部的 hungDriver 同构(app_test 为外部包,需自带一份)。
type appHungDriver struct{}
func (appHungDriver) Open(string) (driver.Conn, error) { return appHungConn{}, nil }
type appHungConn struct{}
func (appHungConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("hung: not implemented") }
func (appHungConn) Close() error { return nil }
func (appHungConn) Begin() (driver.Tx, error) { return nil, errors.New("hung: not implemented") }
// Ping 阻塞到 ctx 取消(实现 driver.Pinger,方法名 Ping 而非 PingContext)。
func (appHungConn) Ping(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
var appHungRegisterOnce sync.Once
func registerAppHungDialect() {
appHungRegisterOnce.Do(func() {
sql.Register("xlgo_app_hung_hdb1", appHungDriver{})
database.RegisterDialect(database.DialectSpec{
Name: "app_hung_hdb1",
Dialector: func(string) gorm.Dialector {
db, _ := sql.Open("xlgo_app_hung_hdb1", "")
return appHungDialector{sqlDB: db}
},
DSN: func(*config.DatabaseConfig) string { return "app_hung://" },
})
})
}
// appHungDialector 让 gorm.Open 成功并注入挂起 *sql.DB,使 initDB 的 pingWithTimeout 触发。
type appHungDialector struct{ sqlDB *sql.DB }
func (d appHungDialector) Name() string { return "app_hung_hdb1" }
func (d appHungDialector) Initialize(db *gorm.DB) error { db.Config.ConnPool = d.sqlDB; return nil }
func (d appHungDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
func (d appHungDialector) DataTypeOf(*schema.Field) string { return "" }
func (d appHungDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
func (d appHungDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
func (d appHungDialector) QuoteTo(w clause.Writer, s string) { _, _ = w.WriteString(s) }
func (d appHungDialector) Explain(s string, _ ...any) string { return s }
// TestAppInitHungDBBounded_Hdb1 端到端回归 H-db-1App 级闭环):
// App.Init 启用 MySQL 且 DB 挂起时,initDB 的 pingWithTimeout 3s 失败 -> InitDB 返回错误
// -> App.Init 失败(failAfterInit 回滚)。全程有界,不因挂起 DB 无限卡死 App.Init。
// 修复前 gorm.Open 的 automatic Ping() 在 pingWithTimeout 之前就无限阻塞 -> App.Init 永久 hang。
// 同时验证 App.Init 失败后无 goroutine 泄漏(closeResources 正常)。
func TestAppInitHungDBBounded_Hdb1(t *testing.T) {
registerAppHungDialect()
cfg := &config.Config{
App: config.AppConfig{Env: "test"},
Server: config.ServerConfig{Port: 18091},
Database: config.DatabaseConfig{
Driver: "app_hung_hdb1",
Host: "localhost", Port: 3306, User: "u", Password: "p", Name: "n",
},
}
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithMySQL())
done := make(chan error, 1)
start := time.Now()
go func() { done <- app.Init() }()
select {
case err := <-done:
elapsed := time.Since(start)
if err == nil {
t.Fatalf("挂起 DB 下 App.Init 应失败,got nil")
}
// initDB pingWithTimeout 3s 失败后重试 5 次(每次 ping 3s + retryDelay 1s 起步翻倍),
// 全跑约 5*4s=20s+。放宽到 35s 上限。关键是不触发下方 45s watchdog 的"无限阻塞"。
if elapsed > 35*time.Second {
t.Fatalf("App.Init 耗时 %v 超过 35s 上限(H-db-1initDB ping 应被 3s 约束,重试 5 次应 ~20s", elapsed)
}
case <-time.After(45 * time.Second):
t.Fatalf("App.Init 在挂起 DB 上无限阻塞(H-db-1gorm.Open automatic ping 或 initDB ping 未被超时约束)")
}
// App.Init 失败后应能正常 ShutdownfailAfterInit 已回滚资源,Shutdown 幂等)。
shutdownDone := make(chan error, 1)
go func() { shutdownDone <- app.Shutdown() }()
select {
case err := <-shutdownDone:
if err != nil {
t.Logf("App.Shutdown after failed Init returned err: %v(可接受,资源已回滚)", err)
}
case <-time.After(10 * time.Second):
t.Fatalf("App.Shutdown 在 Init 失败后无限阻塞(closeResources 未正常收口)")
}
// rootCtx 已被 failAfterInit cancel,无残留 goroutineStartProbing 未启动因 Init 失败)。
// 此处不直接断言 goroutine 数(受测试 runtime 噪声影响),靠 Shutdown 有界退出间接证明。
_ = context.Background()
}
+463
View File
@@ -0,0 +1,463 @@
package xlgo_test
import (
"context"
"errors"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/cron"
"github.com/EthanCodeCraft/xlgo-core/logger"
"gorm.io/gorm"
)
// TestAppGoAddWaitRace_M1 回归(须 -race):App.Go 的 wg.Add 与 Shutdown 的 wg.Wait 并发,
// 必须由 lifecycleMu 串行化(Add happens-before Wait),否则违反 WaitGroup 契约。
// 修复前:-race 报 wg.Add/wg.Wait data race 或 "negative WaitGroup counter" panic。
func TestAppGoAddWaitRace_M1(t *testing.T) {
const M = 20
const iters = 20
for iter := 0; iter < iters; iter++ {
app := xlgo.New()
var spawned, exited atomic.Int32
gate := make(chan struct{})
var goDone sync.WaitGroup
goDone.Add(M)
for i := 0; i < M; i++ {
go func() {
<-gate
app.Go(func(ctx context.Context) {
spawned.Add(1)
<-ctx.Done()
exited.Add(1)
})
goDone.Done()
}()
}
shutCh := make(chan error, 1)
go func() {
<-gate
shutCh <- app.Shutdown()
}()
close(gate)
goDone.Wait()
err := <-shutCh
if err != nil {
t.Fatalf("iter %d: Shutdown error: %v", iter, err)
}
sp, ex := spawned.Load(), exited.Load()
if sp != ex {
t.Fatalf("iter %d: spawned %d != exited %d (goroutine leak / Add-after-Wait)", iter, sp, ex)
}
}
}
// TestAppGoRefusedAfterShutdown_M1Shutdown 后 Go() 必须拒绝(state>=Stopping),不 spawn。
func TestAppGoRefusedAfterShutdown_M1(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18102)))
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
ran := make(chan struct{}, 1)
app.Go(func(context.Context) { ran <- struct{}{} })
time.Sleep(50 * time.Millisecond) // 给可能被 spawn 的 goroutine 时间发送
select {
case <-ran:
t.Fatal("Go ran after Shutdown — should be refused (state=Stopping)")
default:
}
}
// TestAppInitAfterShutdownRejected_M1Edge 1):Shutdown 后再 Init 必须返 ErrAppClosed。
func TestAppInitAfterShutdownRejected_M1(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18101)))
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
if err := app.Init(); !errors.Is(err, xlgo.ErrAppClosed) {
t.Fatalf("Init after Shutdown err = %v, want ErrAppClosed", err)
}
}
// TestAppShutdownIdempotent_M1:并发 Shutdown 幂等,OnStop 仅执行一次。
func TestAppShutdownIdempotent_M1(t *testing.T) {
var stopCount atomic.Int32
app := xlgo.New(
xlgo.WithConfig(testConfig(18100)),
xlgo.WithHook(xlgo.Hook{Name: "count-stop", OnStop: func(*xlgo.App) error { stopCount.Add(1); return nil }}),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); _ = app.Shutdown() }()
go func() { defer wg.Done(); _ = app.Shutdown() }()
wg.Wait()
if stopCount.Load() != 1 {
t.Fatalf("OnStop called %d times, want 1 (idempotent)", stopCount.Load())
}
if err := app.Shutdown(); err != nil {
t.Fatalf("third Shutdown error: %v", err)
}
if stopCount.Load() != 1 {
t.Fatalf("OnStop called %d times after 3rd, want 1", stopCount.Load())
}
}
// TestAppInitFailureRollsBackCron_M1Edge 3 + 资源回滚):Init 失败(OnInit hook 报错)时,
// 已 cron.Start() 的全局调度器必须被 failAfterInit 停止,canary 任务不再触发。
// 修复前:Init 失败不停 cron → canary 在下个 1s tick 跑 → ran > 0。
// TestAppShutdownTimeoutCoversOnStop_M1 回归:OnStop 必须受 server.shutdown_timeout 约束。
// 修复前:OnStop 同步执行且不看超时,阻塞 hook 会拖死 Shutdown。
func TestAppShutdownTimeoutCoversOnStop_M1(t *testing.T) {
cfg := testConfig(18105)
cfg.Server.ShutdownTimeout = 30 * time.Millisecond
release := make(chan struct{})
defer close(release)
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{Name: "slow-stop", OnStop: func(*xlgo.App) error {
<-release
return nil
}}),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
start := time.Now()
err := app.Shutdown()
if err == nil || !strings.Contains(err.Error(), "OnStop hook") {
t.Fatalf("Shutdown err = %v, want OnStop timeout error", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("Shutdown took %s, OnStop timeout did not bound shutdown", elapsed)
}
}
// TestWithConfigClonesAndValidates_M1 回归:WithConfig 捕获私有快照,并在 Init 时校验。
func TestWithConfigClonesAndValidates_M1(t *testing.T) {
cfg := testConfig(18106)
app := xlgo.New(xlgo.WithConfig(cfg))
cfg.Server.Port = -1 // 不应污染 App 内部快照
if err := app.Init(); err != nil {
t.Fatalf("Init with cloned config failed after caller mutation: %v", err)
}
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
bad := testConfig(-1)
err := xlgo.New(xlgo.WithConfig(bad)).Init()
if err == nil || !strings.Contains(err.Error(), "配置校验失败") {
t.Fatalf("Init with invalid WithConfig err = %v, want validation error", err)
}
}
func TestAppInitFailureRollsBackCron_M1(t *testing.T) {
cron.StopGlobalWithTimeout(time.Second) // 清前序残留
t.Cleanup(func() {
cron.GetScheduler().RemoveTask("canary-m1")
cron.StopGlobalWithTimeout(time.Second)
})
var ran atomic.Int32
// Phase 3canary 注册到 App 专属调度器(WithCronTask),Init 失败时 stopCron 必须停掉它。
app := xlgo.New(
xlgo.WithConfig(testConfig(18104)),
xlgo.WithCronTask("canary-m1", cron.Every(time.Millisecond), func(context.Context) error {
ran.Add(1)
return nil
}),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom-init") }}),
)
err := app.Init()
if err == nil || !strings.Contains(err.Error(), "boom-init") {
t.Fatalf("Init err = %v, want boom-init", err)
}
time.Sleep(1300 * time.Millisecond) // 跨一个 1s ticker
if ran.Load() != 0 {
t.Fatalf("canary ran %d times after Init failure — cron not stopped by rollback", ran.Load())
}
}
// TestAppGoRefusedAfterInitFailure_M1Init 失败后 failAfterInit 置 state=Stopping
// 后续 Go() 拒绝;先前的 Go goroutine 因 rootCtx cancel 退出(不泄漏)。
func TestAppGoRefusedAfterInitFailure_M1(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18103)),
xlgo.WithMigrator(func(*gorm.DB) error { return nil }), // 无 WithMySQL → Init 确定性失败
)
firstDone := make(chan struct{})
app.Go(func(ctx context.Context) { <-ctx.Done(); close(firstDone) })
if err := app.Init(); err == nil {
t.Fatal("expected Init error")
}
select {
case <-firstDone:
case <-time.After(5 * time.Second):
t.Fatal("first Go goroutine did not exit after Init failure (rootCtx not cancelled)")
}
ran := make(chan struct{}, 1)
app.Go(func(context.Context) { ran <- struct{}{} })
time.Sleep(50 * time.Millisecond)
select {
case <-ran:
t.Fatal("Go ran after Init failure — should be refused (state=Stopping)")
default:
}
}
// TestAppInitFailureDoesNotCloseUnownedGlobals_M1 回归:一个尚未成功初始化任何资源的
// App Init 失败时,不得关闭进程里已有的全局 logger/db/redis 等资源。
func TestAppInitFailureDoesNotCloseUnownedGlobals_M1(t *testing.T) {
dir := t.TempDir()
if err := logger.Init(&config.Config{
App: config.AppConfig{Env: "test"},
Server: config.ServerConfig{Mode: "development"},
Log: config.LogConfig{Dir: dir, MaxSize: 1, MaxBackups: 1, MaxAge: 1},
}); err != nil {
t.Fatalf("logger.Init: %v", err)
}
t.Cleanup(func() { _ = logger.Close() })
missingConfig := filepath.Join(t.TempDir(), "missing.yaml")
if err := xlgo.New(xlgo.WithConfigPath(missingConfig)).Init(); err == nil {
t.Fatal("expected Init error without config")
}
const mark = "M1_UNOWNED_LOGGER_STILL_ACTIVE"
logger.Info(mark)
_ = logger.Sync()
data, err := os.ReadFile(filepath.Join(dir, "app.log"))
if err != nil {
t.Fatalf("read app.log: %v", err)
}
if !strings.Contains(string(data), mark) {
t.Fatal("unowned global logger was closed by failed App.Init")
}
}
// TestAppInitShutdownConcurrentSerialization_M1 直接覆盖 initMu 串行化 doInit/doShutdown
// Edge 2):Init 持 initMu 期间(OnInit hook 阻塞),并发 Shutdown 必须阻塞等 Init 释放,
// 不能与 doInit 并发改资源。确定性逻辑测试,不依赖 -race 概率。
func TestAppInitFailureRestoresReplacedLogger_M1(t *testing.T) {
oldMgr := logger.NewLogManager()
oldDir := t.TempDir()
if err := oldMgr.Init(&config.Config{
App: config.AppConfig{Env: "test"},
Server: config.ServerConfig{Mode: "development"},
Log: config.LogConfig{Dir: oldDir, MaxSize: 1, MaxBackups: 1, MaxAge: 1},
}); err != nil {
t.Fatalf("old logger Init: %v", err)
}
logger.SetDefaultLogManager(oldMgr)
t.Cleanup(func() {
_ = logger.Close()
logger.SetDefaultLogManager(logger.NewLogManager())
})
cfg := testConfig(18105)
cfg.Log.Dir = t.TempDir()
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithLogger(),
xlgo.WithHook(xlgo.Hook{Name: "fail-after-logger", OnInit: func(*xlgo.App) error {
return errors.New("boom-after-logger")
}}),
)
err := app.Init()
if err == nil || !strings.Contains(err.Error(), "boom-after-logger") {
t.Fatalf("Init err = %v, want boom-after-logger", err)
}
if got := logger.GetDefaultLogManager(); got != oldMgr {
t.Fatalf("default logger manager was not restored after Init failure: got %p want %p", got, oldMgr)
}
const mark = "M1_RESTORED_LOGGER_STILL_ACTIVE"
logger.Info(mark)
_ = logger.Sync()
data, err := os.ReadFile(filepath.Join(oldDir, "app.log"))
if err != nil {
t.Fatalf("read old app.log: %v", err)
}
if !strings.Contains(string(data), mark) {
t.Fatal("restored logger did not write to old app.log")
}
}
func TestAppInitShutdownConcurrentSerialization_M1(t *testing.T) {
initStarted := make(chan struct{})
releaseInit := make(chan struct{})
app := xlgo.New(
xlgo.WithConfig(testConfig(18200)),
xlgo.WithHook(xlgo.Hook{
Name: "block-init",
OnInit: func(*xlgo.App) error {
close(initStarted)
<-releaseInit
return nil
},
}),
)
initDone := make(chan error, 1)
go func() { initDone <- app.Init() }()
<-initStarted // OnInit 在 doInit 末尾运行 → 此时 Init 持 initMu
// 并发 Shutdown:必须阻塞等 initMu,不能在 Init 完成前返回。
shutDone := make(chan error, 1)
go func() { shutDone <- app.Shutdown() }()
select {
case <-shutDone:
t.Fatal("Shutdown returned before Init finished — initMu not serializing doInit/doShutdown")
case <-time.After(150 * time.Millisecond):
// 期望:Shutdown 仍阻塞等 initMu
}
close(releaseInit) // 让 OnInit 返回 → doInit 完成 → Init 释放 initMu
select {
case err := <-initDone:
if err != nil {
t.Fatalf("Init: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Init did not return after release")
}
select {
case <-shutDone:
// Shutdown 在 Init 后获得 initMu 并完成
case <-time.After(5 * time.Second):
t.Fatal("Shutdown did not return after Init finished — initMu not released?")
}
}
// TestAppStartServerListenFailureSkipsOnReady_M1 回归:监听失败时不得先执行 OnReady。
// 修复前 StartServer 在 goroutine 内 ListenAndServe,主 goroutine 立即跑 OnReady
// 端口被占用时仍可能先触发 ready 副作用,再返回启动失败。
func TestAppStartServerListenFailureSkipsOnReady_M1(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("occupy port: %v", err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
cfg := testConfig(port)
cfg.Server.Host = "127.0.0.1"
var ready atomic.Int32
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{
Name: "ready-must-not-run",
OnReady: func(*xlgo.App) error {
ready.Add(1)
return nil
},
}),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
err = app.StartServer()
if err == nil || !strings.Contains(err.Error(), "服务器监听失败") {
t.Fatalf("StartServer err = %v, want listen failure", err)
}
if ready.Load() != 0 {
t.Fatalf("OnReady ran %d times despite listen failure", ready.Load())
}
_ = app.Shutdown()
}
// TestAppStartServerTLSFailureSkipsOnReady_M1 回归:TLS 证书装配失败也不得触发 OnReady。
func TestAppStartServerTLSFailureSkipsOnReady_M1(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve port: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
if err := ln.Close(); err != nil {
t.Fatalf("release port: %v", err)
}
cfg := testConfig(port)
cfg.Server.Host = "127.0.0.1"
cfg.Server.TLS.Enabled = true
cfg.Server.TLS.CertFile = filepath.Join(t.TempDir(), "missing.crt")
cfg.Server.TLS.KeyFile = filepath.Join(t.TempDir(), "missing.key")
var ready atomic.Int32
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{
Name: "tls-ready-must-not-run",
OnReady: func(*xlgo.App) error {
ready.Add(1)
return nil
},
}),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
err = app.StartServer()
if err == nil || !strings.Contains(err.Error(), "服务器 TLS 配置无效") {
t.Fatalf("StartServer err = %v, want TLS configuration failure", err)
}
if ready.Load() != 0 {
t.Fatalf("OnReady ran %d times despite TLS failure", ready.Load())
}
_ = app.Shutdown()
}
// TestAppStartServerUnixSocketListens_M1 回归:server.unix_socket 必须真的通过
// net.Listen("unix", path) 监听,而不是把 path 塞给 TCP ListenAndServe 的 Addr。
func TestAppStartServerUnixSocketListens_M1(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows unix socket support varies by environment")
}
sock := filepath.Join(t.TempDir(), "xlgo.sock")
cfg := testConfig(0)
cfg.Server.UnixSocket = sock
app := xlgo.New(
xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{
Name: "dial-unix-ready",
OnReady: func(*xlgo.App) error {
conn, err := net.DialTimeout("unix", sock, time.Second)
if err != nil {
return err
}
_ = conn.Close()
return errors.New("stop after unix socket ready")
},
}),
)
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
err := app.StartServer()
if err == nil || !strings.Contains(err.Error(), "stop after unix socket ready") {
t.Fatalf("StartServer err = %v, want OnReady sentinel after unix dial", err)
}
}
+101
View File
@@ -0,0 +1,101 @@
package xlgo_test
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/gin-gonic/gin"
)
// TestAppRateLimitPerAppCountingIsolation 固化 #1 修复:app.RateLimitRegistry().LoginRateLimit()
// 返回的中间件捕获 App 自己的 Registry,多 App 下 per-App 计数隔离。修复前包级 LoginRateLimit()
// 解析全局默认(多 App 仅最后 Init 的 App),App A 用满额度后 App B 首请求即被拒。
//
// 验证:App A 用满 10/10 后第 11 次 429App B 同 IP 仍可 10 次 200、第 11 次 429(独立计数)。
func TestAppRateLimitPerAppCountingIsolation(t *testing.T) {
// REST 模式:限流映射 HTTP 429
cfgA := testConfig(18140)
cfgA.Server.ResponseMode = "rest"
cfgB := testConfig(18141)
cfgB.Server.ResponseMode = "rest"
appA := xlgo.New(xlgo.WithConfig(cfgA))
if appA.RateLimitRegistry() == nil {
t.Fatal("appA.RateLimitRegistry() = nil before Init (should be pre-created in New)")
}
if err := appA.Init(); err != nil {
t.Fatalf("appA Init: %v", err)
}
defer appA.Shutdown()
// app-bound 中间件:捕获 appA 的 Registry,不查全局默认
appA.GetRouter().GET("/login", appA.RateLimitRegistry().LoginRateLimit(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
appB := xlgo.New(xlgo.WithConfig(cfgB))
if err := appB.Init(); err != nil {
t.Fatalf("appB Init: %v", err)
}
defer appB.Shutdown()
appB.GetRouter().GET("/login", appB.RateLimitRegistry().LoginRateLimit(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := func(h http.Handler) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/login", nil)
r.RemoteAddr = "192.0.2.7:1234" // 两 App 用同一 IP
h.ServeHTTP(w, r)
return w
}
// App A 用满 10 次(200),第 11 次 429
for i := 0; i < 10; i++ {
if w := req(appA.GetRouter()); w.Code != http.StatusOK {
t.Fatalf("appA request %d: got %d, want 200", i+1, w.Code)
}
}
if w := req(appA.GetRouter()); w.Code != http.StatusTooManyRequests {
t.Fatalf("appA 11th: got %d, want 429 (limit reached)", w.Code)
}
// App B 同 IP:应独立计数,10 次 200、第 11 次 429。
// 包级 facade 下 B 会共享 A 的 loginLimiterA 已用满)-> 首请求即 429,本测试即暴露该回归。
for i := 0; i < 10; i++ {
if w := req(appB.GetRouter()); w.Code != http.StatusOK {
t.Fatalf("appB request %d: got %d, want 200 (per-App isolation: B should not share A's count)", i+1, w.Code)
}
}
if w := req(appB.GetRouter()); w.Code != http.StatusTooManyRequests {
t.Fatalf("appB 11th: got %d, want 429 (B independent limit reached)", w.Code)
}
}
// TestAppRateLimitRegistryCustomNoLeakOnRollback 固化 #2 修复路径:app-bound CustomRateLimit
// 的 limiter 登记在 App 的 Registry 上,Init 失败回滚时由 registry.Stop() 收口,不泄漏。
// 验证:注册 CustomRateLimit 任务后 Init 失败,rollback 调 registry.StopcustomLimiters 被清)。
func TestAppRateLimitRegistryCustomNoLeakOnRollback(t *testing.T) {
cfg := testConfig(18142)
app := xlgo.New(xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
)
// app-bound CustomRateLimit(首请求才创建 limiter,但 Init 会失败故不会触发请求;
// 此用例固化 app.RateLimitRegistry() 在 pre-Init 可用、Init 失败回滚不 panic)
app.GetRouter().GET("/c", app.RateLimitRegistry().CustomRateLimit(5, time.Minute), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
// Init 失败后 rollbackReplacedResources 已 Stop App 的 Registry 并把 a.rateLimitRegistry 置 nil
// 故 app.RateLimitRegistry() 返回 nilrollback 已收口,无泄漏、无 panic)。
if app.RateLimitRegistry() != nil {
t.Fatal("a.rateLimitRegistry should be nil after Init-failure rollback")
}
}
+107
View File
@@ -0,0 +1,107 @@
package xlgo_test
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/gin-gonic/gin"
)
// TestAppRateLimitMultiAppShutdownIsolation 固化 Phase 5 核心契约:单进程多 App 时,
// App A 的 Shutdown 不得重置/停止 App B 的限流器。修复前 App.Shutdown 调全局
// middleware.StopRateLimiters,把全局 loginLimiter 置 nilApp B 下次请求会懒创建
// 新 limiter(计数清零)--稳态客户端借 App A 的 Shutdown 窗口绕过限流。
//
// 验证:App B 的 /login 已用 10/10 次后,App A.Shutdown,第 11 次仍应 429(计数保留)。
// 修复前第 11 次会 200(计数被重置)。
func TestAppRateLimitMultiAppShutdownIsolation(t *testing.T) {
saved := middleware.SwapDefaultRateLimitRegistry(middleware.NewRateLimitRegistry())
defer middleware.SwapDefaultRateLimitRegistry(saved)
// 用 REST 响应模式:限流映射 HTTP 429business 模式下是 200+业务码 429,不便断言)
cfgA := testConfig(18130)
cfgA.Server.ResponseMode = "rest"
cfgB := testConfig(18131)
cfgB.Server.ResponseMode = "rest"
appA := xlgo.New(xlgo.WithConfig(cfgA))
if err := appA.Init(); err != nil {
t.Fatalf("appA Init: %v", err)
}
appA.GetRouter().GET("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
appB := xlgo.New(xlgo.WithConfig(cfgB))
if err := appB.Init(); err != nil {
t.Fatalf("appB Init: %v", err)
}
defer appB.Shutdown()
appB.GetRouter().GET("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// 10 次请求 App B 的 /login(限流 10/min,同一 IP-> 全 200
for i := 0; i < 10; i++ {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.RemoteAddr = "192.0.2.1:1234"
appB.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d: got %d, want 200 (under limit)", i+1, w.Code)
}
}
// 第 11 次(不经 A.Shutdown)应 429 -- 先确认限流器确实计数到上限(排除 IP/计数 bug)
{
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.RemoteAddr = "192.0.2.1:1234"
appB.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("sanity: 11th without shutdown got %d, want 429 (limiter not counting?)", w.Code)
}
}
// App A Shutdown -- 不得重置/停止 App B 的 login limiter
if err := appA.Shutdown(); err != nil {
t.Fatalf("appA Shutdown: %v", err)
}
// 第 12 次请求 App B 的 /login -> 应仍 429(计数保留在 10registryB 未被 A.Shutdown 重置)。
// 修复前 A.Shutdown 调 StopRateLimiters 把全局 loginLimiter 置 nil,下次请求懒创建新 limiter(计数清零)-> 200。
{
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.RemoteAddr = "192.0.2.1:1234"
appB.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Errorf("12th request after appA Shutdown: got %d, want 429 (limiter count should be preserved, not reset by appA.Shutdown)", w.Code)
}
}
}
// TestAppRateLimitRollbackOnInitFailure 固化 Phase 5 回滚:Init 失败后全局默认 Registry
// 必须恢复为 Init 前的实例。
func TestAppRateLimitRollbackOnInitFailure(t *testing.T) {
preInit := middleware.NewRateLimitRegistry()
saved := middleware.SwapDefaultRateLimitRegistry(preInit)
defer middleware.SwapDefaultRateLimitRegistry(saved)
app := xlgo.New(
xlgo.WithConfig(testConfig(18132)),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
)
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
if middleware.GetDefaultRateLimitRegistry() != preInit {
t.Fatal("rollback did not restore preInit rate limit registry as global default")
}
}
+71
View File
@@ -0,0 +1,71 @@
package xlgo_test
import (
"errors"
"testing"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/storage"
)
// localStorageCfg 构造一个本地存储配置(tmpdir 由调用方提供)。
func localStorageCfg(port int, dir string) *config.Config {
cfg := testConfig(port)
cfg.Storage = config.StorageConfig{
Driver: "local",
Local: config.LocalStorageConfig{Path: dir},
}
return cfg
}
// TestAppWithStorageSwapsDefault 固化 Phase 2 契约:App.Init(WithStorage) 必须把 App 的
// StorageManager 提升为全局默认。证据:Init 前 GetStorage()=nil(空 manager),Init 后非 nil。
func TestAppWithStorageSwapsDefault(t *testing.T) {
// 用空 manager 作"Init 前"默认,确保 GetStorage() 起点 nil
saved := storage.SwapDefaultStorageManager(storage.NewStorageManager())
defer storage.SwapDefaultStorageManager(saved)
if s := storage.GetStorage(); s != nil {
t.Fatalf("precondition: GetStorage() should be nil, got %T", s)
}
dir := t.TempDir()
app := xlgo.New(xlgo.WithConfig(localStorageCfg(18094, dir)), xlgo.WithStorage())
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
defer app.Shutdown()
if s := storage.GetStorage(); s == nil {
t.Fatal("storage.GetStorage() = nil after App.Init with WithStorage(未把 App manager 提升为全局默认)")
}
}
// TestAppStorageRollbackOnInitFailure 固化 Phase 2 回滚契约:Init 在 storage 之后失败时,
// rollbackReplacedResources 必须把全局默认 swap 回 Init 前的旧 manager。
// 用 OnInit hook 触发失败(OnInit 在 storage init 之后、commitReplacedResources 之前)。
func TestAppStorageRollbackOnInitFailure(t *testing.T) {
saved := storage.SwapDefaultStorageManager(storage.NewStorageManager())
defer storage.SwapDefaultStorageManager(saved)
dir := t.TempDir()
app := xlgo.New(
xlgo.WithConfig(localStorageCfg(18095, dir)),
xlgo.WithStorage(),
xlgo.WithHook(xlgo.Hook{
Name: "force-fail",
OnInit: func(*xlgo.App) error { return errors.New("boom") },
}),
)
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
// 回滚后全局默认应恢复为 saved(空 manager-> GetStorage() 返回 nil
if s := storage.GetStorage(); s != nil {
t.Errorf("Init 失败回滚未恢复 storage 全局默认: GetStorage()=%T", s)
}
}
+361
View File
@@ -0,0 +1,361 @@
package xlgo_test
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func testConfig(port int) *config.Config {
return &config.Config{
App: config.AppConfig{Env: "test"},
Server: config.ServerConfig{Port: port, Mode: "development"},
Log: config.LogConfig{Dir: filepath.Join(os.TempDir(), "xlgo_app_test_logs"), MaxSize: 1, MaxBackups: 1, MaxAge: 1},
}
}
func writeConfig(t *testing.T, name string, port int) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name)
content := "app:\n env: test\nserver:\n port: " + strconv.Itoa(port) + "\n mode: development\nlog:\n dir: " + filepath.ToSlash(filepath.Join(dir, "logs")) + "\n max_size: 1\n max_backups: 1\n max_age: 1\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
func TestAppWithConfigPath(t *testing.T) {
path := writeConfig(t, "config.yaml", 18081)
app := xlgo.New(xlgo.WithConfigPath(path))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
if app.GetServer() != nil {
t.Fatal("Init should not start server")
}
}
func TestAppWithConfigNoDefaultRoutes(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18082)))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected /health 404 without health routes, got %d", w.Code)
}
}
func TestAppWithHealthRoutes(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18083)), xlgo.WithHealthRoutes())
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected /health 200, got %d", w.Code)
}
}
func TestAppWithHealthCheckFailure(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18084)),
xlgo.WithHealthRoutes(),
xlgo.WithHealthCheck("custom", func(_ context.Context) error { return errors.New("down") }),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected /health 503, got %d", w.Code)
}
}
func TestAppMigratorRequiresMySQL(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18085)),
xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }),
)
err := app.Init()
if err == nil || !strings.Contains(err.Error(), "未启用 MySQL") {
t.Fatalf("expected mysql disabled migrator error, got %v", err)
}
}
func TestAppRoutesModule(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18086)),
xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) {
r.GET("/hello", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"hello": "world"}) })
})),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/hello", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected module route 200, got %d", w.Code)
}
}
func TestAppWithoutDefaultRoutesOverrides(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18087)),
xlgo.WithDefaultRoutes(),
xlgo.WithoutDefaultRoutes(),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected /health 404 after WithoutDefaultRoutes, got %d", w.Code)
}
}
// TestAppWithSwaggerRoutesOnly 验证可以单独启用 Swagger 而不开启 health。
func TestAppWithSwaggerRoutesOnly(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18088)),
xlgo.WithSwaggerRoutes(),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
// /health 不应注册
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected /health 404 when only Swagger enabled, got %d", w.Code)
}
// /swagger/* 应注册(具体子路径返回什么取决于 swag 文档是否生成,这里只验证未 404)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Fatalf("expected /swagger/index.html to be registered, got 404")
}
}
// TestAppWithoutAutoMigrateOverride 验证 WithoutAutoMigrate 能覆盖
// 之前通过 WithMigrator 隐式开启的迁移,不再要求 MySQL 已启用。
func TestAppWithoutAutoMigrateOverride(t *testing.T) {
migratorCalled := false
app := xlgo.New(
xlgo.WithConfig(testConfig(18089)),
xlgo.WithMigrator(func(_ *gorm.DB) error {
migratorCalled = true
return nil
}),
xlgo.WithoutAutoMigrate(),
)
if err := app.Init(); err != nil {
t.Fatalf("Init should succeed when AutoMigrate is disabled, got %v", err)
}
if migratorCalled {
t.Fatal("migrator must not be called when WithoutAutoMigrate is applied")
}
}
// TestAppDefaultsLightweight 验证 v1.0.2 起 xlgo.New 默认是轻量应用:
// 不启 MySQL/Redis/Storage、不注册 health/swagger 路由。
func TestAppDefaultsLightweight(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18090)))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
r := app.GetRouter()
for _, path := range []string{"/health", "/swagger/index.html"} {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected %s 404 by default, got %d", path, w.Code)
}
}
// 默认未注册 MySQL/Redis 健康检查项,故 healthChecks 应为空
// 这里通过启用 health 路由后只返回 status:ok 来间接验证
app2 := xlgo.New(xlgo.WithConfig(testConfig(18091)), xlgo.WithHealthRoutes())
if err := app2.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app2.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected /health 200 with no checks, got %d", w.Code)
}
}
// TestAppWithConfigPathDrivesManager 验证 WithConfigPath 真正驱动 App 自己的
// config.Manager,并把它推到全局 default,使 config.Get 能取到加载后的配置。
func TestAppWithConfigPathDrivesManager(t *testing.T) {
path := writeConfig(t, "config_drive.yaml", 18092)
app := xlgo.New(xlgo.WithConfigPath(path))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
cfg := config.Get()
if cfg == nil {
t.Fatal("config.Get returned nil after WithConfigPath; manager not promoted to default")
}
if cfg.Server.Port != 18092 {
t.Fatalf("expected port 18092 from loaded config, got %d", cfg.Server.Port)
}
if config.GetInt("server.port") != 18092 {
t.Fatalf("expected GetInt(server.port)=18092, got %d", config.GetInt("server.port"))
}
}
// TestAppMetricsInstrumentsRegistryRoutes_H8c 端到端验证 H8c:经注册中心注册的业务路由
// 被指标中间件采集,不依赖 RegisterMetricsRoute 的调用顺序。修复前 RegisterMetricsRoute
// 用 r.Use 仅采集其后注册的路由;修复后采集中间件在 Apply 内作首个全局中间件装入。
func TestAppMetricsInstrumentsRegistryRoutes_H8c(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18093)),
xlgo.WithMetricsRoute(),
xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) {
r.GET("/h8c-biz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
})),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
r := app.GetRouter()
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8c-biz", nil))
if w.Code != http.StatusOK {
t.Fatalf("/h8c-biz status = %d, want 200", w.Code)
}
}
// 拉取 /metrics,断言 /h8c-biz 路由被采集(route 标签存在)。
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if w.Code != http.StatusOK {
t.Fatalf("/metrics status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `route="/h8c-biz"`) {
t.Fatalf("metrics output should contain route=\"/h8c-biz\" series, got:\n%s", body)
}
}
// freePort 返回一个当前空闲的 TCP 端口。
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freePort: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// TestAppOnReadyFailureReleasesPort H-1 回归:OnReady 失败时 Run 应返回错误,
// 且 HTTP 端口被释放(监听 goroutine 不泄漏)。
// 修复前 OnReady 失败直接 return,监听 goroutine 仍阻塞在 ListenAndServe
// 端口不释放、goroutine 泄漏。
func TestAppOnReadyFailureReleasesPort(t *testing.T) {
port := freePort(t)
app := xlgo.New(
xlgo.WithConfig(testConfig(port)),
xlgo.WithHook(xlgo.Hook{
Name: "fail-on-ready",
OnReady: func(*xlgo.App) error { return errors.New("boom") },
}),
)
errCh := make(chan error, 1)
go func() { errCh <- app.Run() }()
select {
case err := <-errCh:
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected OnReady failure error, got %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("Run did not return after OnReady failure (server goroutine leaked?)")
}
// 端口应已释放:能重新 bind。容忍 TIME_WAIT 短暂残留。
deadline := time.Now().Add(3 * time.Second)
for {
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
if err == nil {
l.Close()
break
}
if time.Now().After(deadline) {
t.Fatalf("port %d not released 3s after Run returned: %v (server goroutine leaked)", port, err)
}
time.Sleep(50 * time.Millisecond)
}
}
// TestAppInitFailureCancelsGoGoroutine H-2 回归:App.Go 启动的 goroutine 在 Init 失败后
// 应因 rootCtx cancel 而退出,不泄漏。
// 修复前 Init 失败不 cancel rootCtxgoroutine 永久阻塞在 ctx.Done()。
func TestAppInitFailureCancelsGoGoroutine(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18087)),
// WithMigrator 无 WithMySQL → Init 确定性失败("未启用 MySQL"
xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }),
)
ctxDone := make(chan struct{})
app.Go(func(ctx context.Context) {
<-ctx.Done()
close(ctxDone)
})
if err := app.Init(); err == nil {
t.Fatal("expected Init error")
}
select {
case <-ctxDone:
// 好:goroutine 在 Init 失败后退出
case <-time.After(5 * time.Second):
t.Fatal("App.Go goroutine did not exit after Init failure (rootCtx not cancelled, leaked)")
}
}
+120
View File
@@ -0,0 +1,120 @@
package xlgo_test
import (
"context"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/trace"
"github.com/gin-gonic/gin"
)
// TestWithTraceInitErrorPropagated 固化 Phase 1 契约:WithTrace 启用后 doInit 必须调
// trace.Init 并把错误向上传播。用非法 ExporterType 触发 trace.Init 失败(config.Validate
// 只校验 SampleRatio,故 bogus exporter 能过 Validate、在 trace.Init 失败,证明是 App 主动调 Init)。
func TestWithTraceInitErrorPropagated(t *testing.T) {
cfg := testConfig(18090)
cfg.Trace.Enabled = true
cfg.Trace.ExporterType = "bogus-exporter"
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
err := app.Init()
if err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail with bogus trace exporter, got nil")
}
// "初始化 trace 失败" 包裹 trace.Init 的 "不支持的导出器类型"
msg := err.Error()
if !strings.Contains(msg, "trace") && !strings.Contains(msg, "导出器") {
t.Fatalf("expected trace init error, got %v", err)
}
}
// TestWithTraceNoopMiddlewareDoesNotBreakRequest 固化:WithTrace + Enabled=false(默认)
// 时 trace.Init 安装 Noop tracertrace.Middleware 装入链后不应破坏正常请求。
func TestWithTraceNoopMiddlewareDoesNotBreakRequest(t *testing.T) {
cfg := testConfig(18091)
// Trace.Enabled 保持默认 false -> noop
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
defer app.Shutdown()
// Init 后(middleware 链已装入)注册路由,确保该路由经过 trace 中间件。
app.GetRouter().GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("trace noop middleware broke request: got status %d, body %s", w.Code, w.Body.String())
}
}
// TestWithTraceShutdownReleasesExporterGoroutine 固化 Phase 1 生命周期闭环:
// WithTrace + Enabled=true + stdout exporterInit 后 BatchSpanProcessor 后台 goroutine
// 在运行;Shutdown 必须调 trace.Close 让其退出,否则 goroutine 泄漏(H-14 修了 Close 幂等,
// 但 App 此前从未调 Close -- 本测试断言 App.Shutdown 确实调了)。
//
// 不发任何请求 -> stdout exporter 不输出 -> 无测试输出污染;仅观测 goroutine 计数。
func TestWithTraceShutdownReleasesExporterGoroutine(t *testing.T) {
cfg := testConfig(18092)
cfg.Trace.Enabled = true
cfg.Trace.ExporterType = "stdout"
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
baseline := runtime.NumGoroutine()
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
afterInit := runtime.NumGoroutine()
if afterInit <= baseline {
t.Logf("note: afterInit=%d baseline=%d (batch goroutine delta not detected; test may be noisy)", afterInit, baseline)
}
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
// trace.Close 的 provider.Shutdown 异步停止 batch goroutine,轮询等待回归 baseline。
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= baseline {
break
}
time.Sleep(20 * time.Millisecond)
}
if after := runtime.NumGoroutine(); after > baseline {
t.Errorf("trace.Close 未释放 exporter goroutineApp.Shutdown 疑未调 trace.Close: baseline=%d after=%d", baseline, after)
}
}
// TestWithTraceEnabledStdoutInitOK 固化:WithTrace + Enabled=true + stdout 是合法组合,
// trace.Init 成功,App.Init 成功;二次 trace.Close 幂等(H-14)。
func TestWithTraceEnabledStdoutInitOK(t *testing.T) {
cfg := testConfig(18093)
cfg.Trace.Enabled = true
cfg.Trace.ExporterType = "stdout"
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
if err := app.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := app.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
// Shutdown 已调 trace.Close;再调一次必须幂等不报错(H-14 契约)
if err := trace.Close(context.Background()); err != nil {
t.Fatalf("trace.Close after Shutdown should be idempotent: %v", err)
}
}
+162 -46
View File
@@ -3,68 +3,90 @@ package cache
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// CacheService 缓存服务接口
type CacheService interface {
// Get 获取缓存值,如果存在则反序列化到 dest 并返回 true
Get(ctx context.Context, key string, dest any) bool
// Get 获取缓存值,命中则反序列化到 dest 并返回 (true, nil);未命中返回 (false, nil)
// Redis 未就绪、命令错误或反序列化失败返回 (false, err)。调用方须显式处理错误。
Get(ctx context.Context, key string, dest any) (bool, error)
// Set 设置缓存值
Set(ctx context.Context, key string, value any, ttl time.Duration) error
// Delete 删除缓存
Delete(ctx context.Context, key string) error
// DeleteByPattern 按模式删除缓存
DeleteByPattern(ctx context.Context, pattern string) error
// Exists 检查缓存是否存在
Exists(ctx context.Context, key string) bool
// Exists 检查缓存是否存在;未命中返回 (false, nil),后端错误返回 (false, err)。
Exists(ctx context.Context, key string) (bool, error)
}
// redisCache Redis 缓存实现
// redisCache Redis 缓存实现
//
// 不在构造时快照 redis.ClientM12 修复:原 NewRedisCache 构造时取 database.GetRedis()
// 若在 database.InitRedis 之前构造则永久 nil、即使后续 Redis 就绪也是 no-op)。
// 改为每次操作实时取 redis 客户端,使"先构造后 Init Redis"的顺序也能正确工作。
//
// Phase 4:持有可选的注入 clientNewRedisCacheWithRedis),注入优先、否则回退全局
// database.GetRedis()(照 jwt.TokenBlacklist.redisClient 模型)。App 经 InitWithRedis
// 注入 per-App redisManager.Client(),使缓存走 App 自己的 Redis 而非全局(修复跨 App 串越)。
type redisCache struct {
client *redis.Client
injectedClient *redis.Client
}
// NewRedisCache 创建 Redis 缓存实例
// client 返回缓存使用的 Redis 客户端:注入优先,否则回退全局 database.GetRedis()。
func (c *redisCache) client() *redis.Client {
if c != nil && c.injectedClient != nil {
return c.injectedClient
}
return database.GetRedis()
}
// NewRedisCache 创建 Redis 缓存实例(懒取全局 Redis,兼容 standalone 用法)。
func NewRedisCache() CacheService {
return &redisCache{
client: database.RedisClient,
}
return &redisCache{}
}
// Get 获取缓存值
func (c *redisCache) Get(ctx context.Context, key string, dest any) bool {
if c.client == nil {
return false
// NewRedisCacheWithRedis 创建使用指定 Redis 客户端的缓存实例(多 Redis/测试隔离)。
func NewRedisCacheWithRedis(client *redis.Client) CacheService {
return &redisCache{injectedClient: client}
}
// Get 获取缓存值。命中返回 (true, nil);未命中返回 (false, nil)
// Redis 未就绪返回 (false, ErrRedisNotReady)Redis 命令错误或反序列化失败返回 (false, err)。
func (c *redisCache) Get(ctx context.Context, key string, dest any) (bool, error) {
cli := c.client()
if cli == nil {
return false, ErrRedisNotReady
}
val, err := c.client.Get(ctx, key).Result()
val, err := cli.Get(ctx, key).Result()
if err != nil {
if err != redis.Nil {
logger.Warn("缓存获取失败", zap.String("key", key), zap.Error(err))
if err == redis.Nil {
return false, nil
}
return false
return false, err
}
if err := json.Unmarshal([]byte(val), dest); err != nil {
logger.Warn("缓存反序列化失败", zap.String("key", key), zap.Error(err))
return false
return false, err
}
return true
return true, nil
}
// Set 设置缓存值
func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
if c.client == nil {
return nil // Redis 未启用,跳过缓存
cli := c.client()
if cli == nil {
return ErrRedisNotReady
}
data, err := json.Marshal(value)
@@ -73,7 +95,7 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du
return err
}
if err := c.client.Set(ctx, key, data, ttl).Err(); err != nil {
if err := cli.Set(ctx, key, data, ttl).Err(); err != nil {
logger.Warn("缓存设置失败", zap.String("key", key), zap.Error(err))
return err
}
@@ -83,11 +105,12 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du
// Delete 删除缓存
func (c *redisCache) Delete(ctx context.Context, key string) error {
if c.client == nil {
return nil
cli := c.client()
if cli == nil {
return ErrRedisNotReady
}
if err := c.client.Del(ctx, key).Err(); err != nil {
if err := cli.Del(ctx, key).Err(); err != nil {
logger.Warn("缓存删除失败", zap.String("key", key), zap.Error(err))
return err
}
@@ -97,8 +120,9 @@ func (c *redisCache) Delete(ctx context.Context, key string) error {
// DeleteByPattern 按模式删除缓存(使用 SCAN 避免阻塞 Redis)
func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error {
if c.client == nil {
return nil
cli := c.client()
if cli == nil {
return ErrRedisNotReady
}
var cursor uint64
@@ -106,7 +130,7 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
for {
// 使用 SCAN 命令迭代查找匹配的键
keys, nextCursor, err := c.client.Scan(ctx, cursor, pattern, 100).Result()
keys, nextCursor, err := cli.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
logger.Warn("缓存键扫描失败", zap.String("pattern", pattern), zap.Error(err))
return err
@@ -114,7 +138,7 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
// 删除找到的键
if len(keys) > 0 {
if err := c.client.Del(ctx, keys...).Err(); err != nil {
if err := cli.Del(ctx, keys...).Err(); err != nil {
logger.Warn("缓存批量删除失败", zap.Strings("keys", keys), zap.Error(err))
return err
}
@@ -137,27 +161,119 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
return nil
}
// Exists 检查缓存是否存在
func (c *redisCache) Exists(ctx context.Context, key string) bool {
if c.client == nil {
return false
// Exists 检查缓存是否存在。命中返回 (true, nil);未命中返回 (false, nil)
// Redis 未就绪返回 (false, ErrRedisNotReady);命令错误返回 (false, err)。
func (c *redisCache) Exists(ctx context.Context, key string) (bool, error) {
cli := c.client()
if cli == nil {
return false, ErrRedisNotReady
}
return c.client.Exists(ctx, key).Val() > 0
n, err := cli.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
// 全局缓存实例
var globalCache CacheService
// CacheManager 缓存管理器(#10)。照 database.Manager 模式:
// 实例化 + DefaultCache 全局默认 + 包级 facade 代理,支持测试注入 mock 实现。
type CacheManager struct {
mu sync.Mutex
svc CacheService
}
// defaultCachePtr 全局默认缓存管理器(CK3 修复:atomic.Pointer 替代裸指针)。
var defaultCachePtr atomic.Pointer[CacheManager]
func init() {
defaultCachePtr.Store(NewCacheManager())
}
// NewCacheManager 创建缓存管理器实例。
func NewCacheManager() *CacheManager { return &CacheManager{} }
// GetDefaultCache 返回全局默认缓存管理器(并发安全,CK3 修复)。
func GetDefaultCache() *CacheManager {
return defaultCachePtr.Load()
}
// SetDefaultCacheManager 提升指定 CacheManager 为全局默认(atomic 置换,并发安全)。
func SetDefaultCacheManager(m *CacheManager) {
if m != nil {
defaultCachePtr.Store(m)
}
}
// SwapDefaultCacheManager 将指定 CacheManager 置为全局默认,返回被替换的旧 Manager。
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存(照
// SwapDefaultRedisManager / SwapDefaultStorageManager 模式)。nil 被忽略,返回当前默认。
func SwapDefaultCacheManager(m *CacheManager) *CacheManager {
if m == nil {
return defaultCachePtr.Load()
}
return defaultCachePtr.Swap(m)
}
// Init 初始化缓存服务(基于 DefaultRedis 的客户端)。
func (m *CacheManager) Init() {
m.mu.Lock()
defer m.mu.Unlock()
m.svc = NewRedisCache()
}
// InitWithRedis 用指定 Redis 客户端初始化缓存服务(多 Redis/测试隔离)。
func (m *CacheManager) InitWithRedis(client *redis.Client) {
m.mu.Lock()
defer m.mu.Unlock()
m.svc = NewRedisCacheWithRedis(client)
}
// Set 设置缓存服务实现(用于注入 mock 或自定义实现)。
func (m *CacheManager) Set(svc CacheService) {
m.mu.Lock()
defer m.mu.Unlock()
m.svc = svc
}
// Get 返回缓存服务(未初始化时延迟初始化)。
func (m *CacheManager) Get() CacheService {
m.mu.Lock()
defer m.mu.Unlock()
if m.svc == nil {
m.svc = NewRedisCache()
}
return m.svc
}
// --- 包级 facade(代理到 GetDefaultCache()CK3 修复) ---
// Init 初始化全局缓存实例
func Init() {
globalCache = NewRedisCache()
GetDefaultCache().Init()
}
// GetCache 获取全局缓存实例
func GetCache() CacheService {
if globalCache == nil {
Init()
}
return globalCache
return GetDefaultCache().Get()
}
// Get 获取全局缓存值。命中返回 (true, nil);未命中返回 (false, nil)
// 缓存未初始化或 Redis 错误返回 (false, err)。
func Get(ctx context.Context, key string, dest any) (bool, error) {
svc := GetCache()
if svc == nil {
return false, ErrRedisNotReady
}
return svc.Get(ctx, key, dest)
}
// Exists 检查全局缓存是否存在。未命中返回 (false, nil);缓存未初始化或
// Redis 错误返回 (false, err)。
func Exists(ctx context.Context, key string) (bool, error) {
svc := GetCache()
if svc == nil {
return false, ErrRedisNotReady
}
return svc.Exists(ctx, key)
}
+199
View File
@@ -0,0 +1,199 @@
package cache
import (
"context"
"errors"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func setupM10MiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
database.SetTestRedisClient(client)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
return mr
}
func TestM10CacheWritesReturnRedisNotReady(t *testing.T) {
database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
c := &redisCache{}
ctx := context.Background()
if err := c.Set(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Set without Redis err = %v, want ErrRedisNotReady", err)
}
if err := c.Delete(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Delete without Redis err = %v, want ErrRedisNotReady", err)
}
if err := c.DeleteByPattern(ctx, "k:*"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("DeleteByPattern without Redis err = %v, want ErrRedisNotReady", err)
}
}
func TestM10RawHelpersReturnRedisNotReady(t *testing.T) {
database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
ctx := context.Background()
if _, err := Incr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Incr without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := IncrBy(ctx, "k", 2); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("IncrBy without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := Decr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Decr without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := GetTTL(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("GetTTL without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := SetExpire(ctx, "k", time.Minute); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("SetExpire without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := GetRaw(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("GetRaw without Redis err = %v, want ErrRedisNotReady", err)
}
if err := SetRaw(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("SetRaw without Redis err = %v, want ErrRedisNotReady", err)
}
}
func TestM10ExistsReturnsBackendErrors(t *testing.T) {
setupM10MiniRedis(t)
c := &redisCache{}
ctx := context.Background()
exists, err := c.Exists(ctx, "missing")
if err != nil {
t.Fatalf("Exists missing err = %v", err)
}
if exists {
t.Fatal("Exists missing = true, want false")
}
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set present: %v", err)
}
exists, err = c.Exists(ctx, "present")
if err != nil || !exists {
t.Fatalf("Exists present = %v, err=%v; want true,nil", exists, err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if exists, err = c.Exists(canceled, "present"); err == nil || exists {
t.Fatalf("Exists canceled = %v, err=%v; want false,error", exists, err)
}
}
func TestM10GetReturnsBackendAndDecodeErrors(t *testing.T) {
setupM10MiniRedis(t)
c := &redisCache{}
ctx := context.Background()
var got string
ok, err := c.Get(ctx, "missing", &got)
if err != nil || ok {
t.Fatalf("Get missing = %v, err=%v; want false,nil", ok, err)
}
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set present: %v", err)
}
ok, err = c.Get(ctx, "present", &got)
if err != nil || !ok || got != "value" {
t.Fatalf("Get present = %v, %q, err=%v; want true,value,nil", ok, got, err)
}
if err := c.client().Set(ctx, "bad-json", "{", time.Minute).Err(); err != nil {
t.Fatalf("Set bad-json: %v", err)
}
ok, err = c.Get(ctx, "bad-json", &got)
if err == nil || ok {
t.Fatalf("Get bad-json = %v, err=%v; want false,error", ok, err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
ok, err = c.Get(canceled, "present", &got)
if err == nil || ok {
t.Fatalf("Get canceled = %v, err=%v; want false,error", ok, err)
}
}
func TestM10PackageExistsReturnsBackendErrors(t *testing.T) {
setupM10MiniRedis(t)
orig := GetDefaultCache()
t.Cleanup(func() { SetDefaultCacheManager(orig) })
SetDefaultCacheManager(NewCacheManager())
Init()
ctx := context.Background()
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set through facade: %v", err)
}
exists, err := Exists(ctx, "present")
if err != nil || !exists {
t.Fatalf("facade Exists = %v, err=%v; want true,nil", exists, err)
}
}
func TestM10PackageGetReturnsBackendErrors(t *testing.T) {
setupM10MiniRedis(t)
orig := GetDefaultCache()
t.Cleanup(func() { SetDefaultCacheManager(orig) })
SetDefaultCacheManager(NewCacheManager())
Init()
ctx := context.Background()
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set through facade: %v", err)
}
var got string
ok, err := Get(ctx, "present", &got)
if err != nil || !ok || got != "value" {
t.Fatalf("facade Get = %v, %q, err=%v; want true,value,nil", ok, got, err)
}
}
// TestM10CustomCacheServiceCompile asserts a user-provided CacheService
// implementation compiles against the (bool, error) contract. This is a
// compile-time guard: if the interface changes again, this stops building.
type customCacheSvc struct{}
func (customCacheSvc) Get(ctx context.Context, key string, dest any) (bool, error) { return false, nil }
func (customCacheSvc) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
return nil
}
func (customCacheSvc) Delete(ctx context.Context, key string) error { return nil }
func (customCacheSvc) DeleteByPattern(ctx context.Context, p string) error { return nil }
func (customCacheSvc) Exists(ctx context.Context, key string) (bool, error) { return false, nil }
func TestM10CustomCacheServiceCompiles(t *testing.T) {
var _ CacheService = customCacheSvc{}
}
// TestM10RedisNotReadyOnGetExists asserts the Redis-not-ready error is
// surfaced (not swallowed) when no backend is configured.
func TestM10RedisNotReadyOnGetExists(t *testing.T) {
database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
c := &redisCache{}
ctx := context.Background()
if _, err := c.Get(ctx, "k", new(string)); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Get without Redis err = %v, want ErrRedisNotReady", err)
}
if _, err := c.Exists(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
t.Fatalf("Exists without Redis err = %v, want ErrRedisNotReady", err)
}
}
+69
View File
@@ -0,0 +1,69 @@
package cache
import (
"testing"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// TestRedisCacheInjectedClientPriority 固化 Phase 4 核心契约(jwt 模型):
// redisCache.client() 注入优先、全局兜底。注入的 client 即使与 database.GetRedis()
// 不同也必须用注入的--这是多 App 缓存隔离的基础。
func TestRedisCacheInjectedClientPriority(t *testing.T) {
// 全局 redis = clientG
mrG := miniredis.RunT(t)
clientG := redis.NewClient(&redis.Options{Addr: mrG.Addr()})
t.Cleanup(func() { _ = clientG.Close() })
origGlobal := database.SetTestRedisClient(clientG)
t.Cleanup(func() { database.SetTestRedisClient(origGlobal) })
// 注入 clientA(与全局 clientG 不同实例)
mrA := miniredis.RunT(t)
clientA := redis.NewClient(&redis.Options{Addr: mrA.Addr()})
t.Cleanup(func() { _ = clientA.Close() })
// 注入优先
injected := &redisCache{injectedClient: clientA}
if got := injected.client(); got != clientA {
t.Fatalf("injected redisCache.client() = %v, want clientA (注入优先)", got)
}
// 未注入 -> 回退全局 clientG
fallback := &redisCache{}
if got := fallback.client(); got != clientG {
t.Fatalf("fallback redisCache.client() = %v, want clientG (全局兜底)", got)
}
// NewRedisCacheWithRedis 构造的实例持注入 client
svc := NewRedisCacheWithRedis(clientA)
rc, ok := svc.(*redisCache)
if !ok {
t.Fatalf("NewRedisCacheWithRedis returned %T, want *redisCache", svc)
}
if rc.client() != clientA {
t.Fatalf("NewRedisCacheWithRedis client = %v, want clientA", rc.client())
}
}
// TestSwapDefaultCacheManagerReturnsOld 固化 Phase 4Swap 返回被替换的旧 Manager 且不关闭。
func TestSwapDefaultCacheManagerReturnsOld(t *testing.T) {
orig := defaultCachePtr.Load()
defer SwapDefaultCacheManager(orig)
first := NewCacheManager()
if prev := SwapDefaultCacheManager(first); prev != orig {
t.Fatalf("Swap returned %v, want orig", prev)
}
second := NewCacheManager()
if returned := SwapDefaultCacheManager(second); returned != first {
t.Fatalf("Swap returned %v, want first", returned)
}
if defaultCachePtr.Load() != second {
t.Fatal("Swap did not install second as default")
}
if got := SwapDefaultCacheManager(nil); got != second {
t.Fatalf("Swap(nil) = %v, want second (current default)", got)
}
}
+68 -58
View File
@@ -3,16 +3,16 @@ package cache
import (
"context"
"strings"
"sync"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
)
// KeyBuilder 缓存键名构建器
// 评分: ⭐⭐⭐⭐⭐
// 理由: 多项目共用 Redis 时,通过前缀避免 key 冲突
// KeyBuilder 缓存键名构建器CK2 修复:mu 保护并发访问)。
// 使用场景: 多个小项目共用一台 Redis 服务器,每个项目设置不同前缀
type KeyBuilder struct {
mu sync.Mutex
prefix string // 项目/站点前缀,如 "site_a"
_separator string // 分隔符,默认 ":"
_cacheType string // 缓存类型标识,如 "cache"
@@ -22,38 +22,39 @@ type KeyBuilder struct {
type KeyBuilderOption func(*KeyBuilder)
// WithPrefix 设置前缀(项目/站点别名)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 核心配置,用于区分不同项目
// 示例: WithPrefix("site_a") -> 所有 key 自动添加 "site_a" 前缀
func WithPrefix(prefix string) KeyBuilderOption {
return func(kb *KeyBuilder) {
if kb == nil {
return
}
kb.prefix = prefix
}
}
// WithSeparator 设置分隔符
// 评分: ⭐⭐⭐⭐
// 理由: 可自定义分隔符,适应不同命名风格
// 示例: 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
}
}
// NewKeyBuilder 创建键名构建器
// 评分: ⭐⭐⭐⭐⭐
// 理由: 链式配置,灵活易用
func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
kb := &KeyBuilder{
prefix: "",
@@ -61,17 +62,20 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
_cacheType: "cache",
}
for _, opt := range opts {
if opt == nil {
continue
}
opt(kb)
}
return kb
}
// Build 构建完整键名
// 评分: ⭐⭐⭐⭐⭐
// 理由: 自动拼接前缀,避免手动拼接错误
// Build 构建完整键名CK2 修复:mu 读保护)。
// 格式: {cacheType}{separator}{prefix}{separator}{key}
// 示例: kb.Build("user:1") -> "cache_site_a_user:1"
// 示例: kb.Build("user:1") -> "cache:site_a:user:1"
func (kb *KeyBuilder) Build(key string) string {
kb.mu.Lock()
defer kb.mu.Unlock()
parts := []string{}
if kb._cacheType != "" {
parts = append(parts, kb._cacheType)
@@ -83,11 +87,11 @@ func (kb *KeyBuilder) Build(key string) string {
return strings.Join(parts, kb._separator)
}
// BuildTemp 构建临时缓存键名(带过期时间)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 区分临时和永久缓存,便于批量管理
// BuildTemp 构建临时缓存键名(CK2 修复:mu 读保护)。
// 格式: "temp{separator}{prefix}{separator}{key}"
func (kb *KeyBuilder) BuildTemp(key string) string {
kb.mu.Lock()
defer kb.mu.Unlock()
parts := []string{"temp"}
if kb.prefix != "" {
parts = append(parts, kb.prefix)
@@ -96,11 +100,11 @@ func (kb *KeyBuilder) BuildTemp(key string) string {
return strings.Join(parts, kb._separator)
}
// BuildPerm 构建永久缓存键名(不带过期时间)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 永久存储的数据使用不同标识
// BuildPerm 构建永久缓存键名(CK2 修复:mu 读保护)。
// 格式: "perm{separator}{prefix}{separator}{key}"
func (kb *KeyBuilder) BuildPerm(key string) string {
kb.mu.Lock()
defer kb.mu.Unlock()
parts := []string{"perm"}
if kb.prefix != "" {
parts = append(parts, kb.prefix)
@@ -109,11 +113,11 @@ func (kb *KeyBuilder) BuildPerm(key string) string {
return strings.Join(parts, kb._separator)
}
// BuildLock 构建分布式锁键名
// 评分: ⭐⭐⭐⭐⭐
// 理由: 锁使用独立命名空间,避免与业务缓存冲突
// BuildLock 构建分布式锁键名CK2 修复:mu 读保护)。
// 格式: "lock{separator}{prefix}{separator}{key}"
func (kb *KeyBuilder) BuildLock(key string) string {
kb.mu.Lock()
defer kb.mu.Unlock()
parts := []string{"lock"}
if kb.prefix != "" {
parts = append(parts, kb.prefix)
@@ -122,11 +126,11 @@ func (kb *KeyBuilder) BuildLock(key string) string {
return strings.Join(parts, kb._separator)
}
// BuildCounter 构建计数器键名
// 评分: ⭐⭐⭐⭐⭐
// 理由: 计数器使用独立命名空间
// BuildCounter 构建计数器键名CK2 修复:mu 读保护)。
// 格式: "counter{separator}{prefix}{separator}{key}"
func (kb *KeyBuilder) BuildCounter(key string) string {
kb.mu.Lock()
defer kb.mu.Unlock()
parts := []string{"counter"}
if kb.prefix != "" {
parts = append(parts, kb.prefix)
@@ -135,11 +139,11 @@ func (kb *KeyBuilder) BuildCounter(key string) string {
return strings.Join(parts, kb._separator)
}
// BuildSession 构建会话键名
// 评分: ⭐⭐⭐⭐⭐
// 理由: 会话缓存统一管理
// BuildSession 构建会话键名CK2 修复:mu 读保护)。
// 格式: "session{separator}{prefix}{separator}{key}"
func (kb *KeyBuilder) BuildSession(key string) string {
kb.mu.Lock()
defer kb.mu.Unlock()
parts := []string{"session"}
if kb.prefix != "" {
parts = append(parts, kb.prefix)
@@ -148,34 +152,37 @@ func (kb *KeyBuilder) BuildSession(key string) string {
return strings.Join(parts, kb._separator)
}
// BuildPattern 构建匹配模式(用于 SCAN/Keys)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 批量查询/删除时使用
// 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*"
// BuildPattern 构建匹配模式(用于 SCAN/Keys)CK2 修复:mu 读保护)。
// 示例: kb.BuildPattern("user:*") -> "cache:site_a:user:*"
func (kb *KeyBuilder) BuildPattern(pattern string) string {
return kb.Build(pattern)
}
// GetPrefix 获取当前前缀
// GetPrefix 获取当前前缀CK2 修复:mu 读保护)。
func (kb *KeyBuilder) GetPrefix() string {
kb.mu.Lock()
defer kb.mu.Unlock()
return kb.prefix
}
// SetPrefix 动态设置前缀
// 评分: ⭐⭐⭐⭐
// 理由: 运行时可切换前缀,适用于多租户场景
// SetPrefix 动态设置前缀(CK2 修复:mu 写保护,并发安全)。
func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder {
kb.mu.Lock()
defer kb.mu.Unlock()
kb.prefix = prefix
return kb
}
// ===== 全局键名构建器 =====
var globalKeyBuilder *KeyBuilder
// globalKeyBuilder 全局构建器,受 globalKBMu 保护;globalKBOnce 保证自动初始化只执行一次(M13)。
var (
globalKeyBuilder *KeyBuilder
globalKBMu sync.RWMutex
globalKBOnce sync.Once
)
// InitKeyBuilder 初始化全局键名构建器
// 评分: ⭐⭐⭐⭐⭐
// 理由: 应用启动时配置,全局统一命名
// 参数: prefix 站点别名,如果为空则自动从配置读取
// 示例:
//
@@ -192,12 +199,13 @@ func InitKeyBuilder(prefix string, opts ...KeyBuilderOption) {
}
opts = append([]KeyBuilderOption{WithPrefix(prefix)}, opts...)
globalKeyBuilder = NewKeyBuilder(opts...)
kb := NewKeyBuilder(opts...)
globalKBMu.Lock()
globalKeyBuilder = kb
globalKBMu.Unlock()
}
// AutoInitKeyBuilder 自动从配置初始化键名构建器
// 评分: ⭐⭐⭐⭐⭐
// 理由: 无需手动传入参数,完全依赖配置文件
// 配置示例:
//
// app:
@@ -207,20 +215,23 @@ func AutoInitKeyBuilder(opts ...KeyBuilderOption) {
InitKeyBuilder("", opts...)
}
// GetKeyBuilder 获取全局键名构建器
// 评分: ⭐⭐⭐⭐⭐
// 理由: 如果未初始化,自动从配置创建
// GetKeyBuilder 获取全局键名构建器,未初始化时用 sync.Once 自动初始化一次(M13)。
func GetKeyBuilder() *KeyBuilder {
if globalKeyBuilder == nil {
// 自动从配置初始化
AutoInitKeyBuilder()
}
globalKBOnce.Do(func() {
// 仅在仍为 nil 时自动初始化(已由 InitKeyBuilder 设置则跳过)。
globalKBMu.RLock()
kb := globalKeyBuilder
globalKBMu.RUnlock()
if kb == nil {
AutoInitKeyBuilder()
}
})
globalKBMu.RLock()
defer globalKBMu.RUnlock()
return globalKeyBuilder
}
// K 快捷构建键名(使用全局构建器)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 简短易用,减少代码量
// 示例: cache.K("user:1") -> 自动添加前缀
func K(key string) string {
return GetKeyBuilder().Build(key)
@@ -254,15 +265,14 @@ func KSession(key string) string {
// ===== 带键名构建器的缓存操作 =====
// SetWithPrefix 带前缀的缓存设置
// 评分: ⭐⭐⭐⭐⭐
// 理由: 自动添加前缀,简化调用
func SetWithPrefix(ctx context.Context, key string, value any, ttl time.Duration, prefix string) error {
kb := NewKeyBuilder(WithPrefix(prefix))
return GetCache().Set(ctx, kb.Build(key), value, ttl)
}
// GetWithPrefix 带前缀的缓存获取
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) bool {
// GetWithPrefix 带前缀的缓存获取。命中返回 (true, nil);未命中返回 (false, nil)
// Redis 错误或反序列化失败返回 (false, err)。
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) (bool, error) {
kb := NewKeyBuilder(WithPrefix(prefix))
return GetCache().Get(ctx, kb.Build(key), dest)
}
+31
View File
@@ -0,0 +1,31 @@
package cache
import (
"sync"
"testing"
)
// TestGlobalKeyBuilderConcurrentInitGet_M13:并发 InitKeyBuilder/GetKeyBuilder/K 不触发 data race
// 且不 nil-panicM13sync.Once + RWMutex 保护全局构建器)。须配合 -race 运行。
func TestGlobalKeyBuilderConcurrentInitGet_M13(t *testing.T) {
// 预置一个已知前缀,避免依赖 config.Get。
InitKeyBuilder("race_site")
t.Cleanup(func() { globalKBMu.Lock(); globalKeyBuilder = nil; globalKBMu.Unlock() })
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(3)
go func() { defer wg.Done(); InitKeyBuilder("race_site") }()
go func() { defer wg.Done(); _ = GetKeyBuilder() }()
go func() { defer wg.Done(); _ = K("user:1") }()
}
wg.Wait()
// 最终 GetKeyBuilder 非 nilK 返回带前缀键。
if GetKeyBuilder() == nil {
t.Fatal("GetKeyBuilder nil after concurrent init")
}
if got := K("user:1"); got == "user:1" {
t.Errorf("K returned unprefixed key %q (prefix not applied)", got)
}
}
+12 -1
View File
@@ -62,6 +62,17 @@ func TestKeyBuilderNoPrefix(t *testing.T) {
}
}
func TestKeyBuilderNilOptionIgnored(t *testing.T) {
kb := cache.NewKeyBuilder(nil, cache.WithPrefix("site"))
if got := kb.Build("user:1"); got != "cache:site:user:1" {
t.Fatalf("Build with nil option = %q, want cache:site:user:1", got)
}
cache.WithPrefix("ignored")(nil)
cache.WithSeparator("_")(nil)
cache.WithCacheType("ignored")(nil)
}
func TestKeyBuilderSetPrefix(t *testing.T) {
kb := cache.NewKeyBuilder(cache.WithPrefix("site_a"))
@@ -211,4 +222,4 @@ func contains(s, sub string) bool {
}
}
return false
}
}
+200 -88
View File
@@ -3,6 +3,7 @@ package cache
import (
"context"
"errors"
"fmt"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
@@ -11,12 +12,47 @@ import (
// 分布式锁错误
var (
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
ErrLockExpired = errors.New("锁已过期")
ErrRedisNotReady = errors.New("Redis 未初始化")
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
ErrLockExpired = errors.New("锁已过期")
ErrRedisNotReady = errors.New("Redis 未初始化")
// ErrLockNotAcquired 表示锁被其它客户端持有,业务函数未执行。
ErrLockNotAcquired = errors.New("未获取到锁")
// ErrInvalidLockTTL 表示锁 TTL 小于 Redis PX 支持的 1ms 粒度或非正。
ErrInvalidLockTTL = errors.New("锁 TTL 必须大于等于 1ms")
// ErrInvalidLockRetryInterval 表示重试或续期间隔非法。
ErrInvalidLockRetryInterval = errors.New("锁重试/续期间隔必须大于 0")
// ErrLockUnexpectedResult Lua 脚本返回了非预期的结果类型(C1b:裸类型断言防护)。
ErrLockUnexpectedResult = errors.New("锁脚本返回非预期结果")
// ErrLockFuncNil 表示分布式锁业务函数为空。
ErrLockFuncNil = errors.New("锁业务函数不能为空")
)
// LockToken 锁令牌(用于安全释放锁)
// toInt64 将 Lua 脚本返回值安全断言为 int64(C1b:禁止裸断言 panic)。
// go-redis 对整数返回 int64,但 nil/错误响应下可能为其他类型。
func toInt64(v any) (int64, error) {
n, ok := v.(int64)
if !ok {
return 0, fmt.Errorf("脚本返回类型 %T: %w", v, ErrLockUnexpectedResult)
}
return n, nil
}
func ttlMillis(ttl time.Duration) (int64, error) {
if ttl < time.Millisecond {
return 0, ErrInvalidLockTTL
}
return int64(ttl / time.Millisecond), nil
}
// LockToken 锁令牌(用于安全释放锁)。
//
// 安全说明(C1d 设计局限):Token 是随机 UUID(非单调递增的 fencing token)。
// 本实现基于 Redis SET PX + Lua CAS,保证"持有者才能解锁/续期",但**无法防 TTL 到期后的
// 双 worker 并发**:若 worker A 因 GC/网络停滞超过 TTL,锁过期后 worker B 获得锁,
// A 恢复后仍可能写过期数据。完整的 fencing token 防护需:① 用 Redis INCR 生成单调 token
// ② 下游存储层(DB/外部服务)记录已见最大 token 并拒绝旧 token 写入。
// 框架无法单方面保证②,需下游配合,故本类型仅提供 UUID token。对 TTL 到期敏感的场景,
// 请确保 ttl >> 业务最长执行时间,或下游实现 fencing token 校验。
type LockToken struct {
Key string // 锁的键名
Token string // 锁的唯一标识(UUID
@@ -58,24 +94,30 @@ end
`
// NewLock 创建分布式锁
// 评分: ⭐⭐⭐⭐⭐
// 理由: 使用 UUID 作为 Token,保证锁的安全释放
// 参数: key 锁名称,ttl 锁定时长
// 返回: LockToken 用于后续解锁或续期
func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, error) {
if database.RedisClient == nil {
rdb := database.GetRedis()
if rdb == nil {
return nil, ErrRedisNotReady
}
token := utils.UUID()
ttlMs := int64(ttl / time.Millisecond)
result, err := database.RedisClient.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
ttlMs, err := ttlMillis(ttl)
if err != nil {
return nil, err
}
if result.(int64) == 1 {
result, err := rdb.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
if err != nil {
return nil, err
}
n, err := toInt64(result)
if err != nil {
return nil, err
}
if n == 1 {
return &LockToken{Key: key, Token: token}, nil
}
@@ -93,10 +135,9 @@ func Lock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
}
// Unlock 安全释放锁
// 评分: ⭐⭐⭐⭐⭐
// 理由: 使用 Lua 脚本保证只有锁的持有者才能释放
func Unlock(ctx context.Context, token *LockToken) error {
if database.RedisClient == nil {
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
@@ -104,12 +145,16 @@ func Unlock(ctx context.Context, token *LockToken) error {
return ErrLockNotHeld
}
result, err := database.RedisClient.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
result, err := rdb.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
if err != nil {
return err
}
if result.(int64) == 0 {
n, err := toInt64(result)
if err != nil {
return err
}
if n == 0 {
return ErrLockNotHeld
}
@@ -119,18 +164,18 @@ func Unlock(ctx context.Context, token *LockToken) error {
// UnlockByKey 按键名释放锁(不安全,仅用于旧代码兼容)
// 注意: 此函数不检查 Token,任何客户端都能释放锁
func UnlockByKey(ctx context.Context, key string) error {
if database.RedisClient == nil {
return nil
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
return database.RedisClient.Del(ctx, key).Err()
return rdb.Del(ctx, key).Err()
}
// ExtendLock 续期锁
// 评分: ⭐⭐⭐⭐⭐
// 理由: 长任务执行时防止锁过期被其他客户端抢占
// 参数: token 锁令牌,ttl 新的过期时间
func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error {
if database.RedisClient == nil {
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
@@ -138,24 +183,32 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
return ErrLockNotHeld
}
ttlMs := int64(ttl / time.Millisecond)
result, err := database.RedisClient.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
ttlMs, err := ttlMillis(ttl)
if err != nil {
return err
}
if result.(int64) == 0 {
result, err := rdb.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
if err != nil {
return err
}
n, err := toInt64(result)
if err != nil {
return err
}
if n == 0 {
return ErrLockNotHeld
}
return nil
}
// TryLock 尝试获取锁,失败时等待重试
// 评分: ⭐⭐⭐⭐⭐
// 理由: 高并发场景常用,避免立即失败
// TryLock 尝试获取锁,失败时等待重试。重试等待响应 ctx 取消(C1c 修复)。
func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval time.Duration, maxRetry int) (*LockToken, error) {
if retryInterval <= 0 {
return nil, ErrInvalidLockRetryInterval
}
for i := 0; i < maxRetry; i++ {
token, err := NewLock(ctx, key, ttl)
if err != nil {
@@ -164,46 +217,71 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t
if token != nil {
return token, nil
}
time.Sleep(retryInterval)
// 响应 ctx 取消,避免最长阻塞 maxRetry*retryIntervalC1c:禁止 time.Sleep 无视 ctx)。
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(retryInterval):
}
}
return nil, nil
return nil, ErrLockNotAcquired
}
// WithLock 使用分布式锁执行函数(自动管理锁)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 自动获取、续期、释放锁,避免忘记释放
// WithLock 使用分布式锁执行函数(自动管理锁)
// 参数: key 锁名称,ttl 锁定时长,fn 业务函数
// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() error) error {
// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend
//
// 解锁用独立 Background ctxC1a 一致性修复):fn 返回或 panic 后,原 ctx 可能已被
// 调用方取消,用其解锁会失败导致锁泄漏到 TTL。fn panic 时 defer 也保证解锁执行。
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) error) error {
if fn == nil {
return ErrLockFuncNil
}
token, err := NewLock(ctx, key, ttl)
if err != nil {
return err
}
if token == nil {
return nil // 未获取到锁,跳过执行
return ErrLockNotAcquired
}
defer Unlock(ctx, token)
defer func() {
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = Unlock(unlockCtx, token)
}()
return fn()
return fn(ctx)
}
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 长任务执行时自动续期,防止锁过期
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)
// 参数: key 锁名称,initialTTL 初始锁定时长,extendInterval 续期间隔,fn 业务函数
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func() error) error {
//
// 并发安全说明(C1a 修复):续期 goroutine 与父用"父关停 + 子 ack"双 channel 协调——
// 父用 close(stop) 通知子退出(close 由唯一所有者执行,安全),子用 close(finished) ack。
// 避免旧实现 done 无缓冲 + 子 defer close(done) + 父 done<-struct{}{} 的 send-on-closed panic
// ctx 取消或 ExtendLock 失败时 done 已 closed,父再 send 即 panicUnlock 不执行、锁泄漏到 TTL)。
// Unlock 用 context.Background() 派生超时,避免原 ctx 已取消致 Unlock 失败再泄漏。
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func(context.Context) error) error {
if fn == nil {
return ErrLockFuncNil
}
if extendInterval <= 0 {
return ErrInvalidLockRetryInterval
}
token, err := NewLock(ctx, key, initialTTL)
if err != nil {
return err
}
if token == nil {
return nil // 未获取到锁,跳过执行
return ErrLockNotAcquired
}
// 启动续期协程
done := make(chan struct{})
// 父关停信号(仅父 close)与子 ack 信号(仅子 close)。
stop := make(chan struct{})
finished := make(chan struct{})
go func() {
defer close(done)
defer close(finished) // 子退出时 ack,父等待 finished
ticker := time.NewTicker(extendInterval)
defer ticker.Stop()
@@ -211,106 +289,140 @@ func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duratio
select {
case <-ctx.Done():
return
case <-done:
case <-stop:
return
case <-ticker.C:
// 续期锁(每次续期为 initialTTL)
// 续期锁(每次续期为 initialTTL)。续期失败则停止续期,fn 应尽快结束。
if err := ExtendLock(ctx, token, initialTTL); err != nil {
return // 续期失败,停止续期
return
}
}
}
}()
// 执行业务函数
err = fn()
// defer 兜底:fn panic 时也要停止续期 goroutine 并释放锁(C1a panic 路径修复)。
// 无 defer 时 fn panic 会导致 close(stop) 不执行 → 续期 goroutine 永久泄漏,且 Unlock 不执行 → 锁泄漏到 TTL。
defer func() {
close(stop)
<-finished
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = Unlock(unlockCtx, token)
}()
// 停止续期并释放锁
done <- struct{}{}
Unlock(ctx, token)
// 执行业务函数。fn 必须接收 ctx,避免请求取消后业务 loader/DB/HTTP 调用继续运行。
err = fn(ctx)
return err
}
// IsLocked 检查锁是否被占用(不获取锁)
// IsLocked 检查锁是否被占用(不获取锁)
//
// M-E 修复(失败语义统一):Redis 未初始化时返回 (false, ErrRedisNotReady) 而非 (false, nil)——
// 后者与"锁确实未被占用"不可区分,调用方可能误以为可获取锁而进入临界区(正确性 bug)。
// 调用方应 errors.Is(err, ErrRedisNotReady) 区分"Redis 不可用"与"锁未占用"。
// L-G 修复:用 .Result() 显式返回 Redis 错误(原 .Val() 吞错致故障被当"未占用")。
//
// 契约说明:锁操作有正确性影响(无锁进入临界区 = bug),故 Redis 不可用时显式返错;
// 与 cache.Get/Set 等数据操作(性能层、best-effort 静默 no-op)区分。
func IsLocked(ctx context.Context, key string) (bool, error) {
if database.RedisClient == nil {
return false, nil
rdb := database.GetRedis()
if rdb == nil {
return false, ErrRedisNotReady
}
return database.RedisClient.Exists(ctx, key).Val() > 0, nil
n, err := rdb.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
// GetLockTTL 获取锁的剩余过期时间
// GetLockTTL 获取锁的剩余过期时间
//
// M-E 修复:Redis 未初始化时返回 (0, ErrRedisNotReady) 而非 (0, nil),调用方可区分
// "Redis 不可用"与"锁不存在/已过期"(后者 TTL=-1/-2)。
func GetLockTTL(ctx context.Context, key string) (time.Duration, error) {
if database.RedisClient == nil {
return 0, nil
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return database.RedisClient.TTL(ctx, key).Result()
return rdb.TTL(ctx, key).Result()
}
// ForceUnlock 强制释放锁(危险操作,仅用于管理场景)
// 注意: 此函数不检查 Token,强制删除锁
//
// M-E 修复:Redis 未初始化时返回 ErrRedisNotReady 而非 nil——原 nil 让调用方误以为
// 已解锁成功、实则从未操作。管理脚本应据此重试或告警,而非假设成功。
func ForceUnlock(ctx context.Context, key string) error {
if database.RedisClient == nil {
return nil
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
return database.RedisClient.Del(ctx, key).Err()
return rdb.Del(ctx, key).Err()
}
// ===== 计数器操作 =====
// Incr 自增计数器
func Incr(ctx context.Context, key string) (int64, error) {
if database.RedisClient == nil {
return 0, nil
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return database.RedisClient.Incr(ctx, key).Result()
return rdb.Incr(ctx, key).Result()
}
// IncrBy 指定增量自增
func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
if database.RedisClient == nil {
return 0, nil
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return database.RedisClient.IncrBy(ctx, key, value).Result()
return rdb.IncrBy(ctx, key, value).Result()
}
// Decr 自减计数器
func Decr(ctx context.Context, key string) (int64, error) {
if database.RedisClient == nil {
return 0, nil
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return database.RedisClient.Decr(ctx, key).Result()
return rdb.Decr(ctx, key).Result()
}
// GetTTL 获取键的剩余过期时间
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
if database.RedisClient == nil {
return 0, nil
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return database.RedisClient.TTL(ctx, key).Result()
return rdb.TTL(ctx, key).Result()
}
// SetExpire 设置键的过期时间
func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
if database.RedisClient == nil {
return false, nil
rdb := database.GetRedis()
if rdb == nil {
return false, ErrRedisNotReady
}
return database.RedisClient.Expire(ctx, key, ttl).Result()
return rdb.Expire(ctx, key, ttl).Result()
}
// GetRaw 获取原始字符串值(不反序列化)
func GetRaw(ctx context.Context, key string) (string, error) {
if database.RedisClient == nil {
return "", nil
rdb := database.GetRedis()
if rdb == nil {
return "", ErrRedisNotReady
}
return database.RedisClient.Get(ctx, key).Result()
return rdb.Get(ctx, key).Result()
}
// SetRaw 设置原始值(不序列化)
func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error {
if database.RedisClient == nil {
return nil
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
return database.RedisClient.Set(ctx, key, value, ttl).Err()
}
return rdb.Set(ctx, key, value, ttl).Err()
}
+413
View File
@@ -0,0 +1,413 @@
package cache_test
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/cache"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// setupMiniRedis 启动一个 miniredis 实例并把它注入 database 内部,返回清理函数。
func setupMiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
// 通过 SetTestRedisClient 注入 miniredis 客户端到 database 包的内部 redisClient
database.SetTestRedisClient(client)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
return mr
}
// ===== C1aWithLockAutoExtend 不 panic / 不泄漏锁 =====
// 回归 C1actx 取消时 WithLockAutoExtend 不 send-on-closed panic,且锁被释放。
// 旧实现:ctx 取消时子 goroutine defer close(done),父 fn() 后 done<-struct{}{} 即 panic
// Unlock 不执行,锁泄漏到 TTL。
func TestWithLockAutoExtendCtxCancelNoPanic(t *testing.T) {
mr := setupMiniRedis(t)
_ = mr
ctx, cancel := context.WithCancel(context.Background())
key := "c1a:cancel"
ran := make(chan struct{})
var panicked any
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() { panicked = recover() }()
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(runCtx context.Context) error {
close(ran)
// 阻塞直到 ctx 被取消,模拟长任务。
<-runCtx.Done()
return runCtx.Err()
})
// WithLockAutoExtend 返回 fn 的错误(ctx.Err),不应 panic。
if err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("unexpected err: %v", err)
}
}()
// 等 fn 启动 + 至少一次续期 ticker,再取消 ctx,制造最大竞态窗口。
<-ran
time.Sleep(250 * time.Millisecond)
cancel()
wg.Wait()
if panicked != nil {
t.Fatalf("WithLockAutoExtend panicked on ctx cancel (C1a send-on-closed): %v", panicked)
}
// 锁必须被释放(Unlock 用 Background ctx 执行)。
locked, err := cache.IsLocked(context.Background(), key)
if err != nil {
t.Fatalf("IsLocked: %v", err)
}
if locked {
t.Error("lock leaked after ctx cancel (Unlock not executed)")
}
}
// 回归 C1a:fn 正常返回时锁被释放,无 panic。
func TestWithLockAutoExtendNormalRelease(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1a:normal"
called := false
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
called = true
return nil
})
if err != nil {
t.Fatalf("WithLockAutoExtend: %v", err)
}
if !called {
t.Error("fn not called")
}
locked, _ := cache.IsLocked(ctx, key)
if locked {
t.Error("lock not released after normal fn return")
}
}
// 回归 C1a:fn 执行超过续期间隔,锁被续期不丢失(续期 goroutine 工作)。
func TestWithLockAutoExtendExtendsLock(t *testing.T) {
mr := setupMiniRedis(t)
ctx := context.Background()
key := "c1a:extend"
// initialTTL=500msextendInterval=100ms。fn 执行 800ms,期间应多次续期。
// 若续期失效,锁会在 500ms 过期,另一 worker 可获取。
err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func(context.Context) error {
time.Sleep(800 * time.Millisecond)
return nil
})
if err != nil {
t.Fatalf("WithLockAutoExtend: %v", err)
}
// 期间另一 worker 应无法获取锁(续期生效)。
// 此断言在 fn 执行中验证更准;这里用 fn 返回后锁已释放验证基础闭环。
locked, _ := cache.IsLocked(ctx, key)
if locked {
t.Error("lock not released after fn")
}
_ = mr
}
// 回归 C1afn 执行中另一 worker 拿不到锁(续期保活)。
func TestWithLockAutoExtendBlocksContender(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1a:block"
fnStarted := make(chan struct{})
fnDone := make(chan struct{})
lockDone := make(chan struct{})
var contenderGotLock atomic.Bool
go func() {
defer close(lockDone)
cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func(context.Context) error {
close(fnStarted)
time.Sleep(600 * time.Millisecond)
close(fnDone)
return nil
})
}()
<-fnStarted
// 期间尝试获取锁,应失败(nil)。
token, err := cache.NewLock(ctx, key, 2*time.Second)
if err != nil {
t.Fatalf("NewLock contender: %v", err)
}
if token != nil {
contenderGotLock.Store(true)
_ = cache.Unlock(ctx, token)
}
<-fnDone
<-lockDone
if contenderGotLock.Load() {
t.Error("contender acquired lock while auto-extend active (extend failed)")
}
}
// 回归 C1a:fn panic 时锁仍被释放、续期 goroutine 不泄漏(defer 兜底)。
// 旧实现(无 deferfn panic → close(stop) 不执行 → 续期 goroutine 永久泄漏 + Unlock 不执行 → 锁泄漏。
func TestWithLockAutoExtendFnPanicReleasesLock(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1a:panic"
var panicked any
func() {
defer func() { panicked = recover() }()
_ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
time.Sleep(150 * time.Millisecond) // 让续期 ticker 至少触发一次
panic("boom")
})
}()
if panicked == nil {
t.Fatal("expected fn panic to propagate")
}
// 锁必须被释放(defer Unlock 执行)。
locked, _ := cache.IsLocked(ctx, key)
if locked {
t.Error("lock leaked after fn panic (defer Unlock not executed)")
}
}
// 回归 C1a/HIGHWithLock 在 ctx 取消后仍能解锁(Unlock 用 Background ctx)。
// 旧实现 defer Unlock(ctx, token) 用原 ctxctx 取消致 Unlock 失败、锁泄漏到 TTL。
func TestWithLockCtxCancelReleasesLock(t *testing.T) {
setupMiniRedis(t)
ctx, cancel := context.WithCancel(context.Background())
key := "c1a:withlock"
fnStarted := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
_ = cache.WithLock(ctx, key, 10*time.Second, func(runCtx context.Context) error {
close(fnStarted)
<-runCtx.Done()
return runCtx.Err()
})
}()
<-fnStarted
cancel()
<-done
locked, _ := cache.IsLocked(context.Background(), key)
if locked {
t.Error("lock leaked after ctx cancel (WithLock Unlock should use Background ctx)")
}
}
func TestWithLockPassesContextToFunction(t *testing.T) {
setupMiniRedis(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
key := "m10:ctx-aware"
var seen context.Context
err := cache.WithLock(ctx, key, time.Second, func(runCtx context.Context) error {
seen = runCtx
cancel()
<-runCtx.Done()
return runCtx.Err()
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("WithLock ctx-aware err = %v, want context.Canceled", err)
}
if seen != ctx {
t.Fatal("WithLock 应把调用方 ctx 传入业务函数")
}
}
func TestWithLockRejectsNilFunction(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
if err := cache.WithLock(ctx, "m10:nil-fn", time.Second, nil); !errors.Is(err, cache.ErrLockFuncNil) {
t.Fatalf("WithLock nil fn err = %v, want ErrLockFuncNil", err)
}
if err := cache.WithLockAutoExtend(ctx, "m10:nil-fn-auto", time.Second, 100*time.Millisecond, nil); !errors.Is(err, cache.ErrLockFuncNil) {
t.Fatalf("WithLockAutoExtend nil fn err = %v, want ErrLockFuncNil", err)
}
}
// ===== C1b:类型断言不 panic =====
// 回归 C1bNewLock/Unlock/ExtendLock 在正常路径返回正确结果(Lua 脚本返 int64)。
// 此用例锁定正常路径不被 toInt64 改坏;裸断言 panic 路径需构造非 int64 返回,
// miniredis Lua 恒返整数,故 panic 路径由 toInt64 的 comma-ok 防护(代码审查保证)。
func TestLockUnlockExtendCycle(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1b:cycle"
// 加锁
token, err := cache.NewLock(ctx, key, 5*time.Second)
if err != nil || token == nil {
t.Fatalf("NewLock: err=%v token=%v", err, token)
}
// 重复加锁应失败(返回 nil token)
t2, err := cache.NewLock(ctx, key, 5*time.Second)
if err != nil {
t.Fatalf("second NewLock err: %v", err)
}
if t2 != nil {
t.Error("second NewLock should return nil token (lock held)")
_ = cache.Unlock(ctx, t2)
}
// 续期
if err := cache.ExtendLock(ctx, token, 5*time.Second); err != nil {
t.Errorf("ExtendLock: %v", err)
}
// 用错误 token 解锁应失败
wrong := &cache.LockToken{Key: key, Token: "wrong-token"}
if err := cache.Unlock(ctx, wrong); !errors.Is(err, cache.ErrLockNotHeld) {
t.Errorf("Unlock wrong token err = %v, want ErrLockNotHeld", err)
}
// 正确解锁
if err := cache.Unlock(ctx, token); err != nil {
t.Errorf("Unlock: %v", err)
}
// 解锁后另一方可获取
t3, err := cache.NewLock(ctx, key, 5*time.Second)
if err != nil || t3 == nil {
t.Fatalf("NewLock after unlock: err=%v token=%v", err, t3)
}
_ = cache.Unlock(ctx, t3)
}
// ===== C1cTryLock 响应 ctx 取消 =====
// 回归 C1cTryLock 在 ctx 取消时立即返回,不阻塞 maxRetry*retryInterval。
// 旧实现 time.Sleep 不响应 ctx,取消后仍要等满所有重试。
func TestTryLockRespectsCtxCancel(t *testing.T) {
setupMiniRedis(t)
// 先占用锁,使 TryLock 必然重试。
holder, err := cache.NewLock(context.Background(), "c1c:try", 10*time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder: err=%v token=%v", err, holder)
}
defer cache.Unlock(context.Background(), holder)
ctx, cancel := context.WithCancel(context.Background())
// retryInterval=200msmaxRetry=10 → 旧实现取消后最长阻塞 2s。
start := time.Now()
go func() {
time.Sleep(150 * time.Millisecond)
cancel()
}()
token, err := cache.TryLock(ctx, "c1c:try", 5*time.Second, 200*time.Millisecond, 10)
elapsed := time.Since(start)
if !errors.Is(err, context.Canceled) {
t.Errorf("TryLock err = %v, want context.Canceled", err)
}
if token != nil {
t.Error("TryLock should return nil token (held by other)")
_ = cache.Unlock(context.Background(), token)
}
// 取消应在 ~150ms 后返回,远小于 2s。
if elapsed > 1*time.Second {
t.Errorf("TryLock took %v, should return shortly after ctx cancel (C1c: time.Sleep ignored ctx)", elapsed)
}
}
func TestLockRejectsSubMillisecondTTL(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
if token, err := cache.NewLock(ctx, "m10:ttl", time.Nanosecond); !errors.Is(err, cache.ErrInvalidLockTTL) || token != nil {
t.Fatalf("NewLock sub-ms ttl token=%v err=%v, want ErrInvalidLockTTL", token, err)
}
token, err := cache.NewLock(ctx, "m10:ttl-valid", time.Second)
if err != nil || token == nil {
t.Fatalf("NewLock valid setup token=%v err=%v", token, err)
}
defer cache.Unlock(ctx, token)
if err := cache.ExtendLock(ctx, token, 0); !errors.Is(err, cache.ErrInvalidLockTTL) {
t.Errorf("ExtendLock zero ttl err = %v, want ErrInvalidLockTTL", err)
}
}
func TestWithLockAutoExtendRejectsNonPositiveInterval(t *testing.T) {
setupMiniRedis(t)
called := false
err := cache.WithLockAutoExtend(context.Background(), "m10:interval", time.Second, 0, func(context.Context) error {
called = true
return nil
})
if !errors.Is(err, cache.ErrInvalidLockRetryInterval) {
t.Fatalf("WithLockAutoExtend zero interval err = %v, want ErrInvalidLockRetryInterval", err)
}
if called {
t.Fatal("WithLockAutoExtend should not run fn with invalid interval")
}
}
func TestWithLockHeldReturnsErrLockNotAcquired(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "m10:not-acquired"
holder, err := cache.NewLock(ctx, key, time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder token=%v err=%v", holder, err)
}
defer cache.Unlock(ctx, holder)
called := false
err = cache.WithLock(ctx, key, time.Second, func(context.Context) error {
called = true
return nil
})
if !errors.Is(err, cache.ErrLockNotAcquired) {
t.Fatalf("WithLock held err = %v, want ErrLockNotAcquired", err)
}
if called {
t.Fatal("WithLock should not run fn when lock is held")
}
}
func TestTryLockExhaustedReturnsErrLockNotAcquired(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "m10:try-exhausted"
holder, err := cache.NewLock(ctx, key, time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder token=%v err=%v", holder, err)
}
defer cache.Unlock(ctx, holder)
token, err := cache.TryLock(ctx, key, time.Second, time.Millisecond, 2)
if !errors.Is(err, cache.ErrLockNotAcquired) {
t.Fatalf("TryLock exhausted err = %v, want ErrLockNotAcquired", err)
}
if token != nil {
t.Fatalf("TryLock exhausted token = %v, want nil", token)
}
}
+26 -22
View File
@@ -2,6 +2,7 @@ package cache_test
import (
"context"
"errors"
"testing"
"time"
@@ -32,50 +33,53 @@ func TestLockErrors(t *testing.T) {
t.Errorf("Unlock with nil token should return ErrLockNotHeld or ErrRedisNotReady, got %v", err)
}
// Test IsLocked without Redis (should return false)
// Test IsLocked without Redis — M-E:应返回 (false, ErrRedisNotReady)
// 让调用方可区分"Redis 不可用"与"锁未占用"(原 (false,nil) 与"未占用"不可区分)。
locked, err := cache.IsLocked(ctx, "test_key")
if err != nil {
t.Errorf("IsLocked error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("IsLocked without Redis should return ErrRedisNotReady, got %v", err)
}
if locked {
t.Error("IsLocked should return false without Redis")
}
// Test GetLockTTL without Redis
// Test GetLockTTL without Redis — M-E:应返回 (0, ErrRedisNotReady)。
ttl, err := cache.GetLockTTL(ctx, "test_key")
if err != nil {
t.Errorf("GetLockTTL error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("GetLockTTL without Redis should return ErrRedisNotReady, got %v", err)
}
if ttl != 0 {
t.Error("GetLockTTL should return 0 without Redis")
}
if err := cache.UnlockByKey(ctx, "test_key"); !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("UnlockByKey without Redis should return ErrRedisNotReady, got %v", err)
}
}
func TestIncrDecr(t *testing.T) {
ctx := context.Background()
// Test Incr without Redis
// Redis 未初始化时应显式返回 ErrRedisNotReady,避免调用方误判为计数器值为 0。
n, err := cache.Incr(ctx, "counter")
if err != nil {
t.Errorf("Incr error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("Incr without Redis should return ErrRedisNotReady, got %v", err)
}
if n != 0 {
t.Error("Incr should return 0 without Redis")
}
// Test IncrBy
n, err = cache.IncrBy(ctx, "counter", 10)
if err != nil {
t.Errorf("IncrBy error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("IncrBy without Redis should return ErrRedisNotReady, got %v", err)
}
if n != 0 {
t.Error("IncrBy should return 0 without Redis")
}
// Test Decr
n, err = cache.Decr(ctx, "counter")
if err != nil {
t.Errorf("Decr error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("Decr without Redis should return ErrRedisNotReady, got %v", err)
}
if n != 0 {
t.Error("Decr should return 0 without Redis")
@@ -86,8 +90,8 @@ func TestSetExpire(t *testing.T) {
ctx := context.Background()
ok, err := cache.SetExpire(ctx, "test_key", time.Minute)
if err != nil {
t.Errorf("SetExpire error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("SetExpire without Redis should return ErrRedisNotReady, got %v", err)
}
if ok {
t.Error("SetExpire should return false without Redis")
@@ -99,14 +103,14 @@ func TestGetRawSetRaw(t *testing.T) {
// Test SetRaw
err := cache.SetRaw(ctx, "test_key", "test_value", time.Minute)
if err != nil {
t.Errorf("SetRaw error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("SetRaw without Redis should return ErrRedisNotReady, got %v", err)
}
// Test GetRaw
val, err := cache.GetRaw(ctx, "test_key")
if err != nil {
t.Errorf("GetRaw error: %v", err)
if !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("GetRaw without Redis should return ErrRedisNotReady, got %v", err)
}
if val != "" {
t.Error("GetRaw should return empty string without Redis")
@@ -144,4 +148,4 @@ func TestKFunctions(t *testing.T) {
if sessionKey == "" {
t.Error("KSession should not return empty string")
}
}
}
+226 -73
View File
@@ -9,38 +9,78 @@ import (
"golang.org/x/text/cases"
"golang.org/x/text/language"
xlgo "github.com/EthanCodeCraft/xlgo-core"
)
func createProject(name string) {
func createProject(name string) error {
// P1 #21:校验项目名,拒绝路径穿越与非法 Go 包名。
if err := validateProjectName(name); err != nil {
return err
}
// #nosec G703 -- name was validated by validateProjectName: no path separators, no "..", no leading dot, Go identifier only.
if _, err := os.Stat(name); !os.IsNotExist(err) {
fmt.Printf("目录 %s 已存在\n", name)
return
return fmt.Errorf("目录 %s 已存在", name)
}
// 创建目录结构
dirs := []string{
name,
name + "/config",
name + "/handler",
name + "/model",
name + "/repository",
name + "/service",
name + "/middleware",
name + "/public",
name + "/logs",
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Printf("创建目录失败: %s\n", err)
return
// 解析 --template 与 --module 参数(默认 template=api
tmplName := "api"
module := name
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--template", "-t":
if i+1 >= len(args) {
return fmt.Errorf("%s 缺少参数值", args[i])
}
tmplName = args[i+1]
i++
case "--module", "-m":
if i+1 >= len(args) {
return fmt.Errorf("%s 缺少参数值", args[i])
}
module = args[i+1]
i++
default:
// P1 #21:未知参数显式报错,不再静默忽略。
return fmt.Errorf("未知参数: %s", args[i])
}
}
// 获取模块路径
module := name
if len(os.Args) > 3 && os.Args[3] == "--module" && len(os.Args) > 4 {
module = os.Args[4]
// P1 #21:校验 module 路径,避免模板元字符 {{ }} 经 Sprintf 进 go.mod 后触发 Parse 报错。
if err := validateModulePath(module); err != nil {
return err
}
// 校验模板名
switch tmplName {
case "minimal", "api", "fullstack":
// ok
default:
return fmt.Errorf("未知模板: %s(可选: minimal / api / fullstack", tmplName)
}
// minimal 模板目录结构最小化;api/fullstack 含完整分层目录
var dirs []string
dirs = append(dirs, name, name+"/public", name+"/logs")
if tmplName != "minimal" {
dirs = append(dirs,
name+"/config",
name+"/handler",
name+"/model",
name+"/repository",
name+"/service",
name+"/middleware",
)
}
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)
}
}
caser := cases.Title(language.English)
@@ -52,68 +92,169 @@ func createProject(name string) {
Year: time.Now().Year(),
}
// 按模板选择 main.go 与 config.yaml
var mainTmpl, configTmpl string
switch tmplName {
case "minimal":
mainTmpl, configTmpl = templates.MainMinimal, templates.ConfigMinimal
case "fullstack":
mainTmpl, configTmpl = templates.MainFull, templates.ConfigFull
default: // api
mainTmpl, configTmpl = templates.Main, templates.Config
}
// 创建文件
files := map[string]string{
name + "/main.go": templates.Main,
name + "/config.yaml": templates.Config,
name + "/go.mod": fmt.Sprintf(templates.GoMod, module),
name + "/Makefile": templates.Makefile,
name + "/.gitignore": templates.Gitignore,
name + "/handler/home.go": templates.Handler,
name + "/main.go": mainTmpl,
name + "/config.yaml": configTmpl,
name + "/go.mod": fmt.Sprintf(templates.GoMod, module, xlgo.Version),
name + "/Makefile": templates.Makefile,
name + "/.gitignore": templates.Gitignore,
}
// api/fullstack 模板带示例 handler
if tmplName != "minimal" {
files[name+"/handler/home.go"] = templates.Handler
}
for path, content := range files {
tmpl, err := template.New(path).Parse(content)
if err != nil {
fmt.Printf("解析模板失败: %s\n", err)
return
}
file, err := os.Create(path)
if err != nil {
fmt.Printf("创建文件失败: %s\n", err)
return
}
defer file.Close()
if err := tmpl.Execute(file, data); err != nil {
fmt.Printf("写入文件失败: %s\n", err)
return
if err := renderTemplateFile(path, content, data); err != nil {
// #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back.
_ = os.RemoveAll(name) // P1 #21:部分失败回滚,避免留下半成品项目
return err
}
}
fmt.Printf("✓ 项目 %s 创建成功\n", name)
fmt.Printf("✓ 项目 %s 创建成功(模板: %s\n", name, tmplName)
fmt.Println("\n下一步:")
fmt.Printf(" cd %s\n", name)
fmt.Println(" go mod tidy")
fmt.Println(" go run main.go")
return nil
}
func makeFile(fileType, name string) {
// renderTemplateFile 解析并渲染单个模板文件,每次调用显式关闭句柄
// P1 #21:不在循环内 defer 累积句柄,且 Close 错误纳入返回)。
func renderTemplateFile(path, content string, data TemplateData) (retErr error) {
tmpl, err := template.New(path).Parse(content)
if err != nil {
return fmt.Errorf("解析模板 %s 失败: %w", path, err)
}
// #nosec G304 -- path is from createProject's files map built from validated project name plus fixed filenames.
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("创建文件 %s 失败: %w", path, err)
}
defer func() {
if cerr := file.Close(); cerr != nil && retErr == nil {
retErr = fmt.Errorf("关闭文件 %s 失败: %w", path, cerr)
}
}()
if err := tmpl.Execute(file, data); err != nil {
return fmt.Errorf("写入文件 %s 失败: %w", path, err)
}
return nil
}
// validateProjectName 校验项目名(P1 #21):非空、无路径分隔符/.. /前导点,
// 且可派生为合法 Go 包名(须以字母开头,仅含字母/数字/下划线)。
func validateProjectName(name string) error {
if name == "" {
return fmt.Errorf("项目名不能为空")
}
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return fmt.Errorf("项目名不能包含路径分隔符或 ..(防止在预期目录外创建文件): %q", name)
}
if strings.HasPrefix(name, ".") {
return fmt.Errorf("项目名不能以 . 开头: %q", name)
}
if !isValidGoIdentifier(name) {
return fmt.Errorf("项目名 %q 无法生成合法 Go 包名:须以字母开头,仅含字母、数字、下划线", name)
}
return nil
}
// isValidGoIdentifier 判断 s 是否为脚手架接受的 ASCII 标识符:字母开头,后续允许字母、数字、下划线。
func isValidGoIdentifier(s string) bool {
if s == "" {
return false
}
for i, r := range s {
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
isNum := r >= '0' && r <= '9'
if i == 0 {
if !isLetter {
return false
}
} else if !isLetter && !isNum && r != '_' {
return false
}
}
return true
}
// validateModulePath 校验 --moduleP1 #21):非空、不含模板元字符与空白。
func validateModulePath(module string) error {
if module == "" {
return fmt.Errorf("模块路径不能为空")
}
if strings.Contains(module, "{{") || strings.Contains(module, "}}") {
return fmt.Errorf("模块路径不能包含模板元字符 {{ }}: %q", module)
}
if strings.ContainsAny(module, " \t\r\n") {
return fmt.Errorf("模块路径不能包含空白字符: %q", module)
}
return nil
}
func makeFile(fileType, name string) error {
if err := validateMakeName(name); err != nil {
return err
}
name = strings.ToLower(name)
caser := cases.Title(language.English)
nameTitle := caser.String(name)
nameTitle := makeNameTitle(name)
switch fileType {
case "handler":
createHandler(name, nameTitle)
return createHandler(name, nameTitle)
case "repository":
createRepository(name, nameTitle)
return createRepository(name, nameTitle)
case "model":
createModel(name, nameTitle)
return createModel(name, nameTitle)
case "service":
createService(name, nameTitle)
return createService(name, nameTitle)
default:
fmt.Printf("未知类型: %s\n", fileType)
fmt.Println("可用类型: handler, repository, model, service")
return fmt.Errorf("未知类型: %s(可用类型: handler, repository, model, service", fileType)
}
}
func createHandler(name, nameTitle string) {
// validateMakeName 校验 xlgo make 的资源名,避免路径穿越或生成不可编译的 Go 代码。
func validateMakeName(name string) error {
if name == "" {
return fmt.Errorf("名称不能为空")
}
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name)
}
if strings.HasPrefix(name, ".") {
return fmt.Errorf("名称不能以 . 开头: %q", name)
}
if !isValidGoIdentifier(name) {
return fmt.Errorf("名称 %q 必须是合法标识符:须以字母开头,仅含字母、数字、下划线", name)
}
return nil
}
// makeNameTitle 把 snake_case 名称转为导出的 CamelCase 类型名。
func makeNameTitle(name string) string {
caser := cases.Title(language.English)
nameTitle := caser.String(strings.ReplaceAll(name, "_", " "))
return strings.ReplaceAll(nameTitle, " ", "")
}
func createHandler(name, nameTitle string) error {
path := fmt.Sprintf("handler/%s.go", name)
if fileExists(path) {
fmt.Printf("文件 %s 已存在\n", path)
return
return fmt.Errorf("文件 %s 已存在", path)
}
content := fmt.Sprintf(templates.HandlerMake,
@@ -125,48 +266,56 @@ func createHandler(name, nameTitle string) {
nameTitle, name, name, nameTitle,
nameTitle, name, name, nameTitle,
)
content = replaceModuleImports(content)
writeFile(path, content)
if err := writeFile(path, content); err != nil {
return err
}
fmt.Printf("✓ 创建处理器: %s\n", path)
return nil
}
func createRepository(name, nameTitle string) {
func createRepository(name, nameTitle string) error {
path := fmt.Sprintf("repository/%s_repository.go", name)
if fileExists(path) {
fmt.Printf("文件 %s 已存在\n", path)
return
return fmt.Errorf("文件 %s 已存在", path)
}
content := fmt.Sprintf(templates.RepositoryMake,
nameTitle, name, nameTitle, nameTitle,
nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle,
nameTitle, nameTitle, nameTitle,
nameTitle, nameTitle,
)
content = replaceModuleImports(content)
writeFile(path, content)
if err := writeFile(path, content); err != nil {
return err
}
fmt.Printf("✓ 创建仓库: %s\n", path)
return nil
}
func createModel(name, nameTitle string) {
func createModel(name, nameTitle string) error {
path := fmt.Sprintf("model/%s.go", name)
if fileExists(path) {
fmt.Printf("文件 %s 已存在\n", path)
return
return fmt.Errorf("文件 %s 已存在", path)
}
content := fmt.Sprintf(templates.ModelMake,
nameTitle, name, nameTitle, nameTitle, name,
)
writeFile(path, content)
if err := writeFile(path, content); err != nil {
return err
}
fmt.Printf("✓ 创建模型: %s\n", path)
return nil
}
func createService(name, nameTitle string) {
func createService(name, nameTitle string) error {
path := fmt.Sprintf("service/%s_service.go", name)
if fileExists(path) {
fmt.Printf("文件 %s 已存在\n", path)
return
return fmt.Errorf("文件 %s 已存在", path)
}
content := fmt.Sprintf(templates.ServiceMake,
@@ -177,7 +326,11 @@ func createService(name, nameTitle string) {
nameTitle, nameTitle,
nameTitle, nameTitle,
)
content = replaceModuleImports(content)
writeFile(path, content)
if err := writeFile(path, content); err != nil {
return err
}
fmt.Printf("✓ 创建服务: %s\n", path)
return nil
}
+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)
}
}
+27 -11
View File
@@ -3,21 +3,30 @@ package main
import (
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
)
func printUsage() {
fmt.Println(`xlgo - Go Web 框架脚手架工具
用法:
xlgo new <项目名> 创建新项目
xlgo make handler <名称> 创建处理器
xlgo new <项目名> [--template <模板>] [--module <模块路径>] 创建新项目
xlgo make handler <名称> 创建处理器
xlgo make repository <名称> 创建仓库
xlgo make model <名称> 创建模型
xlgo make service <名称> 创建服务
xlgo version 显示版本号
xlgo version 显示版本号
模板 (xlgo new --template <名称>):
minimal 轻量 HTTP 服务,不依赖 MySQL/Redis(默认入门)
api 标准业务 API,含 MySQL/Redis/JWT 与 handler/model/repository/service 分层(默认)
fullstack 全组件,一键启用 MySQL/Redis/Storage/Swagger/AutoMigrate
示例:
xlgo new myapp
xlgo new myapp --template minimal
xlgo new myapp --template fullstack --module github.com/me/myapp
xlgo make handler user
xlgo make repository user`)
}
@@ -30,24 +39,31 @@ func main() {
command := os.Args[1]
// P1 #21:命令执行失败时以非零码退出,便于 `xlgo new x && cd x` 等脚本/CI 正确中断。
switch command {
case "new":
if len(os.Args) < 3 {
fmt.Println("用法: xlgo new <项目名>")
return
fmt.Fprintln(os.Stderr, "用法: xlgo new <项目名>")
os.Exit(2)
}
if err := createProject(os.Args[2]); err != nil {
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
os.Exit(1)
}
createProject(os.Args[2])
case "make":
if len(os.Args) < 4 {
fmt.Println("用法: xlgo make <类型> <名称>")
fmt.Println("类型: handler, repository, model, service")
return
fmt.Fprintln(os.Stderr, "用法: xlgo make <类型> <名称>")
fmt.Fprintln(os.Stderr, "类型: handler, repository, model, service")
os.Exit(2)
}
if err := makeFile(os.Args[2], os.Args[3]); err != nil {
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
os.Exit(1)
}
makeFile(os.Args[2], os.Args[3])
case "version":
fmt.Println("xlgo v1.0.0")
fmt.Printf("xlgo v%s\n", xlgo.Version)
default:
printUsage()
+227 -139
View File
@@ -2,8 +2,12 @@ package main
// Templates 存放所有代码生成模板
var templates = struct {
Main string
Config string
Main string // api 模板:标准业务 APImysql+redis+jwt+分层)
MainMinimal string // minimal 模板:轻量 HTTP,无外部依赖
MainFull string // fullstack 模板:全组件
Config string // api 模板配置
ConfigMinimal string
ConfigFull string
GoMod string
Makefile string
Gitignore string
@@ -18,26 +22,15 @@ var templates = struct {
Main: `package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"{{.Module}}/config"
"{{.Module}}/database"
"{{.Module}}/handler"
"{{.Module}}/logger"
"{{.Module}}/middleware"
"{{.Module}}/storage"
"{{.Module}}/validation"
)
var configPath string
@@ -49,104 +42,28 @@ func init() {
func main() {
flag.Parse()
// 加载配置
cfg, err := config.Load(configPath)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
app := xlgo.New(
xlgo.WithConfigPath(configPath),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
// 如需 Swagger 文档:xlgo.WithSwaggerRoutes() 或 xlgo.WithDefaultRoutes()
// 如需 MySQL/Redis/Storagexlgo.WithMySQL() / xlgo.WithRedis() / xlgo.WithStorage()
// 一键启用全部默认组件:使用 xlgo.NewFullStack(...) 替代 xlgo.New(...)
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
)
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
// 初始化日志
if err := logger.Init(cfg); err != nil {
fmt.Printf("初始化日志失败: %v\n", err)
os.Exit(1)
}
defer logger.Sync()
// 初始化数据库
if err := database.InitMySQL(cfg); err != nil {
logger.Fatalf("初始化 MySQL 失败: %v", err)
}
defer database.Close()
// 初始化 Redis
if err := database.InitRedis(cfg); err != nil {
logger.Fatalf("初始化 Redis 失败: %v", err)
}
defer database.CloseRedis()
// 初始化存储
if err := storage.Init(&cfg.Storage); err != nil {
logger.Fatalf("初始化存储失败: %v", err)
}
// 初始化验证器
validation.InitValidator()
// 设置 Gin 模式
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
}
// 创建路由
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.Logger())
r.Use(middleware.CORS())
// 静态文件服务
r.Static("/public", "./public")
// 注册路由
setupRoutes(r)
// 启动服务器
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: r,
}
// 优雅关闭
go func() {
logger.Infof("服务器启动,监听端口 %d", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatalf("服务器启动失败: %v", err)
}
}()
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("正在关闭服务器...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Fatalf("服务器强制关闭: %v", err)
}
logger.Info("服务器已关闭")
}
func setupRoutes(r *gin.Engine) {
// 初始化限速器
middleware.InitRateLimiters()
// Swagger 文档
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// 健康检查
r.GET("/health", handler.HealthCheck)
// API 路由
func registerRoutes(r *gin.RouterGroup) {
api := r.Group("/api/v1")
{
// 首页
api.GET("/", handler.Home)
}
api.GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Welcome to {{.Name}}!"})
})
}
`,
@@ -158,13 +75,190 @@ func setupRoutes(r *gin.Engine) {
env: "dev" # dev/test/prod
debug: true
base_url: "http://localhost:8080"
token_expire: 86400 # Token过期时间(秒)
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机;内网IP=绑定指定网卡
port: 8080
mode: development # development 或 production
read_timeout: 15s # 读超时
write_timeout: 30s # 写超时
idle_timeout: 60s # 空闲超时
shutdown_timeout: 30s # 优雅关闭超时
response_mode: business # business(默认,全200+业务码) 或 rest(按错误码映射HTTP status)
database:
driver: mysql # mysql(默认)或 postgres
host: localhost
port: 3306
user: root
password: your_password
name: {{.NameLower}}
max_idle_conns: 10
max_open_conns: 100
# dsn: "自定义连接字符串,设置后优先于上面的字段"
redis:
host: localhost
port: 6379
password: ""
db: 0
jwt:
secret: your_jwt_secret_key_change_me_to_32_bytes_at_least
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
refresh_expire: "168h" # 刷新 token 过期时间
issuer: xlgo
algorithm: HS256 # HS256(默认)/HS384/HS512
storage:
driver: local
local:
path: ./public
base_url: http://localhost:8080/public
log:
dir: ./logs
max_size: 100
max_backups: 30
max_age: 30
compress: true
`,
// MainMinimal minimal 模板:轻量 HTTP 服务,不依赖 MySQL/Redis/Storage
MainMinimal: `package main
import (
"flag"
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
)
var configPath string
func init() {
flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径")
}
func main() {
flag.Parse()
app := xlgo.New(
xlgo.WithConfigPath(configPath),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
)
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
}
func registerRoutes(r *gin.RouterGroup) {
api := r.Group("/api/v1")
api.GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Hello {{.Name}}!"})
})
}
`,
// ConfigMinimal minimal 模板配置:仅 app + server + log,无数据库/Redis
ConfigMinimal: `app:
name: "{{.Name}}"
site_name: "{{.NameLower}}"
version: "1.0.0"
env: "dev"
debug: true
base_url: "http://localhost:8080"
server:
port: 8080
mode: development
log:
dir: ./logs
max_size: 100
max_backups: 30
max_age: 30
compress: true
`,
// MainFull fullstack 模板:全组件(FullStack),含 Swagger + Storage
MainFull: `package main
import (
"flag"
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
)
var configPath string
func init() {
flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径")
}
func main() {
flag.Parse()
// NewFullStack 一键启用全部组件:Logger/MySQL/Redis/Storage/Wire/Health/Swagger/AutoMigrate
// 如需排除个别组件,追加对应 Without* Option,例如 xlgo.WithoutSwaggerRoutes()
app := xlgo.NewFullStack(
xlgo.WithConfigPath(configPath),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
// xlgo.WithModels(&User{}, &Order{}), // 注册模型以启用自动迁移
)
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
}
func registerRoutes(r *gin.RouterGroup) {
api := r.Group("/api/v1")
api.GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Welcome to {{.Name}} (fullstack)!"})
})
}
`,
// ConfigFull fullstack 模板配置:全组件配置
ConfigFull: `app:
name: "{{.Name}}"
site_name: "{{.NameLower}}"
version: "1.0.0"
env: "dev"
debug: true
base_url: "http://localhost:8080"
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
port: 8080
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business
database:
driver: mysql # mysql(默认)或 postgres
host: localhost
port: 3306
user: root
@@ -180,8 +274,11 @@ redis:
db: 0
jwt:
secret: your_jwt_secret_key_here
expire: 86400
secret: your_jwt_secret_key_change_me_to_32_bytes_at_least
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
refresh_expire: "168h" # 刷新 token 过期时间
issuer: xlgo
algorithm: HS256 # HS256(默认)/HS384/HS512
storage:
driver: local
@@ -198,24 +295,14 @@ log:
`,
// GoMod go.mod 文件模板
// %s: module 名称;%s: xlgo 框架版本(来自 xlgo.Version,避免字面量散落)
GoMod: `module %s
go 1.25
require (
github.com/EthanCodeCraft/xlgo-core v%s
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.5.1
github.com/spf13/viper v1.18.2
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.3
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.21.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.5.4
gorm.io/gorm v1.25.7
)
`,
@@ -281,9 +368,8 @@ config.local.yaml
Handler: `package handler
import (
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"{{.Module}}/response"
)
// HealthCheck 健康检查
@@ -305,8 +391,8 @@ func Home(c *gin.Context) {
HandlerMake: `package handler
import (
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"xlgo/response"
)
// %sHandler %s 处理器
@@ -388,39 +474,41 @@ func (h *%sHandler) Delete(c *gin.Context) {
import (
"context"
"xlgo/database"
"github.com/EthanCodeCraft/xlgo-core/database"
xlrepo "github.com/EthanCodeCraft/xlgo-core/repository"
"xlgo/model"
)
// %sRepository %s 仓库
type %sRepository struct {
*BaseRepo[model.%s]
*xlrepo.BaseRepo[model.%s]
}
// New%sRepository 创建 %s 仓库
func New%sRepository() *%sRepository {
return &%sRepository{
BaseRepo: NewBaseRepo[model.%s](database.GetDB()),
BaseRepo: xlrepo.NewBaseRepo[model.%s](database.GetDB()),
}
}
// FindByName 根据名称查询
// FindByName 根据名称查询
//
// 走 BaseRepo.FindOne → readConn 读连接:默认路由到从库(读写分离,H6c),
// 需读主库用 database.UseMaster(ctx),事务内自动 join 外层事务。
// 不要用 r.GetDB() 自行查询——它返回注入的主库且不参与路由(M-35 footgun)。
func (r *%sRepository) FindByName(ctx context.Context, name string) (*model.%s, error) {
var m model.%s
err := r.GetDB().WithContext(ctx).Where("name = ?", name).First(&m).Error
if err != nil {
return nil, err
}
return &m, nil
return r.FindOne(ctx, "name = ?", name)
}
`,
// ModelMake make model 命令模板
ModelMake: `package model
import xlmodel "github.com/EthanCodeCraft/xlgo-core/model"
// %s %s 模型
type %s struct {
BaseModel
xlmodel.BaseModel
Name string ` + "`" + `gorm:"size:100;not null" json:"name"` + "`" + `
Description string ` + "`" + `gorm:"size:500" json:"description"` + "`" + `
Status int ` + "`" + `gorm:"default:1" json:"status"` + "`" + ` // 1: 启用, 0: 禁用
+37 -4
View File
@@ -4,21 +4,54 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
)
func writeFile(path, content string) {
func writeFile(path, content string) error {
dir := filepath.Dir(path)
// #nosec G301,G703 -- path is built by makeFile from fixed scaffold directories and a name that rejects separators/".."; 0755 is intentional for generated dirs.
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Printf("创建目录失败: %s\n", err)
return
return fmt.Errorf("创建目录失败: %w", err)
}
// #nosec G306,G703 -- path is built by makeFile from fixed scaffold directories and a validated name; 0644 is intentional for generated source files.
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
fmt.Printf("写入文件失败: %s\n", err)
return fmt.Errorf("写入文件失败: %w", err)
}
return nil
}
func fileExists(path string) bool {
// #nosec G703 -- path is built by makeFile from fixed scaffold directories and a name that rejects separators/"..".
_, err := os.Stat(path)
if err == nil {
return true
}
// 仅当"不存在"才返回 false;权限错误等其它错误视为"可能存在/不可覆写"
// 避免把无权限访问的路径误判为可创建(M20:原 !os.IsNotExist 把权限错误当存在,
// 反而安全;但语义模糊,显式区分更清晰)。
return !os.IsNotExist(err)
}
func currentModule() string {
data, err := os.ReadFile("go.mod")
if err != nil {
return "xlgo"
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "module ") {
return strings.TrimSpace(strings.TrimPrefix(line, "module "))
}
}
return "xlgo"
}
func replaceModuleImports(content string) string {
module := currentModule()
content = strings.ReplaceAll(content, "\"xlgo/model\"", fmt.Sprintf("\"%s/model\"", module))
content = strings.ReplaceAll(content, "\"xlgo/repository\"", fmt.Sprintf("\"%s/repository\"", module))
content = strings.ReplaceAll(content, "\"xlgo/database\"", fmt.Sprintf("\"%s/database\"", module))
content = strings.ReplaceAll(content, "\"xlgo/response\"", "\"github.com/EthanCodeCraft/xlgo-core/response\"")
return content
}
+290 -102
View File
@@ -4,17 +4,68 @@ import (
"archive/zip"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
// 解压安全相关错误。
var (
// ErrPathTraversal zip 条目名逃逸目标目录(C5a Zip-Slip)。
ErrPathTraversal = errors.New("zip entry escapes destination directory")
// ErrSymlinkEntry zip 条目为符号链接,已拒绝(防经软链二次穿越)。
ErrSymlinkEntry = errors.New("zip entry is a symlink, rejected")
// ErrDecompressLimit 解压大小超过上限(C5b 解压炸弹)。
ErrDecompressLimit = errors.New("decompress size limit exceeded")
)
const (
// defaultDecompressLimit 单流/单条目默认解压上限(100MB),防 OOM / 磁盘耗尽(C5b)。
defaultDecompressLimit int64 = 100 * 1024 * 1024
// defaultDecompressTotalLimit Unzip 默认累计解压上限(1GB)。
defaultDecompressTotalLimit int64 = 1 * 1024 * 1024 * 1024
)
// DecompressOptions 解压安全选项。Zip-Slip 防护(前缀锚定 + 拒绝符号链接)始终启用,无需配置;
// 本选项仅控制解压大小上限以防解压炸弹(C5b)。
type DecompressOptions struct {
// MaxBytes 单流 / 单条目解压大小上限(字节)。0 = 默认 100MB,-1 = 不限制。
MaxBytes int64
// MaxTotalBytes Unzip 累计解压大小上限(字节)。0 = 默认 1GB,-1 = 不限制。
// 仅 Unzip 生效。
MaxTotalBytes int64
}
// resolveLimit 解析大小上限:n<0 不限,n==0 用 defn>0 用 n。
func resolveLimit(n, def int64) int64 {
if n < 0 {
return -1
}
if n == 0 {
return def
}
return n
}
// minLimit 返回两个上限中较小者;-1 视为无限。
func minLimit(a, b int64) int64 {
if a < 0 {
return b
}
if b < 0 {
return a
}
if a < b {
return a
}
return b
}
// GzipCompress 压缩数据
// 评分: ⭐⭐⭐⭐⭐
// 理由: API响应压缩、数据传输常用
func GzipCompress(data []byte) ([]byte, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
@@ -28,37 +79,53 @@ func GzipCompress(data []byte) ([]byte, error) {
return buf.Bytes(), nil
}
// GzipDecompress 解压缩数据
// 评分: ⭐⭐⭐⭐⭐
// 理由: GzipCompress 的反向操作
// GzipDecompress 解压缩数据。默认上限 100MB 防解压炸弹 OOM(C5b);
// 需解压更大文件请用 GzipDecompressWithOptions。
func GzipDecompress(data []byte) ([]byte, error) {
return GzipDecompressWithOptions(data, DecompressOptions{})
}
// GzipDecompressWithOptions 解压缩数据,可配置大小上限(C5b)。
func GzipDecompressWithOptions(data []byte, opts DecompressOptions) ([]byte, error) {
buf := bytes.NewReader(data)
gz, err := gzip.NewReader(buf)
if err != nil {
return nil, err
}
defer gz.Close()
return io.ReadAll(gz)
limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
var reader io.Reader = gz
if limit > 0 {
// 多读 1 字节用于判断是否超限。
reader = io.LimitReader(gz, limit+1)
}
out, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if limit > 0 && int64(len(out)) > limit {
return nil, fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit)
}
return out, nil
}
// GzipCompressFile 压缩文件
// 评分: ⭐⭐⭐⭐⭐
// 理由: 日志归档、文件传输常用
func GzipCompressFile(src, dst string) error {
// #nosec G304 -- src/dst 为调用方提供的本地文件路径,压缩 API 固有语义,非不可信输入
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
// #nosec G304 -- 同上,dst 为调用方指定输出路径
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
gz := gzip.NewWriter(dstFile)
defer gz.Close()
// 保留原文件名和时间戳
if info, err := srcFile.Stat(); err == nil {
@@ -66,14 +133,29 @@ func GzipCompressFile(src, dst string) error {
gz.ModTime = info.ModTime()
}
_, err = io.Copy(gz, srcFile)
return err
_, copyErr := io.Copy(gz, srcFile)
// gz.Close 刷出 gzip 尾部(含 CRC/大小校验),失败说明归档损坏,必须向上传播(M16/B18)。
closeErr := gz.Close()
// dstFile.Close 失败(如延迟写盘)同样意味着归档可能不完整。
dstErr := dstFile.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
return dstErr
}
// GzipDecompressFile 解压文件
// 评分: ⭐⭐⭐⭐⭐
// 理由: GzipCompressFile 的反向操作
// GzipDecompressFile 解压文件。默认上限 100MB 防磁盘耗尽(C5b);
// 需解压更大文件请用 GzipDecompressFileWithOptions。
func GzipDecompressFile(src, dst string) error {
return GzipDecompressFileWithOptions(src, dst, DecompressOptions{})
}
// GzipDecompressFileWithOptions 解压文件,可配置大小上限(C5b)。
func GzipDecompressFileWithOptions(src, dst string, opts DecompressOptions) (err error) {
// #nosec G304 -- src 为调用方提供的本地文件路径,解压 API 固有语义,非不可信输入
srcFile, err := os.Open(src)
if err != nil {
return err
@@ -86,130 +168,236 @@ func GzipDecompressFile(src, dst string) error {
}
defer gz.Close()
// #nosec G304 -- dst 为调用方指定输出路径,解压 API 固有语义
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, gz)
return err
}
// Zip 压缩文件或目录
// 评分: ⭐⭐⭐⭐⭐
// 理由: 批量打包下载、备份常用
// 参数: zipPath 目标zip文件路径,paths 要压缩的文件或目录列表
func Zip(zipPath string, paths []string) error {
// 创建目标目录
if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
return err
}
// 创建 zip 文件
archive, err := os.Create(zipPath)
if err != nil {
return err
}
defer archive.Close()
zipWriter := zip.NewWriter(archive)
defer zipWriter.Close()
for _, srcPath := range paths {
srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator))
err = filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// 创建文件头
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Method = zip.Deflate
// 设置相对路径
header.Name, err = filepath.Rel(filepath.Dir(srcPath), path)
if err != nil {
return err
}
if info.IsDir() {
header.Name += string(os.PathSeparator)
}
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
if info.IsDir() {
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
})
if err != nil {
return err
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 dst,避免被拒炸弹遗留残片;
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
defer func() {
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
_ = os.Remove(dst)
}
}()
limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
var written int64
if limit > 0 {
// CopyN 最多读 limit+1 字节,超限即判定为炸弹。
written, err = io.CopyN(dstFile, gz, limit+1)
} else {
// #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹
written, err = io.Copy(dstFile, gz)
}
if err != nil && err != io.EOF {
return err
}
if limit > 0 && written > limit {
return fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit)
}
return nil
}
// Unzip 解压 zip 文件
// 评分: ⭐⭐⭐⭐⭐
// 理由: Zip 的反向操作
// 参数: zipPath zip文件路径,dstDir 目标目录
// Zip 压缩文件或目录
// 参数: zipPath 目标zip文件路径,paths 要压缩的文件或目录列表
func Zip(zipPath string, paths []string) error {
// 创建目标目录
if err := os.MkdirAll(filepath.Dir(zipPath), 0750); err != nil {
return err
}
// 创建 zip 文件
// #nosec G304 -- zipPath 为调用方指定输出路径,压缩 API 固有语义
archive, err := os.Create(zipPath)
if err != nil {
return err
}
zipWriter := zip.NewWriter(archive)
walkErr := func() error {
for _, srcPath := range paths {
srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator))
// #nosec G122 -- 压缩调用方提供的源路径,非解压不可信输入;symlink 跟随是压缩场景的可接受行为
err := filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// 创建文件头
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Method = zip.Deflate
// 设置相对路径
header.Name, err = filepath.Rel(filepath.Dir(srcPath), path)
if err != nil {
return err
}
if info.IsDir() {
header.Name += string(os.PathSeparator)
}
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// #nosec G304,G122 -- path 为 Walk 遍历调用方源路径产生,非不可信输入;压缩场景接受 symlink 跟随语义。
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
})
if err != nil {
return err
}
}
return nil
}()
// zipWriter.Close 刷出中央目录记录,失败说明归档损坏,必须向上传播(M16/B18)。
zipCloseErr := zipWriter.Close()
archiveCloseErr := archive.Close()
if walkErr != nil {
return walkErr
}
if zipCloseErr != nil {
return zipCloseErr
}
return archiveCloseErr
}
// Unzip 解压 zip 文件。默认启用 Zip-Slip 防护(前缀锚定 + 拒绝符号链接),
// 单条目上限 100MB、累计上限 1GB 防解压炸弹(C5b);需自定义上限请用 UnzipWithOptions。
func Unzip(zipPath, dstDir string) error {
return UnzipWithOptions(zipPath, dstDir, DecompressOptions{})
}
// UnzipWithOptions 解压 zip 文件,可配置大小上限(C5b)。Zip-Slip 防护始终启用。
func UnzipWithOptions(zipPath, dstDir string, opts DecompressOptions) error {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer reader.Close()
// 用绝对路径作目标锚定根,避免相对路径 + `..` 组合绕过前缀校验(C5a)。
absDst, err := filepath.Abs(dstDir)
if err != nil {
return fmt.Errorf("resolve unzip destination failed: %w", err)
}
absDst = filepath.Clean(absDst)
entryLimit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
totalLimit := resolveLimit(opts.MaxTotalBytes, defaultDecompressTotalLimit)
var total int64
for _, file := range reader.File {
if err := unzipFile(file, dstDir); err != nil {
written, err := unzipFile(file, absDst, entryLimit, totalLimit, total)
if err != nil {
return err
}
total += written
}
return nil
}
func unzipFile(file *zip.File, dstDir string) error {
filePath := path.Join(dstDir, file.Name)
// unzipFile 解压单个 zip 条目到 absDst 下。
// entryLimit: 单条目上限(-1 不限);totalLimit: 累计上限(-1 不限);accrued: 已解压累计字节。
// 返回本条目写入字节数。
func unzipFile(file *zip.File, absDst string, entryLimit, totalLimit, accrued int64) (written int64, err error) {
// 拒绝符号链接条目,防经软链二次穿越(C5a)。
if file.Mode()&os.ModeSymlink != 0 {
return 0, fmt.Errorf("条目 %s 为符号链接: %w", file.Name, ErrSymlinkEntry)
}
// zip 条目名规范用正斜杠;转成当前平台分隔符后再 Join,并以前缀锚定拒绝 `..` 逃逸(C5a)。
name := filepath.FromSlash(file.Name)
// 拒绝绝对路径与以分隔符开头的条目(非标准、可疑,避免平台语义差异)。
// filepath.IsAbs 在 Windows 不认 "/x"(无盘符)为绝对路径,故补充分隔符前缀检查。
if filepath.IsAbs(name) || strings.HasPrefix(name, string(os.PathSeparator)) || strings.HasPrefix(file.Name, "/") {
return 0, fmt.Errorf("条目 %s 为绝对路径: %w", file.Name, ErrPathTraversal)
}
target := filepath.Join(absDst, name)
if target == absDst || !strings.HasPrefix(target, absDst+string(os.PathSeparator)) {
return 0, fmt.Errorf("条目 %s 逃逸目标目录: %w", file.Name, ErrPathTraversal)
}
if file.FileInfo().IsDir() {
return os.MkdirAll(filePath, 0755)
if err := os.MkdirAll(target, 0750); err != nil {
return 0, err
}
return 0, nil
}
// 创建父目录
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return err
if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil {
return 0, err
}
// 打开 zip 中的文件
rc, err := file.Open()
if err != nil {
return err
return 0, err
}
defer rc.Close()
// 创建目标文件
dstFile, err := os.Create(filePath)
// #nosec G304 -- target 经前缀锚定校验(absDst+sep),已防 Zip-Slip 逃逸
dstFile, err := os.Create(target)
if err != nil {
return err
return 0, err
}
defer dstFile.Close()
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 target,避免被拒炸弹遗留残片;
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
defer func() {
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
_ = os.Remove(target)
}
}()
_, err = io.Copy(dstFile, rc)
return err
// 计算本次拷贝上限:单条目上限与累计剩余上限中较小者(-1 视为无限)。
// remaining: 累计剩余(-1 表示累计不限);totalLimit>0 时若已无剩余,直接判超限。
remaining := int64(-1)
if totalLimit > 0 {
remaining = totalLimit - accrued
if remaining <= 0 {
return 0, fmt.Errorf("累计解压超过上限 %d 字节: %w", totalLimit, ErrDecompressLimit)
}
}
cap := minLimit(entryLimit, remaining)
// cap 为 -1(两者皆不限)或 >0(有限上限,剩余已保证 >0),不会是 0。
if cap > 0 {
written, err = io.CopyN(dstFile, rc, cap+1)
} else {
// #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹
written, err = io.Copy(dstFile, rc)
}
if err != nil && err != io.EOF {
return written, err
}
if cap > 0 && written > cap {
return written, fmt.Errorf("条目 %s 超过解压上限 %d 字节: %w", file.Name, cap, ErrDecompressLimit)
}
return written, nil
}
+347
View File
@@ -0,0 +1,347 @@
package compress_test
import (
"archive/zip"
"bytes"
"compress/gzip"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/compress"
)
// makeZipAt 构造一个 zip 文件,entries 为 name -> contentdir 条目用空 content 且 IsDir=true。
func makeZipAt(t *testing.T, zipPath string, entries []struct {
name string
content string
isDir bool
}) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(zipPath), 0750); err != nil {
t.Fatalf("mkdir: %v", err)
}
f, err := os.Create(zipPath)
if err != nil {
t.Fatalf("create zip: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
for _, e := range entries {
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
if e.isDir {
hdr.SetMode(os.ModeDir | 0750)
}
w, err := zw.CreateHeader(hdr)
if err != nil {
t.Fatalf("CreateHeader %s: %v", e.name, err)
}
if !e.isDir {
if _, err := w.Write([]byte(e.content)); err != nil {
t.Fatalf("write %s: %v", e.name, err)
}
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip writer: %v", err)
}
}
// ===== C5aZip-Slip =====
// 回归 C5a:zip 条目名含 `..` 逃逸目标目录必须被拒绝,且不在 dst 外创建/覆盖文件。
func TestUnzipZipSlipRejected(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "evil.zip")
// 在 dst 的父目录放一个蜜罐文件,确保穿越不会覆盖它。
canary := filepath.Join(dir, "canary.txt")
if err := os.WriteFile(canary, []byte("original"), 0644); err != nil {
t.Fatalf("write canary: %v", err)
}
dst := filepath.Join(dir, "out")
makeZipAt(t, zipPath, []struct {
name string
content string
isDir bool
}{
{name: "../canary.txt", content: "pwned"},
})
err := compress.Unzip(zipPath, dst)
if !errors.Is(err, compress.ErrPathTraversal) {
t.Errorf("Unzip with ../ entry err = %v, want ErrPathTraversal", err)
}
// 蜜罐文件必须未被覆盖。
data, err := os.ReadFile(canary)
if err != nil {
t.Fatalf("read canary: %v", err)
}
if string(data) != "original" {
t.Errorf("canary overwritten by Zip-Slip: got %q", string(data))
}
}
// 回归 C5a:绝对路径条目必须被拒绝。
func TestUnzipAbsolutePathRejected(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "abs.zip")
dst := filepath.Join(dir, "out")
makeZipAt(t, zipPath, []struct {
name string
content string
isDir bool
}{
{name: "/etc/evil.txt", content: "x"},
})
if err := compress.Unzip(zipPath, dst); !errors.Is(err, compress.ErrPathTraversal) {
t.Errorf("Unzip absolute path err = %v, want ErrPathTraversal", err)
}
}
// 回归 C5a:合法相对路径条目不误伤(含子目录)。
func TestUnzipNormalEntriesWork(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "ok.zip")
dst := filepath.Join(dir, "out")
makeZipAt(t, zipPath, []struct {
name string
content string
isDir bool
}{
{name: "sub/", isDir: true},
{name: "sub/a.txt", content: "hello"},
{name: "top.txt", content: "world"},
})
if err := compress.Unzip(zipPath, dst); err != nil {
t.Fatalf("Unzip normal: %v", err)
}
if b, err := os.ReadFile(filepath.Join(dst, "sub", "a.txt")); err != nil || string(b) != "hello" {
t.Errorf("sub/a.txt = %q, err=%v, want 'hello'", string(b), err)
}
if b, err := os.ReadFile(filepath.Join(dst, "top.txt")); err != nil || string(b) != "world" {
t.Errorf("top.txt = %q, err=%v, want 'world'", string(b), err)
}
}
// 回归 C5a:符号链接条目必须被拒绝(防经软链二次穿越)。
func TestUnzipSymlinkRejected(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "symlink.zip")
dst := filepath.Join(dir, "out")
makeZipAt(t, zipPath, []struct {
name string
content string
isDir bool
}{
{name: "lnk", content: "/etc/passwd"}, // content 为链接目标,mode 设为 symlink
})
// 把条目 mode 改成符号链接:重建 zip 时设置 ModeSymlink。
// makeZipAt 不支持 symlink mode,这里直接用底层 API 重建。
zipPath2 := filepath.Join(dir, "symlink2.zip")
f, err := os.Create(zipPath2)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
hdr := &zip.FileHeader{Name: "lnk", Method: zip.Deflate}
hdr.SetMode(os.ModeSymlink | 0777)
w, err := zw.CreateHeader(hdr)
if err != nil {
t.Fatalf("CreateHeader: %v", err)
}
if _, err := w.Write([]byte("/etc/passwd")); err != nil {
t.Fatalf("write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if err := compress.Unzip(zipPath2, dst); !errors.Is(err, compress.ErrSymlinkEntry) {
t.Errorf("Unzip symlink err = %v, want ErrSymlinkEntry", err)
}
}
// ===== C5b:解压炸弹 =====
// 回归 C5bGzipDecompress 超限返回错误而非 OOM。
// 用显式小上限验证限流代码路径(与默认 100MB 走同一 io.LimitReader + 超限判定逻辑)。
func TestGzipDecompressBombLimit(t *testing.T) {
// 2MB 解压后数据。
big := bytes.Repeat([]byte("A"), 2*1024*1024)
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(big); err != nil {
t.Fatalf("gzip write: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("gzip close: %v", err)
}
compressed := buf.Bytes()
// 显式上限 1MB → 必须拒绝(2MB 超限)。
if _, err := compress.GzipDecompressWithOptions(compressed, compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}); !errors.Is(err, compress.ErrDecompressLimit) {
t.Errorf("GzipDecompress over-limit err = %v, want ErrDecompressLimit", err)
}
// 显式 -1 不限 → 成功解压完整 2MB。
out, err := compress.GzipDecompressWithOptions(compressed, compress.DecompressOptions{MaxBytes: -1})
if err != nil {
t.Errorf("GzipDecompress unlimited err = %v", err)
}
if len(out) != len(big) {
t.Errorf("unlimited decompressed len = %d, want %d", len(out), len(big))
}
// 默认上限(100MB)放行 2MB 正常数据。
if _, err := compress.GzipDecompress(compressed); err != nil {
t.Errorf("GzipDecompress default err = %v (2MB should pass default 100MB limit)", err)
}
}
// 回归 C5b:Unzip 单条目上限——超大条目被拒。
func TestUnzipEntryBombLimit(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "bomb.zip")
dst := filepath.Join(dir, "out")
// 构造一个含 2MB(解压后)条目的 zip。
f, err := os.Create(zipPath)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
w, err := zw.Create("big.txt")
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := w.Write(bytes.Repeat([]byte("A"), 2 * 1024 * 1024)); err != nil {
t.Fatalf("write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close: %v", err)
}
// 显式单条目上限 1MB → 必须拒绝。
opts := compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}
if err := compress.UnzipWithOptions(zipPath, dst, opts); !errors.Is(err, compress.ErrDecompressLimit) {
t.Errorf("Unzip entry bomb err = %v, want ErrDecompressLimit", err)
}
}
// 回归 C5b:Unzip 累计上限——多个条目累计超限被拒。
func TestUnzipTotalBombLimit(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "many.zip")
dst := filepath.Join(dir, "out")
// 5 个 1MB 条目 = 累计 5MB;单条目上限 2MB(不超),累计上限 3MB → 第 4 个累计 4MB 超限。
f, err := os.Create(zipPath)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
chunk := bytes.Repeat([]byte("B"), 1*1024*1024)
for i := 0; i < 5; i++ {
w, err := zw.Create("file" + string(rune('0'+i)) + ".dat")
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := w.Write(chunk); err != nil {
t.Fatalf("write: %v", err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close: %v", err)
}
opts := compress.DecompressOptions{MaxBytes: 2 * 1024 * 1024, MaxTotalBytes: 3 * 1024 * 1024}
if err := compress.UnzipWithOptions(zipPath, dst, opts); !errors.Is(err, compress.ErrDecompressLimit) {
t.Errorf("Unzip total bomb err = %v, want ErrDecompressLimit", err)
}
}
// 回归 C5bGzipDecompressFile 单流封顶——超限返回错误而非磁盘耗尽。
func TestGzipDecompressFileBombLimit(t *testing.T) {
dir := t.TempDir()
// 2MB 解压后数据写入 gzip 文件。
big := bytes.Repeat([]byte("A"), 2*1024*1024)
srcGz := filepath.Join(dir, "src.gz")
{
f, err := os.Create(srcGz)
if err != nil {
t.Fatalf("create: %v", err)
}
gz := gzip.NewWriter(f)
if _, err := gz.Write(big); err != nil {
t.Fatalf("write: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gz: %v", err)
}
f.Close()
}
// 显式上限 1MB → 必须拒绝(2MB 超限)。
dst1 := filepath.Join(dir, "out1.txt")
if err := compress.GzipDecompressFileWithOptions(srcGz, dst1, compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}); !errors.Is(err, compress.ErrDecompressLimit) {
t.Errorf("GzipDecompressFile over-limit err = %v, want ErrDecompressLimit", err)
}
// 显式 -1 不限 → 成功解压完整 2MB。
dst2 := filepath.Join(dir, "out2.txt")
if err := compress.GzipDecompressFileWithOptions(srcGz, dst2, compress.DecompressOptions{MaxBytes: -1}); err != nil {
t.Errorf("GzipDecompressFile unlimited err = %v", err)
}
b, err := os.ReadFile(dst2)
if err != nil {
t.Fatalf("read out: %v", err)
}
if len(b) != len(big) {
t.Errorf("unlimited out len = %d, want %d", len(b), len(big))
}
// 默认上限(100MB)放行 2MB。
dst3 := filepath.Join(dir, "out3.txt")
if err := compress.GzipDecompressFile(srcGz, dst3); err != nil {
t.Errorf("GzipDecompressFile default err = %v (2MB should pass default 100MB limit)", err)
}
}
// 兼容性回归:原有 Zip/Unzip 闭环(合法归档)在默认防护下仍正常。
func TestZipUnzipRoundTripStillWorks(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.txt")
content := "round trip content"
if err := os.WriteFile(src, []byte(content), 0644); err != nil {
t.Fatalf("write: %v", err)
}
zipPath := filepath.Join(dir, "rt.zip")
if err := compress.Zip(zipPath, []string{src}); err != nil {
t.Fatalf("Zip: %v", err)
}
dst := filepath.Join(dir, "out")
if err := compress.Unzip(zipPath, dst); err != nil {
t.Fatalf("Unzip: %v", err)
}
// Zip 用 filepath.Rel(src 的父目录, src) = "src.txt"。
b, err := os.ReadFile(filepath.Join(dst, "src.txt"))
if err != nil {
// 目录结构可能因平台分隔符略有差异,尝试找 src.txt。
_ = filepath.Walk(dst, func(p string, _ os.FileInfo, _ error) error {
if strings.HasSuffix(p, "src.txt") {
b, err = os.ReadFile(p)
return filepath.SkipDir
}
return nil
})
}
if string(b) != content {
t.Errorf("round trip = %q, want %q", string(b), content)
}
}
+873 -200
View File
File diff suppressed because it is too large Load Diff
+499
View File
@@ -0,0 +1,499 @@
package config_test
import (
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
)
// writeConfig 写入临时配置文件并返回路径。
func writeConfig(t *testing.T, name, content string) string {
t.Helper()
dir := filepath.Join(os.TempDir(), "xlgo_c10_test")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return p
}
func validConfigYAML(port int) string {
return "app:\n name: c10\n env: dev\nserver:\n port: " + itoa(port) + "\n"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
// TestSetDefaultManagerConcurrent 并发置换默认 Manager 与并发读取,
// 必须经 -race 无竞争(C10a)。
func TestSetDefaultManagerConcurrent(t *testing.T) {
// 准备若干可加载的 Manager
paths := make([]string, 4)
for i := range paths {
paths[i] = writeConfig(t, "c10_concurrent_"+itoa(i)+".yaml", validConfigYAML(9000+i))
}
defer func() {
for _, p := range paths {
os.Remove(p)
}
}()
var wg sync.WaitGroup
stop := make(chan struct{})
// 写者:并发 SetDefaultManager
for i := 0; i < 4; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
m := config.NewManager(paths[idx])
_, _ = m.Load()
config.SetDefaultManager(m)
}
}(i)
}
// 读者:并发 Get / GetViper / GetString
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = config.Get()
_ = config.GetViper()
_ = config.GetString("server.port")
}
}()
}
// 跑足够长以让 -race 采到
time.Sleep(150 * time.Millisecond)
close(stop)
wg.Wait()
// 还原全局状态,避免污染其他测试
config.SetDefaultManager(nil)
}
// TestLoadReturnsDefensiveCopy Load 返回的配置与 Get() 内部指针独立,
// 修改返回值不污染全局(C10c)。
func TestLoadReturnsDefensiveCopy(t *testing.T) {
p := writeConfig(t, "c10_defensive.yaml", validConfigYAML(8081))
defer os.Remove(p)
config.Set(nil)
cfg, err := config.Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 8081 {
t.Fatalf("port = %d, want 8081", cfg.Server.Port)
}
// 调用方修改返回值
cfg.Server.Port = 1
cfg.App.Name = "mutated"
// 全局读取不受影响
got := config.Get()
if got == nil {
t.Fatal("Get returned nil")
}
if got.Server.Port != 8081 {
t.Errorf("global port polluted = %d, want 8081 (C10c)", got.Server.Port)
}
if got.App.Name == "mutated" {
t.Errorf("global app name polluted (C10c)")
}
config.SetDefaultManager(nil)
}
// TestLoadDefensiveCopySliceContract 固化 M-G 的深拷贝语义契约:
// 标量字段与切片字段均独立(修改不污染全局)。M-G 修复后 Load() 返回 Clone() 深拷贝,
// 切片字段不再共享底层数组——调用方可安全修改切片元素。本测试锁定该行为,防止回退到浅拷贝。
func TestLoadDefensiveCopySliceContract(t *testing.T) {
content := "app:\n name: c10slice\nserver:\n port: 8090\ncors:\n allowed_origins:\n - https://a.example.com\n - https://b.example.com\n"
p := writeConfig(t, "c10_slice.yaml", content)
defer os.Remove(p)
config.Set(nil)
cfg, err := config.Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
// 标量独立
cfg.Server.Port = 1
if got := config.Get().Server.Port; got != 8090 {
t.Errorf("scalar polluted = %d, want 8090", got)
}
// M-G:切片底层数组独立(深拷贝)——修改切片元素不污染全局。
cfg.CORS.AllowedOrigins[0] = "https://mutated.example.com"
if got := config.Get().CORS.AllowedOrigins[0]; got != "https://a.example.com" {
t.Errorf("slice should be deep-copied (independent), global polluted to %q, want %q", got, "https://a.example.com")
}
// append 也不应污染全局(深拷贝后调用方持独立切片)
cfg.CORS.AllowedOrigins = append(cfg.CORS.AllowedOrigins, "https://c.example.com")
if got := config.Get().CORS.AllowedOrigins; len(got) != 2 {
t.Errorf("global slice length should be unaffected by caller append, got %d, want 2", len(got))
}
config.SetDefaultManager(nil)
}
func TestReloadInvalidConfigKeepsOld(t *testing.T) {
p := writeConfig(t, "c10_reload_bad.yaml", validConfigYAML(8082))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := m.Get().Server.Port; got != 8082 {
t.Fatalf("initial port = %d, want 8082", got)
}
// 覆盖为非法配置(端口越界)
if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
err := m.Reload()
if err == nil {
t.Fatal("Reload invalid config should return error (C10b)")
}
if !strings.Contains(err.Error(), "server.port") {
t.Errorf("error should mention server.port, got %v", err)
}
// 旧配置保留
if got := m.Get().Server.Port; got != 8082 {
t.Errorf("old config not preserved = %d, want 8082 (C10b)", got)
}
}
// TestHotReloadInvalidConfigKeepsOld 文件监听路径遇非法配置保留旧配置,
// 且不触发回调;监听仍存活,后续合法变更正常生效(C10b + C10d 监听健壮性)。
func TestHotReloadInvalidConfigKeepsOld(t *testing.T) {
p := writeConfig(t, "c10_watch_bad.yaml", validConfigYAML(8083))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
changes := make(chan int, 16)
m.RegisterCallback(func(c *config.Config) {
select {
case changes <- c.Server.Port:
default:
}
})
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
defer m.StopWatcher()
// 1) 写入非法配置:应保留旧配置、不触发回调
if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil {
t.Fatalf("WriteFile invalid: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if got := m.Get().Server.Port; got != 8083 {
t.Errorf("invalid config leaked into global = %d, want 8083 (C10b)", got)
break
}
time.Sleep(20 * time.Millisecond)
}
select {
case port := <-changes:
t.Errorf("callback fired for invalid config with port %d (C10b)", port)
default:
}
// 2) 写入合法配置:监听仍存活,应触发回调且全局更新
if err := os.WriteFile(p, []byte(validConfigYAML(8084)), 0644); err != nil {
t.Fatalf("WriteFile valid: %v", err)
}
deadline = time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if got := m.Get().Server.Port; got == 8084 {
break
}
time.Sleep(20 * time.Millisecond)
}
if got := m.Get().Server.Port; got != 8084 {
t.Fatalf("watcher did not reload valid config = %d, want 8084", got)
}
}
// TestStopWatcherReleasesGoroutine StopWatcher 后监听 goroutine 退出,无泄漏(C10d)。
func TestStopWatcherReleasesGoroutine(t *testing.T) {
p := writeConfig(t, "c10_stop.yaml", validConfigYAML(8085))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
// 等待监听 goroutine 就绪
time.Sleep(100 * time.Millisecond)
before := runtime.NumGoroutine()
m.StopWatcher()
// 轮询确认 goroutine 退出
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() < before {
break
}
time.Sleep(20 * time.Millisecond)
}
if after := runtime.NumGoroutine(); after >= before {
t.Errorf("watcher goroutine not released: before=%d after=%d (C10d)", before, after)
}
// 幂等:再次 Stop 不 panic
m.StopWatcher()
}
// TestStartWatcherIdempotent 重复 StartWatcher 不创建多个监听 goroutine(幂等)。
func TestStartWatcherIdempotent(t *testing.T) {
p := writeConfig(t, "c10_idem.yaml", validConfigYAML(8086))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher 1: %v", err)
}
time.Sleep(80 * time.Millisecond)
once := runtime.NumGoroutine()
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher 2: %v", err)
}
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher 3: %v", err)
}
time.Sleep(80 * time.Millisecond)
twice := runtime.NumGoroutine()
if twice > once {
t.Errorf("idempotent StartWatcher leaked goroutine: once=%d twice=%d", once, twice)
}
m.StopWatcher()
}
func TestStopWatcherWaitsInFlightReload(t *testing.T) {
p := writeConfig(t, "c10_stop_wait.yaml", validConfigYAML(8091))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
entered := make(chan struct{})
release := make(chan struct{})
m.RegisterCallback(func(*config.Config) {
close(entered)
<-release
})
if err := m.StartWatcher(); err != nil {
t.Fatalf("StartWatcher: %v", err)
}
if err := os.WriteFile(p, []byte(validConfigYAML(8092)), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("热更新回调未触发")
}
stopped := make(chan struct{})
go func() {
m.StopWatcher()
close(stopped)
}()
select {
case <-stopped:
t.Fatal("StopWatcher 不应在 reload 回调结束前返回")
case <-time.After(120 * time.Millisecond):
}
close(release)
select {
case <-stopped:
case <-time.After(2 * time.Second):
t.Fatal("StopWatcher 未等待到 reload 回调结束")
}
}
func TestLoadWithWatchFailureKeepsOldWatcher(t *testing.T) {
p := writeConfig(t, "c10_keep_old.yaml", validConfigYAML(8093))
defer os.Remove(p)
changes := make(chan int, 4)
if _, err := config.LoadWithWatch(p, func(c *config.Config) {
changes <- c.Server.Port
}); err != nil {
t.Fatalf("LoadWithWatch old: %v", err)
}
defer config.StopWatcher()
missing := filepath.Join(filepath.Dir(p), "missing.yaml")
if _, err := config.LoadWithWatch(missing, nil); err == nil {
t.Fatal("加载缺失配置应失败")
}
if err := os.WriteFile(p, []byte(validConfigYAML(8094)), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
select {
case port := <-changes:
if port != 8094 {
t.Fatalf("旧 watcher 回调端口错误: %d", port)
}
case <-time.After(3 * time.Second):
t.Fatal("加载失败后旧 watcher 不应被停止")
}
}
func TestSetDefaultManagerStopsOldWatcher(t *testing.T) {
oldPath := writeConfig(t, "c10_old_default.yaml", validConfigYAML(8095))
newPath := writeConfig(t, "c10_new_default.yaml", validConfigYAML(8096))
defer os.Remove(oldPath)
defer os.Remove(newPath)
oldManager := config.NewManager(oldPath)
if _, err := oldManager.Load(); err != nil {
t.Fatalf("old Load: %v", err)
}
changes := make(chan int, 4)
oldManager.RegisterCallback(func(c *config.Config) {
changes <- c.Server.Port
})
if err := oldManager.StartWatcher(); err != nil {
t.Fatalf("old StartWatcher: %v", err)
}
config.SetDefaultManager(oldManager)
newManager := config.NewManager(newPath)
if _, err := newManager.Load(); err != nil {
t.Fatalf("new Load: %v", err)
}
config.SetDefaultManager(newManager)
defer config.SetDefaultManager(nil)
if err := os.WriteFile(oldPath, []byte(validConfigYAML(8097)), 0644); err != nil {
t.Fatalf("WriteFile old: %v", err)
}
select {
case port := <-changes:
t.Fatalf("旧 watcher 已停止,不应收到端口 %d", port)
case <-time.After(500 * time.Millisecond):
}
}
func TestSetGetAndViperReturnCopies(t *testing.T) {
cfg := &config.Config{
App: config.AppConfig{Name: "copy", Env: "dev"},
CORS: config.CORSConfig{
AllowedOrigins: []string{"https://a.example.com"},
},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("Set: %v", err)
}
defer config.SetDefaultManager(nil)
cfg.App.Name = "mutated-input"
cfg.CORS.AllowedOrigins[0] = "https://mutated-input.example.com"
got := config.Get()
if got.App.Name != "copy" {
t.Fatalf("Set 应保存副本,实际 App.Name=%q", got.App.Name)
}
if got.CORS.AllowedOrigins[0] != "https://a.example.com" {
t.Fatalf("Set 应深拷贝切片,实际 origin=%q", got.CORS.AllowedOrigins[0])
}
got.App.Name = "mutated-get"
got.CORS.AllowedOrigins[0] = "https://mutated-get.example.com"
gotAgain := config.Get()
if gotAgain.App.Name != "copy" || gotAgain.CORS.AllowedOrigins[0] != "https://a.example.com" {
t.Fatalf("Get 应返回副本,实际 %+v", gotAgain)
}
if err := config.Set(&config.Config{Server: config.ServerConfig{Port: 99999}}); err == nil {
t.Fatal("Set 非法配置应返回错误")
}
if got := config.Get().App.Name; got != "copy" {
t.Fatalf("Set 非法配置不应覆盖旧配置,实际 App.Name=%q", got)
}
}
func TestGetViperReturnsSnapshot(t *testing.T) {
p := writeConfig(t, "c10_viper_snapshot.yaml", validConfigYAML(8098))
defer os.Remove(p)
if _, err := config.Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
defer config.SetDefaultManager(nil)
v := config.GetViper()
if v == nil {
t.Fatal("GetViper returned nil")
}
v.Set("app.name", "mutated")
if got := config.GetString("app.name"); got != "c10" {
t.Fatalf("GetViper 应返回快照,不应污染内部 viper,实际 app.name=%q", got)
}
}
+329
View File
@@ -0,0 +1,329 @@
package config_test
import (
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
)
// TestSetKeepsViperInSync_Hconfig1 固化 H-config-1Set(cfg) 后 Get() 与
// GetString/GetInt/GetBool/GetViper 必须读到同一配置世界,消除原"Set 只更新类型化视图、
// viper 视图停留旧值"的静默分裂(违反 C1 单一配置源)。
func TestSetKeepsViperInSync_Hconfig1(t *testing.T) {
cfg := &config.Config{
App: config.AppConfig{Name: "sync-test", SiteName: "sync_site", Env: "prod", Debug: true},
Server: config.ServerConfig{Port: 7777},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("Set: %v", err)
}
defer config.SetDefaultManager(nil)
// 类型化视图
got := config.Get()
if got == nil || got.App.Name != "sync-test" || got.Server.Port != 7777 {
t.Fatalf("Get() 不一致: %+v", got)
}
// viper 视图必须同源
if v := config.GetString("app.name"); v != "sync-test" {
t.Errorf("GetString(app.name) = %q, want sync-testH-config-1 同源)", v)
}
if v := config.GetInt("server.port"); v != 7777 {
t.Errorf("GetInt(server.port) = %d, want 7777H-config-1", v)
}
if v := config.GetBool("app.debug"); v != true {
t.Errorf("GetBool(app.debug) = %v, want trueH-config-1", v)
}
if v := config.GetString("app.env"); v != "prod" {
t.Errorf("GetString(app.env) = %q, want prodH-config-1", v)
}
vp := config.GetViper()
if vp == nil || vp.GetString("app.name") != "sync-test" {
t.Errorf("GetViper().GetString(app.name) 不一致(H-config-1: %v", vp)
}
// Set(nil) 清空,GetString 返回空(m.v=nil
if err := config.Set(nil); err != nil {
t.Fatalf("Set(nil): %v", err)
}
if v := config.GetString("app.name"); v != "" {
t.Errorf("Set(nil) 后 GetString 应为空, got %q", v)
}
}
// TestSetDurationGetDurationConsistent_Hconfig1 锁定 H-config-1 已知行为:Duration 字段经 Set
// 重建 viper 后,GetString 字面格式与文件加载不同(mapstructure 存 time.Duration -> Duration.String()),
// 但 typed view 与 GetDuration 在两条路径下一致。Duration 应经 typed view / GetDuration 读取。
func TestSetDurationGetDurationConsistent_Hconfig1(t *testing.T) {
cfg := &config.Config{
JWT: config.JWTConfig{Secret: strings.Repeat("k", 32), Expire: 24 * time.Hour},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("Set: %v", err)
}
defer config.SetDefaultManager(nil)
if got := config.Get().JWT.Expire; got != 24*time.Hour {
t.Errorf("Get().JWT.Expire = %v, want 24h", got)
}
if got := config.GetViper().GetDuration("jwt.expire"); got != 24*time.Hour {
t.Errorf("GetDuration(jwt.expire) = %v, want 24hDuration 同源应读 GetDuration/typed view", got)
}
}
// TestReloadCallbackPanicIsolation_Mconfig1 固化 M-config-1:单个热重载回调 panic 不得
// 向上传播、不得阻断后续回调、不得让 watcher/reload 链路静默失效。
func TestReloadCallbackPanicIsolation_Mconfig1(t *testing.T) {
p := writeConfig(t, "m1_panic.yaml", validConfigYAML(8301))
defer os.Remove(p)
m := config.NewManager(p)
if _, err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
called := make(chan int, 4)
m.RegisterCallback(func(*config.Config) {
panic("boom from user callback") // 模拟用户回调 panic
})
m.RegisterCallback(func(c *config.Config) {
called <- c.Server.Port
})
// Reload 不应把回调 panic 向上传播
if err := m.Reload(); err != nil {
t.Fatalf("Reload 不应传播回调 panic: %v", err)
}
// 第二个回调仍触发:panic 被隔离,未阻断后续回调
select {
case port := <-called:
if port != 8301 {
t.Errorf("第二个回调端口 = %d, want 8301", port)
}
case <-time.After(time.Second):
t.Fatal("panic 后续回调未触发(M-config-1 隔离失败)")
}
// 后续 reload 仍正常,watcher/reload 链路未静默失效
if err := os.WriteFile(p, []byte(validConfigYAML(8302)), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := m.Reload(); err != nil {
t.Fatalf("panic 后 Reload 应仍可用: %v", err)
}
select {
case port := <-called:
if port != 8302 {
t.Errorf("第二次 reload 回调端口 = %d, want 8302", port)
}
case <-time.After(time.Second):
t.Fatal("panic 后 reload 链路静默失效(M-config-1")
}
}
// TestMySQLDSN_TLS_Mconfig2 固化 M-config-2 MySQL TLSTLS=false 无 tls 参数;
// TLS=true 无 CA 用内置 tls=trueTLS=true 有 CA 用 tls=MySQLTLSConfigName。
func TestMySQLDSN_TLS_Mconfig2(t *testing.T) {
db := config.DatabaseConfig{Host: "h", Port: 3306, User: "u", Password: "p", Name: "n"}
if dsn := db.MySQLDSN(); strings.Contains(dsn, "tls=") {
t.Errorf("TLS=false 不应含 tls 参数, dsn=%s", dsn)
}
db.TLS = true
if dsn := db.MySQLDSN(); !strings.Contains(dsn, "&tls=true") {
t.Errorf("TLS=true 无 CA 应用内置 tls=true, dsn=%s", dsn)
}
db.TLSRootCA = "/path/ca.pem"
if dsn := db.MySQLDSN(); !strings.Contains(dsn, "&tls="+config.MySQLTLSConfigName) {
t.Errorf("TLS=true 有 CA 应用 tls=%s, dsn=%s", config.MySQLTLSConfigName, dsn)
}
// CustomDSN 优先,TLS 字段不影响 DSN()
db.CustomDSN = "custom-dsn"
if dsn := db.DSN(); dsn != "custom-dsn" {
t.Errorf("CustomDSN 应优先, got %s", dsn)
}
}
// TestPostgresDSN_SSLMode_Mconfig2 固化 M-config-2 Postgres SSLMode:空默认 prefer
// 显式值透传(sslmode 不加引号,与原 disable 格式一致)。
func TestPostgresDSN_SSLMode_Mconfig2(t *testing.T) {
db := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n"}
if dsn := db.PostgresDSN(); !strings.Contains(dsn, "sslmode=prefer ") {
t.Errorf("空 SSLMode 默认 prefer, dsn=%s", dsn)
}
for _, mode := range []string{"disable", "allow", "require", "verify-ca", "verify-full"} {
db.SSLMode = mode
if dsn := db.PostgresDSN(); !strings.Contains(dsn, "sslmode="+mode+" ") {
t.Errorf("SSLMode=%s 应透传, dsn=%s", mode, dsn)
}
}
}
// TestValidateMaxIdleExceedsOpen_Lconfig3 固化 L-config-3MaxOpenConns>0 时
// MaxIdleConns>MaxOpenConns 视为配置错误;MaxOpenConns=0(无限)不校验。
func TestValidateMaxIdleExceedsOpen_Lconfig3(t *testing.T) {
cfg := &config.Config{
Database: config.DatabaseConfig{
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
MaxOpenConns: 10, MaxIdleConns: 20,
},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "max_idle_conns") {
t.Fatalf("MaxIdleConns>MaxOpenConns 应报错, got: %v", err)
}
cfg.Database.MaxOpenConns = 0 // 未配置/无限,不校验 idle
if err := cfg.Validate(); err != nil {
t.Fatalf("MaxOpenConns=0 时不该校验 idle, got: %v", err)
}
cfg.Database.MaxOpenConns = 20 // 合法
if err := cfg.Validate(); err != nil {
t.Fatalf("合法配置不应报错, got: %v", err)
}
}
// TestValidatePostgresSSLMode_Mconfig2 固化 M-config-2:非法 SSLMode 在 Validate 阶段报错。
func TestValidatePostgresSSLMode_Mconfig2(t *testing.T) {
cfg := &config.Config{
Database: config.DatabaseConfig{
Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n",
SSLMode: "invalid-mode",
},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "ssl_mode") {
t.Fatalf("非法 SSLMode 应报错, got: %v", err)
}
cfg.Database.SSLMode = "require"
if err := cfg.Validate(); err != nil {
t.Fatalf("合法 SSLMode=require 不应报错, got: %v", err)
}
}
// TestValidateTLSRootCAWithoutTLS_Mconfig2 固化 M-config-2TLSRootCA 需配合 tls:true。
func TestValidateTLSRootCAWithoutTLS_Mconfig2(t *testing.T) {
cfg := &config.Config{
Database: config.DatabaseConfig{
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
TLSRootCA: "/path/ca.pem", // TLS=false
},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "tls_root_ca") {
t.Fatalf("TLSRootCA 无 tls:true 应报错, got: %v", err)
}
cfg.Database.TLS = true
if err := cfg.Validate(); err != nil {
t.Fatalf("TLS=true + TLSRootCA 合法不应报错, got: %v", err)
}
}
// TestDSNAddrNilReceiver_Lconfig6 固化 L-config-6DSN/MySQLDSN/PostgresDSN/Addr 对 nil receiver 安全。
func TestDSNAddrNilReceiver_Lconfig6(t *testing.T) {
var db *config.DatabaseConfig
if v := db.DSN(); v != "" {
t.Errorf("nil DatabaseConfig.DSN() = %q, want empty", v)
}
if v := db.MySQLDSN(); v != "" {
t.Errorf("nil MySQLDSN() = %q, want empty", v)
}
if v := db.PostgresDSN(); v != "" {
t.Errorf("nil PostgresDSN() = %q, want empty", v)
}
var r *config.RedisConfig
if v := r.Addr(); v != "" {
t.Errorf("nil RedisConfig.Addr() = %q, want empty", v)
}
}
// TestCloneDeepCopiesAllSliceFields_Mconfig4 固化 M-config-4:反射枚举 Config 所有切片/map 字段,
// 断言 Clone 深拷贝(不同底层数组/map)。fixture 必须填充每个切片字段--新增切片字段若未在 Clone
// 中处理、或未在 fixture 中填充,本测试都会失败,形成机械守卫。
func TestCloneDeepCopiesAllSliceFields_Mconfig4(t *testing.T) {
original := allSlicesPopulatedConfig()
clone := original.Clone()
var check func(o, c reflect.Value, path string)
check = func(o, c reflect.Value, path string) {
for o.Kind() == reflect.Pointer {
o = o.Elem()
}
for c.Kind() == reflect.Pointer {
c = c.Elem()
}
if o.Kind() != reflect.Struct {
return
}
for i := 0; i < o.NumField(); i++ {
of := o.Field(i)
cf := c.Field(i)
p := path + "." + o.Type().Field(i).Name
switch of.Kind() {
case reflect.Pointer, reflect.Struct:
check(of, cf, p)
case reflect.Slice:
if of.IsNil() || of.Len() == 0 {
t.Errorf("M-config-4: fixture 未填充切片 %s(新增切片字段须同步 fixture 与 Clone", p)
continue
}
if cf.IsNil() || cf.Len() != of.Len() {
t.Errorf("M-config-4: Clone 后切片 %s 为 nil 或长度不一致", p)
continue
}
if of.Pointer() == cf.Pointer() {
t.Errorf("M-config-4: Clone 未深拷贝切片 %s(共享底层数组)", p)
}
case reflect.Map:
if of.IsNil() || of.Len() == 0 {
t.Errorf("M-config-4: fixture 未填充 map %s", p)
continue
}
if of.Pointer() == cf.Pointer() {
t.Errorf("M-config-4: Clone 未深拷贝 map %s(共享 map", p)
}
}
}
}
check(reflect.ValueOf(original), reflect.ValueOf(clone), "Config")
}
// allSlicesPopulatedConfig 返回一个填充了所有切片字段的 Config,供 Clone 覆盖断言使用。
// 新增任何切片/map 字段到 Config 或其子结构体时,必须同步在此填充,否则
// TestCloneDeepCopiesAllSliceFields_Mconfig4 会以"未填充"失败提醒。
func allSlicesPopulatedConfig() *config.Config {
return &config.Config{
CORS: config.CORSConfig{
AllowedOrigins: []string{"https://a.example.com"},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"X-Test"},
ExposedHeaders: []string{"X-Exp"},
},
Upload: config.UploadConfig{
AllowedImageTypes: []string{"image/jpeg"},
AllowedVideoTypes: []string{"video/mp4"},
},
Storage: config.StorageConfig{
Local: config.LocalStorageConfig{
Upload: config.UploadPolicy{
AllowedExts: []string{".jpg"},
AllowedMIMEs: []string{"image/jpeg"},
},
},
OSS: config.OSSStorageConfig{
Upload: config.UploadPolicy{
AllowedExts: []string{".png"},
AllowedMIMEs: []string{"image/png"},
},
},
},
}
}
+155 -4
View File
@@ -3,6 +3,7 @@ package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -90,6 +91,100 @@ func TestDatabaseConfigDSN(t *testing.T) {
}
}
func TestDatabaseConfigPostgresDSN(t *testing.T) {
db := config.DatabaseConfig{
Driver: config.DriverPostgres,
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "password",
Name: "testdb",
}
dsn := db.DSN()
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)
}
// 显式 MySQL DSN 不受 Driver 影响
if db.MySQLDSN() == "" {
t.Error("MySQLDSN should not be empty")
}
}
func TestDatabaseConfigUnknownDriverDoesNotFallback(t *testing.T) {
db := config.DatabaseConfig{
Driver: "no-such-driver",
Host: "localhost",
Port: 3306,
User: "root",
Password: "password",
Name: "testdb",
}
if dsn := db.DSN(); dsn != "" {
t.Fatalf("未知 driver 不应静默回退 MySQL,实际 DSN=%q", dsn)
}
cfg := &config.Config{Database: db}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "database.driver") {
t.Fatalf("未知 driver 应在 Validate 阶段报错,实际: %v", err)
}
}
// TestDatabaseConfigDSNPasswordEscape_M9:含特殊字符的密码须被转义,不破坏 DSN。
func TestDatabaseConfigDSNPasswordEscape_M9(t *testing.T) {
// MySQL:密码含 @/:/空格 → url.QueryEscape
mysql := config.DatabaseConfig{
Driver: config.DriverMySQL,
Host: "localhost",
Port: 3306,
User: "root",
Password: "p@ss w:ord",
Name: "testdb",
}
mdsn := mysql.MySQLDSN()
// 密码段应被转义,@ 不应与 DSN 的 @ 分隔符混淆
if !strings.Contains(mdsn, "root:p%40ss+w%3Aord@tcp") {
t.Errorf("MySQL DSN password not escaped: %s", mdsn)
}
// Postgres:密码含单引号 → 翻倍转义
pg := config.DatabaseConfig{
Driver: config.DriverPostgres,
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "p'ord",
Name: "testdb",
}
pdsn := pg.PostgresDSN()
if !strings.Contains(pdsn, `password='p\'ord'`) {
t.Errorf("Postgres DSN password not escaped: %s", pdsn)
}
// Timezone 可配置
pg2 := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n", Timezone: "UTC"}
if !strings.Contains(pg2.PostgresDSN(), "TimeZone='UTC'") {
t.Errorf("Postgres DSN should honor Timezone=UTC: %s", pg2.PostgresDSN())
}
mysql2 := config.DatabaseConfig{Driver: config.DriverMySQL, Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", Timezone: "UTC"}
if !strings.Contains(mysql2.MySQLDSN(), "loc=UTC") {
t.Errorf("MySQL DSN should honor Timezone=UTC: %s", mysql2.MySQLDSN())
}
}
func TestDatabaseConfigCustomDSN(t *testing.T) {
db := config.DatabaseConfig{
Driver: config.DriverPostgres,
CustomDSN: "custom-connection-string",
}
if db.DSN() != "custom-connection-string" {
t.Errorf("CustomDSN should take precedence, got %s", db.DSN())
}
}
func TestRedisConfigAddr(t *testing.T) {
redis := config.RedisConfig{
Host: "localhost",
@@ -130,8 +225,8 @@ redis:
db: 0
jwt:
secret: "test-secret"
expire: 3600
secret: "test-secret-12345678901234567890123456789012"
expire: "1h"
`
tmpFile, err := setupTempFile("test_config.yaml", content)
if err != nil {
@@ -216,10 +311,66 @@ func TestConfigGetString(_ *testing.T) {
// 此函数保留为占位符,避免删除后影响其他测试引用
}
func TestConfigLoadReloadsDifferentFiles(t *testing.T) {
first, err := setupTempFile("first_config.yaml", "app:\n name: first\nserver:\n port: 1001\n")
if err != nil {
t.Fatalf("WriteFile first error: %v", err)
}
second, err := setupTempFile("second_config.yaml", "app:\n name: second\nserver:\n port: 1002\n")
if err != nil {
t.Fatalf("WriteFile second error: %v", err)
}
defer os.Remove(first)
defer os.Remove(second)
cfg, err := config.Load(first)
if err != nil {
t.Fatalf("Load first error: %v", err)
}
if cfg.App.Name != "first" || cfg.Server.Port != 1001 {
t.Fatalf("unexpected first config: %+v", cfg)
}
cfg, err = config.Load(second)
if err != nil {
t.Fatalf("Load second error: %v", err)
}
if cfg.App.Name != "second" || cfg.Server.Port != 1002 {
t.Fatalf("unexpected second config: %+v", cfg)
}
}
func TestConfigManagerIsolation(t *testing.T) {
first, err := setupTempFile("manager_first.yaml", "app:\n name: manager_first\n")
if err != nil {
t.Fatalf("WriteFile first error: %v", err)
}
second, err := setupTempFile("manager_second.yaml", "app:\n name: manager_second\n")
if err != nil {
t.Fatalf("WriteFile second error: %v", err)
}
defer os.Remove(first)
defer os.Remove(second)
m1 := config.NewManager(first)
m2 := config.NewManager(second)
cfg1, err := m1.Load()
if err != nil {
t.Fatalf("m1 Load error: %v", err)
}
cfg2, err := m2.Load()
if err != nil {
t.Fatalf("m2 Load error: %v", err)
}
if cfg1.App.Name != "manager_first" || cfg2.App.Name != "manager_second" {
t.Fatalf("managers should be isolated: %+v %+v", cfg1, cfg2)
}
}
func TestConfigSet(t *testing.T) {
cfg := &config.Config{
App: config.AppConfig{
Name: "Manual",
Name: "Manual",
SiteName: "manual_site",
},
}
@@ -287,4 +438,4 @@ func TestUploadConfig(t *testing.T) {
if upload.MaxFileSize != 10 {
t.Error("UploadConfig failed")
}
}
}
+109
View File
@@ -0,0 +1,109 @@
package config
import (
"fmt"
"strings"
"time"
)
// Validate 校验配置完整性与取值合法性(#16)。
// 在 Manager.Load 解析后自动调用,把"运行时第一次请求才暴露"的配置错误
// 提前到进程启动期。返回的 error 描述具体字段,便于定位。
func (c *Config) Validate() error {
if c == nil {
return fmt.Errorf("配置为空")
}
var problems []string
// Server
if c.Server.Port < 0 || c.Server.Port > 65535 {
problems = append(problems, fmt.Sprintf("server.port 超出范围(0-65535): %d", c.Server.Port))
}
if c.Server.TLS.Enabled {
if strings.TrimSpace(c.Server.TLS.CertFile) == "" || strings.TrimSpace(c.Server.TLS.KeyFile) == "" {
problems = append(problems, "server.tls 启用后必须同时配置 cert_file 与 key_file")
}
}
if !validDuration(c.Server.ReadTimeout) || !validDuration(c.Server.WriteTimeout) ||
!validDuration(c.Server.IdleTimeout) || !validDuration(c.Server.ShutdownTimeout) {
problems = append(problems, "server 的 timeout 配置不能为负值")
}
// JWT:仅当配置了 secret 时校验(未启用 jwt 的项目可留空)
if c.JWT.Secret != "" {
if len(c.JWT.Secret) < 32 {
problems = append(problems, fmt.Sprintf("jwt.secret 长度不足 32 字节(当前 %d),HMAC 密钥过短不安全", len(c.JWT.Secret)))
}
if c.JWT.Expire < 0 || c.JWT.RefreshExpire < 0 {
problems = append(problems, "jwt.expire / jwt.refresh_expire 不能为负值")
}
}
// 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 strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Name) == "" {
problems = append(problems, "database.name 启用数据库后必填")
}
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 时校验
if strings.TrimSpace(c.Redis.Host) != "" {
if c.Redis.Port <= 0 || c.Redis.Port > 65535 {
problems = append(problems, fmt.Sprintf("redis.port 超出范围(1-65535): %d", c.Redis.Port))
}
}
// Trace:仅当启用时校验采样比例。ExporterType/Propagator 的枚举校验委托 trace.Init
// (随 OTel 版本可能扩展,避免 config 与 trace 双源枚举漂移)。
if c.Trace.Enabled {
if c.Trace.SampleRatio < 0 || c.Trace.SampleRatio > 1 {
problems = append(problems, fmt.Sprintf("trace.sample_ratio 超出范围(0.0-1.0): %v", c.Trace.SampleRatio))
}
}
if len(problems) > 0 {
return fmt.Errorf("配置校验失败: %s", strings.Join(problems, "; "))
}
return nil
}
// 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
}
+81
View File
@@ -0,0 +1,81 @@
package config_test
import (
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
)
func validBase() *config.Config {
return &config.Config{
Server: config.ServerConfig{Port: 8080},
}
}
func TestValidateOK(t *testing.T) {
if err := validBase().Validate(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateServerPort(t *testing.T) {
c := validBase()
c.Server.Port = 99999
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "server.port") {
t.Fatalf("expected server.port error, got %v", err)
}
}
func TestValidateJWTSecretTooShort(t *testing.T) {
c := validBase()
c.JWT.Secret = "short"
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "jwt.secret") {
t.Fatalf("expected jwt.secret error, got %v", err)
}
}
func TestValidateJWTSecretAbsentSkipped(t *testing.T) {
c := validBase()
// secret 为空时不校验(未启用 jwt)
if err := c.Validate(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateTraceSampleRatioWhenEnabled(t *testing.T) {
c := validBase()
c.Trace.Enabled = true
c.Trace.SampleRatio = 1.5
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "trace.sample_ratio") {
t.Fatalf("expected trace.sample_ratio error, got %v", err)
}
}
func TestValidateTraceDisabledSkipsRatio(t *testing.T) {
c := validBase()
// Enabled=false 时即使 SampleRatio 非法也不校验(trace 未启用)
c.Trace.SampleRatio = 1.5
if err := c.Validate(); err != nil {
t.Fatalf("unexpected error when trace disabled: %v", err)
}
}
func TestValidateDatabaseMissingHost(t *testing.T) {
c := validBase()
c.Database.Driver = "mysql"
c.Database.Port = 3306
c.Database.Name = "db"
// Host 留空
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "database.host") {
t.Fatalf("expected database.host error, got %v", err)
}
}
func TestValidateTLSMissingCert(t *testing.T) {
c := validBase()
c.Server.TLS.Enabled = true
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "cert_file") {
t.Fatalf("expected tls cert_file error, got %v", err)
}
}
+105 -15
View File
@@ -6,20 +6,47 @@ import (
"os"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
)
// writeMu 串行化所有 Console 的写出序列(P1 #7)。
// 用包级锁而非 per-Console 锁:多个 Console 实例可能共享同一底层 writer(如 stdout),
// per-Console 锁无法阻止跨实例交错;且 Windows printColor 的"设色→写→复位"三步必须整体
// 原子,否则并发彩色输出会串色。控制台输出非热路径,单一全局写锁的串行化开销可忽略。
var writeMu sync.Mutex
// Level 日志级别
type Level int
type Level int32
const (
// LevelDebug 调试级别(最低)。
LevelDebug Level = iota
// LevelInfo 普通信息。
LevelInfo
// LevelSuccess 成功信息。
LevelSuccess
// LevelWarn 警告。
LevelWarn
// LevelError 错误(最高常规级别)。
LevelError
// LevelSilent 完全静默:所有调用都不输出
LevelSilent Level = 127
)
// String 返回级别名称
func (l Level) String() string {
if c, ok := colors[l]; ok {
return c.Name
}
if l == LevelSilent {
return "Silent"
}
return "Unknown"
}
// Color 颜色定义
type Color struct {
Code string
@@ -34,14 +61,25 @@ var colors = map[Level]Color{
LevelError: {Code: "1;31", Name: "Error"}, // 亮红色
}
// Console 控制台打印器
// Console 控制台打印器
//
// console 包定位:开发期彩色 stdout 工具,跟 fmt.Println 同级。
// 不写文件、不感知运行环境、不做任何隐式 level 切换——
// 所有 level 行为都由调用方显式控制(SetLevel / WithLevel)。
//
// 业务可观测信息(用户登录、订单状态变更等"上线后必须保留的事件")
// 请使用 logger 包;console 仅用于开发期肉眼调试。
type Console struct {
output io.Writer
isColor bool
showTime bool
showCall bool
timeFmt string
skipCall int
output io.Writer
isColor bool
showTime bool
showCall bool
timeFmt string
skipCall int
// level 通过 atomic 访问,支持运行期热切换且并发安全。
// 用 int32 存储 Level0 = LevelDebug,与零值默认对齐。
level atomic.Int32
}
// Option 配置选项
@@ -68,11 +106,14 @@ func WithTime(show bool) Option {
}
}
// WithCaller 设置是否显示调用位置
func WithCaller(show bool, skip int) Option {
// WithCaller 设置是否显示调用位置
// skip 可选,默认 2(直接调用方);自封装一层时传 3。
func WithCaller(show bool, skip ...int) Option {
return func(c *Console) {
c.showCall = show
c.skipCall = skip
if len(skip) > 0 && skip[0] > 0 {
c.skipCall = skip[0]
}
}
}
@@ -83,6 +124,17 @@ func WithTimeFormat(fmt string) Option {
}
}
// WithLevel 设置最低输出级别。低于该级别的调用会被静默丢弃。
//
// 例:WithLevel(LevelWarn) 只输出 Warn 与 Error
//
// WithLevel(LevelSilent) 完全静默,常用于压测或上线观察期临时关闭调试输出。
func WithLevel(l Level) Option {
return func(c *Console) {
c.level.Store(int32(l))
}
}
// New 创建控制台打印器
func New(opts ...Option) *Console {
c := &Console{
@@ -93,17 +145,53 @@ func New(opts ...Option) *Console {
timeFmt: "15:04:05.000",
skipCall: 2,
}
// 默认 LevelDebug:开发期所有级别都打印。生产期请显式 SetLevel/WithLevel。
c.level.Store(int32(LevelDebug))
for _, opt := range opts {
opt(c)
}
return c
}
// SetLevel 运行期切换最低输出级别。并发安全。
func (c *Console) SetLevel(l Level) {
c.level.Store(int32(l))
}
// Level 返回当前最低输出级别
func (c *Console) Level() Level {
return Level(c.level.Load())
}
// Default 默认控制台
var Default = New()
// SetLevel 设置默认控制台的最低输出级别。并发安全。
//
// 典型用法(在 main 中根据 cfg 显式切换):
//
// if cfg.IsProduction() {
// console.SetLevel(console.LevelWarn) // 生产期只看 Warn / Error
// }
//
// 框架不会自动根据环境模式切换,选择权完全在调用方。
func SetLevel(l Level) {
Default.SetLevel(l)
}
// GetLevel 返回默认控制台当前最低输出级别。
// (命名加 Get 前缀是因为 Level 已被类型占用,Go 不允许同名函数。)
func GetLevel() Level {
return Default.Level()
}
// print 内部打印函数
func (c *Console) print(level Level, s ...any) {
// 级别过滤:低于阈值或调用方显式 LevelSilent 时直接返回,零开销
if level < c.Level() {
return
}
var sb strings.Builder
// 时间
@@ -127,12 +215,15 @@ func (c *Console) print(level Level, s ...any) {
// 内容
sb.WriteString(fmt.Sprint(s...))
// 输出
// 输出。P1 #7:全局写锁串行化整个写序列,避免并发交错输出;
// Windows 下 printColor 的"设色→写→复位"三步也被此锁整体保护,防止串色。
writeMu.Lock()
if c.isColor {
c.printColor(color.Code, sb.String())
} else {
fmt.Fprintln(c.output, sb.String())
}
writeMu.Unlock()
}
// getCaller 获取调用位置
@@ -141,9 +232,8 @@ func (c *Console) getCaller() string {
if !ok {
return ""
}
// 只取文件名
idx := strings.LastIndex(file, "/")
if idx >= 0 {
// 只取文件名(兼容 / 与 \ 分隔)
if idx := strings.LastIndexAny(file, "/\\"); idx >= 0 {
file = file[idx+1:]
}
return fmt.Sprintf("%s:%d", file, line)
+124
View File
@@ -1,6 +1,8 @@
package console_test
import (
"bytes"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/console"
@@ -26,3 +28,125 @@ func TestConsole(t *testing.T) {
c.Warn("自定义控制台 - Warn")
c.Error("自定义控制台 - Error")
}
// TestConsoleLevelFilter 验证显式 level 屏蔽:低于阈值的调用不输出。
// 这是方案 A 的核心契约——用户显式控制何时屏蔽,框架不做隐式行为。
func TestConsoleLevelFilter(t *testing.T) {
var buf bytes.Buffer
c := console.New(
console.WithOutput(&buf),
console.WithColor(false),
console.WithTime(false),
console.WithCaller(false),
console.WithLevel(console.LevelWarn),
)
c.Debug("DEBUG_MARK")
c.Info("INFO_MARK")
c.Success("SUCCESS_MARK")
c.Warn("WARN_MARK")
c.Error("ERROR_MARK")
out := buf.String()
// Warn / Error 必须输出
if !strings.Contains(out, "WARN_MARK") {
t.Errorf("Warn should be printed at LevelWarn, got: %q", out)
}
if !strings.Contains(out, "ERROR_MARK") {
t.Errorf("Error should be printed at LevelWarn, got: %q", out)
}
// Debug / Info / Success 必须被静默
for _, mark := range []string{"DEBUG_MARK", "INFO_MARK", "SUCCESS_MARK"} {
if strings.Contains(out, mark) {
t.Errorf("%s should be filtered at LevelWarn, but found in: %q", mark, out)
}
}
}
// TestConsoleLevelSilent 验证 LevelSilent 完全静默所有调用。
func TestConsoleLevelSilent(t *testing.T) {
var buf bytes.Buffer
c := console.New(
console.WithOutput(&buf),
console.WithColor(false),
console.WithLevel(console.LevelSilent),
)
c.Debug("D")
c.Info("I")
c.Success("S")
c.Warn("W")
c.Error("E")
if buf.Len() != 0 {
t.Errorf("LevelSilent should suppress all output, got %d bytes: %q", buf.Len(), buf.String())
}
}
// TestConsoleSetLevel 验证运行期热切换 level。
func TestConsoleSetLevel(t *testing.T) {
var buf bytes.Buffer
c := console.New(
console.WithOutput(&buf),
console.WithColor(false),
console.WithTime(false),
console.WithCaller(false),
)
// 默认 LevelDebugDebug 应输出
c.Debug("FIRST")
if !strings.Contains(buf.String(), "FIRST") {
t.Errorf("Debug should print at default LevelDebug, got: %q", buf.String())
}
buf.Reset()
// 切到 LevelError 后,Debug 应静默
c.SetLevel(console.LevelError)
if got := c.Level(); got != console.LevelError {
t.Errorf("Level() = %v, want LevelError", got)
}
c.Debug("SECOND")
if buf.Len() != 0 {
t.Errorf("Debug should be filtered after SetLevel(LevelError), got: %q", buf.String())
}
// Error 仍然输出
c.Error("THIRD")
if !strings.Contains(buf.String(), "THIRD") {
t.Errorf("Error should print at LevelError, got: %q", buf.String())
}
}
// TestConsolePackageLevelAPI 验证包级 SetLevel / GetLevel 操作的是 Default 实例。
func TestConsolePackageLevelAPI(t *testing.T) {
original := console.GetLevel()
t.Cleanup(func() { console.SetLevel(original) })
console.SetLevel(console.LevelWarn)
if got := console.GetLevel(); got != console.LevelWarn {
t.Errorf("GetLevel() = %v, want LevelWarn", got)
}
if got := console.Default.Level(); got != console.LevelWarn {
t.Errorf("Default.Level() = %v, want LevelWarn (package SetLevel must affect Default)", got)
}
}
// TestConsoleLevelString 验证 Level.String 输出可读名称(错误信息 / 日志会用到)。
func TestConsoleLevelString(t *testing.T) {
cases := map[console.Level]string{
console.LevelDebug: "Debug",
console.LevelInfo: "Info",
console.LevelSuccess: "Success",
console.LevelWarn: "Warn",
console.LevelError: "Error",
console.LevelSilent: "Silent",
}
for l, want := range cases {
if got := l.String(); got != want {
t.Errorf("Level(%d).String() = %q, want %q", l, got, want)
}
}
}
+37 -19
View File
@@ -4,6 +4,7 @@ package console
import (
"fmt"
"os"
"syscall"
"unsafe"
)
@@ -21,31 +22,48 @@ var colorMap = map[string]uintptr{
"1;31": 12, // 亮红色
}
// printColor 彩色打印
// printColor 彩色打印
//
// 修复 M17:原实现对 syscall.Stdout 设置颜色、却把文本写到 c.output——
// 当 c.output 非 stdout(如 WithOutput 指向文件)时,颜色落到错误句柄、文本落文件,
// 二者分裂。现按 c.output 实际类型取句柄:*os.File 用其 Fd(),否则放弃着色只写文本。
func (c *Console) printColor(code, msg string) {
color := colorMap[code]
if color == 0 {
color = 7 // 默认淡灰色
}
proc := kernel32.NewProc("SetConsoleTextAttribute")
_, _, _ = proc.Call(uintptr(syscall.Stdout), color)
fmt.Fprintln(c.output, msg)
_, _, _ = proc.Call(uintptr(syscall.Stdout), 7) // 恢复默认颜色
}
// EnableVirtualTerminal 启用虚拟终端支持(Windows 10+)
func EnableVirtualTerminal() error {
// 尝试启用 ANSI 转义序列支持
proc := kernel32.NewProc("GetConsoleMode")
var mode uint32
_, _, err := proc.Call(uintptr(syscall.Stdout), uintptr(unsafe.Pointer(&mode)))
if err != syscall.Errno(0) {
return err
handle, ok := consoleHandle(c.output)
if !ok {
// 非 *os.File(如 bytes.Buffer/文件),无法设置控制台属性,退化为纯文本。
fmt.Fprintln(c.output, msg)
return
}
mode |= 0x0004 // ENABLE_VIRTUAL_TERMINAL_PROCESSING
proc = kernel32.NewProc("SetConsoleMode")
_, _, err = proc.Call(uintptr(syscall.Stdout), uintptr(mode))
return err
proc := kernel32.NewProc("SetConsoleTextAttribute")
_, _, _ = proc.Call(handle, color)
fmt.Fprintln(c.output, msg)
_, _, _ = proc.Call(handle, 7) // 恢复默认颜色
}
// consoleHandle 从 io.Writer 取 Windows 控制台句柄;非 *os.File 返回 (0, false)。
func consoleHandle(w interface{ Write([]byte) (int, error) }) (uintptr, bool) {
f, ok := w.(*os.File)
if !ok {
return 0, false
}
fd := f.Fd()
switch fd {
case uintptr(syscall.Stdout), uintptr(syscall.Stderr), uintptr(syscall.Stdin):
return fd, true
default:
// 重定向到文件/管道的 *os.FileSetConsoleTextAttribute 无意义,退化为纯文本。
return 0, false
}
}
// M17:原 EnableVirtualTerminal 为死代码且从未被调用,已移除。
// Windows 10+ 默认支持 ANSI/VT 着色;需要 VT 的调用方应自行调 kernel32。)
// 保留 unsafe 引用以备将来 VT 扩展;当前 printColor 不再需要,故显式抑制未用告警。
var _ = unsafe.Sizeof(uintptr(0))
+494 -120
View File
@@ -3,20 +3,33 @@ package cron
import (
"context"
"fmt"
"runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/logger"
"go.uber.org/zap"
)
// Task 定时任务
type Task struct {
Name string // 任务名称
Schedule Schedule // 调度规则
Handler TaskHandler // 任务处理函数
Enabled bool // 是否启用
LastRun time.Time // 上次运行时间
NextRun time.Time // 下次运行时间
RunCount int // 运行次数
Name string // 任务名称
Schedule Schedule // 调度规则
Handler TaskHandler // 任务处理函数
Enabled bool // 是否启用
LastRun time.Time // 上次运行时间
NextRun time.Time // 下次运行时间
RunCount int // 运行次数
LastError error // CR3 修复:最近一次执行错误
// running 防止长任务跨 tick 重叠执行(C12b)。
// 用指针以便 GetTask/ListTasks 返回 Task 拷贝时不触发 atomic.Bool 值类型的
// copylocks 警告;拷贝共享同一守卫状态,但该字段未导出,外部无法操作。
// nil 表示未初始化(仅非 AddTask 构造的 Task),checkAndRun/RunTask 以 nil 守卫跳过。
running *atomic.Bool
}
// TaskHandler 任务处理函数
@@ -27,19 +40,57 @@ type Schedule interface {
Next(now time.Time) time.Time
}
func validateSchedule(schedule Schedule) {
switch s := schedule.(type) {
case *IntervalSchedule:
validateInterval(s.Interval)
case *DailySchedule:
validateClock(s.Hour, s.Minute, "Daily")
case *WeeklySchedule:
validateWeekday(s.Day)
validateClock(s.Hour, s.Minute, "Weekly")
}
}
func validateInterval(interval time.Duration) {
if interval <= 0 {
panic("cron: interval must be positive")
}
}
func validateClock(hour, minute int, name string) {
if hour < 0 || hour > 23 {
panic(fmt.Sprintf("cron: %s hour must be in [0,23]", name))
}
if minute < 0 || minute > 59 {
panic(fmt.Sprintf("cron: %s minute must be in [0,59]", name))
}
}
func validateWeekday(day time.Weekday) {
if day < time.Sunday || day > time.Saturday {
panic("cron: Weekly day must be in [Sunday,Saturday]")
}
}
// Scheduler 调度器
type Scheduler struct {
tasks map[string]*Task
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
running bool
tasks map[string]*Task
mu sync.RWMutex
lifecycleMu sync.Mutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
running bool
}
func newSchedulerContext() (context.Context, context.CancelFunc) {
return context.WithCancel(context.Background())
}
// NewScheduler 创建调度器
func NewScheduler() *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := newSchedulerContext()
return &Scheduler{
tasks: make(map[string]*Task),
ctx: ctx,
@@ -49,12 +100,21 @@ func NewScheduler() *Scheduler {
// AddTask 添加任务
func (s *Scheduler) AddTask(name string, schedule Schedule, handler TaskHandler) *Task {
if schedule == nil {
panic("cron: AddTask requires a non-nil schedule")
}
if handler == nil {
panic("cron: AddTask requires a non-nil handler")
}
validateSchedule(schedule)
task := &Task{
Name: name,
Schedule: schedule,
Handler: handler,
Enabled: true,
NextRun: schedule.Next(time.Now()),
running: &atomic.Bool{},
}
s.mu.Lock()
@@ -100,7 +160,7 @@ func (s *Scheduler) DisableTask(name string) error {
return nil
}
// GetTask 获取任务
// GetTask 获取任务(返回拷贝快照,避免外部并发读 live 指针,C12a)。
func (s *Scheduler) GetTask(name string) (*Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -109,43 +169,77 @@ func (s *Scheduler) GetTask(name string) (*Task, error) {
if !ok {
return nil, fmt.Errorf("任务不存在: %s", name)
}
return task, nil
cp := *task
return &cp, nil
}
// ListTasks 获取所有任务
// ListTasks 获取所有任务(返回拷贝快照,C12a)。
func (s *Scheduler) ListTasks() []*Task {
s.mu.RLock()
defer s.mu.RUnlock()
tasks := make([]*Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
cp := *task
tasks = append(tasks, &cp)
}
return tasks
}
// RunTask 立即运行任务
func (s *Scheduler) RunTask(name string) error {
// executeTask 在 exec 边界运行 task handlerrecover 捕获 panic 转为 error(含调用栈),
// 防止调度 goroutine 内未 recover 的 panic 终止整个进程(M13)。
// 命名返回值让 defer 在 panic 时改写 err。两侧调用点(RunTask / checkAndRun goroutine
// 共用此边界,panic 一律转为 error 记入 LastError 并向上返回,不破坏 running 守卫与 wg.Done
// (recover 在本函数内部完成,外侧 defer 仍正常执行)。
func (s *Scheduler) executeTask(t *Task) (err error) {
s.mu.RLock()
task, ok := s.tasks[name]
ctx := s.ctx
s.mu.RUnlock()
return s.executeTaskWithContext(ctx, t)
}
func (s *Scheduler) executeTaskWithContext(ctx context.Context, t *Task) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("cron task %q panic recovered: %v\n%s", t.Name, r, debug.Stack())
}
}()
return t.Handler(ctx)
}
// RunTask 立即运行任务(手动触发,同步返回 handler 错误)。
//
// 占用 per-task running 守卫,与调度循环互斥,防止同一任务重叠执行(C12b)。
// 不推进 NextRun(手动触发不影响调度节奏,C12c)。LastRun/RunCount 在锁内更新(C12a)。
//
// L-Jhandler 收到的 ctx 为调度器 s.ctx。Stop 后 s.ctx 已取消,handler 会收到 canceled
// ctx——这是预期行为(手动触发在调度器停止后不应继续执行长任务)。调用方若需在 Stop 后
// 仍运行一次性任务,应在自己的 ctx 下执行而非依赖调度器。
func (s *Scheduler) RunTask(name string) error {
s.mu.Lock()
task, ok := s.tasks[name]
s.mu.Unlock()
if !ok {
return fmt.Errorf("任务不存在: %s", name)
}
return s.runTask(task)
}
// 占用 running 守卫(nil 守卫直接放行,仅防御非 AddTask 构造的 Task)。
if task.running != nil && !task.running.CompareAndSwap(false, true) {
return fmt.Errorf("任务正在执行中: %s", name)
}
defer func() {
if task.running != nil {
task.running.Store(false)
}
}()
// runTask 执行任务
func (s *Scheduler) runTask(task *Task) error {
task.LastRun = time.Now()
task.RunCount++
err := task.Handler(s.ctx)
err := s.executeTask(task)
s.mu.Lock()
task.NextRun = task.Schedule.Next(time.Now())
task.LastRun = time.Now()
task.RunCount++
task.LastError = err // M13: 手动路径也记 LastError(与调度路径对齐)
s.mu.Unlock()
return err
@@ -153,34 +247,66 @@ func (s *Scheduler) runTask(task *Task) error {
// Start 启动调度器
func (s *Scheduler) Start() {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
select {
case <-s.ctx.Done():
s.ctx, s.cancel = newSchedulerContext()
default:
}
ctx := s.ctx
s.running = true
s.wg.Add(1)
s.mu.Unlock()
s.wg.Add(1)
go s.run()
go s.run(ctx)
}
// Stop 停止调度器
// Stop 停止调度器并无限等待在跑任务退出(要求 handler 尊重 ctx.Done)。
// 若担心某 handler 不响应 ctx 而永久阻塞,请用 StopWithTimeout。
func (s *Scheduler) Stop() {
s.StopWithTimeout(0)
}
// StopWithTimeout 停止调度器,最多等待 timeout 让在跑任务退出(P1 #10)。
// 返回 true 表示所有任务已退出;false 表示超时(仍有任务未响应 ctx.Done 而运行)。
// timeout<=0 等价于无限等待(同 Stop)。幂等:未运行时直接返回 true。
func (s *Scheduler) StopWithTimeout(timeout time.Duration) bool {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return
return true
}
s.running = false
cancel := s.cancel
s.mu.Unlock()
s.cancel()
s.wg.Wait()
cancel()
if timeout <= 0 {
s.wg.Wait()
return true
}
done := make(chan struct{})
go func() { s.wg.Wait(); close(done) }()
select {
case <-done:
return true
case <-time.After(timeout):
return false
}
}
// run 运行调度循环
func (s *Scheduler) run() {
func (s *Scheduler) run(ctx context.Context) {
defer s.wg.Done()
ticker := time.NewTicker(1 * time.Second)
@@ -188,25 +314,86 @@ func (s *Scheduler) run() {
for {
select {
case <-s.ctx.Done():
case <-ctx.Done():
return
case <-ticker.C:
s.checkAndRun()
s.checkAndRun(ctx)
}
}
}
// checkAndRun 检查并运行到期任务
func (s *Scheduler) checkAndRun() {
// checkAndRun 检查并运行到期任务
//
// C12bper-task running 守卫(CAS)防止长任务跨 tick 重叠 spawn。
// C12c:占用守卫后**先推进 NextRun**(以上次 NextRun 锚定,非 time.Now()),
// 避免每周期累积 handler 时长致调度漂移;推进在 spawn 前,下次 tick 不会重复 spawn。
// C12aNextRun 推进在写锁内;LastRun/RunCount 在 goroutine 内写锁更新。
//
// M-H 修复:锁内只收集到期任务(CAS 占用 + 推进 NextRun + wg.Add),锁外再 spawn。
// 原实现整个遍历+spawn 持写锁,task 多时阻塞 GetTask/AddTask/RunTask 等管理 API。
//
// wg.Add 必须在锁内完成:Stop 先持锁置 running=false 再 wg.Wait,若 wg.Add 在锁外,
// Stop 可能在两个 due 任务之间读到计数器 0 并提前返回,留下后启动的 goroutine 在调度器
// 停止后运行(其 wg.Done 还会触发计数器下溢 panic)。锁内 Add 保证 Stop 的 wg.Wait
// 一定能等到本批全部 goroutine。收集阶段已 CAS 占用守卫并推进 NextRun,故 spawn 必须
// 执行(否则守卫不释放、NextRun 已推进却未跑)。spawn 用 s.ctx——Stop 后 ctx 已取消,
// handler 收到 canceled ctx 应及时退出。
func (s *Scheduler) checkAndRun(ctxs ...context.Context) {
now := time.Now()
s.mu.RLock()
for _, task := range s.tasks {
if task.Enabled && !task.NextRun.IsZero() && now.After(task.NextRun) {
go s.runTask(task)
}
var ctx context.Context
enforceRunning := len(ctxs) > 0 && ctxs[0] != nil
if len(ctxs) > 0 && ctxs[0] != nil {
ctx = ctxs[0]
} else {
s.mu.RLock()
ctx = s.ctx
s.mu.RUnlock()
}
s.mu.Lock()
if enforceRunning && (!s.running || ctx.Err() != nil) {
s.mu.Unlock()
return
}
var due []*Task
for _, task := range s.tasks {
if !task.Enabled || task.NextRun.IsZero() || !now.After(task.NextRun) {
continue
}
// 占用 running 守卫;正在执行则跳过本轮(防重叠 C12b)。
if task.running != nil && !task.running.CompareAndSwap(false, true) {
continue
}
// 先推进 NextRun(以上次 NextRun 锚定防漂移 C12c),再收集待 spawn。
task.NextRun = task.Schedule.Next(task.NextRun)
s.wg.Add(1) // 锁内 Add,保证 Stop 的 wg.Wait 等到本批(见上方注释)
due = append(due, task)
}
s.mu.Unlock()
for _, t := range due {
go func(t *Task) {
defer s.wg.Done()
defer func() {
if t.running != nil {
t.running.Store(false)
}
}()
err := s.executeTaskWithContext(ctx, t)
s.mu.Lock()
t.LastRun = time.Now()
t.RunCount++
t.LastError = err // CR3 修复:记录错误不再静默丢弃
s.mu.Unlock()
if err != nil {
logger.Error("cron task failed",
zap.String("task", t.Name),
zap.Error(err))
}
}(t)
}
s.mu.RUnlock()
}
// IntervalSchedule 固定间隔调度
@@ -221,6 +408,7 @@ func (s *IntervalSchedule) Next(now time.Time) time.Time {
// Every 每隔指定时间运行
func Every(interval time.Duration) *IntervalSchedule {
validateInterval(interval)
return &IntervalSchedule{Interval: interval}
}
@@ -241,6 +429,7 @@ func (s *DailySchedule) Next(now time.Time) time.Time {
// Daily 每日指定时间运行
func Daily(hour, minute int) *DailySchedule {
validateClock(hour, minute, "Daily")
return &DailySchedule{Hour: hour, Minute: minute}
}
@@ -251,27 +440,35 @@ type WeeklySchedule struct {
Minute int
}
// Next 计算下次运行时间
// Next 计算下次运行时间
//
// C12d:原实现 `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻,
// 当天目标时刻未到(如周一 9:00 目标、当前周一 12:00 之前的 8:00)被错误跳一周。
// 改为:先算今天的目标时刻,按 `((day-now)+7)%7` 加天数,再与 now 比较——
// 当天未到点则本周,当天已过则下周。
func (s *WeeklySchedule) Next(now time.Time) time.Time {
daysUntil := int(s.Day) - int(now.Weekday())
if daysUntil <= 0 {
daysUntil += 7
next := time.Date(now.Year(), now.Month(), now.Day(), s.Hour, s.Minute, 0, 0, now.Location())
daysUntil := (int(s.Day) - int(now.Weekday()) + 7) % 7
next = next.AddDate(0, 0, daysUntil)
if !next.After(now) {
next = next.AddDate(0, 0, 7)
}
next := time.Date(now.Year(), now.Month(), now.Day()+daysUntil, s.Hour, s.Minute, 0, 0, now.Location())
return next
}
// Weekly 每周指定时间运行
func Weekly(day time.Weekday, hour, minute int) *WeeklySchedule {
validateWeekday(day)
validateClock(hour, minute, "Weekly")
return &WeeklySchedule{Day: day, Hour: hour, Minute: minute}
}
// FullCronSchedule 完整 Cron 表达式调度
// 格式: "分钟 小时 日 月 星期" (5字段)
// 示例: "0 12 * * *" 每天12点
// "0 0 1 * *" 每月1号凌晨
// "0 9-17 * * 1-5" 周一到周五 9点到17点每小时
//
// "0 0 1 * *" 每月1号凌晨
// "0 9-17 * * 1-5" 周一到周五 9点到17点每小时
type FullCronSchedule struct {
Minute string // 分钟: 0-59, "*", "*/n", "a-b", "a,b,c"
Hour string // 小时: 0-23, "*", "*/n", "a-b", "a,b,c"
@@ -306,42 +503,115 @@ func (s *FullCronSchedule) match(t time.Time) bool {
s.matchField(s.Weekday, int(t.Weekday()), 0, 6)
}
// matchField 匹配单个字段
// matchField 匹配单个字段C12e 重写)。
//
// 旧实现先判 `-` 再判 `*/` 最后列表,且 parseInt 忽略非数字逐位累积:
// - `1-5,8` 因整字段含 `-` 被当范围,parseInt("5,8")=58 → 范围被破坏为 1..58,列表项丢失。
// - `garbage` → parseInt=0,分/时/周字段 value=0 时误触发。
// - `*/garbage` → step=0 → return true 匹配全部。
// - 周日 `7` 不匹配(Go Sunday=0)。
//
// 新实现:先按逗号拆列表,每项独立判 `*/n` / `a-b/n` / `a-b` / 单值(列表分支独立于范围分支);
// 全部用 strconv.Atoi 返错;weekday 字段(min=0,max=67→0,范围 lo>hi 环绕。
func (s *FullCronSchedule) matchField(field string, value int, min, max int) bool {
if field == "*" {
return true
}
// 处理范围 "a-b"
if strings.Contains(field, "-") {
parts := strings.Split(field, "-")
if len(parts) == 2 {
start := parseInt(parts[0])
end := parseInt(parts[1])
return value >= start && value <= end
for _, raw := range strings.Split(field, ",") {
item := strings.TrimSpace(raw)
if item == "" {
continue
}
}
// 处理步长 "*/n"
if strings.HasPrefix(field, "*/") {
step := parseInt(strings.TrimPrefix(field, "*/"))
if step > 0 {
return (value - min) % step == 0
}
return true
}
// 处理列表 "a,b,c"
for _, p := range strings.Split(field, ",") {
if parseInt(strings.TrimSpace(p)) == value {
if matchCronItem(item, value, min, max) {
return true
}
}
return false
}
// ParseCron 解析完整 Cron 表达式
// matchCronItem 处理单个 cron 字段项(已按逗号拆分)。
func matchCronItem(item string, value, min, max int) bool {
isWeekday := min == 0 && max == 6
// 步长 "*/n" 或 "a-b/n"
if idx := strings.Index(item, "/"); idx >= 0 {
base := item[:idx]
step, err := strconv.Atoi(item[idx+1:])
if err != nil || step <= 0 {
return false
}
lo, hi := min, max
if base != "*" {
rlo, rhi, err := parseCronRange(base, min, max)
if err != nil {
return false
}
lo, hi = rlo, rhi
}
return value >= lo && value <= hi && (value-lo)%step == 0
}
// 范围 "a-b"
if strings.Contains(item, "-") {
lo, hi, err := parseCronRange(item, min, max)
if err != nil {
return false
}
if isWeekday && lo > hi {
// 环绕:lo..6 0..hi(如 "6-1" = 周六、周日、周一)
return (value >= lo && value <= max) || (value >= min && value <= hi)
}
return value >= lo && value <= hi
}
// 单值
v, err := strconv.Atoi(item)
if err != nil {
return false
}
if isWeekday && v == 7 {
v = 0
}
if v < min || v > max {
return false
}
return v == value
}
// parseCronRange 解析 "a-b" 范围,含边界校验与 weekday 7→0 归一化。
func parseCronRange(s string, min, max int) (int, int, error) {
parts := strings.SplitN(s, "-", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid range %q", s)
}
lo, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
hi, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err1 != nil || err2 != nil {
return 0, 0, fmt.Errorf("invalid range %q", s)
}
isWeekday := min == 0 && max == 6
if isWeekday {
// 7 → 0(周日)。但若两端归一化后都为 0 而原始值不同(如 "0-7"/"7-0"),
// 语义为"整周"却坍缩成"仅周日",属歧义范围,拒绝以免静默错误匹配。
nlo, nhi := lo, hi
if nlo == 7 {
nlo = 0
}
if nhi == 7 {
nhi = 0
}
if nlo == nhi && lo != hi {
return 0, 0, fmt.Errorf("ambiguous weekday range %q (0 与 7 均为周日)", s)
}
lo, hi = nlo, nhi
}
if lo < min || lo > max || hi < min || hi > max {
return 0, 0, fmt.Errorf("range out of bounds %q", s)
}
return lo, hi, nil
}
// ParseCron 解析完整 Cron 表达式。
// 格式: "分钟 小时 日 月 星期"
// 示例:
//
@@ -350,11 +620,50 @@ func (s *FullCronSchedule) matchField(field string, value int, min, max int) boo
// "0 9-17 * * 1-5" - 工作日9-17点每小时
// "0 0 1 * *" - 每月1号凌晨
// "0 0 * * 0" - 每周日凌晨
//
// 非法表达式会 panicfail-fast),动态输入请用 ParseCronStrict 处理 error。
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
func ParseCron(expr string) *FullCronSchedule {
if sched, err := ParseCronStrict(expr); err == nil {
return sched
} else {
panic(err)
}
}
// ParseCronOrDefault parses a Cron expression and falls back to all "*" (every
// minute) when expr is invalid. Prefer ParseCronStrict or ParseCron for new
// code; this helper exists for callers that intentionally want legacy fallback
// semantics.
func ParseCronOrDefault(expr string) *FullCronSchedule {
if sched, err := ParseCronStrict(expr); err == nil {
return sched
}
return &FullCronSchedule{"*", "*", "*", "*", "*"}
}
// ParseCronStrict 严格解析 Cron 表达式,校验字段数与各字段范围,非法返 error。
// 字段范围:分钟 0-59,小时 0-23,日 1-31,月 1-12,星期 0-6(周日=0,7 归一为 0)。
func ParseCronStrict(expr string) (*FullCronSchedule, error) {
fields := strings.Fields(expr)
if len(fields) != 5 {
// 默认每分钟执行
return &FullCronSchedule{"*", "*", "*", "*", "*"}
return nil, fmt.Errorf("cron: 需要 5 个字段,实际 %d", len(fields))
}
specs := []struct {
val string
min, max int
name string
}{
{fields[0], 0, 59, "minute"},
{fields[1], 0, 23, "hour"},
{fields[2], 1, 31, "day"},
{fields[3], 1, 12, "month"},
{fields[4], 0, 6, "weekday"},
}
for _, sp := range specs {
if err := validateCronField(sp.val, sp.min, sp.max, sp.name); err != nil {
return nil, err
}
}
return &FullCronSchedule{
Minute: fields[0],
@@ -362,7 +671,65 @@ func ParseCron(expr string) *FullCronSchedule {
Day: fields[2],
Month: fields[3],
Weekday: fields[4],
}, nil
}
// validateCronField 校验单个 cron 字段语法与范围。
func validateCronField(field string, min, max int, name string) error {
if field == "*" {
return nil
}
for _, raw := range strings.Split(field, ",") {
item := strings.TrimSpace(raw)
if item == "" {
return fmt.Errorf("cron %s: 空列表项", name)
}
if err := validateCronItem(item, min, max, name); err != nil {
return err
}
}
return nil
}
func validateCronItem(item string, min, max int, name string) error {
isWeekday := min == 0 && max == 6
norm := func(v int) int {
if isWeekday && v == 7 {
return 0
}
return v
}
if idx := strings.Index(item, "/"); idx >= 0 {
base := item[:idx]
step, err := strconv.Atoi(item[idx+1:])
if err != nil || step <= 0 {
return fmt.Errorf("cron %s: 非法步长 %q", name, item)
}
if base == "*" {
return nil
}
lo, hi, err := parseCronRange(base, min, max)
if err != nil {
return fmt.Errorf("cron %s: %v", name, err)
}
_ = lo
_ = hi
return nil
}
if strings.Contains(item, "-") {
if _, _, err := parseCronRange(item, min, max); err != nil {
return fmt.Errorf("cron %s: %v", name, err)
}
return nil
}
v, err := strconv.Atoi(item)
if err != nil {
return fmt.Errorf("cron %s: 非数字 %q", name, item)
}
if v = norm(v); v < min || v > max {
return fmt.Errorf("cron %s: %d 超出范围 [%d,%d]", name, v, min, max)
}
return nil
}
// CronSchedule 简化 Cron 调度(仅分钟和小时)
@@ -399,55 +766,53 @@ func (s *CronSchedule) matchValue(pattern string, value int) bool {
return false
}
// splitPattern 将逗号分隔的模式解析为值列表(C12e:用 strconv.Atoi,非法项跳过)。
func splitPattern(pattern string) []int {
var values []int
for _, p := range split(pattern, ',') {
v := parseInt(p)
if v >= 0 {
values = append(values, v)
for _, p := range strings.Split(pattern, ",") {
v, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
continue
}
values = append(values, v)
}
return values
}
func split(s string, sep byte) []string {
var parts []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == sep {
parts = append(parts, s[start:i])
start = i + 1
}
}
parts = append(parts, s[start:])
return parts
}
func parseInt(s string) int {
var v int
for _, c := range s {
if c >= '0' && c <= '9' {
v = v*10 + int(c-'0')
}
}
return v
}
// Cron 创建类 Cron 调度
func Cron(minute, hour string) *CronSchedule {
return &CronSchedule{Minute: minute, Hour: hour}
}
// 全局调度器
var globalScheduler *Scheduler
var schedulerOnce sync.Once
// 全局调度器。init 时预创建默认实例(与 storage.DefaultStorage / cache.defaultCachePtr
// 对齐),使 SwapDefaultScheduler 返回非 nil、App 回滚总能恢复一个有效默认。用 atomic.Pointer
// 保护并发读写,消除原 once+裸指针读写竞态。GetScheduler 仍保留懒初始化分支作防御。
var globalScheduler atomic.Pointer[Scheduler]
// GetScheduler 获取全局调度器
func init() {
globalScheduler.Store(NewScheduler())
}
// GetScheduler 获取全局调度器(并发安全;init 后通常非 nil,保留懒初始化作防御)。
func GetScheduler() *Scheduler {
schedulerOnce.Do(func() {
globalScheduler = NewScheduler()
})
return globalScheduler
if s := globalScheduler.Load(); s != nil {
return s
}
ns := NewScheduler()
if globalScheduler.CompareAndSwap(nil, ns) {
return ns
}
return globalScheduler.Load()
}
// SwapDefaultScheduler 将指定 Scheduler 置为全局默认,并返回被替换的旧调度器。
// 旧调度器不会被停止,供 App 初始化这类需要失败回滚的生命周期流程暂存(照
// database.SwapDefaultManager / SwapDefaultRedisManager 模式)。nil 被忽略,返回当前默认。
func SwapDefaultScheduler(s *Scheduler) *Scheduler {
if s == nil {
return globalScheduler.Load()
}
return globalScheduler.Swap(s)
}
// AddTask 添加任务到全局调度器
@@ -460,7 +825,16 @@ func Start() {
GetScheduler().Start()
}
// Stop 停止全局调度器
// Stop 停止全局调度器(无限等待,见 Scheduler.Stop)。
func Stop() {
GetScheduler().Stop()
}
}
// StopGlobalWithTimeout 停止全局调度器并最多等待 timeout(P1 #10)。
// 全局调度器尚未创建时无操作返回 true,不会因此惰性创建它——供 App.Shutdown 安全调用。
func StopGlobalWithTimeout(timeout time.Duration) bool {
if s := globalScheduler.Load(); s != nil {
return s.StopWithTimeout(timeout)
}
return true
}
+190
View File
@@ -0,0 +1,190 @@
package cron
import (
"context"
"sync/atomic"
"testing"
"time"
)
// stepSchedule 是一个固定步长调度,Next(now)=now+step,用于确定性测试 C12c 锚定。
type stepSchedule struct{ step time.Duration }
func (s stepSchedule) Next(now time.Time) time.Time { return now.Add(s.step) }
// TestC12bNoOverlapManualDrive 验证 per-task running 守卫防止重叠执行。
//
// 修复前:checkAndRun 无 running 守卫,handler 未完成时下一次 checkAndRunNextRun 仍为过去)
// 会再次 spawn 同一任务,并发执行。
// 手动驱动两轮 checkAndRunhandler 阻塞至释放——修复后第二轮被 running 守卫拦截,仅 1 次 spawn。
func TestC12bNoOverlapManualDrive(t *testing.T) {
s := NewScheduler()
var started int32
release := make(chan struct{})
task := s.AddTask("block", stepSchedule{step: time.Microsecond}, func(ctx context.Context) error {
atomic.AddInt32(&started, 1)
<-release
return nil
})
task.NextRun = time.Now().Add(-time.Hour) // 过去 → 到期
s.checkAndRun() // 第一轮:spawnhandler 阻塞
for atomic.LoadInt32(&started) == 0 {
time.Sleep(time.Millisecond)
}
s.checkAndRun() // 第二轮:修复后 running 守卫拦截,不再 spawn
time.Sleep(50 * time.Millisecond) // 给潜在二次 spawn 启动时间
if n := atomic.LoadInt32(&started); n != 1 {
t.Errorf("task overlapped: started = %d, want 1 (C12b)", n)
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for task.running != nil && task.running.Load() {
if time.Now().After(deadline) {
t.Fatalf("running guard never released")
}
time.Sleep(time.Millisecond)
}
}
// TestC12cNextRunAnchoredOnPrevious 验证 NextRun 以上次 NextRun 锚定(不漂移)。
//
// 修复前:NextRun = schedule.Next(time.Now())handler 完成后的 now),每周期累积 handler 时长。
// 修复后:NextRun = schedule.Next(task.NextRun)(上次计划时间锚定)。
//
// 将 NextRun 设到过去的固定锚点 T0,手动驱动 3 轮 checkAndRun(每轮等 handler 完成),
// 断言 NextRun == T0 + 3*step(锚定)。漂移实现下 NextRun ≈ now+step(远大于 T0+3*step)。
func TestC12cNextRunAnchoredOnPrevious(t *testing.T) {
s := NewScheduler()
step := 100 * time.Millisecond
done := make(chan struct{}, 16)
task := s.AddTask("anchored", stepSchedule{step: step}, func(ctx context.Context) error {
// 模拟 handler 耗时——修复后不应影响 NextRun 锚定。
time.Sleep(50 * time.Millisecond)
done <- struct{}{}
return nil
})
// 将 NextRun 设到过去的固定锚点 T0(远早于 now,确保每轮都到期触发)。
T0 := time.Now().Add(-10 * time.Second)
task.NextRun = T0
for i := 1; i <= 3; i++ {
s.checkAndRun()
// 等 handler 执行;若 NextRun 未锚定(漂移实现下 NextRun 跳到未来,
// 后续 checkAndRun 不再到期),done 不会收到 → 超时明确失败而非挂起。
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("iter %d: handler did not fire (NextRun drifted to future, not anchored on previous) (C12c)", i)
}
// 等 running 守卫释放(defer 在 handler 返回后置 false)。
deadline := time.Now().Add(2 * time.Second)
for task.running != nil && task.running.Load() {
if time.Now().After(deadline) {
t.Fatalf("running guard never released (iter %d)", i)
}
time.Sleep(time.Millisecond)
}
}
got := task.NextRun
want := T0.Add(3 * step)
if diff := got.Sub(want); diff < -5*time.Millisecond || diff > 5*time.Millisecond {
t.Errorf("NextRun not anchored on previous: got %v, want %v (diff %v, C12c drift)", got, want, diff)
}
}
// C12e 直接测试 matchField(未导出,故 internal test)。
//
// 修复前:matchField 先判 `-` 把整字段当范围,parseInt 忽略非数字逐位累积:
// - "1-5,8" 含 `-` → parseInt("5,8")=58 → 范围 1..58 全匹配,列表项 8 丢失。
// - "garbage" → parseInt=0value=0 误触发。
// - "*/garbage" → step=0 → return true 匹配全部。
var schedForMatch = &FullCronSchedule{}
func TestC12eMatchFieldListAndRangeIndependent(t *testing.T) {
// "1-5,8" 仅匹配 1,2,3,4,5,8。
for _, v := range []int{1, 2, 3, 4, 5, 8} {
if !schedForMatch.matchField("1-5,8", v, 0, 59) {
t.Errorf("1-5,8 should match %d (C12e)", v)
}
}
for _, v := range []int{0, 6, 7, 9, 30, 58} {
if schedForMatch.matchField("1-5,8", v, 0, 59) {
t.Errorf("1-5,8 should NOT match %d (C12e range broken to 1..58)", v)
}
}
}
func TestC12eMatchFieldGarbageNotMatch(t *testing.T) {
for v := 0; v <= 59; v++ {
if schedForMatch.matchField("garbage", v, 0, 59) {
t.Errorf("garbage should not match any value, matched %d (C12e)", v)
}
}
}
func TestC12eMatchFieldStarSlashGarbageNotMatchAll(t *testing.T) {
// 修复前 step=0 → return true 匹配全部。
matched := 0
for v := 0; v <= 59; v++ {
if schedForMatch.matchField("*/garbage", v, 0, 59) {
matched++
}
}
if matched != 0 {
t.Errorf("*/garbage should match nothing, matched %d (C12e step=0 bug)", matched)
}
}
func TestC12eMatchFieldWeekday7IsSunday(t *testing.T) {
// weekday 字段 7 → 0(周日)。
if !schedForMatch.matchField("7", 0, 0, 6) {
t.Error("weekday 7 should match Sunday(0) (C12e)")
}
if schedForMatch.matchField("7", 7, 0, 6) {
t.Error("weekday 7 should not match value 7 (out of range after normalize)")
}
}
func TestC12eMatchFieldRangeAndStepAndList(t *testing.T) {
// 范围 9-17。
for _, v := range []int{9, 12, 17} {
if !schedForMatch.matchField("9-17", v, 0, 23) {
t.Errorf("9-17 should match %d", v)
}
}
for _, v := range []int{8, 18} {
if schedForMatch.matchField("9-17", v, 0, 23) {
t.Errorf("9-17 should NOT match %d", v)
}
}
// 步长 */15。
for _, v := range []int{0, 15, 30, 45} {
if !schedForMatch.matchField("*/15", v, 0, 59) {
t.Errorf("*/15 should match %d", v)
}
}
for _, v := range []int{1, 7, 16} {
if schedForMatch.matchField("*/15", v, 0, 59) {
t.Errorf("*/15 should NOT match %d", v)
}
}
// 范围步长 9-17/2 → 9,11,13,15,17。
for _, v := range []int{9, 11, 13, 15, 17} {
if !schedForMatch.matchField("9-17/2", v, 0, 23) {
t.Errorf("9-17/2 should match %d", v)
}
}
for _, v := range []int{10, 12, 14, 16} {
if schedForMatch.matchField("9-17/2", v, 0, 23) {
t.Errorf("9-17/2 should NOT match %d", v)
}
}
}
+321
View File
@@ -0,0 +1,321 @@
package cron_test
import (
"context"
"sync"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/cron"
)
// ============================================================
// C12arunTask 计数写入无锁 + GetTask/ListTasks 返回 live 指针 → data race
// ============================================================
// TestC12aConcurrentReadWriteNoRace 验证并发 RunTask/GetTask/ListTasks + 调度运行
// 无数据竞争(-race)。
//
// 修复前:runTask 无锁写 LastRun/RunCountGetTask/ListTasks 返回 live 指针并发读 →
// -race 必采到 DATA RACE。修复后:写入纳入锁、Getter 返回拷贝。
func TestC12aConcurrentReadWriteNoRace(t *testing.T) {
scheduler := cron.NewScheduler()
scheduler.AddTask("t1", cron.Every(50*time.Millisecond), func(ctx context.Context) error {
return nil
})
scheduler.AddTask("t2", cron.Every(80*time.Millisecond), func(ctx context.Context) error {
return nil
})
scheduler.Start()
t.Cleanup(scheduler.Stop)
stop := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
// 读取返回拷贝的字段——若写侧无锁,与此处读竞争(C12a)。
if g, err := scheduler.GetTask("t1"); err == nil {
_ = g.RunCount
_ = g.LastRun
}
for _, tk := range scheduler.ListTasks() {
_ = tk.RunCount
_ = tk.LastRun
}
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = scheduler.RunTask("t2")
}
}()
time.Sleep(300 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestC12aGetTaskReturnsCopy 验证 GetTask 返回拷贝,修改返回值不影响内部状态。
func TestC12aGetTaskReturnsCopy(t *testing.T) {
scheduler := cron.NewScheduler()
scheduler.AddTask("t", cron.Every(time.Minute), func(ctx context.Context) error {
return nil
})
got, err := scheduler.GetTask("t")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
got.RunCount = 9999
got.Enabled = false
again, _ := scheduler.GetTask("t")
if again.RunCount == 9999 {
t.Error("GetTask returned live pointer (RunCount mutated internally)")
}
if !again.Enabled {
t.Error("GetTask returned live pointer (Enabled mutated internally)")
}
}
// TestC12aListTasksReturnsCopies 验证 ListTasks 元素为拷贝。
func TestC12aListTasksReturnsCopies(t *testing.T) {
scheduler := cron.NewScheduler()
scheduler.AddTask("t", cron.Every(time.Minute), func(ctx context.Context) error {
return nil
})
list := scheduler.ListTasks()
list[0].RunCount = 777
again := scheduler.ListTasks()
if again[0].RunCount == 777 {
t.Error("ListTasks returned live pointer (RunCount mutated internally)")
}
}
// ============================================================
// C12b:长任务跨 tick 重叠执行
// ============================================================
// TestC12bRunTaskConcurrentManualTriggerReturnsError 验证手动 RunTask 占用守卫期间,
// 再次 RunTask 返"任务正在执行中"错误。
func TestC12bRunTaskConcurrentManualTriggerReturnsError(t *testing.T) {
scheduler := cron.NewScheduler()
started := make(chan struct{})
release := make(chan struct{})
scheduler.AddTask("block", cron.Every(time.Hour), func(ctx context.Context) error {
close(started)
<-release
return nil
})
var firstErr error
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
firstErr = scheduler.RunTask("block")
}()
<-started
secondErr := scheduler.RunTask("block")
if secondErr == nil {
t.Error("second RunTask should fail while first is running (C12b running guard)")
}
close(release)
wg.Wait()
if firstErr != nil {
t.Errorf("first RunTask error: %v", firstErr)
}
}
// ============================================================
// C12d:Weekly 当天未到点目标被跳一周
// ============================================================
func TestC12dWeeklySameDayBeforeTarget(t *testing.T) {
schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0}
// 周一 8:00(目标 9:00 未到)→ 应返回本周一 9:00,不跳周。
now := time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC) // 2026-06-29 是周一
if now.Weekday() != time.Monday {
t.Fatalf("test fixture: expected Monday, got %v", now.Weekday())
}
next := schedule.Next(now)
want := time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC)
if !next.Equal(want) {
t.Errorf("Weekly same-day-before-target: got %v, want %v (C12d skip-week bug)", next, want)
}
}
func TestC12dWeeklySameDayAfterTarget(t *testing.T) {
schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0}
// 周一 10:00(目标 9:00 已过)→ 下周一 9:00。
now := time.Date(2026, 6, 29, 10, 0, 0, 0, time.UTC)
next := schedule.Next(now)
want := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC)
if !next.Equal(want) {
t.Errorf("Weekly same-day-after-target: got %v, want %v", next, want)
}
}
func TestC12dWeeklyCrossWeek(t *testing.T) {
schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0}
// 周三 → 下周一。
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) // 周三
if now.Weekday() != time.Wednesday {
t.Fatalf("test fixture: expected Wednesday, got %v", now.Weekday())
}
next := schedule.Next(now)
want := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC)
if !next.Equal(want) {
t.Errorf("Weekly cross-week: got %v, want %v", next, want)
}
}
// ============================================================
// C12ecron 解析缺陷(周日 7 / 范围环绕 / 严格解析)
// 1-5,8 / garbage / */garbage 的 matchField 直接测试见 cron_c12_internal_test.go
// ============================================================
func TestC12eWeekdaySundayAs7(t *testing.T) {
schedule := cron.FullCronSchedule{
Minute: "0",
Hour: "0",
Day: "*",
Month: "*",
Weekday: "7",
}
// 0 0 * * 7 → 每周日凌晨。从周六 23:00 找下一个匹配应落周日 00:00。
now := time.Date(2026, 6, 27, 23, 0, 0, 0, time.UTC) // 周六
if now.Weekday() != time.Saturday {
t.Fatalf("test fixture: expected Saturday, got %v", now.Weekday())
}
next := schedule.Next(now)
if next.Weekday() != time.Sunday {
t.Errorf("0 0 * * 7 should land on Sunday, got %v (C12e 7≠Sunday)", next.Weekday())
}
if next.Hour() != 0 || next.Minute() != 0 {
t.Errorf("0 0 * * 7 should land on 00:00, got %v", next)
}
}
func TestC12eWeekdayRangeWraparound(t *testing.T) {
// "6-1" 在 weekday 环绕:周六(6)、周日(0)、周一(1)。
schedule := cron.FullCronSchedule{
Minute: "0",
Hour: "0",
Day: "*",
Month: "*",
Weekday: "6-1",
}
for _, w := range []time.Weekday{time.Saturday, time.Sunday, time.Monday} {
tt := time.Date(2026, 6, 29, 0, 0, 0, 0, time.UTC) // 周一
for tt.Weekday() != w {
tt = tt.AddDate(0, 0, 1)
}
next := schedule.Next(tt.Add(-1 * time.Minute))
if next.Weekday() != w {
t.Errorf("6-1 should match %v, got %v", w, next.Weekday())
}
}
// 周二不应被 6-1 匹配,下一个应是周六。
tt := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) // 周二
if tt.Weekday() != time.Tuesday {
t.Fatalf("test fixture: expected Tuesday, got %v", tt.Weekday())
}
next := schedule.Next(tt.Add(-1 * time.Minute))
if next.Weekday() != time.Saturday {
t.Errorf("6-1 should skip Tuesday, next match %v, want Saturday", next.Weekday())
}
}
// TestC12eParseCronStrict 验证严格解析。
func TestC12eParseCronStrict(t *testing.T) {
cases := []struct {
expr string
ok bool
}{
{"0 12 * * *", true},
{"*/15 * * * *", true},
{"0 9-17 * * 1-5", true},
{"0 0 1 * 7", true}, // 周日 7 合法
{"0 0 * * 0-7", false},
{"invalid", false},
{"1-5,8 0 * * *", true},
{"60 0 * * *", false}, // 分钟越界
{"0 25 * * *", false}, // 小时越界
{"0 0 0 * *", false}, // 日越界
{"0 0 * 13 *", false}, // 月越界
{"0 0 * * 9", false}, // 周越界
{"garbage 0 * * *", false},
{"*/0 * * * *", false}, // step=0 非法
}
for _, c := range cases {
_, err := cron.ParseCronStrict(c.expr)
if c.ok && err != nil {
t.Errorf("ParseCronStrict(%q) unexpected error: %v", c.expr, err)
}
if !c.ok && err == nil {
t.Errorf("ParseCronStrict(%q) expected error, got nil", c.expr)
}
}
}
// TestC12eParseCronFallback verifies ParseCron now fails fast on invalid input.
func TestC12eParseCronFallback(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("ParseCron(invalid) should panic")
}
}()
_ = cron.ParseCron("invalid")
}
func TestC12eParseCronOrDefaultFallback(t *testing.T) {
s := cron.ParseCronOrDefault("invalid")
if s.Minute != "*" || s.Hour != "*" || s.Day != "*" || s.Month != "*" || s.Weekday != "*" {
t.Errorf("ParseCronOrDefault(invalid) should fall back to all-*, got %+v", s)
}
// 合法表达式不回退。
s = cron.ParseCron("1-5,8 0 * * *")
if s.Minute != "1-5,8" {
t.Errorf("ParseCron valid: Minute = %q, want 1-5,8", s.Minute)
}
}
// TestC12eCronScheduleGarbageNoMatch 验证简化 Cron 不再 parseInt 容错为 0。
func TestC12eCronScheduleGarbageNoMatch(t *testing.T) {
bad := cron.CronSchedule{Minute: "garbage", Hour: "9"}
// garbage → splitPattern 返空 → matchMinute 返 false → 24h 内无匹配。
now := time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC)
next := bad.Next(now)
if !next.IsZero() {
t.Errorf("CronSchedule garbage minute should not match, got next=%v (C12e)", next)
}
}
+168
View File
@@ -0,0 +1,168 @@
package cron
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
)
func mustPanicM13(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Fatalf("%s should panic", name)
}
}()
fn()
}
// TestCronRunTaskPanicRecovered_M13 回归:RunTask handler panic 时,executeTask 边界
// recover 转为 error 返回调用方并记入 LastError,不崩进程;running 守卫随后释放。
// 修复前:panic 直接上抛 → 测试进程崩溃。
func TestCronRunTaskPanicRecovered_M13(t *testing.T) {
s := NewScheduler()
s.AddTask("panic", Every(time.Minute), func(context.Context) error { panic("boom") })
err := s.RunTask("panic")
if err == nil || !strings.Contains(err.Error(), "panic recovered") {
t.Fatalf("RunTask err = %v, want a panic-recovered error", err)
}
got, _ := s.GetTask("panic")
if got.LastError == nil || !strings.Contains(got.LastError.Error(), "panic recovered") {
t.Fatalf("LastError = %v, want panic-recovered error", got.LastError)
}
// running 守卫必须已释放:再次 RunTask 不应返“任务正在执行中”(会再次 panic→recover)。
err2 := s.RunTask("panic")
if err2 != nil && strings.Contains(err2.Error(), "任务正在执行中") {
t.Fatal("running guard not released after panic (second RunTask blocked)")
}
}
// TestCronRunTaskRecordsLastError_M13 回归:RunTask 手动路径此前只更 LastRun/RunCount
// 不记 LastError。修复后与调度路径一致,正常 error 也记入 LastError。
func TestCronRunTaskRecordsLastError_M13(t *testing.T) {
s := NewScheduler()
sentinel := errors.New("sentinel fail")
s.AddTask("err", Every(time.Minute), func(context.Context) error { return sentinel })
err := s.RunTask("err")
if !errors.Is(err, sentinel) {
t.Fatalf("RunTask err = %v, want sentinel", err)
}
got, _ := s.GetTask("err")
if !errors.Is(got.LastError, sentinel) {
t.Fatalf("LastError = %v, want sentinel", got.LastError)
}
if got.RunCount != 1 {
t.Fatalf("RunCount = %d, want 1", got.RunCount)
}
}
// TestCronCheckAndRunPanicRecovered_M13 回归:调度 goroutine 内 handler panic 时,
// recover 防止进程崩溃;同 tick 派生的兄弟 goroutine 不受影响;无 goroutine 泄漏。
// 同包测试直接驱动 checkAndRun + wg.Wait,零 sleep、确定性。
// 修复前:派生 goroutine 内未 recover 的 panic 终止测试进程。
func TestCronCheckAndRunPanicRecovered_M13(t *testing.T) {
s := NewScheduler()
var ran atomic.Int32
s.AddTask("panic", Every(time.Millisecond), func(context.Context) error { panic("boom") })
s.AddTask("canary", Every(time.Millisecond), func(context.Context) error {
ran.Add(1)
return nil
})
// AddTask 设 NextRun=now+1ms;手动改到过去确保两者 due,跨过 1s ticker 的不确定性。
s.mu.Lock()
s.tasks["panic"].NextRun = time.Now().Add(-time.Second)
s.tasks["canary"].NextRun = time.Now().Add(-time.Second)
s.mu.Unlock()
s.checkAndRun()
s.wg.Wait() // 等派生的两个 goroutine 退出,证无泄漏
pt, _ := s.GetTask("panic")
if pt.LastError == nil || !strings.Contains(pt.LastError.Error(), "panic recovered") {
t.Fatalf("panic task LastError = %v, want panic-recovered error", pt.LastError)
}
if ran.Load() == 0 {
t.Fatal("canary did not run — sibling goroutine killed by panic?")
}
}
func TestCronAddTaskRejectsNilScheduleOrHandler_M13(t *testing.T) {
s := NewScheduler()
mustPanicM13(t, "nil schedule", func() {
s.AddTask("nil-schedule", nil, func(context.Context) error { return nil })
})
mustPanicM13(t, "nil handler", func() {
s.AddTask("nil-handler", Every(time.Minute), nil)
})
}
func TestCronScheduleConstructorsRejectInvalidBounds_M13(t *testing.T) {
mustPanicM13(t, "Every(0)", func() { Every(0) })
mustPanicM13(t, "Every(negative)", func() { Every(-time.Second) })
mustPanicM13(t, "Daily hour", func() { Daily(24, 0) })
mustPanicM13(t, "Daily minute", func() { Daily(23, 60) })
mustPanicM13(t, "Weekly day", func() { Weekly(time.Weekday(9), 9, 0) })
mustPanicM13(t, "Weekly hour", func() { Weekly(time.Monday, -1, 0) })
mustPanicM13(t, "Weekly minute", func() { Weekly(time.Monday, 9, -1) })
}
func TestCronAddTaskRejectsInvalidExportedScheduleValues_M13(t *testing.T) {
s := NewScheduler()
mustPanicM13(t, "invalid interval schedule", func() {
s.AddTask("bad-interval", &IntervalSchedule{}, func(context.Context) error { return nil })
})
mustPanicM13(t, "invalid daily schedule", func() {
s.AddTask("bad-daily", &DailySchedule{Hour: 99}, func(context.Context) error { return nil })
})
mustPanicM13(t, "invalid weekly schedule", func() {
s.AddTask("bad-weekly", &WeeklySchedule{Day: time.Weekday(8)}, func(context.Context) error { return nil })
})
}
func TestCronStartAfterStopRebuildsContext_M13(t *testing.T) {
s := NewScheduler()
s.AddTask("ctx", Every(time.Hour), func(ctx context.Context) error {
return ctx.Err()
})
s.Start()
s.Stop()
s.Start()
t.Cleanup(s.Stop)
if err := s.RunTask("ctx"); err != nil {
t.Fatalf("RunTask after Stop/Start got ctx err = %v, want nil", err)
}
}
func TestCronCheckAndRunSkipsAfterStopContextCanceled_M13(t *testing.T) {
s := NewScheduler()
var ran atomic.Int32
s.AddTask("due", Every(time.Minute), func(context.Context) error {
ran.Add(1)
return nil
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
s.mu.Lock()
s.running = false
s.tasks["due"].NextRun = time.Now().Add(-time.Second)
s.mu.Unlock()
s.checkAndRun(ctx)
s.wg.Wait()
if got := ran.Load(); got != 0 {
t.Fatalf("task ran %d times after stopped/canceled ctx, want 0", got)
}
}
+9 -5
View File
@@ -232,10 +232,14 @@ func TestParseCron(t *testing.T) {
}
// 无效表达式返回默认
schedule = cron.ParseCron("invalid")
if schedule.Minute != "*" || schedule.Hour != "*" {
t.Error("ParseCron invalid should return default")
}
func() {
defer func() {
if recover() == nil {
t.Fatal("ParseCron invalid should panic")
}
}()
_ = cron.ParseCron("invalid")
}()
}
func TestFullCronScheduleMatch(t *testing.T) {
@@ -261,4 +265,4 @@ func TestFullCronScheduleMatch(t *testing.T) {
if next.Minute() != 10 || next.Hour() != 10 {
t.Errorf("Next after 10:07 should be 10:10, got %v", next)
}
}
}
+184
View File
@@ -0,0 +1,184 @@
package database
import (
"errors"
"fmt"
"strings"
"sync"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// 内置驱动常量(更多驱动可通过 RegisterDialect 扩展)
const (
DriverMySQL = config.DriverMySQL
DriverPostgres = config.DriverPostgres
)
// DialectorFactory 根据 DSN 返回 GORM Dialector
type DialectorFactory func(dsn string) gorm.Dialector
// DialectSpec 描述一种数据库方言:如何建立连接 + 如何拼接 DSN
type DialectSpec struct {
// Name 驱动主名称(如 "mysql"、"postgres"、"sqlite"),大小写不敏感
Name string
// Aliases 驱动别名(如 postgres 的 "postgresql"、"pg"
Aliases []string
// Dialector 由 DSN 构造 GORM Dialector
Dialector DialectorFactory
// DSN 由 DatabaseConfig 拼接连接字符串。可选。
// 不提供时仅 CustomDSN 能直接生效;需要由配置字段拼接连接串的自定义驱动应显式提供该函数。
DSN config.DSNBuilder
}
var (
dialectsMu sync.RWMutex
dialects = map[string]DialectorFactory{}
)
// RegisterDialect 注册一种数据库方言。
// 同时把 DSN 构建器登记到 config 包,使 cfg.Database.DSN() 也能识别新驱动。
// 已注册的同名驱动会被覆盖。
//
// 用法示例(接入 SQLite):
//
// import "gorm.io/driver/sqlite"
//
// database.RegisterDialect(database.DialectSpec{
// Name: "sqlite",
// Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
// DSN: func(c *config.DatabaseConfig) string { return c.Name }, // 文件路径
// })
func RegisterDialect(spec DialectSpec) {
if spec.Dialector == nil || strings.TrimSpace(spec.Name) == "" {
return
}
dialectsMu.Lock()
for _, n := range append([]string{spec.Name}, spec.Aliases...) {
key := normalizeDriver(n)
if key != "" {
dialects[key] = spec.Dialector
}
}
dialectsMu.Unlock()
if spec.DSN != nil {
config.RegisterDSNBuilder(spec.Name, spec.DSN, spec.Aliases...)
}
}
// LookupDialect 查找已注册的 Dialector 工厂
func LookupDialect(driver string) (DialectorFactory, bool) {
key := normalizeDriver(driver)
dialectsMu.RLock()
defer dialectsMu.RUnlock()
f, ok := dialects[key]
return f, ok
}
// RegisteredDialects 返回所有已注册的驱动名(用于诊断)
func RegisteredDialects() []string {
dialectsMu.RLock()
defer dialectsMu.RUnlock()
names := make([]string, 0, len(dialects))
for k := range dialects {
names = append(names, k)
}
return names
}
// Dialector 根据配置返回 GORM Dialector。
// 驱动由 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 {
normalized := normalizeDriver(driver)
if normalized == "" {
normalized = DriverMySQL
}
if f, ok := LookupDialect(normalized); ok {
return f(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))
}
// driverDescription 返回带别名提示的驱动描述(用于错误信息和日志)
func driverDescription(driver string) string {
key := normalizeDriver(driver)
if key == "" {
return DriverMySQL + " (default)"
}
if _, ok := LookupDialect(key); ok {
return key
}
return fmt.Sprintf("%s (unregistered)", key)
}
func init() {
// 内置 MySQL
RegisterDialect(DialectSpec{
Name: DriverMySQL,
Dialector: func(dsn string) gorm.Dialector { return mysql.Open(dsn) },
DSN: func(c *config.DatabaseConfig) string { return c.MySQLDSN() },
})
// 内置 PostgreSQL
RegisterDialect(DialectSpec{
Name: DriverPostgres,
Aliases: []string{"postgresql", "pg"},
Dialector: func(dsn string) gorm.Dialector { return postgres.Open(dsn) },
DSN: func(c *config.DatabaseConfig) string { return c.PostgresDSN() },
})
}
+914
View File
@@ -0,0 +1,914 @@
package database
import (
"context"
cryptorand "crypto/rand"
"database/sql"
"errors"
"fmt"
"math/big"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
"go.uber.org/zap"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
type dbModeContextKey struct{}
// txContextKey 携带外层事务的 *gorm.DB,使 repository 等上层在调用时能 join 到外层事务
// H6c:外层 ctx 事务无法 join)。由 WithTx 注入、TxFromContext 读取。
type txContextKey struct{}
const (
dbModeMaster = "master"
dbModeReplica = "replica"
)
func normalizeContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
// ReplicaPicker 从库选择策略
type ReplicaPicker interface {
Pick(replicas []*gorm.DB) *gorm.DB
}
// RoundRobinPicker 轮询选择从库
type RoundRobinPicker struct {
mu sync.Mutex
counter int
}
// Pick 轮询选择一个从库
func (p *RoundRobinPicker) Pick(replicas []*gorm.DB) *gorm.DB {
if len(replicas) == 0 {
return nil
}
p.mu.Lock()
idx := p.counter % len(replicas)
p.counter = (idx + 1) % len(replicas)
p.mu.Unlock()
return replicas[idx]
}
// RandomPicker 随机选择从库。
//
// 使用 crypto/rand 生成随机索引,并发安全且不可预测。len(replicas)<=0 返回 nil
// crypto/rand 失败(极罕见,如熵池耗尽)时回退到 replicas[0],保证可用性。
type RandomPicker struct{}
// Pick 随机选择一个从库
func (p *RandomPicker) Pick(replicas []*gorm.DB) *gorm.DB {
if len(replicas) == 0 {
return nil
}
n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(len(replicas))))
if err != nil {
return replicas[0]
}
return replicas[int(n.Int64())]
}
// Manager 数据库管理器,持有主库与从库连接实例
type Manager struct {
cfg *config.Config
master *gorm.DB
replicas []*gorm.DB
picker ReplicaPicker
mu sync.Mutex
// opMu 串行化 InitDB / InitDBWithReplicas / Close 这类资源生命周期操作。
// 避免关闭已开始后初始化又发布新连接,或初始化发布后被并发 Close 置空。
opMu sync.Mutex
// #21 健康自愈
healthy atomic.Bool // 主库是否健康
replicaHealthy []atomic.Bool // 每个从库的健康标记,索引与 replicas 对齐
probeFailures int // 主库连续探活失败次数
probeMu sync.Mutex // 保护 probeFailures
replicaHealthSet bool // replicaHealthy 是否已按 replicas 长度初始化
}
// NewManager 创建数据库管理器
func NewManager(cfg *config.Config) *Manager {
return &Manager{cfg: cfg, picker: &RandomPicker{}}
}
// getCfg 在锁内读取 m.cfgP1 #11:消除与 InitDB 写 m.cfg 的数据竞争)。
func (m *Manager) getCfg() *config.Config {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg
}
// setCfg 在锁内写入 m.cfgP1 #11)。
func (m *Manager) setCfg(cfg *config.Config) {
m.mu.Lock()
m.cfg = cfg
m.mu.Unlock()
}
// SetPicker 设置从库选择策略
func (m *Manager) SetPicker(p ReplicaPicker) {
if p == nil {
return
}
m.mu.Lock()
m.picker = p
m.mu.Unlock()
}
// Picker 返回当前从库选择策略
func (m *Manager) Picker() ReplicaPicker {
m.mu.Lock()
defer m.mu.Unlock()
return m.picker
}
// Master 返回主库实例。
// 经 m.mu 锁保护读取,避免与 InitDB/Close 的写竞争返回已关闭/nil 池(C11d)。
func (m *Manager) Master() *gorm.DB {
m.mu.Lock()
defer m.mu.Unlock()
return m.master
}
// Replicas 返回所有从库实例的拷贝。
// 经 m.mu 锁保护读取并返回拷贝,避免调用方持活切片与 InitDBWithReplicas/Close 重置竞争(C11d)。
func (m *Manager) Replicas() []*gorm.DB {
m.mu.Lock()
defer m.mu.Unlock()
if m.replicas == nil {
return nil
}
out := make([]*gorm.DB, len(m.replicas))
copy(out, m.replicas)
return out
}
// Replica 按策略选择一个从库;无从库时返回主库。
// #21:启用探活后,自动过滤不健康的从库;全不健康时回退到全部从库(仍可服务)。
// 全程持 m.mu 锁,避免 replicas/master 与重建路径写竞争(C11d)。
func (m *Manager) Replica() *gorm.DB {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.replicas) == 0 {
return m.master
}
pool := m.replicas
// 启用探活且至少有一个健康标记时,仅从健康从库中选取
if m.replicaHealthSet {
var healthy []*gorm.DB
for i, r := range m.replicas {
if i < len(m.replicaHealthy) && m.replicaHealthy[i].Load() {
healthy = append(healthy, r)
}
}
if len(healthy) > 0 {
pool = healthy
}
// healthy 为空时回退到全部 replicas,避免读流量完全中断
}
if m.picker != nil {
if db := m.picker.Pick(pool); db != nil {
return db
}
}
return pool[0]
}
// IsHealthy 返回主库当前健康状态(#21)。供 readiness/health 探针联动。
func (m *Manager) IsHealthy() bool {
return m.healthy.Load()
}
// initReplicaHealth 按 replicas 数量初始化健康标记(全部为健康)。
// 已初始化(replicaHealthSet=true)时早返回;重建从库前须先 resetReplicaHealth 重置,
// 否则健康切片长度与新 replicas 错位(C11a)。
func (m *Manager) initReplicaHealth() {
m.mu.Lock()
defer m.mu.Unlock()
if m.replicaHealthSet {
return
}
m.replicaHealthy = make([]atomic.Bool, len(m.replicas))
for i := range m.replicaHealthy {
m.replicaHealthy[i].Store(true)
}
m.replicaHealthSet = true
}
// ensureReplicaHealthLocked 按当前 replicas 重建健康标记。调用方须持有 m.mu。
func (m *Manager) ensureReplicaHealthLocked() {
m.replicaHealthy = make([]atomic.Bool, len(m.replicas))
for i := range m.replicaHealthy {
m.replicaHealthy[i].Store(true)
}
m.replicaHealthSet = true
}
// resetReplicaHealth 清空从库健康标记,使下次 initReplicaHealth 按新 replicas 长度重建。
// 重建从库(InitDBWithReplicas/Close 前必须调用,避免健康切片与新 replicas 长度错位(C11a)。
// 调用方须持有 m.mu。
func (m *Manager) resetReplicaHealth() {
m.replicaHealthy = nil
m.replicaHealthSet = false
}
// StartProbing 启动主库与从库的健康探活后台循环(#21)。
// 阻塞调用方,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
// 周期 ping 主库,连续失败达阈值后标记不健康(IsHealthy=false);
// 同时 ping 各从库,失败则从读流量剔除,恢复后自动重新纳入。
func (m *Manager) StartProbing(ctx context.Context) {
ctx = normalizeContext(ctx)
m.initReplicaHealth()
cfg := m.getCfg() // P1 #11:锁内快照,避免与 InitDB 写 m.cfg 竞态
interval := 30 * time.Second
if cfg != nil && cfg.Database.HealthCheckInterval > 0 {
interval = cfg.Database.HealthCheckInterval
}
threshold := 3
if cfg != nil && cfg.Database.HealthCheckFailureThreshold > 0 {
threshold = cfg.Database.HealthCheckFailureThreshold
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.probeOnce(ctx, threshold)
}
}
}
// probeOnce 执行一轮主库+从库探活并更新健康标记。
func (m *Manager) probeOnce(ctx context.Context, threshold int) {
// 主库
if err := m.HealthCheck(ctx); err != nil {
m.probeMu.Lock()
m.probeFailures++
if m.probeFailures >= threshold {
if m.healthy.Load() {
logger.Warnf("数据库主库连续探活失败 %d 次,标记为不健康: %v", m.probeFailures, err)
}
m.healthy.Store(false)
}
m.probeMu.Unlock()
} else {
m.probeMu.Lock()
if m.probeFailures >= threshold && !m.healthy.Load() {
logger.Info("数据库主库探活恢复,重新标记为健康")
}
m.probeFailures = 0
m.probeMu.Unlock()
m.healthy.Store(true)
}
// 从库
m.mu.Lock()
replicas := make([]*gorm.DB, len(m.replicas))
copy(replicas, m.replicas)
if len(replicas) > 0 && !m.replicaHealthSet {
m.ensureReplicaHealthLocked()
}
healthSet := m.replicaHealthSet
replicaHealthy := m.replicaHealthy // 快照切片头,避免与 resetReplicaHealth 写竞争
m.mu.Unlock()
if !healthSet {
return
}
for i, r := range replicas {
if r == nil {
continue
}
sqlDB, err := r.DB()
if err != nil {
if i < len(replicaHealthy) {
replicaHealthy[i].Store(false)
}
continue
}
// M11H-db-1):从库探活 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞探活 goroutine。
if err := pingWithTimeout(sqlDB, ctx); err != nil {
if i < len(replicaHealthy) && replicaHealthy[i].Load() {
logger.Warnf("数据库从库 #%d 探活失败,暂时剔除读流量: %v", i, err)
}
if i < len(replicaHealthy) {
replicaHealthy[i].Store(false)
}
} else {
if i < len(replicaHealthy) {
replicaHealthy[i].Store(true)
}
}
}
}
// FromContext 根据上下文选择数据库
func (m *Manager) FromContext(ctx context.Context) *gorm.DB {
ctx = normalizeContext(ctx)
mode, ok := ctx.Value(dbModeContextKey{}).(string)
if !ok {
return m.Replica()
}
switch mode {
case dbModeMaster:
return m.Master()
case dbModeReplica:
return m.Replica()
default:
return m.Replica()
}
}
// Open 打开主库连接
func (m *Manager) Open(ctx context.Context) error {
cfg := m.getCfg() // P1 #11:锁内读取,避免与 InitDB 写竞态
if cfg == nil {
return errors.New("数据库配置未设置")
}
return m.InitDB(ctx, cfg)
}
// OpenWithReplicas 打开主库与从库连接
func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) error {
cfg := m.getCfg() // P1 #11:锁内读取
if cfg == nil {
return errors.New("数据库配置未设置")
}
return m.InitDBWithReplicas(ctx, cfg, replicaDSNs)
}
// closeDB 关闭 gorm.DB 底层连接池。nil 或未初始化(无 ConnPool)时返回 nil,不 panic。
// 用于重建/关闭路径释放旧池,避免直接覆盖致泄漏(C11b/C11c)。
func closeDB(db *gorm.DB) error {
if db == nil {
return nil
}
sqlDB, err := db.DB()
if err != nil {
return err
}
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
m.master = nil
m.replicas = nil
m.resetReplicaHealth()
m.healthy.Store(false)
m.mu.Unlock()
var errs []error
if err := closeDB(master); err != nil {
errs = append(errs, err)
}
for _, replica := range replicas {
if err := closeDB(replica); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// HealthCheck 健康检查,主库不可达时返回错误。
//
// M11 完整覆盖(H-db-1 修复):ping 经 pingWithTimeout 受 healthCheckTimeout(3s) 约束,
// 使后台探活(probeOnce)与 /health 端点(app.go 经本方法)都不会被挂起 DB(连接活但不
// 响应)无限阻塞。ctx 自带更短 deadline 时优先尊重 ctx。
func (m *Manager) HealthCheck(ctx context.Context) error {
ctx = normalizeContext(ctx)
m.mu.Lock()
db := m.master
m.mu.Unlock()
if db == nil {
return errors.New("数据库主库未初始化")
}
sqlDB, err := db.DB()
if err != nil {
return err
}
return pingWithTimeout(sqlDB, ctx)
}
// DefaultManager 默认数据库管理器,包级 facade 代理到它。
//
// 主线A 修复:改用 atomic.Pointer 保护读写,消除原裸指针(外部直接
// `database.DefaultManager = ...` 赋值与请求 goroutine 经 facade 读取)之间的数据竞争。
// 与 config.defaultManager / database.DefaultRedis / storage.DefaultStorage /
// cache.defaultCachePtr / jwt.defaultManager 对齐——框架内包级可变全局一律 atomic.Pointer。
//
// 类型由 *Manager 变更为 atomic.Pointer[Manager]breaking):下游若直接调用
// DefaultManager.Init/Master 等方法需改用 InitDB/GetDB 等 facade,或
// DefaultManager.Load().Init(...),或经 GetDefaultManager() 取实例后再调方法。
var DefaultManager atomic.Pointer[Manager]
func init() {
DefaultManager.Store(NewManager(nil))
}
// SwapDefaultManager 将指定 Manager 置为全局默认,并返回被替换的旧 Manager。
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存。
// nil 被忽略,以防 facade Load 到 nil panic。
func SwapDefaultManager(m *Manager) *Manager {
if m == nil {
return DefaultManager.Load()
}
return DefaultManager.Swap(m)
}
// SetDefaultManager 提升指定 Manager 为全局默认,并关闭被替换的旧 Manager。
// 用于多实例场景或测试注入。nil 被忽略以防 facade Load 到 nil panic。
// 若调用方需要保留旧 Manager 用于回滚,请使用 SwapDefaultManager。
func SetDefaultManager(m *Manager) {
if m == nil {
return
}
old := SwapDefaultManager(m)
if old != nil && old != m {
if err := old.Close(); err != nil {
logger.Warnf("关闭被替换的旧数据库 manager 失败: %v", err)
}
}
}
// GetDefaultManager 返回全局默认 Manageratomic 读取,并发安全)。
// 替代直接读 DefaultManager 包级变量(类型已改为 atomic.Pointer,直接读得到的是 atomic
// 值而非 *Manager)。需直接持有 Manager 调用其方法时用本函数或 DefaultManager.Load()。
func GetDefaultManager() *Manager {
return DefaultManager.Load()
}
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。
// ctx 控制 Ping 与重试等待;调用方取消 ctx 时初始化会尽快返回。
func (m *Manager) InitDB(ctx context.Context, cfg *config.Config) error {
ctx = normalizeContext(ctx)
m.opMu.Lock()
defer m.opMu.Unlock()
return m.initDB(ctx, cfg)
}
func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error {
if cfg == nil {
return errors.New("数据库配置未设置")
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("数据库初始化已取消: %w", err)
}
m.setCfg(cfg) // P1 #11:锁内写入,避免与 StartProbing/Open 读竞态
// GORM 日志配置
var gormLogLevel gormlogger.LogLevel
if cfg.IsDevelopment() {
gormLogLevel = gormlogger.Info
} else {
gormLogLevel = gormlogger.Warn
}
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
}
// 重试配置
maxRetries := 5
retryDelay := time.Second
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)
if err != nil {
lastErr = err
// 不可恢复的错误(认证失败、未知数据库、DSN 非法等)直接返回,不必重试
if !isTransientDBError(err) {
return fmt.Errorf("数据库连接失败(不可恢复): %w", err)
}
} else {
sqlDB, err := db.DB()
if err != nil {
lastErr = err
warnCloseDB(db, "关闭刚打开的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮泄漏
} else {
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
sqlDB.SetConnMaxLifetime(time.Hour)
if cfg.Database.ConnMaxIdleTime > 0 {
sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime)
}
// M11H-db-1):启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 致 InitDB 重试失败而非无限阻塞。
if err := pingWithTimeout(sqlDB, ctx); err == nil {
// 成功:安装为新主库,关闭旧主库池(重建路径覆盖前先释放旧资源,C11b)
m.mu.Lock()
old := m.master
m.master = db
m.mu.Unlock()
m.healthy.Store(true) // Ping 通过才标记健康(#21)
warnCloseDB(old, "关闭旧数据库主库连接池失败")
logger.Info("数据库主库连接成功",
zap.String("driver", driverDescription(cfg.Database.Driver)),
zap.String("host", cfg.Database.Host),
zap.Int("port", cfg.Database.Port))
return nil
} else {
// Ping 失败(如服务端暂时不可达)视作可重试
lastErr = err
warnCloseDB(db, "关闭 Ping 失败的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮覆盖泄漏
}
}
}
logger.Warnf("数据库连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, lastErr)
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
}
}
return fmt.Errorf("数据库连接失败(重试 %d 次): %w", maxRetries, lastErr)
}
// isTransientDBError 判断数据库连接错误是否值得重试。
// 认证失败、未知数据库、非法 DSN/驱动等属于配置类错误,重试无意义,直接返回更友好。
// D5 修复:覆盖 MySQL 和 PostgreSQL 常见非瞬态错误。
func isTransientDBError(err error) bool {
if err == nil {
return true
}
msg := err.Error()
nonTransient := []string{
// MySQL
"Access denied", // 认证失败(用户名/密码错误)
"authentication plugin", // 认证插件不支持
"Unknown database", // 目标库不存在
"invalid DSN", // DSN 语法错误
"unknown driver", // 驱动未注册
"unsupported driver", // 驱动不支持
// PostgreSQLD5 修复;P1 #12:移除过宽的独立 "database" 子串——它会把
// "the database system is starting up" 等瞬态错误误判为非瞬态而放弃重试。
// PG 目标库不存在的消息形如 database "x" does not exist,用 "does not exist" 精确匹配)。
"password authentication failed", // pg 密码错误
"does not exist", // pg 目标库/角色不存在
"no pg_hba.conf entry", // pg_hba.conf 拒绝
}
for _, sub := range nonTransient {
if strings.Contains(msg, sub) {
return false
}
}
return true
}
// replicaMaxOpenConns 计算从库连接池 MaxOpenConnsH-A 修复)。
// masterMax>0 时取 max(1, masterMax/2)(从库适当减少,但绝不截断为 0);
// masterMax<=0(未配置/无限)时返回 0,与主库"无限"语义一致。
func replicaMaxOpenConns(masterMax int) int {
if masterMax <= 0 {
return 0
}
half := masterMax / 2
if half < 1 {
half = 1
}
return half
}
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
// replicaDSNs: 从库连接字符串列表(需与主库驱动匹配)
func (m *Manager) InitDBWithReplicas(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(ctx, cfg); err != nil {
return err
}
// C11c: 重建从库前关闭旧从库池;C11a: 重置健康状态,使下次 initReplicaHealth 按新 replicas 长度重建
m.mu.Lock()
oldReplicas := m.replicas
m.replicas = nil
m.resetReplicaHealth()
m.mu.Unlock()
for _, r := range oldReplicas {
warnCloseDB(r, "关闭旧数据库从库连接池失败")
}
// 初始化从库
if len(replicaDSNs) > 0 {
var gormLogLevel gormlogger.LogLevel
if cfg.IsDevelopment() {
gormLogLevel = gormlogger.Info
} else {
gormLogLevel = gormlogger.Warn
}
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)
continue
}
sqlDB, err := replicaDB.DB()
if err != nil {
logger.Warnf("数据库从库 %d 获取连接池失败: %v", i+1, err)
warnCloseDB(replicaDB, "关闭刚打开的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
continue
}
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
// H-A 修复:从库 MaxOpenConns 适当减少,但须避免截断为 0。
// database/sql 中 SetMaxOpenConns(0) 表示"无限制"——原实现 MaxOpenConns/2 在
// 配置为 1 时得 0,反而让从库连接池无上限(与"减少"意图相反、高并发下打爆 DB);
// 配置为 0(未配置/无限)时 0/2=0 恰好"无限",与主库一致,保持语义。
// 现规则:MaxOpenConns>0 时取 max(1, /2)<=0 时从库亦无限(0),与主库对齐。
sqlDB.SetMaxOpenConns(replicaMaxOpenConns(cfg.Database.MaxOpenConns))
sqlDB.SetConnMaxLifetime(time.Hour)
// M11H-db-1):从库启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞 InitDBWithReplicas。
if err := pingWithTimeout(sqlDB, ctx); err != nil {
logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err)
warnCloseDB(replicaDB, "关闭 Ping 失败的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
continue
}
newReplicas = append(newReplicas, replicaDB)
logger.Info("数据库从库连接成功", zap.Int("index", i+1))
}
m.mu.Lock()
m.replicas = newReplicas
m.ensureReplicaHealthLocked()
m.mu.Unlock()
}
return nil
}
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。
func InitDB(ctx context.Context, cfg *config.Config) error {
return DefaultManager.Load().InitDB(ctx, cfg)
}
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
func InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error {
return DefaultManager.Load().InitDBWithReplicas(ctx, cfg, replicaDSNs)
}
// GetReadDB 获取读库实例(按策略选择从库)
func GetReadDB() *gorm.DB {
return DefaultManager.Load().Replica()
}
// GetWriteDB 获取写库实例(主库)
func GetWriteDB() *gorm.DB {
return DefaultManager.Load().Master()
}
// GetDB 获取数据库实例(默认主库,兼容旧代码)
func GetDB() *gorm.DB {
return DefaultManager.Load().Master()
}
// GetReplicas 获取所有从库实例
func GetReplicas() []*gorm.DB {
return DefaultManager.Load().Replicas()
}
// SetReplicaPicker 设置默认管理器的从库选择策略
func SetReplicaPicker(p ReplicaPicker) {
DefaultManager.Load().SetPicker(p)
}
// UseMaster 强制使用主库(用于事务或需要实时数据的场景)
func UseMaster(ctx context.Context) context.Context {
ctx = normalizeContext(ctx)
return context.WithValue(ctx, dbModeContextKey{}, dbModeMaster)
}
// UseReplica 强制使用从库(用于报表查询等场景)
func UseReplica(ctx context.Context) context.Context {
ctx = normalizeContext(ctx)
return context.WithValue(ctx, dbModeContextKey{}, dbModeReplica)
}
// GetDBFromContext 根据上下文选择数据库
func GetDBFromContext(ctx context.Context) *gorm.DB {
return DefaultManager.Load().FromContext(ctx)
}
// WithTx 将外层事务注入 ctx,使上层(如 repository.BaseRepo)在调用时能 join 到该事务
// 而非另开连接/路由到主从库(H6c)。
//
// 用法:
//
// err := database.TransactionWithContext(ctx, func(tx *gorm.DB) error {
// ctx2 := database.WithTx(ctx, tx)
// // 传 ctx2 给 repo 方法,repo 内部会优先使用该 tx
// return repo.FindByID(ctx2, id) // 此查询参与外层事务
// })
//
// 注意:tx 仅在该 ctx 的生命周期内有效;事务提交/回滚后不得再用该 ctx 携带的 tx。
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
ctx = normalizeContext(ctx)
if tx == nil {
return ctx
}
return context.WithValue(ctx, txContextKey{}, tx)
}
// TxFromContext 取出 ctx 携带的外层事务;无则返回 nil。
func TxFromContext(ctx context.Context) *gorm.DB {
ctx = normalizeContext(ctx)
if tx, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
return tx
}
return nil
}
// AutoMigrate 自动迁移数据库表结构(由应用通过 WithMigrator/WithModels 注册)
func AutoMigrate() error {
logger.Info("数据库表结构迁移完成")
return nil
}
// Close 关闭所有数据库连接(主库与从库),等价于 CloseAll。
// 历史上仅关闭主库、遗留从库池泄漏(C11f),已修正为委托 CloseAll。
func Close() error {
return CloseAll()
}
// CloseAll 关闭所有数据库连接(包括从库)
func CloseAll() error {
return DefaultManager.Load().Close()
}
// Transaction 事务操作(自动使用主库)
func Transaction(fn func(tx *gorm.DB) error) error {
db := DefaultManager.Load().Master()
if db == nil {
return errors.New("数据库未初始化")
}
return db.Transaction(fn)
}
// 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("数据库未初始化")
}
return db.WithContext(ctx).Transaction(fn)
}
// 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("数据库未初始化")
}
return db.WithContext(ctx).Where(query, args...).Find(model).Error
}
// WriteQuery 在主库上执行查询并扫描到 model(强制主库,绕过从库延迟)。
// 注意:命名沿用历史,实际用 .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("数据库未初始化")
}
return db.WithContext(ctx).Where(query, args...).Find(model).Error
}
// healthCheckTimeout 健康检查单次 ping 超时,避免探针被慢/挂起的 DB 长期阻塞(M11)。
const healthCheckTimeout = 3 * time.Second
// pingWithTimeout 带超时的 pingctx 由调用方传入时优先尊重其 deadline。
func pingWithTimeout(sqlDB *sql.DB, parent context.Context) error {
parent = normalizeContext(parent)
ctx, cancel := context.WithTimeout(parent, healthCheckTimeout)
defer cancel()
return sqlDB.PingContext(ctx)
}
// HealthCheck 健康检查(主库 + 从库),单次 ping 限 3s 超时(M11)。
func HealthCheck() map[string]bool {
result := make(map[string]bool)
ctx := context.Background()
m := DefaultManager.Load()
// 检查主库
if master := m.Master(); master != nil {
sqlDB, err := master.DB()
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
result["master"] = true
} else {
result["master"] = false
}
} else {
result["master"] = false
}
// 检查从库
for i, replica := range m.Replicas() {
if replica != nil {
sqlDB, err := replica.DB()
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
result[fmt.Sprintf("replica_%d", i+1)] = true
} else {
result[fmt.Sprintf("replica_%d", i+1)] = false
}
} else {
result[fmt.Sprintf("replica_%d", i+1)] = false
}
}
return result
}
// IsDBHealthy 返回主库探活健康状态(#21)。
// 与 HealthCheck()(实时 ping)不同,这是后台探活维护的缓存标记,
// 供 readiness 探针快速判断是否接流量,避免每次探针都同步 ping。
func IsDBHealthy() bool {
return DefaultManager.Load().IsHealthy()
}
// StartDBProbing 启动主库/从库探活后台循环(#21)。
// 阻塞,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
func StartDBProbing(ctx context.Context) {
DefaultManager.Load().StartProbing(ctx)
}
+404
View File
@@ -0,0 +1,404 @@
package database
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
func init() {
// 部分用例触发 logger.Warnf 路径,确保 logger 已初始化避免 nil deref。
logger.Close()
}
// sentinelDB 返回一个可安全调用 .DB() 的 gorm.DB 占位实例。
// 直接用 &gorm.DB{} 会令内嵌 *Config 为 nil.DB() 访问提升字段 ConnPool 时 nil deref
// 这里给定非 nil Config.DB() 走到 nil ConnPool 分支返回 ErrInvalidDB 而不 panic。
func sentinelDB() *gorm.DB {
return &gorm.DB{Config: &gorm.Config{}}
}
// TestC11MasterReplicasConcurrentReadWrite 验证 Master/Replicas/Replica 全程加锁,
// 与并发重建路径(写 master/replicas)无数据竞争(C11d)。
// 修复前 Master/Replicas 为裸读,-race 必采到竞争。
func TestC11MasterReplicasConcurrentReadWrite(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
sentinels := []*gorm.DB{{}, {}, {}}
replicaSets := [][]*gorm.DB{
{sentinels[0], sentinels[1]},
{sentinels[2]},
{},
}
var wg sync.WaitGroup
stop := make(chan struct{})
// 写者:在锁内置换 master/replicas(模拟 InitDB/InitDBWithReplicas/Close 的写路径)
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
m.mu.Lock()
m.master = sentinels[i%len(sentinels)]
m.replicas = replicaSets[i%len(replicaSets)]
m.mu.Unlock()
}
}()
// 读者:并发调用读取方法
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = m.Master()
_ = m.Replicas()
_ = m.Replica()
_ = m.FromContext(context.Background())
}
}()
}
// 让竞争窗口运行一段时间,确保 -race 能采到(若有未加锁读)
time.Sleep(50 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestC11ReplicasReturnsCopy 验证 Replicas 返回拷贝,调用方修改不影响内部状态(C11d)。
func TestC11ReplicasReturnsCopy(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
m.mu.Lock()
m.replicas = []*gorm.DB{{}, {}}
m.mu.Unlock()
got := m.Replicas()
if len(got) != 2 {
t.Fatalf("expected 2 replicas, got %d", len(got))
}
got[0] = nil
m.mu.Lock()
defer m.mu.Unlock()
if m.replicas[0] == nil {
t.Fatal("Replicas should return a copy, not the live slice")
}
}
// TestC11ReplicaHealthResetOnRebuild 验证重建从库前重置健康状态,
// 使 initReplicaHealth 按新 replicas 长度重建(C11a)。
// 修复前 initReplicaHealth 的 replicaHealthSet 早返回导致健康切片与新 replicas 长度错位。
func TestC11ReplicaHealthResetOnRebuild(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
// 首轮:2 个从库 + 健康初始化
m.mu.Lock()
m.replicas = []*gorm.DB{{}, {}}
m.mu.Unlock()
m.initReplicaHealth()
if !m.replicaHealthSet || len(m.replicaHealthy) != 2 {
t.Fatalf("expected health init for 2 replicas, got set=%v len=%d", m.replicaHealthSet, len(m.replicaHealthy))
}
// 重建:3 个从库 + 重置健康状态(C11a/C11c 路径)
m.mu.Lock()
m.replicas = []*gorm.DB{{}, {}, {}}
m.resetReplicaHealth()
m.mu.Unlock()
m.initReplicaHealth()
if !m.replicaHealthSet || len(m.replicaHealthy) != 3 {
t.Fatalf("C11a: expected re-init aligned with 3 new replicas, got set=%v len=%d", m.replicaHealthSet, len(m.replicaHealthy))
}
for i := range m.replicaHealthy {
if !m.replicaHealthy[i].Load() {
t.Fatal("re-init should mark all replicas healthy")
}
}
}
func TestM4ProbeOnceReinitializesReplicaHealthAfterRebuild(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
m.mu.Lock()
m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
m.resetReplicaHealth()
m.mu.Unlock()
m.probeOnce(context.Background(), 1)
m.mu.Lock()
defer m.mu.Unlock()
if !m.replicaHealthSet {
t.Fatal("probeOnce 应在从库重建后自动重建健康标记")
}
if len(m.replicaHealthy) != 2 {
t.Fatalf("健康标记长度应与从库数量一致,实际 %d", len(m.replicaHealthy))
}
}
// TestC11ReplicaHealthStaleWithoutReset 反向验证:不调 resetReplicaHealth 时
// initReplicaHealth 早返回,健康切片长度不随新 replicas 变化(复现 C11a 缺陷根因)。
func TestC11ReplicaHealthStaleWithoutReset(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
m.mu.Lock()
m.replicas = []*gorm.DB{{}, {}}
m.mu.Unlock()
m.initReplicaHealth()
// 仅换 replicas 不重置健康状态
m.mu.Lock()
m.replicas = []*gorm.DB{{}, {}, {}}
m.mu.Unlock()
m.initReplicaHealth() // replicaHealthSet 仍为 true → 早返回
m.mu.Lock()
defer m.mu.Unlock()
if len(m.replicaHealthy) == 3 {
t.Fatal("without reset, initReplicaHealth should NOT re-align (early-returns); this proves reset is required")
}
if len(m.replicaHealthy) != 2 {
t.Fatalf("expected stale len=2, got %d", len(m.replicaHealthy))
}
}
// TestC11ManagerCloseResetsState 验证 Close 清空 master/replicas 并重置健康状态(C11a/C11c/C11d)。
func TestC11ManagerCloseResetsState(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
m.mu.Lock()
m.master = sentinelDB()
m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
m.replicaHealthy = make([]atomic.Bool, 2)
m.replicaHealthy[0].Store(true)
m.replicaHealthy[1].Store(true)
m.replicaHealthSet = true
m.mu.Unlock()
m.healthy.Store(true)
// closeDB 对空 gorm.DB{}(无 ConnPool)返回 ErrInvalidDBClose 收集后 join 返回;
// 这里不关心关闭错误,只断言状态被重置。
_ = m.Close()
m.mu.Lock()
defer m.mu.Unlock()
if m.master != nil {
t.Fatal("expected master nil after Close")
}
if m.replicas != nil {
t.Fatal("expected replicas nil after Close")
}
if m.replicaHealthSet {
t.Fatal("expected replicaHealthSet reset after Close")
}
if m.replicaHealthy != nil {
t.Fatal("expected replicaHealthy nil after Close")
}
if m.healthy.Load() {
t.Fatal("expected healthy=false after Close")
}
}
// TestC11PackageCloseClosesReplicas 验证包级 Close 委托 CloseAll
// 关闭从库而非仅主库(C11f)。修复前包级 Close 仅关 masterreplicas 残留。
func TestC11PackageCloseClosesReplicas(t *testing.T) {
// 保存并恢复 DefaultManager 状态,避免污染其他用例
defer func() {
DefaultManager.Load().mu.Lock()
DefaultManager.Load().master = nil
DefaultManager.Load().replicas = nil
DefaultManager.Load().resetReplicaHealth()
DefaultManager.Load().healthy.Store(false)
DefaultManager.Load().mu.Unlock()
}()
DefaultManager.Load().mu.Lock()
DefaultManager.Load().master = sentinelDB()
DefaultManager.Load().replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
DefaultManager.Load().mu.Unlock()
// 包级 Close → CloseAll → DefaultManager.Load().Close(),应同时清空 master 与 replicas
_ = Close()
DefaultManager.Load().mu.Lock()
master := DefaultManager.Load().master
repl := DefaultManager.Load().replicas
DefaultManager.Load().mu.Unlock()
if master != nil {
t.Fatal("C11f: expected master nil after package Close")
}
if repl != nil {
t.Fatal("C11f: package Close should close replicas too (delegates to CloseAll), got residual replicas")
}
}
// TestC11HealthCheckLockedRead 验证 HealthCheck 在锁内快照 master,未初始化时返错(C11d)。
func TestC11HealthCheckLockedRead(t *testing.T) {
m := &Manager{picker: &RandomPicker{}}
if err := m.HealthCheck(context.Background()); err == nil {
t.Fatal("expected error when health checking uninitialized master")
}
// 空 gorm.DB(无 ConnPool):DB() 返 ErrInvalidDBHealthCheck 返该错而非 panic
m.mu.Lock()
m.master = sentinelDB()
m.mu.Unlock()
if err := m.HealthCheck(context.Background()); err == nil {
t.Fatal("expected error for gorm.DB without ConnPool")
}
}
func TestSetDefaultManagerClosesReplacedManager(t *testing.T) {
old := NewManager(nil)
old.mu.Lock()
old.master = sentinelDB()
old.replicas = []*gorm.DB{sentinelDB()}
old.replicaHealthy = make([]atomic.Bool, 1)
old.replicaHealthy[0].Store(true)
old.replicaHealthSet = true
old.mu.Unlock()
old.healthy.Store(true)
orig := SwapDefaultManager(old)
t.Cleanup(func() {
current := SwapDefaultManager(orig)
if current != nil && current != orig {
_ = current.Close()
}
})
next := NewManager(nil)
SetDefaultManager(next)
if GetDefaultManager() != next {
t.Fatal("SetDefaultManager 应安装新的默认 manager")
}
old.mu.Lock()
defer old.mu.Unlock()
if old.master != nil || old.replicas != nil || old.replicaHealthy != nil || old.replicaHealthSet {
t.Fatal("SetDefaultManager 应关闭并清空被替换的旧 manager,避免连接池泄漏")
}
if old.healthy.Load() {
t.Fatal("SetDefaultManager 关闭旧 manager 后健康状态应为 false")
}
}
func TestSwapDefaultManagerPreservesReplacedManager(t *testing.T) {
old := NewManager(nil)
old.mu.Lock()
old.master = sentinelDB()
old.replicas = []*gorm.DB{sentinelDB()}
old.mu.Unlock()
orig := SwapDefaultManager(old)
t.Cleanup(func() {
current := SwapDefaultManager(orig)
if current != nil && current != orig {
_ = current.Close()
}
_ = old.Close()
})
next := NewManager(nil)
previous := SwapDefaultManager(next)
if previous != old {
t.Fatal("SwapDefaultManager 应返回被替换的旧 manager")
}
if GetDefaultManager() != next {
t.Fatal("SwapDefaultManager 应安装新的默认 manager")
}
old.mu.Lock()
defer old.mu.Unlock()
if old.master == nil || len(old.replicas) != 1 {
t.Fatal("SwapDefaultManager 不应关闭旧 manager,旧资源需可用于失败回滚")
}
}
type transientDialector struct{}
func (d transientDialector) Name() string { return "m4_transient" }
func (d transientDialector) Initialize(*gorm.DB) error {
return errors.New("temporary connection failure")
}
func (d transientDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
func (d transientDialector) DataTypeOf(*schema.Field) string { return "" }
func (d transientDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
func (d transientDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
func (d transientDialector) QuoteTo(writer clause.Writer, str string) {
_, _ = writer.WriteString(str)
}
func (d transientDialector) Explain(sql string, _ ...any) string { return sql }
func TestM4InitDBHonorsContextDuringRetrySleep(t *testing.T) {
const driver = "m4_transient_retry"
RegisterDialect(DialectSpec{
Name: driver,
Dialector: func(string) gorm.Dialector { return transientDialector{} },
DSN: func(*config.DatabaseConfig) string { return "m4://retry" },
})
m := NewManager(nil)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(30 * time.Millisecond)
cancel()
}()
start := time.Now()
err := m.InitDB(ctx, &config.Config{Database: config.DatabaseConfig{Driver: driver}})
if err == nil || !errors.Is(err, context.Canceled) {
t.Fatalf("InitDB 应返回 context.Canceled,实际: %v", err)
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("InitDB 不应等待完整 retry sleep,耗时 %s", elapsed)
}
}
func TestM4CloseWaitsForLifecycleOperation(t *testing.T) {
m := NewManager(nil)
m.opMu.Lock()
done := make(chan struct{})
go func() {
_ = m.Close()
close(done)
}()
select {
case <-done:
t.Fatal("Close 不应越过正在执行的生命周期操作")
case <-time.After(50 * time.Millisecond):
}
m.opMu.Unlock()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("释放生命周期锁后 Close 未返回")
}
}
+199
View File
@@ -0,0 +1,199 @@
package database
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// hungDriver 是测试用 database/sql 驱动,其 Conn.PingContext 阻塞直到 ctx 取消,
// 模拟"挂起 DB"(TCP 连接活但不响应查询,区别于宕机的 connection-refused 快速失败)。
// 用于回归 H-db-1ping 路径须经 pingWithTimeout 受 healthCheckTimeout(3s) 约束,
// 挂起 DB 不得无限阻塞探活 goroutine / 启动 / /health 端点。
type hungDriver struct{}
func (hungDriver) Open(name string) (driver.Conn, error) { return hungConn{}, nil }
type hungConn struct{}
func (hungConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("hung: not implemented") }
func (hungConn) Close() error { return nil }
func (hungConn) Begin() (driver.Tx, error) { return nil, errors.New("hung: not implemented") }
// Ping 阻塞直到 ctx 取消(模拟挂起 DB 永不响应 ping)。实现 driver.Pinger 接口
// (方法名为 Ping 而非 PingContext,否则 *sql.DB.PingContext 视为 no-op 返回 nil)。
func (hungConn) Ping(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
var registerHungOnce sync.Once
func registerHungDriver() {
registerHungOnce.Do(func() { sql.Register("xlgo_hung_hdb1", hungDriver{}) })
}
func newHungSqlDB(t *testing.T) *sql.DB {
t.Helper()
registerHungDriver()
db, err := sql.Open("xlgo_hung_hdb1", "")
if err != nil {
t.Fatalf("sql.Open hung driver: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// newHungGormDB 构造底层 *sql.DB 为挂起驱动的 gorm.DB,其 DB() 返回挂起 *sql.DB。
func newHungGormDB(t *testing.T) *gorm.DB {
t.Helper()
return &gorm.DB{Config: &gorm.Config{ConnPool: newHungSqlDB(t)}}
}
// assertBoundedPing 在 max+2s 内等待 fn 返回;超时则 failH-db-1ping 应被
// pingWithTimeout 3s 约束,不应无限 hang)。返回 fn 的 error 供调用方断言。
func assertBoundedPing(t *testing.T, fn func() error, max time.Duration) error {
t.Helper()
done := make(chan error, 1)
start := time.Now()
go func() { done <- fn() }()
select {
case err := <-done:
if elapsed := time.Since(start); elapsed > max {
t.Fatalf("ping 路径耗时 %v 超过上限 %vH-db-1:应被 pingWithTimeout 3s 约束)", elapsed, max)
}
return err
case <-time.After(max + 2*time.Second):
t.Fatalf("ping 路径在挂起 DB 上无限阻塞(H-db-1pingWithTimeout 未覆盖该路径)")
return nil
}
}
// TestPingWithTimeoutBoundsHungDB_Hdb1 回归 H-db-1 机制:pingWithTimeout 对挂起 DB 的
// *sql.DB.PingContext 限 healthCheckTimeout 返回,不因 ctx 无 deadline 无限阻塞。
// 修复前若直接 PingContext(Background()) 会无限阻塞。
func TestPingWithTimeoutBoundsHungDB_Hdb1(t *testing.T) {
sqlDB := newHungSqlDB(t)
err := assertBoundedPing(t, func() error {
return pingWithTimeout(sqlDB, context.Background())
}, healthCheckTimeout+1*time.Second)
if err == nil {
t.Fatalf("挂起 DB 的 ping 应返回超时错误,got nil")
}
}
// TestManagerHealthCheckBoundsHungDB_Hdb1 回归 H-db-1 主路径:m.HealthCheck(Background)
// 对挂起主库经 pingWithTimeout ~3s 返回超时错误,不无限阻塞。该路径被后台探活
// probeOncemaster)与 /health 端点(app.go 经 dbm.HealthCheck)共用。
// 修复前 m.HealthCheck 用裸 sqlDB.PingContext(ctx)Background ctx 无 deadline -> 无限阻塞。
func TestManagerHealthCheckBoundsHungDB_Hdb1(t *testing.T) {
m := NewManager(nil)
m.master = newHungGormDB(t)
err := assertBoundedPing(t, func() error {
return m.HealthCheck(context.Background())
}, healthCheckTimeout+1*time.Second)
if err == nil {
t.Fatalf("挂起主库的 HealthCheck 应返回超时错误,got nil")
}
}
// TestProbeOnceReplicaBoundsHungDB_Hdb1 回归 H-db-1 从库探活路径:probeOnce 对挂起从库
// 经 pingWithTimeout ~3s 返回,不无限阻塞探活 goroutine(#21 自愈不冻结)。master 置 nil
// 使 HealthCheck 快速返回"未初始化",让 probeOnce 只在从库 ping 路径耗时。
// 修复前 probeOnce 从库用裸 sqlDB.PingContext(ctx)Background ctx 无 deadline -> 无限阻塞。
func TestProbeOnceReplicaBoundsHungDB_Hdb1(t *testing.T) {
m := NewManager(nil)
m.master = nil // master 路径快速失败,集中测从库 ping 超时
m.replicas = []*gorm.DB{newHungGormDB(t)}
m.replicaHealthSet = true
m.replicaHealthy = make([]atomic.Bool, 1)
m.replicaHealthy[0].Store(true)
_ = assertBoundedPing(t, func() error {
m.probeOnce(context.Background(), 3)
return nil
}, healthCheckTimeout+1*time.Second)
// 挂起从库应被标记不健康(剔除读流量,#21 自愈生效)
if m.replicaHealthy[0].Load() {
t.Errorf("挂起从库应被标记不健康(replicaHealthy=false),got true")
}
}
// hungDialector 让 gorm.Open 成功并注入挂起 *sql.DB 作为 ConnPool,使 initDB 的
// db.DB() 返回挂起 *sql.DB、pingWithTimeout 触发。用于回归启动 ping 路径。
type hungDialector struct{ sqlDB *sql.DB }
func (d hungDialector) Name() string { return "hdb1_hung" }
func (d hungDialector) Initialize(db *gorm.DB) error { db.Config.ConnPool = d.sqlDB; return nil }
func (d hungDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
func (d hungDialector) DataTypeOf(*schema.Field) string { return "" }
func (d hungDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
func (d hungDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
func (d hungDialector) QuoteTo(w clause.Writer, s string) { _, _ = w.WriteString(s) }
func (d hungDialector) Explain(sql string, _ ...any) string { return sql }
// registerHungDialect 注册挂起 dialect(幂等)。
func registerHungDialect(t *testing.T) {
t.Helper()
RegisterDialect(DialectSpec{
Name: "hdb1_hung",
Dialector: func(string) gorm.Dialector { return hungDialector{sqlDB: newHungSqlDB(t)} },
DSN: func(*config.DatabaseConfig) string { return "hdb1_hung://" },
})
}
// hungCfg 构造用挂起 dialect 的 config。
func hungCfg() *config.Config {
return &config.Config{Database: config.DatabaseConfig{Driver: "hdb1_hung"}}
}
// TestInitDBBoundsHungDB_Hdb1 回归 H-db-1 启动 master ping 路径:InitDB(Background) 对
// 挂起主库的 ping 经 pingWithTimeout 3s 失败,5 次重试后总耗时 ~15s 返回错误,不无限阻塞。
// 修复前 initDB 用裸 sqlDB.PingContext(ctx)Background 无 deadline -> 首次 ping 无限阻塞。
// 用 ctx 在首次 ping 超时后取消加速(避免 15s 全跑),同时证明 ctx 仍可中断重试循环。
func TestInitDBBoundsHungDB_Hdb1(t *testing.T) {
registerHungDialect(t)
m := NewManager(nil)
ctx, cancel := context.WithCancel(context.Background())
// 4s 后 cancel:首次 ping 经 pingWithTimeout ~3s 超时返回错误,进入重试前 ctx 已取消,
// initDB 在重试循环的 ctx.Err() 检查处快速返回。若修复前裸 PingContext(Background)
// 首次即无限阻塞,cancel 无法中断 -> assertBoundedPing 超时 fail。
go func() { time.Sleep(4 * time.Second); cancel() }()
_ = assertBoundedPing(t, func() error {
return m.InitDB(ctx, hungCfg())
}, 6*time.Second)
}
// TestInitDBWithReplicasBoundsHungDB_Hdb1 回归 H-db-1 启动 replica ping 路径:
// InitDBWithReplicas 对挂起 replica DSN 的 ping 经 pingWithTimeout 失败(replica 被 continue
// 跳过),主库经挂起 dialect 同样 ping 失败。整流程有界返回,不无限阻塞。
// 修复前 replica 启动 ping 用裸 PingContext -> 挂起 replica 无限阻塞 InitDBWithReplicas。
func TestInitDBWithReplicasBoundsHungDB_Hdb1(t *testing.T) {
registerHungDialect(t)
m := NewManager(nil)
// 用可取消 ctx 限制总时长;主库挂起 ping ~3s 失败后 InitDBWithReplicas 直接返回错误
// (主库失败不进入 replica 初始化)。此处验证主库 ping 路径有界即可覆盖启动 ping 约束。
ctx, cancel := context.WithCancel(context.Background())
go func() { time.Sleep(4 * time.Second); cancel() }()
err := assertBoundedPing(t, func() error {
return m.InitDBWithReplicas(ctx, hungCfg(), []string{"hdb1_hung://replica"})
}, 6*time.Second)
if err == nil {
t.Fatalf("挂起主库的 InitDBWithReplicas 应返回错误,got nil")
}
}
+277
View File
@@ -0,0 +1,277 @@
package database_test
import (
"context"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
func TestCloseAllWithoutInit(t *testing.T) {
if err := database.CloseAll(); err != nil {
t.Fatalf("CloseAll without init should not error: %v", err)
}
if database.GetDB() != nil {
t.Fatal("expected DB nil")
}
if database.GetReadDB() != nil {
t.Fatal("expected read DB nil")
}
if len(database.GetReplicas()) != 0 {
t.Fatal("expected replicas empty")
}
}
func TestDBContextHelpersWithoutInit(t *testing.T) {
ctx := database.UseMaster(context.Background())
if db := database.GetDBFromContext(ctx); db != nil {
t.Fatal("expected nil DB without init")
}
ctx = database.UseReplica(context.Background())
if db := database.GetDBFromContext(ctx); db != nil {
t.Fatal("expected nil read DB without init")
}
}
func TestRoundRobinPicker(t *testing.T) {
replicas := []*gorm.DB{{}, {}, {}}
p := &database.RoundRobinPicker{}
first := p.Pick(replicas)
second := p.Pick(replicas)
third := p.Pick(replicas)
fourth := p.Pick(replicas)
if first == nil || second == nil || third == nil {
t.Fatal("Picker returned nil for non-empty replicas")
}
if first != replicas[0] || second != replicas[1] || third != replicas[2] {
t.Fatal("RoundRobinPicker should cycle through replicas in order")
}
if fourth != replicas[0] {
t.Fatal("RoundRobinPicker should wrap around to the first replica")
}
if p.Pick(nil) != nil || p.Pick([]*gorm.DB{}) != nil {
t.Fatal("Picker should return nil for empty replicas")
}
}
func TestRandomPicker(t *testing.T) {
replicas := []*gorm.DB{{}, {}}
p := &database.RandomPicker{}
picked := p.Pick(replicas)
if picked == nil {
t.Fatal("RandomPicker returned nil for non-empty replicas")
}
if picked != replicas[0] && picked != replicas[1] {
t.Fatal("RandomPicker returned a replica not in the slice")
}
if p.Pick(nil) != nil || p.Pick([]*gorm.DB{}) != nil {
t.Fatal("RandomPicker should return nil for empty replicas")
}
}
func TestManagerReplicaFallbackToMaster(t *testing.T) {
mgr := database.NewManager(&config.Config{})
if mgr.Master() != nil {
t.Fatal("expected nil master before init")
}
if mgr.Replicas() != nil {
t.Fatal("expected nil replicas before init")
}
// 无从库时 Replica 应返回 master(此处均为 nil)
if mgr.Replica() != nil {
t.Fatal("expected Replica to fall back to master when no replicas")
}
}
func TestManagerSetPicker(t *testing.T) {
mgr := database.NewManager(&config.Config{})
rr := &database.RoundRobinPicker{}
mgr.SetPicker(rr)
if mgr.Picker() != rr {
t.Fatal("SetPicker did not install the picker")
}
// nil 不应覆盖已有 picker
mgr.SetPicker(nil)
if mgr.Picker() != rr {
t.Fatal("SetPicker(nil) should not clear the existing picker")
}
}
func TestDefaultManagerHealthCheckWithoutInit(t *testing.T) {
if err := database.GetDefaultManager().HealthCheck(context.Background()); err == nil {
t.Fatal("expected error when health checking uninitialized master")
}
}
func TestNilContextHelpersDoNotPanic(t *testing.T) {
_ = database.CloseAll()
if ctx := database.UseMaster(nil); ctx == nil {
t.Fatal("UseMaster(nil) 应返回可用 context")
}
if ctx := database.UseReplica(nil); ctx == nil {
t.Fatal("UseReplica(nil) 应返回可用 context")
}
if db := database.GetDBFromContext(nil); db != nil {
t.Fatal("未初始化数据库时 GetDBFromContext(nil) 应返回 nil")
}
if ctx := database.WithTx(nil, nil); ctx == nil {
t.Fatal("WithTx(nil, nil) 应返回可用 context")
}
if tx := database.TxFromContext(nil); tx != nil {
t.Fatal("TxFromContext(nil) 应返回 nil")
}
if err := database.TransactionWithContext(nil, func(tx *gorm.DB) error { return nil }); err == nil {
t.Fatal("未初始化数据库时 TransactionWithContext(nil) 应返回错误")
}
if err := database.ReadQuery(nil, &struct{}{}, "1=1"); err == nil {
t.Fatal("未初始化数据库时 ReadQuery(nil) 应返回错误")
}
if err := database.WriteQuery(nil, &struct{}{}, "1=1"); err == nil {
t.Fatal("未初始化数据库时 WriteQuery(nil) 应返回错误")
}
if got := database.HealthCheck(); !got["master"] {
// HealthCheck 内部使用 background context;未初始化时 master=false 即可。
return
}
t.Fatal("未初始化数据库时 HealthCheck master 不应为 true")
}
func TestNilConfigInitializationReturnsError(t *testing.T) {
mgr := database.NewManager(nil)
if err := mgr.InitDB(context.Background(), nil); err == nil {
t.Fatal("InitDB(nil) 应返回错误")
}
if err := mgr.InitDBWithReplicas(context.Background(), nil, nil); err == nil {
t.Fatal("InitDBWithReplicas(nil) 应返回错误")
}
if err := database.InitDB(context.Background(), nil); err == nil {
t.Fatal("包级 InitDB(nil) 应返回错误")
}
if err := database.InitDBWithReplicas(context.Background(), nil, nil); err == nil {
t.Fatal("包级 InitDBWithReplicas(nil) 应返回错误")
}
}
func TestDialectorNilConfigDoesNotPanic(t *testing.T) {
if d := database.Dialector(nil); d == nil {
t.Fatal("Dialector(nil) 应返回兜底 dialector")
}
}
func TestDialectorSelectsByDriver(t *testing.T) {
mysqlCfg := &config.Config{Database: config.DatabaseConfig{
Driver: config.DriverMySQL, Host: "localhost", Port: 3306,
User: "root", Password: "pass", Name: "db",
}}
if name := database.Dialector(mysqlCfg).Name(); name != "mysql" {
t.Fatalf("expected mysql dialector, got %q", name)
}
pgCfg := &config.Config{Database: config.DatabaseConfig{
Driver: config.DriverPostgres, Host: "localhost", Port: 5432,
User: "postgres", Password: "pass", Name: "db",
}}
if name := database.Dialector(pgCfg).Name(); name != "postgres" {
t.Fatalf("expected postgres dialector, got %q", name)
}
// 别名也应解析为 postgres
pgAliasCfg := &config.Config{Database: config.DatabaseConfig{
Driver: "PG", Host: "localhost", Port: 5432,
User: "postgres", Password: "pass", Name: "db",
}}
if name := database.Dialector(pgAliasCfg).Name(); name != "postgres" {
t.Fatalf("expected postgres dialector via alias, got %q", name)
}
// 未指定 Driver 时默认 mysql
defaultCfg := &config.Config{Database: config.DatabaseConfig{
Host: "localhost", Port: 3306, User: "root", Password: "pass", Name: "db",
}}
if name := database.Dialector(defaultCfg).Name(); name != "mysql" {
t.Fatalf("expected default mysql dialector, got %q", name)
}
}
// 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) 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 TestRegisterDialectAndCustomDriver(t *testing.T) {
const driver = "stubdb"
database.RegisterDialect(database.DialectSpec{
Name: driver,
Aliases: []string{"stub"},
Dialector: func(dsn string) gorm.Dialector { return stubDialector{name: "stubdb"} },
DSN: func(c *config.DatabaseConfig) string {
return "stub://" + c.Host
},
})
// Dialector 工厂可以解析主名和别名
if _, ok := database.LookupDialect(driver); !ok {
t.Fatal("expected stubdb dialector to be registered")
}
if _, ok := database.LookupDialect("STUB"); !ok {
t.Fatal("expected stub alias to be registered (case-insensitive)")
}
cfg := &config.Config{Database: config.DatabaseConfig{
Driver: driver, Host: "localhost",
}}
if name := database.Dialector(cfg).Name(); name != "stubdb" {
t.Fatalf("expected stubdb dialector, got %q", name)
}
// config.DSN() 应使用注册的 DSN 构建器
if dsn := cfg.Database.DSN(); dsn != "stub://localhost" {
t.Fatalf("expected DSN built by registered builder, got %q", dsn)
}
// 未知驱动应 fail-closed,避免拼写错误静默连向 MySQL。
unknownCfg := &config.Config{Database: config.DatabaseConfig{
Driver: "no-such-driver", Host: "localhost", Port: 3306,
User: "root", Password: "pass", Name: "db",
}}
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)
}
}
func TestRegisteredDialectsContainsBuiltins(t *testing.T) {
registered := database.RegisteredDialects()
want := map[string]bool{"mysql": false, "postgres": false, "pg": false}
for _, n := range registered {
if _, ok := want[n]; ok {
want[n] = true
}
}
for k, found := range want {
if !found {
t.Errorf("expected %q to be registered by default", k)
}
}
}
-315
View File
@@ -1,315 +0,0 @@
package database
import (
"context"
"fmt"
"math/rand"
"sync"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
"go.uber.org/zap"
"gorm.io/driver/mysql"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
var (
// DB 主库实例(写操作)
DB *gorm.DB
// DBRead 读库实例(读操作)
DBRead *gorm.DB
// replicas 从库列表
replicas []*gorm.DB
// replicaMutex 从库选择锁
replicaMutex sync.Mutex
)
// InitMySQL 初始化 MySQL 连接(带重试机制)
func InitMySQL(cfg *config.Config) error {
var err error
// GORM 日志配置
var gormLogLevel gormlogger.LogLevel
if cfg.IsDevelopment() {
gormLogLevel = gormlogger.Info
} else {
gormLogLevel = gormlogger.Warn
}
gormConfig := &gorm.Config{
Logger: gormlogger.Default.LogMode(gormLogLevel),
}
// 重试配置
maxRetries := 5
retryDelay := time.Second
for i := range maxRetries {
// 连接主库
DB, err = gorm.Open(mysql.Open(cfg.Database.DSN()), gormConfig)
if err == nil {
sqlDB, err := DB.DB()
if err == nil {
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
sqlDB.SetConnMaxLifetime(time.Hour)
if err := sqlDB.Ping(); err == nil {
logger.Info("MySQL 主库连接成功", zap.String("host", cfg.Database.Host), zap.Int("port", cfg.Database.Port))
return nil
}
}
}
logger.Warnf("MySQL 连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, err)
time.Sleep(retryDelay)
retryDelay *= 2
if retryDelay > 30*time.Second {
retryDelay = 30 * time.Second
}
}
return fmt.Errorf("MySQL 连接失败(重试 %d 次): %w", maxRetries, err)
}
// InitMySQLWithReplicas 初始化 MySQL 主从连接
// masterDSN: 主库连接字符串
// replicaDSNs: 从库连接字符串列表
func InitMySQLWithReplicas(cfg *config.Config, replicaDSNs []string) error {
// 先初始化主库
if err := InitMySQL(cfg); err != nil {
return err
}
// 初始化从库
if len(replicaDSNs) > 0 {
var gormLogLevel gormlogger.LogLevel
if cfg.IsDevelopment() {
gormLogLevel = gormlogger.Info
} else {
gormLogLevel = gormlogger.Warn
}
gormConfig := &gorm.Config{
Logger: gormlogger.Default.LogMode(gormLogLevel),
}
for i, dsn := range replicaDSNs {
replicaDB, err := gorm.Open(mysql.Open(dsn), gormConfig)
if err != nil {
logger.Warnf("MySQL 从库 %d 连接失败: %v", i+1, err)
continue
}
sqlDB, err := replicaDB.DB()
if err != nil {
logger.Warnf("MySQL 从库 %d 获取连接池失败: %v", i+1, err)
continue
}
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns / 2) // 从库连接数可适当减少
sqlDB.SetConnMaxLifetime(time.Hour)
if err := sqlDB.Ping(); err != nil {
logger.Warnf("MySQL 从库 %d Ping 失败: %v", i+1, err)
continue
}
replicas = append(replicas, replicaDB)
logger.Info("MySQL 从库连接成功", zap.Int("index", i+1))
}
// 设置默认读库
if len(replicas) > 0 {
DBRead = replicas[0]
} else {
DBRead = DB // 无从库时使用主库
}
} else {
DBRead = DB // 无从库配置时使用主库
}
return nil
}
// GetReadDB 获取读库实例(自动选择从库)
func GetReadDB() *gorm.DB {
if len(replicas) == 0 {
return DB
}
replicaMutex.Lock()
defer replicaMutex.Unlock()
// 随机选择一个从库
idx := rand.Intn(len(replicas))
return replicas[idx]
}
// GetWriteDB 获取写库实例(主库)
func GetWriteDB() *gorm.DB {
return DB
}
// GetDB 获取数据库实例(默认主库,兼容旧代码)
func GetDB() *gorm.DB {
return DB
}
// GetReplicas 获取所有从库实例
func GetReplicas() []*gorm.DB {
return replicas
}
// UseMaster 强制使用主库(用于事务或需要实时数据的场景)
func UseMaster(ctx context.Context) context.Context {
return context.WithValue(ctx, "db_mode", "master")
}
// UseReplica 强制使用从库(用于报表查询等场景)
func UseReplica(ctx context.Context) context.Context {
return context.WithValue(ctx, "db_mode", "replica")
}
// GetDBFromContext 根据上下文选择数据库
func GetDBFromContext(ctx context.Context) *gorm.DB {
mode, ok := ctx.Value("db_mode").(string)
if !ok {
return GetReadDB()
}
switch mode {
case "master":
return DB
case "replica":
return GetReadDB()
default:
return GetReadDB()
}
}
// DBResolver 数据库解析器(用于 GORM 钩子)
type DBResolver struct{}
// BeforeQuery 查询前钩子,自动路由到从库
func (r *DBResolver) BeforeQuery(db *gorm.DB) {
// 如果在事务中,使用主库
if db.Statement.ConnPool != nil {
return
}
// 检查上下文
ctx := db.Statement.Context
if ctx != nil {
mode, ok := ctx.Value("db_mode").(string)
if ok && mode == "master" {
return // 强制主库
}
}
// 读操作路由到从库
if len(replicas) > 0 && DBRead != nil {
db.Statement.ConnPool = DBRead.Statement.ConnPool
}
}
// AutoMigrate 自动迁移数据库表结构(由应用重写)
func AutoMigrate() error {
logger.Info("数据库表结构迁移完成")
return nil
}
// Close 关闭数据库连接
func Close() error {
if DB == nil {
return nil
}
sqlDB, err := DB.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
// CloseAll 关闭所有数据库连接(包括从库)
func CloseAll() error {
// 关闭主库
if err := Close(); err != nil {
return err
}
// 关闭从库
for _, replica := range replicas {
if replica == nil {
continue
}
sqlDB, err := replica.DB()
if err != nil {
continue
}
sqlDB.Close()
}
replicas = nil
DBRead = nil
return nil
}
// Transaction 事务操作(自动使用主库)
func Transaction(fn func(tx *gorm.DB) error) error {
return DB.Transaction(fn)
}
// TransactionWithContext 带上下文的事务操作
func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error {
return DB.WithContext(ctx).Transaction(fn)
}
// ReadQuery 读查询(自动路由到从库)
func ReadQuery(ctx context.Context, model any, query string, args ...any) error {
db := GetDBFromContext(ctx)
return db.WithContext(ctx).Where(query, args...).Find(model).Error
}
// WriteQuery 写查询(强制使用主库)
func WriteQuery(ctx context.Context, model any, query string, args ...any) error {
return DB.WithContext(ctx).Where(query, args...).Find(model).Error
}
// HealthCheck 健康检查
func HealthCheck() map[string]bool {
result := make(map[string]bool)
// 检查主库
if DB != nil {
sqlDB, err := DB.DB()
if err == nil && sqlDB.Ping() == nil {
result["master"] = true
} else {
result["master"] = false
}
} else {
result["master"] = false
}
// 检查从库
for i, replica := range replicas {
if replica != nil {
sqlDB, err := replica.DB()
if err == nil && sqlDB.Ping() == nil {
result[fmt.Sprintf("replica_%d", i+1)] = true
} else {
result[fmt.Sprintf("replica_%d", i+1)] = false
}
} else {
result[fmt.Sprintf("replica_%d", i+1)] = false
}
}
return result
}
+167 -21
View File
@@ -2,7 +2,11 @@ package database
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
@@ -11,38 +15,180 @@ import (
"go.uber.org/zap"
)
var (
// RedisClient 全局 Redis 客户端
RedisClient *redis.Client
)
// RedisManager Redis 连接管理器(#10)。照 database.Manager 模式:
// 实例化 + DefaultRedis 全局默认 + 包级 facade 代理,支持多实例与测试注入。
type RedisManager struct {
mu sync.Mutex
cfg *config.Config
client *redis.Client
}
// InitRedis 初始化 Redis 连接
func InitRedis(cfg *config.Config) error {
RedisClient = redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr(),
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
// DefaultRedis 默认 Redis 管理器,包级 facade 代理到它。
//
// C-1/H-4 修复:改用 atomic.Pointer 保护读写,消除原裸指针置换(SetDefaultRedisManager
// 与请求 goroutine 无锁读(GetRedis 等 facade)之间的数据竞争。与 config.defaultManager
// 对齐。类型由 *RedisManager 变更为 atomic.Pointer[RedisManager]breaking:下游若直接
// 调用 DefaultRedis.Init 等方法需改用 InitRedis 等 facade,或 DefaultRedis.Load().Init)。
var DefaultRedis atomic.Pointer[RedisManager]
func init() {
DefaultRedis.Store(NewRedisManager())
}
// NewRedisManager 创建 Redis 管理器实例。
func NewRedisManager() *RedisManager { return &RedisManager{} }
// 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 修复:写超时
})
}
// 测试连接
ctx := context.Background()
if err := RedisClient.Ping(ctx).Err(); err != nil {
// 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 := 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 {
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
}
// CloseRedis 关闭 Redis 连接
func CloseRedis() error {
if RedisClient != nil {
return RedisClient.Close()
// Close 关闭 Redis 连接
func (m *RedisManager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.client == nil {
return nil
}
return nil
err := m.client.Close()
m.client = nil
return err
}
// GetRedis 获取 Redis 客户端
func GetRedis() *redis.Client {
return RedisClient
// Client 返回当前 Redis 客户端(未初始化返回 nil)。
func (m *RedisManager) Client() *redis.Client {
m.mu.Lock()
defer m.mu.Unlock()
return m.client
}
// setClientForTest 仅测试用:在持锁下替换 manager 的 client,返回旧 client。
// 供 SetTestRedisClient 注入 miniredis 等 mock,消除原包级 redisClient 双源真相。
func (m *RedisManager) setClientForTest(c *redis.Client) *redis.Client {
m.mu.Lock()
defer m.mu.Unlock()
old := m.client
m.client = c
return old
}
// HealthCheck Redis 健康检查。
func (m *RedisManager) HealthCheck(ctx context.Context) error {
ctx = normalizeContext(ctx)
m.mu.Lock()
client := m.client
m.mu.Unlock()
if client == nil {
return fmt.Errorf("Redis 未初始化")
}
return client.Ping(ctx).Err()
}
// --- 包级 facade(代理到 DefaultRedis,兼容存量) ---
// InitRedis 初始化 Redis 连接
func InitRedis(cfg *config.Config) error {
return DefaultRedis.Load().Init(cfg)
}
// CloseRedis 关闭 Redis 连接
func CloseRedis() error {
return DefaultRedis.Load().Close()
}
// HealthCheckRedis Redis 健康检查
func HealthCheckRedis(ctx context.Context) error {
return DefaultRedis.Load().HealthCheck(ctx)
}
// GetRedis 获取 Redis 客户端(未初始化返回 nil)。
//
// H-4 修复:单源——仅从 DefaultRedis.Load().Client() 取,废弃原包级 redisClient
// 回退路径(其在 manager 替换后会返回 stale client,且无锁读存在竞态)。
// 测试注入的 mock client 经 SetTestRedisClient 直接设在当前 manager 上,闭环一致。
func GetRedis() *redis.Client {
return DefaultRedis.Load().Client()
}
// SetTestRedisClient 供测试注入 miniredis 等 mock 客户端。
// 返回旧客户端引用以便测试清理时恢复。生产代码严禁调用。
//
// H-4 修复:改为在当前默认 manager 上持锁替换 clientsetClientForTest),
// 不再写独立的包级 redisClient 变量,消除双源真相与无锁写竞态。
//
// 约束:操作 DefaultRedis.Load() 返回的当前默认 manager。测试应避免在调用本函数
// 的同时并发 SetDefaultRedisManager 替换默认 manager,否则注入/恢复会作用到不同
// manager 上。典型用法为 init 阶段注入、t.Cleanup 恢复,串行执行。
func SetTestRedisClient(c *redis.Client) *redis.Client {
return DefaultRedis.Load().setClientForTest(c)
}
+84
View File
@@ -0,0 +1,84 @@
package database
import (
"context"
"net"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
// startHungRedisListener 启动一个接受 TCP 连接但不读写任何数据的 listener,
// 模拟"挂起 Redis"(连接建立但不响应 RESP,区别于宕机的 connection-refused)。
// 返回 listener 地址;t.Cleanup 关闭 listener。
func startHungRedisListener(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("hung redis listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return // listener 关闭
}
// 故意不读写、不关闭,持住连接模拟挂起。连接由 t.Cleanup 的 ln.Close 间接清理。
_ = conn
}
}()
return ln.Addr().String()
}
// TestRedisHealthCheckBoundsHungRedis_Hdb1 回归 H-db-1 边界(Redis 侧):
// RedisManager.HealthCheck(Background) 对挂起 Redis(连接活但不响应)应受
// redis client 的 ReadTimeout(3sredis.go D7) 约束有界返回错误,不无限阻塞。
// ctx 无 deadline 时由 client ReadTimeout 兜底;ctx 自带更短 deadline 时优先尊重 ctx。
// 修复前/缺 ReadTimeout 时 Ping(Background) 会无限阻塞。
func TestRedisHealthCheckBoundsHungRedis_Hdb1(t *testing.T) {
addr := startHungRedisListener(t)
cfg := redisTestConfig(t, addr)
m := NewRedisManager()
if err := m.Init(cfg); err == nil {
// Init 的 Ping 有 5s ctx,挂起 Redis 下应在 ~3sReadTimeout)失败返回错误。
t.Fatalf("挂起 Redis 的 Init 应返回错误,got nil")
}
// Init 失败后 m.client 未被安装(nil),HealthCheck 返 "Redis 未初始化"。
// 为测 HealthCheck 自身的 ping 超时约束,需 client 已安装但指向挂起 Redis。
// 用 setClientForTest 注入一个指向挂起 Redis 的 client。
client := newRedisClientForTest(addr)
old := m.setClientForTest(client)
t.Cleanup(func() {
if old != nil {
_ = old.Close()
}
_ = client.Close()
})
// HealthCheck(Background):挂起 Redis 下 client.Ping 受 ReadTimeout 3s 约束有界返回。
done := make(chan error, 1)
start := time.Now()
go func() { done <- m.HealthCheck(context.Background()) }()
select {
case err := <-done:
elapsed := time.Since(start)
if err == nil {
t.Fatalf("挂起 Redis 的 HealthCheck 应返回错误,got nil")
}
// 应在 ReadTimeout(3s) 附近返回,给 6s 上限(含连接/重试余量)。
if elapsed > 6*time.Second {
t.Fatalf("HealthCheck 耗时 %v 超过 6s 上限(应受 ReadTimeout 3s 约束)", elapsed)
}
case <-time.After(10 * time.Second):
t.Fatalf("HealthCheck 在挂起 Redis 上无限阻塞(ReadTimeout 未约束 Ping")
}
}
// newRedisClientForTest 构造指向 addr 的 redis.Client(复用 Init 的 client 配置,
// 含 DialTimeout 5s / ReadTimeout 3s / WriteTimeout 3s)。
func newRedisClientForTest(addr string) *redis.Client {
return newRedisClient(addr, "", 0)
}
+56
View File
@@ -0,0 +1,56 @@
package database
import (
"testing"
"github.com/redis/go-redis/v9"
)
func TestSetDefaultRedisManagerClosesReplacedManager(t *testing.T) {
old := NewRedisManager()
old.setClientForTest(redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}))
orig := SwapDefaultRedisManager(old)
t.Cleanup(func() {
current := SwapDefaultRedisManager(orig)
if current != nil && current != orig {
_ = current.Close()
}
})
next := NewRedisManager()
SetDefaultRedisManager(next)
if GetDefaultRedisManager() != next {
t.Fatal("SetDefaultRedisManager 应安装新的默认 manager")
}
if old.Client() != nil {
t.Fatal("SetDefaultRedisManager 应关闭并清空被替换的旧 Redis manager")
}
}
func TestSwapDefaultRedisManagerPreservesReplacedManager(t *testing.T) {
old := NewRedisManager()
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"})
old.setClientForTest(client)
orig := SwapDefaultRedisManager(old)
t.Cleanup(func() {
current := SwapDefaultRedisManager(orig)
if current != nil && current != orig {
_ = current.Close()
}
_ = old.Close()
})
next := NewRedisManager()
previous := SwapDefaultRedisManager(next)
if previous != old {
t.Fatal("SwapDefaultRedisManager 应返回被替换的旧 manager")
}
if GetDefaultRedisManager() != next {
t.Fatal("SwapDefaultRedisManager 应安装新的默认 manager")
}
if old.Client() != client {
t.Fatal("SwapDefaultRedisManager 不应关闭旧 Redis manager,旧资源需可用于失败回滚")
}
}
+45
View File
@@ -0,0 +1,45 @@
package database
import (
"context"
"net"
"strconv"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/alicebob/miniredis/v2"
)
func redisTestConfig(t *testing.T, addr string) *config.Config {
t.Helper()
host, portText, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split redis addr: %v", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("parse redis port: %v", err)
}
return &config.Config{Redis: config.RedisConfig{Host: host, Port: port}}
}
func TestRedisManagerInitClosesPreviousClient(t *testing.T) {
mr1 := miniredis.RunT(t)
mr2 := miniredis.RunT(t)
m := NewRedisManager()
if err := m.Init(redisTestConfig(t, mr1.Addr())); err != nil {
t.Fatalf("first Init: %v", err)
}
first := m.Client()
if first == nil {
t.Fatal("first client is nil")
}
if err := m.Init(redisTestConfig(t, mr2.Addr())); err != nil {
t.Fatalf("second Init: %v", err)
}
if err := first.Ping(context.Background()).Err(); err == nil {
t.Fatal("first client still usable after second Init; old Redis pool was not closed")
}
}
+66
View File
@@ -0,0 +1,66 @@
package database_test
import (
"context"
"sync"
"testing"
"github.com/EthanCodeCraft/xlgo-core/database"
)
func TestCloseRedisWithoutInit(t *testing.T) {
if err := database.CloseRedis(); err != nil {
t.Fatalf("CloseRedis without init should not error: %v", err)
}
if database.GetRedis() != nil {
t.Fatal("expected Redis client nil")
}
}
func TestHealthCheckRedisWithoutInit(t *testing.T) {
if err := database.HealthCheckRedis(context.Background()); err == nil {
t.Fatal("expected health check error without Redis init")
}
}
func TestRedisNilConfigReturnsError(t *testing.T) {
prev := database.SetTestRedisClient(nil)
t.Cleanup(func() { database.SetTestRedisClient(prev) })
if err := database.NewRedisManager().Init(nil); err == nil {
t.Fatal("RedisManager.Init(nil) 应返回错误")
}
if err := database.InitRedis(nil); err == nil {
t.Fatal("包级 InitRedis(nil) 应返回错误")
}
if err := database.HealthCheckRedis(nil); err == nil {
t.Fatal("未初始化 Redis 时 HealthCheckRedis(nil) 应返回错误")
}
}
// TestDefaultRedisConcurrentSetAndGet C-1/H-4 回归:并发 SetDefaultRedisManager
// 与 GetRedis/CloseRedis/HealthCheckRedis facade 读取不应触发数据竞争。
// 修复前 DefaultRedis 是裸 *Redis.PointerSetDefaultRedisManager 无锁写、
// facade 无锁读,-race 必采。修复后 atomic.Pointer 保护。
func TestDefaultRedisConcurrentSetAndGet(t *testing.T) {
// 保存并恢复默认 manager,避免污染其他测试。
orig := database.NewRedisManager()
database.SetDefaultRedisManager(orig)
// 不设 clientGetRedis 返回 nil;本用例只验证读写无竞态。
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(2)
go func() {
defer wg.Done()
database.SetDefaultRedisManager(database.NewRedisManager())
}()
go func() {
defer wg.Done()
_ = database.GetRedis()
_ = database.CloseRedis()
_ = database.HealthCheckRedis(context.Background())
}()
}
wg.Wait()
}
+38
View File
@@ -0,0 +1,38 @@
package database
import (
"errors"
"testing"
)
func TestIsTransientDBError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, true},
{"access denied", errors.New("Error 1045: Access denied for user 'root'@'localhost'"), false},
{"auth plugin", errors.New("authentication plugin 'caching_sha2_password' cannot be loaded"), false},
{"unknown database", errors.New("Error 1049: Unknown database 'foo'"), false},
{"invalid DSN", errors.New("invalid DSN: missing the slash separating the database name"), false},
{"unknown driver", errors.New("sql: unknown driver \"foobar\" (forgotten import?)"), false},
{"connection refused (transient)", errors.New("dial tcp 127.0.0.1:3306: connect: connection refused"), true},
{"i/o timeout (transient)", errors.New("dial tcp 10.0.0.1:3306: i/o timeout"), true},
{"empty msg", errors.New(""), true},
// P1 #12:PG 目标库不存在应视为非瞬态(不重试)。
{"pg database not exist", errors.New(`FATAL: database "app" does not exist (SQLSTATE 3D000)`), false},
{"pg password auth failed", errors.New("FATAL: password authentication failed for user \"app\""), false},
// P1 #12 回归核心:含 "database" 但是瞬态的错误必须仍被判为瞬态(可重试),
// 修复前独立 "database" 子串会把它误判为非瞬态而放弃重试。
{"pg starting up (transient)", errors.New("FATAL: the database system is starting up (SQLSTATE 57P03)"), true},
{"pg in recovery (transient)", errors.New("FATAL: the database system is in recovery mode"), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isTransientDBError(tt.err); got != tt.want {
t.Errorf("isTransientDBError(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+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)
}
}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
# xlgo v1.1.0 实施计划 — HA & Manager 化
> 本文件为 v1.1.0 版本的逐项实施计划,对应体检报告(`docs/plans/Version_v1.0.2_report.md`)第三~四章 #10-#24 架构/HA 改进。
> 范围:13 项(#20 RedisRateLimiter、#23 Recover 带 request_id 已实现,本次核对配合)。
## Context
v1.0.3bug fix)与 v1.0.4DX & Docs)已发布推送,GitHub Release 已创建,工作区干净。v1.0.2 体检报告规划的 v1.1.0 阶段共 13 项架构/HA 改进,目标是把 xlgo 从"一半组件可注入、一半是单例"的撕裂状态,贯彻到"通用 / 高可用 / 易上手"。
本次一次性推完 13 项,发 v1.1.0。已确认的边界决策:
- **版本策略**v1.1.0 接受少量 breaking#11 删 wire 包、#14`AppConfig.TokenExpire`、删 `StartServerWithPort`/`GracefulShutdown``JWTConfig.Expire` 类型变更),在 CHANGELOG「升级说明」章节列出;其余(#15 response)用全局开关默认兼容存量。
- **#10 Manager 化**storage/cache/redis/jwt/logger **5 个全做**(含 logger),照 `database.Manager` + 包级 facade 蓝本。
- **#15 response**:全局 `SetMode(ModeBusiness|ModeREST)`,默认 `ModeBusiness`(现状全 200 + 业务码)兼容;`ModeREST` 下 401/404/500 返回对应 HTTP statusbody 仍带业务码。可在 `ServerConfig.ResponseMode` 配置。
- **#11 wire**:删掉整个 `wire` 包(其事 App Option 已覆盖)。
- **#18**:接受 `prometheus/client_golang` 新依赖。
- **#12**:只提供 `WithHook(Hook{...})` 机制,不引入 etcd 依赖、不提供内置示例。
## 实现方案(按依赖顺序分 6 组)
### 组 1:配置层(其余项的基础)
**#13 Server 参数配置化** — `config/config.go:84-88`
`ServerConfig` 增字段:`ReadTimeout/WriteTimeout/IdleTimeout/ShutdownTimeout/MaxHeaderBytes``TLS{Enabled,CertFile,KeyFile}``UnixSocket string``ResponseMode string``app.go:408-414``http.Server` 改为读这些字段(缺省回退到当前硬编码值)。TLS 开启时用 `ListenAndServeTLS``UnixSocket` 非空时优先 unix socket。Shutdown 超时读 `ShutdownTimeout`
**#14 JWTConfig.Expire time.Duration + 删 TokenExpire** — `config/config.go:44, 209-213`
`JWTConfig.Expire``time.Duration`mapstructure + viper string 解析 `"24h"`),新增 `RefreshExpire time.Duration``Issuer``Algorithm`。**删 `AppConfig.TokenExpire`**breaking)。`jwt/jwt.go` 内过期取值改读 `JWTConfig.Expire`。Duration 解析依赖 mapstructure decode hook。
**#16 Config Validate** — 新增 `config/config.go` `(*Config).Validate() error`
校验:`Server.Port` 范围、`JWT.Secret` 非空且 ≥32 字符(启用 jwt 时)、启用 mysql 时关键字段、Duration 非负等。`config.Manager.Load``config/manager.go:340`)解析后自动调用,错误包裹返回,把"运行时第一次请求才暴露"提前到启动。
**#15 response Mode 开关** — `response/response.go`
新增 `type Mode int``ModeBusiness/ModeREST`、包级 `currentMode` + `SetMode(m)``Mode()``Fail/Unauthorized/NotFound/ServerError/RateLimit/FailWithCode` 内:`ModeREST` 时按错误码/错误类型映射 HTTP status`ErrUnauthorized→401``ErrNotFound→404``ErrServer→500``ErrRateLimit→429`、参数类→400),`c.JSON(status, Response{...})``ModeBusiness` 维持 200。`App.Init` 末尾按 `ServerConfig.ResponseMode``response.SetMode`。新增 `response.Custom(c, httpStatus, code, data)` 显式 API。
### 组 2:组件 Manager 化(#105 组件,照 database.Manager 蓝本)
蓝本参考 `database/manager.go:180-192``DefaultManager` 包级实例 + `SetDefaultManager` + 包级 facade 代理 + 实例方法)。每个组件统一模式:
- 新增 `type Manager struct{ ...; mu sync.Mutex }` + `var DefaultXxx = &Manager{}`
- `SetDefaultManager(m)` 提升用户实例到全局
- 包级 `Init/Get/操作` 函数代理到 `DefaultXxx`**保留,兼容存量**
- `App` 持有各 Manager 实例(`App` 加字段),`WithXxx` 时初始化 App 自己的实例并 `SetDefaultXxx`
顺序(按依赖):**redis → cache → jwt → storage → logger**
1. **redis**`database/redis.go`:新增 `type RedisManager struct{ cfg; client *redis.Client; mu }` + `DefaultRedis``InitRedis/CloseRedis/GetRedis/HealthCheckRedis` 代理。下游 5 处直接读 `database.RedisClient``jwt/jwt.go``middleware/ratelimit.go``cache/cache.go``cache/lock.go` 20+ 处、`app.go`)全部改为 `database.GetRedis()``cache/lock.go` 改为接受 `*redis.Client` 参数或内部 `GetRedis()`
2. **cache**`cache/cache.go``type CacheManager struct{ client *redis.Client; svc CacheService; mu }` + `DefaultCache``redisCache.client` 从硬编码 `database.RedisClient` 改为构造时注入。`Init/GetCache` 代理。`cache/lock.go` 的分布式锁函数改走 `DefaultCache` 或显式传 client。
3. **jwt**`jwt/jwt.go``type Manager struct{ blacklist *TokenBlacklist; cfg *config.JWTConfig; mu }` + `DefaultJWT``TokenBlacklist.Add/IsBlacklisted` 内部 `database.RedisClient``database.GetRedis()``GenerateToken/ParseToken/InvalidateToken/RefreshToken/...` 代理到 `DefaultJWT`
4. **storage**`storage/storage.go``type Manager struct{ cfg; current Storage; mu }` + `DefaultStorage``Init/GetStorage/SetStorage/Upload/...` 代理到 `DefaultStorage.current`。最干净,无外部下游。
5. **logger**`logger/logger.go``type Manager struct{ cfg; logger/apiLog/dbLog *zap.Logger; fileWriters; mu }` + `DefaultLogger`。包级 `Init/Sync/Close/Info/.../APILog/DBLog` 代理。**特殊**logger 是 `App.Init` 最先初始化的组件,`DefaultLogger` 初始化前包级函数须安全降级到 Nop(现状 `Close()` 已重置为 Nop,沿用)。下游 8 包的 `logger.Info(...)` 调用点无需改(仍走包级 facade)。
`App` struct`app.go:41-62`)加字段:`redisMgr *database.RedisManager``cacheMgr *cache.CacheManager``jwtMgr *jwt.Manager``storageMgr *storage.Manager``loggerMgr *logger.Manager`
### 组 3App 生命周期
**#12 Lifecycle Hooks** — `app.go`
新增 `type Hook struct{ Name string; OnInit func(*App) error; OnStart func(*App) error; OnReady func(*App); OnStop func(*App) error }` + `WithHook(Hook) Option``App``hooks []Hook``Init()` 内组件初始化完成后按序调 `OnInit``StartServer` 监听前调 `OnStart`,端口就绪后调 `OnReady``Shutdown` 开头调 `OnStop`。各 hook 错误中断流程并返回。
**#22 App.Go + in-flight goroutine** — `app.go`
`App``wg sync.WaitGroup` + `ctx context.Context`(根 ctx+ `cancel`。新增 `App.Go(fn func(ctx context.Context))``wg.Add(1); go func(){ defer wg.Done(); fn(ctx) }()``Shutdown``OnStop` 后、关 HTTP 前调 `cancel()``wg.Wait()`(带 `ShutdownTimeout` 超时)。
**#11 删 wire 包** — 删 `wire/wire.go`(整包)。清理 `app.go``WithWire/WithoutWire/enableWire``Init()``wire.InitServices()` 调用。`cache.Init()` 原由 wire 触发,改由 `WithRedis`/`WithCache` 触发(或 `App.Init` 显式调)。
**清理双轨**`app.go:494-537`:删 `StartServerWithPort``GracefulShutdown`(与 `App.StartServer`/`App.Shutdown` 重复)。breaking,升级说明列出。
### 组 4:中间件与路由
**#24 RequestID 默认装入** — `app.go:~348`
`App.Init` 中间件链改为无条件 `a.router.Use(middleware.RequestID())`(在 Recovery 之前),让每个响应/panic 日志都带 request_id。核对 #23`middleware/recover.go:20``GetRequestID(c)`,配合 #24 后 panic 日志 request_id 非空。移除 `gin.Recovery()` 双重保险(保留 `middleware.Recover()` 一个即可)。
**#19 请求级 Timeout 中间件** — 新增 `middleware/timeout.go`
`func Timeout(d time.Duration) gin.HandlerFunc``ctx, cancel := context.WithTimeout(c.Request.Context(), d); defer cancel(); c.Request = c.Request.WithContext(ctx); c.Next()`。可通过 `WithRequestTimeout(d)` Option 装入全局,下游 GORM/Redis 走 `c.Request.Context()` 级联取消。
**#18 Prometheus metrics** — 新依赖 `prometheus/client_golang`
新增 `middleware/metrics.go``middleware.Metrics()` 记录 HTTP latency / status code / in-flighthistogram + counter + gauge)。新增 `router/metrics.go``RegisterMetricsRoute(r, path...)` 默认 `/metrics``promhttp.Handler()`。新增 `WithMetricsRoute(path...)` Option。`App.Init` 装入 `Metrics()` 中间件 + 路由。
**#17 livez/readyz** — `router/router.go`
新增 `RegisterLivenessRoute(r)``GET /livez` 永不依赖外部(进程存活即 200)。新增 `RegisterReadinessRoute(r, checks...)``GET /readyz` 复用 `HealthCheck`,失败 503。新增 `WithLivenessRoute()`/`WithReadinessRoute()` Option。`/health` 保留兼容。`livez` 不查依赖、`readyz` 查依赖,对 K8s probe 友好。
### 组 5:依赖健康自愈(#21)
**#21 主库探活 + replica 健康剔除** — `database/manager.go`
- `database.Manager``healthy bool` + `consecutiveFailures int` + `healthMu``Pool.SetConnMaxIdleTime` 配置化(`DatabaseConfig``ConnMaxIdleTime`)。
- 探活:`App.Init` 末尾用 `App.Go` 起后台 goroutine,每 30s `HealthCheck(ctx)`,连续 N 次失败标记 `healthy=false``readyz`/`/health` 读此标记返回 503。
- `ReplicaPicker` 接口加健康度:replica ping 失败剔除轮询,恢复后重新纳入。`RoundRobinPicker`/`RandomPicker` 实现 health-aware 选取。
### 组 6:收尾与发版
- `app.go:27` `const Version = "1.1.0"`
- `CHANGELOG.md``[1.1.0]` 章节(Added/Changed/Fixed + **升级说明** breaking 列表)。README 更新日志 + 底部链接。
- `examples/`full/minimal)同步:full 例子的配置文件加 `server.read_timeout` 等新字段、`jwt.expire: 24h`,验证 5 组件 Manager 化后仍可跑。
- 测试:`go test ./...` 全绿;为 Manager 化、Validate、response Mode、livez/readyz、metrics、timeout、App.Go 补单测。
- `go mod tidy`
## 关键文件清单
| 文件 | 涉及 issue |
|---|---|
| `app.go` | #10 App 字段、#12 hooks、#13 server、#19/#18/#17/#24 路由中间件、#21 探活、#22 App.Go、#11 删 wire、删双轨、Version |
| `config/config.go` | #13 ServerConfig、#14 JWTConfig+删TokenExpire、#16 Validate |
| `config/manager.go` | #16 Load 调 Validate、Duration decode hook |
| `response/response.go` | #15 Mode 开关 + status 映射 |
| `database/redis.go` | #10 redis Manager |
| `database/manager.go` | #21 探活 + replica 健康剔除 |
| `cache/cache.go` `cache/lock.go` | #10 cache Manager + 解耦 RedisClient |
| `jwt/jwt.go` | #10 jwt Manager + #14 Expire |
| `storage/storage.go` | #10 storage Manager |
| `logger/logger.go` | #10 logger Manager |
| `middleware/{requestid,recover}.go` | #24 装入 + #23 核对 |
| `middleware/timeout.go`(新) | #19 |
| `middleware/metrics.go`(新) | #18 |
| `router/router.go` `router/metrics.go`(新) | #17 livez/readyz + #18 /metrics |
| `wire/wire.go` | #11 删除 |
| `go.mod` | #18 prometheus 依赖 |
| `CHANGELOG.md` `README.md` | 发版 |
## 验证
1. `go mod tidy && go build ./...` — 编译通过。
2. `go test ./...` — 全绿,含新增单测。
3. `go run ./example`full 栈)— 启动无错,`/livez`→200、`/readyz`→200(依赖在时)、`/metrics`→prometheus 文本、`/health` 兼容;配置错误时 `Validate` 在启动期拦截。
4. 手动验证 breaking:删 wire 后 `example` 不再 import wire`AppConfig.TokenExpire` 删除后 grep 无残留;`response.SetMode(ModeREST)``Unauthorized` 返回 401。
5. `App.Go` 起一个长任务 goroutine,发 SIGTERM,确认 `Shutdown` 等其退出(日志可见)。
6. 主库探活:手动关 mysql,30s 后 `/readyz`→503、`/health`→503;恢复后自动转 200。
7. 发版:commit + annotated tag `v1.1.0``git push xlgo-core main && git push xlgo-core v1.1.0`release 内容写本地 `gitHub_release_v1.1.0.md`.gitignore 忽略),用户网页创建。
+519
View File
@@ -0,0 +1,519 @@
# xlgo v1.0.2 体检报告
> 资深 Go 视角的代码与架构 Review。目标是把 xlgo 从"业务沉淀工具集"打磨成"通用 / 高可用 / 易上手"的开源 Web 框架。
>
> 整体判断:v1.0.2 已经把"业务耦合 + 不可组合 + 框架内 Fatal"这三个最大的设计债还掉了,框架骨架是健康的。但仍有若干**真实 Bug**和**架构层面的债**,按下方优先级逐项核查即可。
---
## 一、必须立刻修的真实 Bug(带行号)
这些不是设计取舍,是确凿的缺陷,先于一切改进。
### 1. `response.CodeSuccess` 与 `CodeInvalidParams` 撞码 ⚠️
```go
// response/error.go:13-16
CodeSuccess = 1 // 成功
CodeFail = 0 // 通用失败
CodeInvalidParams = 1 // 参数错误 ← 跟 Success 同值!
```
只要任何业务调用 `response.FailWithError(c, response.ErrInvalidParams)`,前端拿到的 `code` 跟成功响应一模一样。这是**生产级 bug**。
**建议**:制定明确的码段策略——
- `0` = success(业内更通用),或者 `200`/`0` 二选一
- `1` 留给"通用失败"
- 参数错误使用 `40001` 等业务码段
- 同时为了避免后续重复,写一个 `init()` 自检:
```go
func init() {
seen := map[int]string{}
for code, name := range allErrorCodes {
if old, ok := seen[code]; ok {
panic(fmt.Sprintf("duplicate error code %d: %s vs %s", code, old, name))
}
seen[code] = name
}
}
```
### 2. CORS 中 `Allow-Credentials` 永远是 `true` ⚠️
```go
// middleware/cors.go:86-91
if corsConfig != nil && corsConfig.AllowCredentials {
c.Header("Access-Control-Allow-Credentials", "true")
} else {
c.Header("Access-Control-Allow-Credentials", "true") // 默认允许 ← 错
}
```
并且当 `Origin: *` 时还会被浏览器拒绝。这是 CORS 经典坑。**修复**:
```go
if corsConfig.AllowCredentials && allowedOrigin != "*" {
c.Header("Access-Control-Allow-Credentials", "true")
}
```
### 3. 日志在开发模式下写两份到同一文件链 ⚠️
```go
// logger/logger.go:95-99
core := zapcore.NewTee(apiCore, dbCore, consoleCore)
Logger = zap.New(core, ...)
```
`Logger` 是全局通用 logger,但 Tee 把 dbCore 也接进去——结果**所有 `logger.Info(...)` 都会同时写到 `api.log``database.log`**,再加一份控制台。`APILog()`/`DBLog()` 的"分流"形同虚设。
**修复**:通用 logger 只写 console + 一个 app.log`APILog()`/`DBLog()` 各自独立 core,互不 Tee。
### 4. `DBResolver.BeforeQuery` 是死代码 ⚠️
```go
// database/mysql.go:386-408
func (r *DBResolver) BeforeQuery(db *gorm.DB) { ... }
```
这个 hook 从未通过 `db.Callback().Query().Before(...)` 注册过。所以**读写分离实际上需要业务侧自己调用 `GetDBFromContext(ctx)`**,但 README/GUIDE 暗示它会自动路由。要么把 hook 真正注册上,要么把这段代码删掉、文档明确"显式 `UseReplica/UseMaster`"。
我倾向**删掉**——GORM 官方有 `dbresolver` plugin,实现得更完整(权重、policy)。引入它比自造轮子更稳。
### 5. 重试策略对所有错误一视同仁
```go
// database/mysql.go:213-240
maxRetries := 5
// 不论是 driver 错、密码错、端口错都重试 5 次,指数退避
```
**密码错误**也会让进程在启动阶段死等 1 分钟才报错,体验很差。建议区分:
- `*mysql.MySQLError` Code 1045access denied/ 1049unknown db)等 → 直接返回,不重试
- `net.OpError``io.EOF`、上下文未到等 → 重试
### 6. `generateJTI` 忽略 `rand.Read` 错误
```go
// jwt/jwt.go:40-44
func generateJTI() string {
bytes := make([]byte, 16)
rand.Read(bytes) // ← 错误丢弃
return base64.URLEncoding.EncodeToString(bytes)
}
```
`crypto/rand.Read` 在 Linux 早期启动或某些容器中**确实会失败**。失败时 JTI 会是全零,黑名单可能误判。改为返回 `(string, error)` 或在失败时 `panic` 都比静默吞错好。
### 7. `repository.QueryBuilder.Page` 的 Count 受残留 limit 影响
```go
// repository/repository.go:404-417
countDB := qb.db.Session(&gorm.Session{}) // ← 复用了已 Limit/Offset 的 db
if err := countDB.WithContext(ctx).Count(&total).Error; err != nil { ... }
```
如果用户先 `.Limit(10)``.Page(...)``countDB` 会带 LIMITCount 是错的。需要 `Limit(-1).Offset(-1).Order("")`
```go
countDB := qb.db.Session(&gorm.Session{}).Limit(-1).Offset(-1).Order("")
```
### 8. `OSSStorage.Upload` 文件名冲突风险
```go
// storage/storage.go:205
objectKey := fmt.Sprintf("%s/%d%s", filepath.Join(...), now.UnixNano(), ext)
```
并发上传 / 容器集群同纳秒会产生**完全相同的 key**,OSS 会覆盖。补一个随机后缀或 uuid:
```go
objectKey := fmt.Sprintf("%s/%d-%s%s", dir, now.UnixNano(), randHex(8), ext)
```
### 9. `go.mod` indirect 里的可疑版本
```
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9
```
这是 2026-04-01 的 pseudo-version,搭配 `golang.org/x/crypto v0.49.0``golang.org/x/net v0.52.0` 看起来正常,但需要确认这是 OTel 1.43 强制带来的传递依赖,还是历史 `go.sum` 没整理。建议跑一次 `go mod tidy && go mod verify` 之后人工 review。
---
## 二、架构层面的"债"(影响通用性与多实例化)
v1.0.2 把 config 和 database 改成了 Manager + 全局 facade 的双轨,但还有几个核心组件**没跟上这套抽象**:
### 10. Storage / Cache / Redis / JWT / Logger 仍是单例
| 组件 | 全局变量 | 多实例可能性 |
|---|---|---|
| `storage.storage` | 包级 var | 不能同时连 OSS + 本地 |
| `cache.globalCache` | 包级 var | 不能为不同业务设不同 prefix/TTL 默认值 |
| `database.RedisClient` | 包级 var | 不能多 Redis(缓存 + 队列 + 限流分库) |
| `jwt.tokenBlacklist` | 包级 var | 不能区分 user-token 和 refresh-token blacklist |
| `logger.Logger` | 包级 var | 不能区分多 app/多模块独立日志 |
**建议**:照 `database.Manager` + `database.DefaultManager` 的模式,每个组件提供 `XxxManager` 类型 + 全局便捷 facadeApp 持有自己的 Manager 实例。这样:
- 单元测试可以注入 mock
- 多 App 共存(比如同进程跑 admin + api 两个 Engine
- 微服务里组件解耦
优先级:**Redis Manager 最重要**(因为 JWT、Cache、RateLimiter、分布式锁都依赖它,目前全是访问 `database.RedisClient`,没法替换)。
### 11. `wire` 包名误导
```go
// wire/wire.go - 整个文件 32 行
func InitServices() { ... }
```
它叫 `wire`,但跟 Google Wire 没关系,也不是 DI 容器。新用户会困惑。建议二选一:
- **删掉**——其实现的事 App 通过 Option 已经做了
- **真正引入 Wire 或 fx/uber**——给一个最小 DI 范式
### 12. `App.Init()` / `App.Run()` 缺少 Lifecycle Hooks
现在的 App 内部是硬编码顺序:config → logger → mysql → redis → storage → wire → migrate → routes。如果用户想插入"Migrate 之前先初始化分布式锁,避免多副本同时迁移"或者"启动后注册到服务发现",没有钩子。
**建议**v1.1.0 路线):
```go
type Hook struct {
Name string
OnInit func(*App) error // Init 流程内
OnStart func(*App) error // 监听端口前
OnReady func(*App) // 端口就绪后
OnStop func(*App) error // Shutdown 前
}
func WithHook(h Hook) Option
```
并提供两个内置示例:`hooks.RegisterEtcd(...)``hooks.RegisterDistributedMigrate(...)`
### 13. Server 参数全部硬编码
```go
// app.go:400-406
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
```
加上 `Shutdown` 30s 超时。这些都该进 `ServerConfig`
```yaml
server:
port: 8080
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
max_header_bytes: 1048576
tls:
enabled: false
cert_file: ""
key_file: ""
unix_socket: "" # 优先级高于 port
```
### 14. `JWTConfig.Expire` 与 `AppConfig.TokenExpire` 重复且单位不明
两个字段都是过期秒数,都没有 `time.Duration` 类型。Go 项目应优先用 `time.Duration` + viper 的 `string` 解析(`"24h"``"30m"`),**单位看就懂**
```go
type JWTConfig struct {
Secret string `mapstructure:"secret"`
Expire time.Duration `mapstructure:"expire"` // "24h"
RefreshExpire time.Duration `mapstructure:"refresh_expire"` // "168h"
Issuer string `mapstructure:"issuer"`
Algorithm string `mapstructure:"algorithm"` // HS256/RS256
}
```
`AppConfig.TokenExpire` 直接删掉。
### 15. `response` 把 4xx/5xx 全压成 HTTP 200
```go
// response/response.go:32-39
func Success(c *gin.Context, data any) {
c.JSON(http.StatusOK, ...) // ← 永远 200
}
// Unauthorized / Fail / NotFound / ServerError 全部 200
```
这是国内典型"业务码 in body"的玩法,**对接 APM、Prometheus、APISIX/网关、Sentry 都很难受**——它们靠 HTTP status 区分异常。建议:
1. 默认仍保留业务码模式(兼容存量),但允许通过全局开关切到"REST 模式"
```go
response.SetMode(response.ModeREST) // 或在 ServerConfig 中
// 401 错误 → 返回 HTTP 401, body 带业务 code
```
2. 或者更优雅:`Fail` 带一个明示的 HTTP status
```go
response.Fail(c, response.ErrUnauthorized) // 自动 401
response.Custom(c, http.StatusBadRequest, ErrInvalidParams, nil)
```
### 16. 配置缺少 Validate
`config.Manager.Load` 解析完直接返回,对必填字段 / 取值范围都没校验。建议加 `Validate() error`
```go
func (c *Config) Validate() error {
if c.Server.Port <= 0 || c.Server.Port > 65535 { ... }
if c.JWT.Secret != "" && len(c.JWT.Secret) < 32 { ... }
// 启用 mysql 时强制要求关键字段
return nil
}
```
并在 `Manager.Load` 内自动调用——配置错把启动时间从"运行时第一次请求"提前到"进程启动",是高可用的小细节。
---
## 三、高可用 / 生产就绪的缺口
### 17. 没有 Liveness / Readiness 区分
v1.0.2 的 `/health` 只有一个,对 K8s 不友好。K8s probe 期望:
- `/livez`:进程是否活着(**永远不依赖外部**,只检查 goroutine、内存)
- `/readyz`:是否可以接流量(依赖 mysql/redis 通透)
建议在保持 `/health` 兼容的同时加:
```go
xlgo.WithLivenessRoute() // GET /livez
xlgo.WithReadinessRoute() // GET /readyz, 复用 healthChecks
```
### 18. 没有 Prometheus / Metrics 中间件
通用 Web 框架不带 metrics endpoint 是硬伤。建议:
```go
import "github.com/prometheus/client_golang/prometheus/promhttp"
func WithMetricsRoute(path ...string) Option // 默认 /metrics
```
并提供一个 `middleware.Metrics()`——HTTP latency、status code、in-flight 这些标配指标。
### 19. 没有请求级超时中间件
`http.Server.ReadTimeout` 是连接级。**业务级**超时需要 `middleware.Timeout(5*time.Second)`
```go
func Timeout(d time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
```
下游 GORM/Redis 调用走 `c.Request.Context()` 才能真正级联取消。
### 20. RateLimiter 内存版无法集群共享
`middleware/ratelimit.go``RateLimiter` 是单进程内存版,多副本部署时限流器各管各的。Redis 版本(`RedisRateLimiter`)应该一并提供,并且:
- 用 lua 脚本实现 token bucket 或滑动窗口(避免多次 round-trip
- 默认每个限流器有 `Name`,方便 Prometheus 上报"被限流次数"
### 21. 没有依赖健康自愈
主库宕机后 `database.Manager.master` 会一直握着断连。建议:
- `Pool.SetConnMaxIdleTime` 配置化
- 探活定时任务:每 30s ping 一次,连续 N 次失败标记"unhealthy"readiness 立即返回 503
- Replica 健康剔除:`ReplicaPicker` 支持权重 + 健康度(v1.1.0 路线)
### 22. Graceful shutdown 没等业务 in-flight goroutine
现在 `Shutdown` 只关 HTTP server。如果业务在 handler 里 spawn 了后台 goroutine(异步发短信、写日志),它们会被进程退出强制砍掉。建议:
- App 暴露 `App.Go(fn func(ctx context.Context))`,内部维护 `sync.WaitGroup`
- Shutdown 时 `wg.Wait()` 带超时
### 23. `gin.Recovery` + `middleware.Recover` 双重保险但没 trace_id
panic 时只记录 stack,没有 request_id / trace_id 关联。建议 Recover 中间件改为:
```go
func Recover() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
rid := c.GetString("request_id")
logger.Error("panic recovered",
zap.String("request_id", rid),
zap.Any("error", r),
zap.ByteString("stack", debug.Stack()))
response.ServerError(c, "服务器内部错误")
}
}()
c.Next()
}
}
```
### 24. RequestID 中间件没默认装入
`response.go` 依赖 `c.GetString("request_id")` 但默认中间件链里没有这一环。建议 `app.Init` 时无条件 `Use(middleware.RequestID())`,让每个响应都带 `request_id`trace 才有意义。
---
## 四、易上手 / 开发体验
### 25. 模块路径与 import alias 不一致
`go.mod``github.com/EthanCodeCraft/xlgo-core`CLAUDE.md 又提"本地导入用 xlgo"——新用户第一次 `go mod tidy` 多半会撞墙。建议:
- 模块路径直接定为 `github.com/EthanCodeCraft/xlgo`(去掉 `-core`),**包名仍然 `xlgo`**
- README 第一段就给完整 import 语句,不要让用户猜
### 26. 代码里大量 "评分: ⭐⭐⭐⭐⭐" 注释
`response.go``storage.go``repository.go``config.go``cache.go``middleware/cors.go` ……到处都是:
```go
// 评分: ⭐⭐⭐⭐⭐
// 理由: 文件下载封装,自动设置响应头
```
这些是 AI 生成留下的"自夸",**对外发布的库代码里出现这个会显得不专业**。建议批量删掉。如果想留设计理由,改成 `// Why: ...` 风格的简洁注释。
### 27. 大量 `Without*` Option 实际上不需要
```go
WithLogger / WithoutLogger
WithMySQL / WithoutMySQL
WithRedis / WithoutRedis
...
```
每对都是 v1.0.2 在"全开 vs 全关"摇摆产生的副产品。既然已经定调"`xlgo.New()` 是轻量",那 `WithoutLogger` 的用途就只剩"用了 `NewFullStack` 又想关掉一个"。**建议**:
- `Without*` 全部标 `Deprecated`
- 文档统一推荐组合:
- `xlgo.New(...)` + 显式 `With*`
- `xlgo.NewFullStack(...)` 全开
- 真要关单项,让 FullStack 接受函数式排除:`xlgo.NewFullStack(xlgo.Disable("redis"))`
### 28. CLI 模板太单一
`xlgo new` 只生成一种模板。`Version_Update_Plan_v1.0.2.md` 第 884 行已经规划了:
```
xlgo new myproject --template minimal
xlgo new myproject --template api
xlgo new myproject --template fullstack
xlgo new myproject --template grpc # 建议补
xlgo new myproject --template microservice
```
是时候做了。最小模板对降低"上手第一公里"的心理负担非常关键。
### 29. 缺一个 examples/ 目录
我看到 README 里有 `make run``go run ./example`,但仓库里**没有 example 目录**。新用户 clone 之后跑不起来。建议至少补两个:
- `examples/minimal/` —— 50 行能跑
- `examples/full/` —— mysql + redis + jwt + 一个 user CRUD
### 30. CHANGELOG 与 Version_Update_Plan 应分离
`Version_Update_Plan_v1.0.2.md` 是规划文档,不应放仓库根。建议:
```
docs/
├── CHANGELOG.md # 追加格式,每个版本 Added/Changed/Fixed
├── plans/
│ └── v1.0.2.md # 历史规划归档
├── architecture.md
└── migration/v1.0.1-to-v1.0.2.md
```
---
## 五、值得期待的 v1.1+ 路线
按优先级排成一个推荐的迭代节奏:
### v1.0.3Bug Fix Release12 周)
**修真实 bug,不破坏 API**
-#1 错误码冲突 + 自检
-#2 CORS Allow-Credentials
-#3 Logger Tee bug
-#4 删掉死代码 DBResolver / 引入 gorm dbresolver
-#6 `generateJTI` 错误处理
-#7 QueryBuilder.Page Count
-#8 OSS 文件名冲突
-#9 go.mod tidy 复查
-#26 删除"评分"注释
### v1.0.4DX & Docs
-#25 模块路径修正
-#28 CLI 多模板
-#29 examples/
-#30 文档结构调整
### v1.1.0HA & Manager 化)
- #10 Storage/Cache/Redis/JWT 全部 Manager 化
- #12 Lifecycle Hooks
- #13 Server 参数全配置化
- #14 `time.Duration` 配置
- #16 Config Validate
- #17 livez / readyz
- #18 metrics 中间件
- #19 请求级 Timeout
- #20 Redis 限流器
- #21 主库探活 + replica 健康剔除
- #22 等业务 goroutine
- #23 Recover 带 request_id
- #24 RequestID 默认装入
### v1.2.0(生态)
- 内置 OpenAPI 3.x(替代 swaggo,后者已半弃维)
- gRPC Gateway 模板
- 多租户模板(参考 #1 错误码 namespace
- RBAC 扩展包
- DDD / Clean Architecture 项目模板
---
## 六、整体判断
xlgo 的 v1.0.2 已经迈过了"内部工具集"到"框架"的这道坎,特别是**dialect 注册表**、**config.Manager + SetDefaultManager**、**database.Manager** 这三个设计是真正的框架式抽象,证明设计上是在往正确方向走。
但要打到"通用 / 高可用 / 易上手",**最值得马上做的两件事**是:
1. **先把第一节那 9 个 Bug 修掉**——它们里任意一个被开源用户踩到,都会发 issue 质疑框架质量。
2. **把 Storage/Cache/Redis/JWT 也 Manager 化**——这是把 v1.0.2 的好抽象"贯彻到底"。否则现在是**一半组件可注入,一半是单例**的撕裂状态,对中大型项目和单元测试都不友好。
建议从 v1.0.3 的 Bug Fix 开始动手,优先级:
> #1 (错误码冲突) → #2 (CORS) → #3 (Logger Tee) → #4 (DBResolver 死代码)
这几个改完都不破坏 API,可以一个 PR 一个 commit 走 review。
+485
View File
@@ -0,0 +1,485 @@
# xlgo Web 框架评估报告(v2.0 优化后版本)
## 一、项目概述
xlgo 是一个基于 Go + Gin 构建的轻量级 Web 开发框架,旨在提供后端开发的基础设施。本报告基于 v2.0 版本(经过全面优化和 zl 工具库移植后)进行评估。
---
## 二、本轮优化内容汇总
### 2.1 新增核心包
| 包名 | 来源 | 函数数 | 核心功能 |
|------|------|--------|----------|
| `utils/` | zl/utils | 111 | 随机数、字符串、时间、转换、文件、URL、验证、加密、HTTP客户端、UUID |
| `console/` | zl/utils/print_*.go | 22 | 彩色控制台输出(Debug/Info/Success/Warn/Error |
| `compress/` | zl/service/compress | 7 | Gzip/Zip 压缩解压 |
| `cache/keybuilder.go` | zl/service/cache | 新增 | 键名前缀管理,多站点共用 Redis |
| `cache/lock.go` | zl/service/cache | 新增 | 分布式锁、计数器、TTL管理 |
| `middleware/requestid.go` | zl/middleware | 2 | 请求ID追踪 |
| `middleware/recover.go` | zl/middleware | 2 | Panic恢复中间件 |
| `handler/handler.go` | zl/response | 增强 | 类型安全参数获取(QueryInt/PathInt/FormInt等) |
| `response/response.go` | zl/response | 增强 | RequestID字段、文件下载、HTML响应、跳转 |
| `config/config.go` | 新增 | AppConfig | 应用配置(SiteName/Env/Version等) |
### 2.2 配置增强
新增 `AppConfig` 配置块,支持站点别名:
```yaml
app:
name: "用户管理系统"
site_name: "user_api" # 站点别名,用于缓存键前缀、日志标识
version: "1.0.0"
env: "prod"
debug: false
base_url: "https://user.example.com"
token_expire: 86400
```
### 2.3 CLI 工具重构
将原来单个 `main.go`(约700行)拆分为模块化结构:
```
cmd/xlgo/
├── main.go # 入口和命令路由(~50行)
├── types.go # 类型定义
├── utils.go # 工具函数
├── commands.go # 命令实现
└── templates.go # 所有模板定义
```
---
## 三、项目规模对比
| 指标 | v1.1.0 | v2.0 | 提升 |
|------|--------|------|------|
| Go 源文件数 | 28 | 54 | +93% |
| 代码行数 | ~2,800 | ~8,400 | +200% |
| 包数量 | 16 | 23 | +44% |
| 函数数量 | ~150 | ~450 | +200% |
| 测试覆盖 | 0% | 8% (2/24包) | +8% | utils/console已有测试 |
---
## 四、模块完整性评估(v2.0
### 4.1 核心模块评分对比
| 模块 | v1.1.0 | v2.0 | 提升 | 说明 |
|------|--------|------|------|------|
| 配置管理 | 8/10 | 9/10 | +1 | 新增 AppConfig、SiteName |
| 数据库 | 8/10 | 8/10 | - | 保持稳定 |
| Redis/缓存 | 9/10 | 10/10 | +1 | 分布式锁、键名前缀、计数器 |
| JWT 认证 | 8/10 | 8/10 | - | 保持稳定 |
| 日志系统 | 9/10 | 9/10 | - | 保持稳定 |
| 中间件 | 8/10 | 9/10 | +1 | RequestID、Recover、彩色输出 |
| 文件存储 | 9/10 | 9/10 | - | 保持稳定 |
| 工具函数 | 3/10 | 9/10 | +6 | 111个实用函数 |
| 验证器 | 9/10 | 9/10 | - | 保持稳定 |
| 错误处理 | 8/10 | 8/10 | - | 保持稳定 |
| 密码加密 | 9/10 | 9/10 | - | 保持稳定 |
| SSE 支持 | 8/10 | 8/10 | - | 保持稳定 |
| WebSocket | 8/10 | 8/10 | - | 保持稳定 |
| 定时任务 | 7/10 | 7/10 | - | 保持稳定 |
| 测试支持 | 8/10 | 8/10 | - | 保持稳定 |
| CLI 工具 | 7/10 | 8/10 | +1 | 模块化重构、模板分离 |
| 压缩解压 | 0/10 | 8/10 | +8 | Gzip/Zip完整实现 |
| 控制台输出 | 0/10 | 9/10 | +9 | 跨平台彩色输出 |
### 4.2 新增 utils 包功能清单
#### 4.2.1 随机数生成 (random.go)
```go
RandString(16) // 随机字符串(字母+数字)
RandDigit(6) // 随机数字字符串
RandInt(1, 100) // 随机整数
RandInt64(0, 1000) // 随机int64
```
#### 4.2.2 字符串处理 (strings.go)
```go
IsBlank(s) // 检查空白
IsAnyBlank(strs...) // 批量检查
DefaultIfBlank(s, def) // 默认值
Substr(s, 0, 10) // 子字符串(支持中文)
StrLen(s) // Unicode长度
EqualsIgnoreCase(a, b) // 不区分大小写
Trim(s) // 去除空白
```
#### 4.2.3 时间日期 (datetime.go)
```go
NowUnix() // 秒时间戳
NowTimestamp() // 毫秒时间戳
FromUnix(unix) // 时间戳转时间
FormatDateTime(t) // 格式化 "2006-01-02 15:04:05"
StartOfDay(t) // 当天开始
EndOfDay(t) // 当天结束
StartOfMonth(t) // 当月开始
EndOfMonth(t) // 当月结束
```
#### 4.2.4 类型转换 (convert.go)
```go
ToInt(s) // 字符串转int
ToIntDefault(s, 0) // 带默认值
ToInt64(s) // 转int64
ToFloat64(s) // float64
CalcPageCount(100, 10) // 计算总页数
CalcOffset(2, 20) // 分页偏移
```
#### 4.2.5 文件操作 (file.go)
```go
FileExists(path) // 检查文件存在
DirExists(path) // 检查目录存在
EnsureDir(path) // 确保目录存在
ReadFile(path) // 读取文件
WriteFile(path, data) // 写入文件
CopyFile(dst, src) // 复制文件
```
#### 4.2.6 URL处理 (url.go)
```go
ParseURL(rawURL) // 解析URL
builder.AddQuery("key", "value") // 链式添加参数
URLEncode(s) // URL编码
URLDecode(s) // URL解码
```
#### 4.2.7 格式验证 (validator.go)
```go
IsPhone(phone) // 手机号验证
IsEmail(email) // 邮箱验证
IsIPv4(ip) // IPv4验证
IsIDCard(id) // 身份证验证
IsChinese(s) // 中文验证
IsNumeric(s) // 数字验证
IsAlphanumeric(s) // 字母数字验证
```
#### 4.2.8 加密编码 (crypto.go)
```go
MD5(s) // MD5哈希
SHA256(s) // SHA256哈希
Base64Encode(data) // Base64编码
Base64URLEncode(data) // URL安全编码
```
#### 4.2.9 HTTP客户端 (http.go)
```go
client := NewHTTPClient()
client.SetTimeout(30*time.Second)
client.SetHeader("Authorization", "Bearer xxx")
client.Get(url, params) // GET请求
client.PostJSON(url, data) // POST JSON
client.Upload(url, files, params) // 文件上传
```
#### 4.2.10 UUID生成 (uuid.go)
```go
UUID() // UUID v4字符串
UUIDShort() // 短UUID(无横线)
UUIDValid(s) // 验证UUID
```
### 4.3 新增 console 包功能
```go
console.Debug("调试信息") // 青色
console.Info("普通信息") // 白色
console.Success("成功信息") // 绿色
console.Warn("警告信息") // 黄色
console.Error("错误信息") // 红色
// 自定义配置
c := console.New(
console.WithColor(true),
console.WithTime(true),
console.WithCaller(true, 2),
)
c.Debug("自定义控制台输出")
```
### 4.4 新增 compress 包功能
```go
// Gzip 数据压缩
GzipCompress(data)
GzipDecompress(data)
// Gzip 文件压缩
GzipCompressFile(src, dst)
GzipDecompressFile(src, dst)
// Zip 打包
Zip(zipPath, paths) // 打包文件/目录
Unzip(zipPath, dstDir) // 解压到目录
```
### 4.5 缓存键名前缀管理
```go
// 自动从配置读取 site_name
cache.K("user:1") // -> "cache:user_api:user:1"
cache.KTemp("token") // -> "temp:user_api:token"
cache.KPerm("config") // -> "perm:user_api:config"
cache.KLock("order:123") // -> "lock:user_api:order:123"
cache.KCounter("visit") // -> "counter:user_api:visit"
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, func(context.Context) error) // 自动管理锁
// 计数器
cache.Incr(ctx, key)
cache.Decr(ctx, key)
```
### 4.6 中间件增强
```go
// RequestID - 请求追踪
r.Use(middleware.RequestID())
// 响应头自动添加 X-Request-ID
// Recover - Panic恢复
r.Use(middleware.Recover()) // 生产环境
r.Use(middleware.RecoverWithDetail()) // 开发环境(返回详细信息)
```
### 4.7 Handler 参数获取增强
```go
// 类型安全的参数获取
page := handler.QueryInt(c, "page", 1)
id := handler.PathInt64(c, "id", 0)
price := handler.QueryFloat64(c, "price", 0.0)
enabled := handler.QueryBool(c, "enabled", false)
count := handler.FormInt(c, "count", 0)
```
---
## 五、多站点共用 Redis 方案
### 5.1 问题背景
多个小项目共用一台 Redis 服务器时,缓存键名可能冲突:
- 项目A: `user:1`
- 项目B: `user:1`
- 项目C: `user:1`
### 5.2 解决方案
通过 `site_name` 配置自动添加前缀:
| 项目 | config.yaml | 实际键名 |
|------|-------------|----------|
| 用户API | `site_name: user_api` | `cache:user_api:user:1` |
| 订单API | `site_name: order_api` | `cache:order_api:user:1` |
| 支付API | `site_name: pay_api` | `cache:pay_api:user:1` |
### 5.3 使用方式
```yaml
# config.yaml
app:
site_name: "my_project"
```
```go
// 无需额外代码,自动生效
cache.K("user:1") // -> "cache:my_project:user:1"
```
---
## 六、性能评估(v2.0
### 6.1 性能优化点
| 优化项 | 说明 |
|--------|------|
| RandString/RandDigit | 使用 sync.Pool 复用 rand.Source |
| StringToBytes/BytesToString | 零拷贝转换 |
| HTTPClient | 链式配置,连接复用 |
| 缓存键名 | 预构建,避免重复拼接 |
### 6.2 性能基准估算
| 场景 | 预估 QPS | 说明 |
|------|----------|------|
| 简单查询 API | 12,000-18,000 | 无 DB 查询 |
| 带 DB 查询 | 3,000-5,000 | 单表主键查询 |
| 缓存查询 | 10,000-15,000 | Redis 缓存命中 |
| SSE 流式响应 | 5,000-8,000 | 单连接 |
| WebSocket 连接 | 10,000+ | 连接数 |
| 文件压缩 | 50-100 MB/s | Gzip |
| HTTP请求 | 1,000-5,000 | 外部API调用 |
---
## 七、扩展性评估(v2.0
| 方面 | v1.1.0 | v2.0 | 说明 |
|------|--------|------|------|
| 存储扩展 | 本地 + OSS | 本地 + OSS | 保持 |
| 缓存扩展 | 基础操作 | 分布式锁、计数器、前缀 | +3功能 |
| 验证扩展 | 自定义规则 | 手机/邮箱/IP等预定义 | +7规则 |
| 通信扩展 | HTTP + SSE + WS | 保持 | - |
| 工具扩展 | 无 | 111个函数 | +111 |
| 压缩扩展 | 无 | Gzip + Zip | +2 |
| 控制台扩展 | 无 | 彩色输出 | +1 |
---
## 八、易用性评估(v2.0
### 8.1 开发效率提升
| 任务 | v1.1.0 | v2.0 | 节省 |
|------|--------|------|------|
| 创建新项目 | 5 分钟 | 5 分钟 | - |
| 添加 CRUD 模块 | 10 分钟 | 10 分钟 | - |
| 请求验证 | 5 分钟 | 5 分钟 | - |
| 参数类型转换 | 手动编写 | 调用 handler.QueryInt | 90% |
| 文件压缩 | 手动实现 | 调用 compress.Zip | 100% |
| 随机字符串 | 手动实现 | 调用 utils.RandString | 100% |
| 时间格式化 | 记忆布局 | 调用 utils.FormatDateTime | 80% |
| 分布式锁 | 手动实现 | 调用 cache.Lock | 100% |
| HTTP请求 | 手动封装 | 调用 NewHTTPClient().Get | 100% |
| 控制台调试 | fmt.Println | console.Debug(彩色) | 50% |
### 8.2 代码简化示例
```go
// v1.1.0 - 手动实现
func GetUser(c *gin.Context) {
pageStr := c.Query("page")
page := 1
if pageStr != "" {
p, err := strconv.Atoi(pageStr)
if err == nil {
page = p
}
}
// ...
}
// v2.0 - 一行搞定
func GetUser(c *gin.Context) {
page := handler.QueryInt(c, "page", 1)
// ...
}
```
---
## 九、安全性评估(v2.0
| 检查项 | v1.1.0 | v2.0 | 说明 |
|--------|--------|------|------|
| SQL 注入 | ✅ GORM | ✅ GORM | 保持 |
| XSS | ⚠️ 应用层 | ⚠️ 应用层 | 保持 |
| CSRF | ❌ 无 | ✅ 完善 | +1 | 多种模式:Cookie/API/DoubleSubmit |
| 密码加密 | ✅ bcrypt | ✅ bcrypt | 保持 |
| 请求验证 | ✅ validator | ✅ validator | 保持 |
| 限流防护 | ✅ 完善 | ✅ 完善 | 保持 |
| JWT 安全 | ✅ 黑名单 | ✅ 黑名单 | 保持 |
| Panic恢复 | ❌ 无 | ✅ Recover中间件 | +1 |
| 请求追踪 | ❌ 无 | ✅ RequestID | +1 |
| 类型安全 | ⚠️ 手动 | ✅ 强类型函数 | +1 |
---
## 十、综合评分(v2.0
| 维度 | v1.1.0 | v2.0 | 提升 |
|------|--------|------|------|
| 完整性 | 8.5/10 | 9.5/10 | +1.0 |
| 性能 | 8.5/10 | 9.0/10 | +0.5 |
| 扩展性 | 7.5/10 | 9.0/10 | +1.5 |
| 易用性 | 9.0/10 | 9.5/10 | +0.5 |
| 安全性 | 8.0/10 | 9.0/10 | +1.0 |
| **总分** | **8.35/10** | **9.2/10** | **+0.85** |
---
## 十一、剩余改进建议
### 11.1 P3 - 下一步优化
| 功能 | 优先级 | 说明 |
|------|--------|------|
| Kafka 生产者 | 低 | 需重新设计连接池 |
| WebSocket 客户端 | 低 | 框架已有服务端实现 |
| 链路追踪 | 中 | OpenTelemetry集成 |
| 单元测试 | 高 | 进行中,覆盖所有包 |
| 性能基准 | 中 | 建立benchmark |
### 11.2 测试建议
```bash
# 运行现有测试
go test ./utils/... ./console/... -v
# 建议添加
go test ./cache/... ./handler/... ./middleware/...
```
---
## 十二、结论
经过本轮全面优化,xlgo 框架已从 **生产就绪** 提升至 **功能丰富** 水平。
### 12.1 核心优势
1. **工具库完善** - 111个实用函数,覆盖日常开发80%场景
2. **多站点支持** - SiteName配置解决共用Redis冲突
3. **类型安全** - QueryInt/PathInt等避免手动转换
4. **开发调试** - 彩色控制台输出,一眼识别日志级别
5. **分布式支持** - Redis分布式锁开箱即用
6. **文件处理** - Gzip/Zip压缩解压
### 12.2 适用场景
- ✅ 中大型 API 服务
- ✅ 用户管理系统
- ✅ AI 应用后端
- ✅ 实时通信应用
- ✅ 多项目共用资源
- ✅ 创业项目 MVP
- ✅ 学习 Go Web 开发
### 12.3 开发效率总结
| 场景 | 使用 xlgo v2.0 | 不使用框架 | 节省 |
|------|----------------|------------|------|
| 用户系统 | 4-5 人天 | 10-16 人天 | 60% |
| AI应用 | 4-5 人天 | 9-14 人天 | 55% |
| 通用API | 2-3 人天 | 5-8 人天 | 60% |
---
## 十三、版本历史
| 版本 | 日期 | 主要变化 |
|------|------|----------|
| v1.0.0 | 2026-04-29 | 基础框架 |
| v1.1.0 | 2026-04-29 | P0/P1/P2修复(12项) |
| v2.0.0 | 2026-04-29 | zl工具库移植、模块重构 |
---
*评估日期:2026-04-29*
*评估版本:v2.0.0*
*优化内容:新增5个包 + 增强5个包 + 配置扩展 + CLI重构 = 约120个函数*
+50
View File
@@ -0,0 +1,50 @@
# xlgo 示例
两个可运行示例,帮助快速上手 xlgo。
## minimal — 最小 HTTP 服务
不依赖 MySQL / Redis / Storage,纯 HTTP + 健康检查。第一次接触 xlgo 从这里开始。
```bash
go run ./examples/minimal
```
访问 http://localhost:8081/health(健康检查)与 http://localhost:8081/api/v1/(示例路由)。
## full — 完整业务 API
包含 MySQL + Redis + JWT + 一个 user CRUD(登录发 token、认证路由、创建/查询用户)。
**运行前需准备**
- MySQL(修改 `examples/full/config.yaml``database` 配置)
- Redis(修改 `examples/full/config.yaml``redis` 配置)
```bash
go run ./examples/full
```
启动后自动迁移 user 表,并初始化示例用户 `alice/secret`(密码以 bcrypt 哈希保存)。接口:
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
| POST | /api/v1/login | 登录,返回 token | 否 |
| GET | /api/v1/users/:id | 查询用户 | 是(Bearer token |
| POST | /api/v1/users | 创建用户 | 是(Bearer token |
登录示例:
```bash
curl -X POST http://localhost:8082/api/v1/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret"}'
```
创建用户示例:
```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` 包完善入参校验、密码强度策略与用户注册流程。
+44
View File
@@ -0,0 +1,44 @@
app:
name: "xlgo-full-example"
env: "dev"
debug: true
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
port: 8082
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business # business(默认) 或 rest
database:
driver: mysql
host: localhost
port: 3306
user: root
password: your_password
name: xlgo_example
max_idle_conns: 10
max_open_conns: 100
redis:
host: localhost
port: 6379
password: ""
db: 0
jwt:
secret: change-me-to-a-long-random-secret-at-least-32-chars
expire: "24h"
refresh_expire: "168h"
issuer: xlgo
algorithm: HS256
log:
dir: ./logs
max_size: 100
max_backups: 30
max_age: 30
compress: true
+175
View File
@@ -0,0 +1,175 @@
// Package main 是 xlgo 的完整示例:MySQL + Redis + JWT + 一个 user CRUD。
//
// 运行前需准备:
// - MySQLconfig.yaml 中 database 配置)
// - Redisconfig.yaml 中 redis 配置)
//
// 启动后会自动迁移 user 表。访问:
//
// POST /api/v1/login {"username":"alice","password":"secret"} → 返回 token
// GET /api/v1/users/:id (需 Authorization: Bearer <token>
// POST /api/v1/users (需 Authorization: Bearer <token>,创建用户)
//
// 示例会在启动时初始化 alice/secret,密码以 bcrypt 哈希保存。
//
// 运行:
//
// go run ./examples/full
package main
import (
"context"
"errors"
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/jwt"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"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"
)
// User 示例模型
type User struct {
gorm.Model
Username string `gorm:"uniqueIndex;size:64" json:"username"`
Password string `gorm:"size:128" json:"-"` // bcrypt 哈希,永不返回给客户端
UserType string `gorm:"size:32" json:"user_type"`
}
var userRepo *repository.BaseRepo[User]
func main() {
app := xlgo.NewFullStack(
xlgo.WithConfigPath("./examples/full/config.yaml"),
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())
},
}),
)
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
}
func registerRoutes(r *gin.RouterGroup) {
// 延迟初始化 repo:此时 App.Init 已完成,database.GetDB() 可用
userRepo = repository.NewBaseRepo[User](database.GetDB())
api := r.Group("/api/v1")
// 公开路由:登录
api.POST("/login", login)
// 认证路由
auth := api.Group("/", middleware.AuthRequired())
auth.GET("/users/:id", getUser)
auth.POST("/users", createUser)
}
func login(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, "参数错误")
return
}
u, err := userRepo.FindOne(c.Request.Context(), "username = ?", req.Username)
if err != nil {
response.Fail(c, "用户名或密码错误")
return
}
if !validation.CheckPassword(u.Password, req.Password) {
response.Fail(c, "用户名或密码错误")
return
}
token, err := jwt.GenerateToken(u.ID, u.Username, "user", u.UserType)
if err != nil {
response.ServerError(c, "生成 token 失败")
return
}
response.Success(c, gin.H{"token": token})
}
func getUser(c *gin.Context) {
id := c.Param("id")
// 示例简化:直接用 repo 查询,实际应转 uint
var uid uint
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, "用户不存在")
return
}
response.Success(c, u)
}
func createUser(c *gin.Context) {
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
}
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)
}
+8
View File
@@ -0,0 +1,8 @@
app:
name: "xlgo-minimal-example"
env: "dev"
debug: true
server:
port: 8081
mode: development
+42
View File
@@ -0,0 +1,42 @@
// Package main 是 xlgo 的最小可运行示例。
//
// 仅依赖一个 config.yaml,不初始化 MySQL / Redis / Storage
// 适合第一次接触 xlgo、或纯 HTTP 场景。
//
// 运行:
//
// go run ./examples/minimal
package main
import (
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
)
func main() {
app := xlgo.New(
xlgo.WithConfigPath("./examples/minimal/config.yaml"),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
)
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
}
func registerRoutes(r *gin.RouterGroup) {
api := r.Group("/api/v1")
api.GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Hello xlgo!"})
})
}
+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+ 个覆盖接口密集/注释最详尽/中间件/示例/工具各类型,初筛"无问题"裁定整体可信。
+34 -9
View File
@@ -3,42 +3,52 @@ module github.com/EthanCodeCraft/xlgo-core
go 1.25.0
require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.19.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
github.com/gorilla/websocket v1.5.3
github.com/mitchellh/mapstructure v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.5.1
github.com/spf13/viper v1.18.2
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
go.opentelemetry.io/contrib/propagators/b3 v1.43.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.49.0
golang.org/x/text v0.35.0
golang.org/x/crypto v0.53.0
golang.org/x/text v0.38.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.5.4
gorm.io/gorm v1.25.7
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.10
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
@@ -51,6 +61,10 @@ require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
@@ -60,10 +74,14 @@ require (
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
@@ -74,16 +92,19 @@ require (
github.com/swaggo/swag v1.16.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
@@ -91,4 +112,8 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+73 -20
View File
@@ -4,8 +4,12 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible h1:Sg/2xHwDrioHpxTN6WMiwbXTpUEinBpHsN7mG21Rc2k=
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -27,6 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -39,6 +45,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -66,8 +76,8 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -75,12 +85,20 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -89,6 +107,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
@@ -99,6 +119,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
@@ -116,14 +138,27 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
@@ -165,8 +200,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
@@ -175,6 +214,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+J
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
@@ -191,29 +232,31 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -223,8 +266,8 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -233,15 +276,15 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
@@ -271,7 +314,17 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.4 h1:igQmHfKcbaTVyAIHNhhB888vvxh8EdQ2uSUT0LPcBso=
gorm.io/driver/mysql v1.5.4/go.mod h1:9rYxJph/u9SWkWc9yY4XJ1F/+xO0S/ChOmbk3+Z5Tvs=
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+43 -20
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
@@ -10,22 +11,39 @@ import (
"github.com/gin-gonic/gin"
)
// DefaultMaxJSONBodyBytes 是 BindJSON 的默认请求体上限。
// 入口层无上限读 JSON 会让任意 handler 暴露 OOM 面;需要更大请求体的业务可改用
// BindJSONWithMaxBytes 显式声明上限。
const DefaultMaxJSONBodyBytes int64 = 1 << 20 // 1 MiB
// HealthCheck 健康检查
// @Summary 健康检查
// @Description 检查服务是否正常运行
// @Tags 系统
// @Accept json
// @Produce json
// @Success 200 {object} response.Response
// @Success 200 {object} map[string]string
// @Router /health [get]
//
// 响应体与 router.RegisterHealthRoute 收敛为同一 schemaH8d):
// 恒 200 + {"status":"ok"},不走 response 业务信封,便于 K8s 探针直读。
// 需要依赖探活(mysql/redis…失败 503)时改用 router.RegisterHealthRoute 传入 checks。
func HealthCheck(c *gin.Context) {
response.Success(c, gin.H{
"status": "ok",
})
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// BindJSON 绑定 JSON 请求
func BindJSON(c *gin.Context, req any) error {
return BindJSONWithMaxBytes(c, req, DefaultMaxJSONBodyBytes)
}
// BindJSONWithMaxBytes 绑定 JSON 请求,并限制最大 body 大小。
// maxBytes<=0 时返回明确错误,避免 http.MaxBytesReader 的非正上限产生不可读语义。
func BindJSONWithMaxBytes(c *gin.Context, req any, maxBytes int64) error {
if maxBytes <= 0 {
return errors.New("handler: max JSON body bytes must be positive")
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
return c.ShouldBindJSON(req)
}
@@ -34,6 +52,11 @@ func BindQuery(c *gin.Context, req any) error {
return c.ShouldBindQuery(req)
}
// MaxPage GetPage 允许的最大页码(M-D 修复:防深分页 DoS)。
// 超大 page 产生超大 OFFSET,大表上为性能灾难/DoS 面。钳制到该上限;
// 需更深度遍历的业务应改用游标/keyset 分页(框架不内置,避免功能扩张)。
const MaxPage = 10000
// GetPage 获取分页参数
func GetPage(c *gin.Context) (int, int) {
page := c.DefaultQuery("page", "1")
@@ -45,6 +68,10 @@ func GetPage(c *gin.Context) (int, int) {
if p < 1 {
p = 1
}
// M-D 修复:钳制 page 上限,防 ?page=999999999 产生超大 OFFSET 拖垮 DB。
if p > MaxPage {
p = MaxPage
}
if ps < 1 {
ps = 20
}
@@ -69,8 +96,6 @@ func GetIDFromPath(c *gin.Context, paramName string) (uint, bool) {
// ===== 类型安全的参数获取函数 =====
// QueryInt 获取 Query 参数中的整数(带默认值)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 类型安全,避免每次手动转换
func QueryInt(c *gin.Context, key string, def int) int {
return utils.ToIntDefault(c.Query(key), def)
}
@@ -95,8 +120,6 @@ func QueryBool(c *gin.Context, key string, def bool) bool {
}
// PathInt 从路径参数获取整数(带默认值)
// 评分: ⭐⭐⭐⭐⭐
// 理由: RESTful API 常用,如 /users/:id
func PathInt(c *gin.Context, key string, def int) int {
val := strings.Trim(c.Param(key), "/")
return utils.ToIntDefault(val, def)
@@ -115,8 +138,6 @@ func PathUint64(c *gin.Context, key string, def uint64) uint64 {
}
// FormInt 获取 POST 表单参数中的整数(带默认值)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 传统表单提交常用
func FormInt(c *gin.Context, key string, def int) int {
return utils.ToIntDefault(c.PostForm(key), def)
}
@@ -159,18 +180,20 @@ func ParseInt(s string, defaultValue int) int {
return utils.ToIntDefault(s, defaultValue)
}
// BadRequest 返回 400 错误
// BadRequest 返回参数/请求错误响应。
//
// 委托 response.FailWithCodeCodeFail),遵循当前响应模式(ModeBusiness 下 HTTP 200
// 错误信息通过 body 中的 code 表达;ModeREST 下 CodeFail 属业务失败不映射 HTTP 错误,仍 200),
// 并写入 RequestID——与 response 模式系统一致,不再硬编 HTTP 状态码、不再丢失链路追踪。
func BadRequest(c *gin.Context, msg string) {
c.JSON(http.StatusBadRequest, response.Response{
Code: response.CodeFail,
Msg: msg,
})
response.FailWithCode(c, response.CodeFail, msg)
}
// InternalError 返回 500 错误
// InternalError 返回服务器错误响应。
//
// 委托 response.ServerErrorCodeServerError),遵循当前响应模式(ModeBusiness 下 HTTP 200
// ModeREST 下 HTTP 500),并写入 RequestID——与 response 模式系统一致,
// 不再硬编 HTTP 状态码、不再丢失链路追踪。
func InternalError(c *gin.Context, msg string) {
c.JSON(http.StatusInternalServerError, response.Response{
Code: response.CodeServerError,
Msg: msg,
})
response.ServerError(c, msg)
}
+161 -8
View File
@@ -1,12 +1,14 @@
package handler_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/handler"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
)
@@ -29,6 +31,25 @@ func TestHealthCheck(t *testing.T) {
}
}
// TestHealthCheckSchemaConverged_H8dhandler.HealthCheck 响应体应与
// router.RegisterHealthRoute 收敛为同一 schema200 + {"status":"ok"}),
// 不再走 response 业务信封 {code,msg,data}。
func TestHealthCheckSchemaConverged_H8d(t *testing.T) {
r := setupTestRouter()
r.GET("/health", handler.HealthCheck)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/health", nil))
body := w.Body.String()
if !strings.Contains(body, `"status":"ok"`) {
t.Fatalf("HealthCheck body = %s, want {\"status\":\"ok\"}", body)
}
if strings.Contains(body, `"code"`) || strings.Contains(body, `"data"`) {
t.Fatalf("HealthCheck should not use response envelope, got %s", body)
}
}
func TestQueryInt(t *testing.T) {
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
@@ -41,8 +62,8 @@ func TestQueryInt(t *testing.T) {
expected int
}{
{"?page=10", 10},
{"?page=abc", 1}, // 无效返回默认
{"", 1}, // 无参数返回默认
{"?page=abc", 1}, // 无效返回默认
{"", 1}, // 无参数返回默认
}
for _, tt := range tests {
@@ -164,9 +185,9 @@ func TestGetPage(t *testing.T) {
expectedSize int
}{
{"?page=2&page_size=50", 2, 50},
{"?page=0&page_size=0", 1, 20}, // 边界修正
{"?page=0&page_size=0", 1, 20}, // 边界修正
{"?page=-1&page_size=200", 1, 100}, // 超限修正
{"", 1, 20}, // 默认值
{"", 1, 20}, // 默认值
}
for _, tt := range tests {
@@ -206,7 +227,26 @@ func TestGetIDFromPath(t *testing.T) {
}
}
// withResponseMode 切换响应模式并在测试结束后恢复为默认 ModeBusiness。
func withResponseMode(t *testing.T, m response.Mode) {
t.Helper()
response.SetMode(m)
t.Cleanup(func() { response.SetMode(response.ModeBusiness) })
}
// decodeBody 解析统一响应体。
func decodeBody(t *testing.T, w *httptest.ResponseRecorder) response.Response {
t.Helper()
var body response.Response
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v (body=%q)", err, w.Body.String())
}
return body
}
func TestBadRequest(t *testing.T) {
// 默认 ModeBusiness:所有失败响应 HTTP 200,错误经 body code 表达。
withResponseMode(t, response.ModeBusiness)
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
handler.BadRequest(c, "参数错误")
@@ -216,12 +256,21 @@ func TestBadRequest(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("BadRequest status = %d, want 400", w.Code)
if w.Code != http.StatusOK {
t.Errorf("BadRequest (ModeBusiness) status = %d, want 200", w.Code)
}
body := decodeBody(t, w)
if body.Code != response.CodeFail {
t.Errorf("BadRequest code = %d, want %d", body.Code, response.CodeFail)
}
if body.Msg != "参数错误" {
t.Errorf("BadRequest msg = %q, want %q", body.Msg, "参数错误")
}
}
func TestInternalError(t *testing.T) {
// 默认 ModeBusiness:服务器错误也走 HTTP 200,错误经 body code 表达。
withResponseMode(t, response.ModeBusiness)
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
handler.InternalError(c, "服务器错误")
@@ -231,8 +280,88 @@ func TestInternalError(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("InternalError (ModeBusiness) status = %d, want 200", w.Code)
}
body := decodeBody(t, w)
if body.Code != response.CodeServerError {
t.Errorf("InternalError code = %d, want %d", body.Code, response.CodeServerError)
}
}
// TestBadRequestRESTModeModeREST 下 CodeFail 属业务失败不映射 HTTP 错误,仍 200。
// 修复前 BadRequest 硬编 400 绕过 Mode;修复后委托 response.FailWithCode 遵循 Mode。
func TestBadRequestRESTMode(t *testing.T) {
withResponseMode(t, response.ModeREST)
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
handler.BadRequest(c, "参数错误")
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
if w.Code != http.StatusOK {
t.Errorf("BadRequest (ModeREST) status = %d, want 200 (CodeFail 不映射)", w.Code)
}
body := decodeBody(t, w)
if body.Code != response.CodeFail {
t.Errorf("BadRequest code = %d, want %d", body.Code, response.CodeFail)
}
}
// TestInternalErrorRESTModeModeREST 下 CodeServerError 映射 HTTP 500。
func TestInternalErrorRESTMode(t *testing.T) {
withResponseMode(t, response.ModeREST)
r := setupTestRouter()
r.GET("/test", func(c *gin.Context) {
handler.InternalError(c, "服务器错误")
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
if w.Code != http.StatusInternalServerError {
t.Errorf("InternalError status = %d, want 500", w.Code)
t.Errorf("InternalError (ModeREST) status = %d, want 500", w.Code)
}
body := decodeBody(t, w)
if body.Code != response.CodeServerError {
t.Errorf("InternalError code = %d, want %d", body.Code, response.CodeServerError)
}
}
// TestBadRequestWritesRequestID:修复前 BadRequest 直接 c.JSON 不写 RequestID(丢链路);
// 修复后委托 response 体系,经 writeResp 写入上下文中的 request_id。
func TestBadRequestWritesRequestID(t *testing.T) {
r := setupTestRouter()
r.Use(func(c *gin.Context) { c.Set("request_id", "req-123"); c.Next() })
r.GET("/test", func(c *gin.Context) {
handler.BadRequest(c, "参数错误")
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
body := decodeBody(t, w)
if body.RequestID != "req-123" {
t.Errorf("BadRequest request_id = %q, want %q (修复前为空)", body.RequestID, "req-123")
}
}
// TestInternalErrorWritesRequestID:同上,覆盖 InternalError。
func TestInternalErrorWritesRequestID(t *testing.T) {
r := setupTestRouter()
r.Use(func(c *gin.Context) { c.Set("request_id", "req-456"); c.Next() })
r.GET("/test", func(c *gin.Context) {
handler.InternalError(c, "服务器错误")
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
body := decodeBody(t, w)
if body.RequestID != "req-456" {
t.Errorf("InternalError request_id = %q, want %q (修复前为空)", body.RequestID, "req-456")
}
}
@@ -258,4 +387,28 @@ func TestBindJSON(t *testing.T) {
if w.Code != 200 {
t.Errorf("BindJSON valid status = %d", w.Code)
}
}
}
func TestBindJSONWithMaxBytesRejectsOversizedBody_M6(t *testing.T) {
r := setupTestRouter()
r.POST("/test", func(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := handler.BindJSONWithMaxBytes(c, &req, 8); err == nil {
c.JSON(http.StatusOK, req)
return
}
handler.BadRequest(c, "绑定失败")
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"name":"body-too-large"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
body := decodeBody(t, w)
if body.Code != response.CodeFail {
t.Fatalf("oversized BindJSON code = %d, want %d", body.Code, response.CodeFail)
}
}
+357 -87
View File
@@ -6,11 +6,17 @@ import (
"encoding/base64"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// Claims JWT 声明
@@ -34,29 +40,119 @@ var (
ErrTokenNotValidYet = errors.New("令牌尚未生效")
//ErrTokenRevoked 令牌已被撤销
ErrTokenRevoked = errors.New("令牌已被撤销")
// ErrBlacklistUnavailable Redis 未初始化或不可用,黑名单功能失效(C9a 修复)。
// Add 返回此错误使调用方(RefreshToken/InvalidateToken)感知黑名单不可用并 fail-closed
// 避免无 Redis 时静默成功致撤销/刷新失效、新旧 token 双有效。
// IsBlacklisted 在无 Redis 时仍返回 false(验证侧 fail-open 是无 Redis 部署的固有局限,
// 文档约束:安全敏感场景必须启用 Redis)。
ErrBlacklistUnavailable = errors.New("token 黑名单不可用:Redis 未初始化")
// ErrEmptySecret JWT 密钥为空(P0 修复)。空 secret 意味着以零长度 HMAC 密钥签发/校验,
// 任何以 "" 签名的 token 都会通过——签发与校验一律 fail-closed 拒绝,杜绝该空密钥绕过。
ErrEmptySecret = errors.New("jwt.secret 未配置:拒绝签发/校验(防空密钥导致任意 token 通过)")
// ErrUnsupportedAlgorithm 配置了不支持的签名算法(P0 修复)。
// 本实现仅支持 HMAC 族(HS256/HS384/HS512);RS256 等非对称算法暂不支持,
// 不再静默回退 HS256——避免用户误以为在用非对称算法、实则 HMAC,并助长算法混淆攻击。
ErrUnsupportedAlgorithm = errors.New("jwt: 不支持的签名算法(仅支持 HS256/HS384/HS512")
// ErrInvalidExpiry 过期时间非法。签发非正过期时间的 token 会立即失效或产生不可预期会话语义。
ErrInvalidExpiry = errors.New("jwt: 过期时间必须大于 0")
// ErrEmptyJTI 空 JTI 无法匹配任何正常 token,却会写入 jwt_bl: 这种永不命中的黑名单键。
ErrEmptyJTI = errors.New("jwt: jti 不能为空")
)
// generateJTI 生成唯一的 JWT ID
func generateJTI() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return base64.URLEncoding.EncodeToString(bytes)
// validMethods 允许的签名算法名(HMAC 族)。ParseWithClaims 传 jwt.WithValidMethods 固定算法,
// 防算法混淆(alg confusionP0):拒绝 alg=none 及非 HMAC 算法——否则若部署配置为非对称算法,
// 攻击者可用公钥作为 HMAC 密钥伪造 token 通过校验。
var validMethods = []string{"HS256", "HS384", "HS512"}
// secretKey 返回 HMAC 密钥字节;cfg 为 nil 或密钥为空时 fail-closed 返回 ErrEmptySecretP0)。
func secretKey(cfg *config.Config) ([]byte, error) {
if cfg == nil || cfg.JWT.Secret == "" {
return nil, ErrEmptySecret
}
return []byte(cfg.JWT.Secret), nil
}
// TokenBlacklist Token 黑名单管理(使用 JTI 优化)
type TokenBlacklist struct{}
// hmacKeyfunc 构造校验签名方法为 HMAC 族并返回密钥的 jwt.Keyfunc(P0:防算法混淆 + 空密钥)。
// 双重防护:① 断言 token.Method 为 *jwt.SigningMethodHMAC,拒绝非 HMAC(含 none/RS/ES);
// ② 经 secretKey 拒绝空密钥。配合 ParseWithClaims 的 jwt.WithValidMethods(validMethods) 使用。
func hmacKeyfunc(cfg *config.Config) jwt.Keyfunc {
return func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("%w: 期望 HMAC,实际 alg=%v", ErrUnsupportedAlgorithm, token.Header["alg"])
}
return secretKey(cfg)
}
}
// generateJTI 生成唯一的 JWT ID
func generateJTI() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("生成 JTI 失败: %w", err)
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
// TokenBlacklist Token 黑名单管理(使用 JTI 优化)。
// client 为 nil 时回退到 database.GetRedis(),兼容存量未注入场景。
type TokenBlacklist struct {
client *redis.Client
}
// NewTokenBlacklist 创建黑名单实例,client 可为 nil(懒取全局 Redis)。
func NewTokenBlacklist(client *redis.Client) *TokenBlacklist {
return &TokenBlacklist{client: client}
}
func (tb *TokenBlacklist) redisClient() *redis.Client {
if tb != nil && tb.client != nil {
return tb.client
}
return database.GetRedis()
}
// blacklistOpTimeout 黑名单 Redis 操作的上下文超时(M-A 修复)。
// Redis 客户端已配 ReadTimeout/WriteTimeout=3sredis.go D7),但鉴权热路径(ParseToken
// 每次调 IsBlacklisted)需更紧边界——显式 ctx 超时把鉴权阻塞上限收敛到 1s,避免 Redis
// 挂起时每个鉴权请求被长时间拖住。健康 Redis 下 Exists/SET 为亚毫秒级,1s 余量充足。
// 注:ctx 超时只约束命令往返,不影响 Set 的服务端 TTL(ttl 可远大于 1s)。
const blacklistOpTimeout = 1 * time.Second
// BlacklistPolicy 控制解析 Token 时遇到黑名单查询错误的处理策略。
// ParseToken 默认 BlacklistFailClosed(黑名单不可检查即拒绝);
// 需显式 fail-open(仅无 Redis 或低安全场景)用 ParseTokenWithBlacklistPolicy(..., BlacklistFailOpen)。
type BlacklistPolicy int
const (
// BlacklistFailOpen 黑名单查询出错时放行 Token(仅用于无 Redis 或低安全场景,非默认)。
BlacklistFailOpen BlacklistPolicy = iota
// BlacklistFailClosed 黑名单查询出错时拒绝 Token(默认;ParseToken 即用此策略)。
BlacklistFailClosed
)
// blacklistCtx 创建带超时的 context 用于黑名单 Redis 操作(M-A)。
func blacklistCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), blacklistOpTimeout)
}
// Add 将 Token 的 JTI 加入黑名单
// 评分: ⭐⭐⭐⭐⭐
// 理由: 使用 JTI(32字节)替代完整 Token(数百字节),大幅节省 Redis 内存
// 参数: jti JWT IDexpiry Token 过期时间
//
// 无 Redis 时返回 ErrBlacklistUnavailableC9a 修复):让调用方(RefreshToken/InvalidateToken
// 感知黑名单不可用并 fail-closed,避免无 Redis 时静默成功致撤销失效。
func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
if database.RedisClient == nil {
// Redis 未启用,跳过黑名单
return nil
if strings.TrimSpace(jti) == "" {
return ErrEmptyJTI
}
ctx := context.Background()
client := tb.redisClient()
if client == nil {
// Redis 未启用,黑名单不可用——fail-closed 让调用方决策。
return ErrBlacklistUnavailable
}
ctx, cancel := blacklistCtx()
defer cancel()
ttl := time.Until(expiry)
if ttl <= 0 {
// Token 已过期,无需加入黑名单
@@ -65,32 +161,119 @@ func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
// 使用 JTI 作为键名(约24字节),而非完整 Token(数百字节)
key := fmt.Sprintf("jwt_bl:%s", jti)
return database.RedisClient.Set(ctx, key, "1", ttl).Err()
return client.Set(ctx, key, "1", ttl).Err()
}
// IsBlacklisted 检查 JTI 是否在黑名单中
func (tb *TokenBlacklist) IsBlacklisted(jti string) bool {
if database.RedisClient == nil {
// Redis 未启用,不检查黑名单
return false
// 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 {
return false, ErrBlacklistUnavailable
}
ctx := context.Background()
ctx, cancel := blacklistCtx()
defer cancel()
key := fmt.Sprintf("jwt_bl:%s", jti)
return database.RedisClient.Exists(ctx, key).Val() > 0
n, err := client.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
// 全局黑名单实例
var tokenBlacklist = &TokenBlacklist{}
// Manager JWT 管理器(#10)。持有独立的 TokenBlacklist
// 支持多实例(如区分 user-token 与 refresh-token 黑名单)。
type Manager struct {
mu sync.Mutex
blacklist *TokenBlacklist
}
// defaultManager 是全局默认 JWT 管理器的真实存储,经 atomic 读写(C9c)。
var defaultManager atomic.Pointer[Manager]
func init() {
defaultManager.Store(NewJWTManager())
}
// currentManager 返回全局默认 JWT 管理器(atomic 读取,C9c)。
// 正常情况下 init 后永不为 nil;防御性地在极罕见的 nil 情况下回退一个懒取 Redis 的实例。
func currentManager() *Manager {
if m := defaultManager.Load(); m != nil {
return m
}
m := NewJWTManager()
defaultManager.Store(m)
return m
}
// GetDefaultJWT 返回全局默认 JWT 管理器(并发安全,C9c/J1 修复)。
// 替代已删除的 DefaultJWT 包级变量。
func GetDefaultJWT() *Manager {
return currentManager()
}
// currentBlacklist 返回全局默认 Manager 持有的黑名单(atomic 读取 Manager 后经 Blacklist()C9c)。
func currentBlacklist() *TokenBlacklist {
return currentManager().Blacklist()
}
// NewJWTManager 创建 JWT 管理器实例(blacklist 懒取全局 Redis)。
func NewJWTManager() *Manager {
return &Manager{blacklist: NewTokenBlacklist(nil)}
}
// NewJWTManagerWithRedis 创建 JWT 管理器并注入指定 Redis 客户端(用于多 Redis/测试隔离)。
func NewJWTManagerWithRedis(client *redis.Client) *Manager {
return &Manager{blacklist: NewTokenBlacklist(client)}
}
// SetDefaultJWTManager 提升指定 Manager 为全局默认(atomic 置换,并发安全,J1 修复)。
func SetDefaultJWTManager(m *Manager) {
if m == nil {
return
}
defaultManager.Store(m)
}
// Blacklist 返回 Manager 持有的黑名单实例。
func (m *Manager) Blacklist() *TokenBlacklist {
m.mu.Lock()
defer m.mu.Unlock()
return m.blacklist
}
// GenerateToken 生成 JWT Token
// 评分: ⭐⭐⭐⭐⭐
// 理由: 自动生成 JTI,支持高效黑名单机制
func GenerateToken(userID uint, username, role, userType string) (string, error) {
cfg := config.Get()
var expiry time.Duration
if cfg != nil {
expiry = cfg.JWT.Expire
}
return generateTokenWithExpiry(cfg, userID, username, role, userType, expiry)
}
func generateTokenWithExpiry(cfg *config.Config, userID uint, username, role, userType string, expiry time.Duration) (string, error) {
if expiry <= 0 {
return "", ErrInvalidExpiry
}
// P0:先校验密钥非空与算法受支持(fail-closed)。secretKey 亦守卫 cfg==nil
// 通过后 cfg 保证非空,后续访问 cfg.JWT.* 安全。
key, err := secretKey(cfg)
if err != nil {
return "", err
}
method, err := signingMethod(cfg.JWT.Algorithm)
if err != nil {
return "", err
}
// 生成唯一的 JWT ID
jti := generateJTI()
jti, err := generateJTI()
if err != nil {
return "", err
}
claims := Claims{
UserID: userID,
@@ -99,70 +282,131 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
UserType: userType,
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(cfg.JWT.Expire) * time.Second)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
Issuer: issuerOrDefault(cfg.JWT.Issuer),
ID: jti, // 同时设置到 RegisteredClaims.ID
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWT.Secret))
token := jwt.NewWithClaims(method, claims)
return token.SignedString(key)
}
// GenerateTokenWithCustomExpiry 生成带自定义过期时间的 Token
func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) {
cfg := config.Get()
jti := generateJTI()
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: "xlgo",
ID: jti,
},
if expireSeconds <= 0 {
return "", ErrInvalidExpiry
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWT.Secret))
return generateTokenWithExpiry(cfg, userID, username, role, userType, time.Duration(expireSeconds)*time.Second)
}
// ParseToken 解析 JWT Token
// 评分: ⭐⭐⭐⭐⭐
// 理由: 使用 JTI 检查黑名单,效率更高
// issuerOrDefault 返回配置的 issuer,未配置时回退 "xlgo"。
func issuerOrDefault(issuer string) string {
if issuer == "" {
return "xlgo"
}
return issuer
}
// signingMethod 根据 algorithm 配置返回 HMAC 签名方法。
// 支持 HS256(默认,空值等价)/HS384/HS512;其它值(含 RS256 等非对称算法,暂不支持)
// 返回 ErrUnsupportedAlgorithm,不再静默回退 HS256P0:防"配 RS256 实得 HMAC"的算法混淆隐患)。
func signingMethod(algorithm string) (jwt.SigningMethod, error) {
switch strings.ToUpper(strings.TrimSpace(algorithm)) {
case "", "HS256":
return jwt.SigningMethodHS256, nil
case "HS384":
return jwt.SigningMethodHS384, nil
case "HS512":
return jwt.SigningMethodHS512, nil
default:
return nil, fmt.Errorf("%w: %q", ErrUnsupportedAlgorithm, algorithm)
}
}
func parseOptions(cfg *config.Config) []jwt.ParserOption {
issuer := "xlgo"
if cfg != nil {
issuer = issuerOrDefault(cfg.JWT.Issuer)
}
return []jwt.ParserOption{
jwt.WithValidMethods(validMethods),
jwt.WithIssuer(issuer),
}
}
func validateIssuer(cfg *config.Config, claims *Claims) error {
want := "xlgo"
if cfg != nil {
want = issuerOrDefault(cfg.JWT.Issuer)
}
if claims == nil || claims.Issuer != want {
return ErrTokenInvalid
}
return nil
}
func mapParseTokenError(err error) error {
if errors.Is(err, jwt.ErrTokenExpired) {
return ErrTokenExpired
}
if errors.Is(err, jwt.ErrTokenMalformed) {
return ErrTokenMalformed
}
if errors.Is(err, jwt.ErrTokenNotValidYet) {
return ErrTokenNotValidYet
}
if errors.Is(err, ErrEmptySecret) {
return ErrEmptySecret
}
if errors.Is(err, ErrUnsupportedAlgorithm) {
return ErrUnsupportedAlgorithm
}
return fmt.Errorf("%w: %w", ErrTokenInvalid, err)
}
func checkTokenBlacklist(claims *Claims, policy BlacklistPolicy) error {
if claims == nil || claims.JTI == "" {
return nil
}
revoked, err := currentBlacklist().IsBlacklisted(claims.JTI)
if err != nil {
if policy == BlacklistFailClosed {
return err
}
logger.Warn("JWT 黑名单检查失败,fail-open 策略放行 token", zap.String("jti", claims.JTI), zap.Error(err))
return nil
}
if revoked {
return ErrTokenRevoked
}
return nil
}
// ParseToken 解析 JWT Token。默认 fail-closed:黑名单后端不可检查时返回
// ErrBlacklistUnavailable,拒绝该 Token。需要显式 fail-open(仅无 Redis 或低安全场景)
// 请用 ParseTokenWithBlacklistPolicy(token, BlacklistFailOpen)。
func ParseToken(tokenString string) (*Claims, error) {
return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailClosed)
}
// ParseTokenWithBlacklistPolicy 使用显式黑名单查询策略解析 JWT Token。
func ParseTokenWithBlacklistPolicy(tokenString string, policy BlacklistPolicy) (*Claims, error) {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
return []byte(cfg.JWT.Secret), nil
})
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), parseOptions(cfg)...)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
if errors.Is(err, jwt.ErrTokenMalformed) {
return nil, ErrTokenMalformed
}
if errors.Is(err, jwt.ErrTokenNotValidYet) {
return nil, ErrTokenNotValidYet
}
return nil, ErrTokenInvalid
return nil, mapParseTokenError(err)
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
// 使用 JTI 检查黑名单(更高效)
if claims.JTI != "" && tokenBlacklist.IsBlacklisted(claims.JTI) {
return nil, ErrTokenRevoked
if err := checkTokenBlacklist(claims, policy); err != nil {
return nil, err
}
return claims, nil
}
@@ -171,23 +415,26 @@ func ParseToken(tokenString string) (*Claims, error) {
}
// InvalidateToken 使 Token 失效(加入黑名单)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 使用 JTI 而非完整 Token,节省 Redis 内存
func InvalidateToken(tokenString string) error {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
return []byte(cfg.JWT.Secret), nil
})
opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation())
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...)
if err != nil {
// Token 无效或已过期,无需加入黑名单
// Token 签名/格式/issuer 无效,无需加入黑名单
return nil
}
if claims, ok := token.Claims.(*Claims); ok {
if claims.JTI != "" && claims.ExpiresAt != nil {
return tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time)
if err := validateIssuer(cfg, claims); err != nil {
return nil
}
if strings.TrimSpace(claims.JTI) == "" {
return ErrEmptyJTI
}
if claims.ExpiresAt != nil {
return currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time)
}
}
@@ -197,22 +444,42 @@ func InvalidateToken(tokenString string) error {
// InvalidateTokenByID 直接通过 JTI 使 Token 失效
// 参数: jti JWT IDexpiry 过期时间
func InvalidateTokenByID(jti string, expiry time.Time) error {
return tokenBlacklist.Add(jti, expiry)
if strings.TrimSpace(jti) == "" {
return ErrEmptyJTI
}
return currentBlacklist().Add(jti, expiry)
}
// RefreshToken 刷新 Token
//
// 安全约束(C9b 修复):将旧 Token 加入黑名单的 Add 错误必须向上传播——若 Add 失败
// (Redis 抖动或未启用)仍签发新 token,会导致旧 token 未拉黑、新旧 token 双有效,
// 形成会话固定窗口。故 Add 失败时不签发新 tokenfail-closed)。
func RefreshToken(tokenString string) (string, error) {
claims, err := ParseToken(tokenString)
if err != nil {
return "", err
}
// 将旧 Token 加入黑名单
if claims.JTI != "" && claims.ExpiresAt != nil {
tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time)
// 将旧 Token 加入黑名单;失败则不签发新 token(C9b:禁止吞 Add 错误)。
if strings.TrimSpace(claims.JTI) == "" {
return "", ErrEmptyJTI
}
if claims.ExpiresAt != nil {
if err := currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time); err != nil {
return "", fmt.Errorf("刷新令牌失败:旧令牌撤销失败: %w", err)
}
}
return GenerateToken(claims.UserID, claims.Username, claims.Role, claims.UserType)
cfg := config.Get()
var expiry time.Duration
if cfg != nil {
expiry = cfg.JWT.RefreshExpire
}
if expiry <= 0 && cfg != nil {
expiry = cfg.JWT.Expire
}
return generateTokenWithExpiry(cfg, claims.UserID, claims.Username, claims.Role, claims.UserType, expiry)
}
// GetJTI 从 Token 中提取 JTI(不验证签名)
@@ -230,9 +497,10 @@ func GetJTI(tokenString string) (string, error) {
return "", ErrTokenInvalid
}
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
func IsTokenRevoked(jti string) bool {
return tokenBlacklist.IsBlacklisted(jti)
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
// 返回 (是否撤销, 错误);黑名单后端不可用时返回 (false, ErrBlacklistUnavailable)。
func IsTokenRevoked(jti string) (bool, error) {
return currentBlacklist().IsBlacklisted(jti)
}
// GetClaimsFromToken 获取 Token 的 Claims(不验证过期)
@@ -240,17 +508,19 @@ func IsTokenRevoked(jti string) bool {
func GetClaimsFromToken(tokenString string) (*Claims, error) {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
return []byte(cfg.JWT.Secret), nil
}, jwt.WithoutClaimsValidation())
opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation())
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...)
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok {
if err := validateIssuer(cfg, claims); err != nil {
return nil, err
}
return claims, nil
}
return nil, ErrTokenInvalid
}
}
+142
View File
@@ -0,0 +1,142 @@
package jwt
import (
"sync"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// c9cSetupConfig 注入测试用 JWT 配置(内部测试无法访问 jwt_test 的 setupTestConfig)。
func c9cSetupConfig(t *testing.T) {
t.Helper()
config.Set(&config.Config{
JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
Expire: time.Hour,
},
})
}
// TestC9cConcurrentSetDefaultAndRead 验证 SetDefaultJWTManager(写)与包级函数
// (读 currentBlacklist → currentManager → defaultManager.Load)并发无数据竞争(C9c)。
// 修复前 tokenBlacklist 为裸包级指针,SetDefaultJWTManager 裸写、ParseToken/IsTokenRevoked/
// InvalidateTokenByID 裸读,-race 必采到指针读写竞争。
func TestC9cConcurrentSetDefaultAndRead(t *testing.T) {
c9cSetupConfig(t)
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) }) // 还原无 Redis 基线
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
// 预生成一个合法 token 供读者 ParseTokenJTI 随机,不会被下方 InvalidateTokenByID 的固定 jti 命中)
token, err := GenerateToken(1, "u", "admin", "super_admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
var wg sync.WaitGroup
stop := make(chan struct{})
// 写者:在"注入 Redis"与"无 Redis"Manager 间原子置换(模拟启动期/测试期 SetDefaultJWTManager
wg.Add(1)
go func() {
defer wg.Done()
withRedis := NewJWTManagerWithRedis(client)
withoutRedis := NewJWTManager()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
if i%2 == 0 {
SetDefaultJWTManager(withRedis)
} else {
SetDefaultJWTManager(withoutRedis)
}
}
}()
// 读者:并发调用经 currentBlacklist() 的包级函数 + currentManager
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_, _ = ParseToken(token)
_, _ = IsTokenRevoked("some-jti")
_ = InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
_ = currentManager()
}
}()
}
// 让竞争窗口运行一段时间,确保 -race 能采到(若有未加锁读)
time.Sleep(50 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestC9cCurrentManagerReflectsSwap 验证 SetDefaultJWTManager 原子置换后,
// 包级函数经 currentManager 读到的是新 Manager 的 blacklistC9c 一致性)。
func TestC9cCurrentManagerReflectsSwap(t *testing.T) {
c9cSetupConfig(t)
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) })
// 基线:无 RedisInvalidateTokenByID 返 ErrBlacklistUnavailable
if err := InvalidateTokenByID("jti-1", time.Now().Add(time.Hour)); !errorIsBlacklistUnavailable(err) {
t.Fatalf("baseline without Redis: want ErrBlacklistUnavailable, got %v", err)
}
// 置换为注入 Redis 的 Manager
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
SetDefaultJWTManager(NewJWTManagerWithRedis(client))
// 置换后包级函数应读到新 blacklistRedis 可用),InvalidateTokenByID 成功
if err := InvalidateTokenByID("jti-2", time.Now().Add(time.Hour)); err != nil {
t.Fatalf("after swap to Redis Manager: InvalidateTokenByID err = %v, want nil", err)
}
// currentManager 应等于刚 Store 的 Manager
m := currentManager()
if m == nil {
t.Fatal("currentManager should not be nil")
}
}
// TestC9cDefaultJWTAliasConsistent 验证 GetDefaultJWT 与 defaultManager 真实存储一致。
func TestC9cDefaultJWTAliasConsistent(t *testing.T) {
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) })
// init 后 GetDefaultJWT 与 currentManager 应指向同一实例
if GetDefaultJWT() != currentManager() {
t.Fatal("GetDefaultJWT should equal currentManager after init")
}
// SetDefaultJWTManager 后同步
m := NewJWTManagerWithRedis(nil)
SetDefaultJWTManager(m)
if GetDefaultJWT() != m {
t.Fatal("GetDefaultJWT should sync after SetDefaultJWTManager")
}
if currentManager() != m {
t.Fatal("currentManager should reflect SetDefaultJWTManager")
}
}
// errorIsBlacklistUnavailable 避免内部测试依赖 errors.Is 包装判断。
func errorIsBlacklistUnavailable(err error) bool {
return err != nil && err.Error() == ErrBlacklistUnavailable.Error()
}
+391 -30
View File
@@ -1,19 +1,23 @@
package jwt_test
import (
"errors"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/jwt"
"github.com/alicebob/miniredis/v2"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
)
func setupTestConfig() {
// 设置测试配置
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "test-secret-key-12345",
Expire: 3600, // 1小时
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
Expire: time.Hour, // 1小时
},
}
config.Set(cfg)
@@ -40,6 +44,7 @@ func TestGenerateToken(t *testing.T) {
func TestParseToken(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
// 先生成 token
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
@@ -89,11 +94,13 @@ func TestParseTokenWrongSecret(t *testing.T) {
// 修改 secret
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "different-secret",
Secret: "different-secret-key-12345678901234567890",
Expire: 3600,
},
}
config.Set(cfg)
if err := config.Set(cfg); err != nil {
t.Fatalf("Set wrong secret config: %v", err)
}
// 应该解析失败
_, err := jwt.ParseToken(token)
@@ -108,25 +115,27 @@ func TestRefreshToken(t *testing.T) {
// 生成 token
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
// 新 token
newToken, err := jwt.RefreshToken(token)
if err != nil {
t.Fatalf("RefreshToken error: %v", err)
// 无 Redis 时 RefreshToken 必须 fail-closedC9b 修复:旧 token 撤销失败不签发新 token
_, err := jwt.RefreshToken(token)
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("RefreshToken without Redis should fail with ErrBlacklistUnavailable, got %v", err)
}
}
if newToken == "" {
t.Error("RefreshToken should return non-empty token")
}
// 新 token 应可解析
claims, err := jwt.ParseToken(newToken)
if err != nil {
t.Fatalf("ParseToken new token error: %v", err)
}
if claims.Username != "testuser" {
t.Error("RefreshToken claims should match original")
}
// setupMiniRedis 启动 miniredis 并注入到 jwt 包级 tokenBlacklist(经 SetDefaultJWTManager)。
// 测试结束 cleanup 还原为无 Redis 的默认 Manager,避免污染后续测试(-shuffle 下尤其关键)。
func setupMiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() {
_ = client.Close()
// 还原包级 tokenBlacklist 为无 Redis 默认 Manager,避免残留指向已关闭 miniredis 的 client。
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
})
// 用注入 Redis 的 Manager 替换默认,使包级 tokenBlacklist 指向 miniredis。
jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(client))
return mr
}
func TestClaimsStructure(t *testing.T) {
@@ -169,15 +178,16 @@ func TestErrorDefinitions(t *testing.T) {
func TestTokenBlacklist(t *testing.T) {
tb := jwt.TokenBlacklist{}
// 无 Redis 时,Add 应返回 nil
// 无 Redis 时,Add 应返回 ErrBlacklistUnavailableC9a 修复:fail-closed
err := tb.Add("test-token", time.Now().Add(time.Hour))
if err != nil {
t.Errorf("TokenBlacklist.Add without Redis should return nil, got %v", err)
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err)
}
// 无 Redis 时,IsBlacklisted 返回 false
if tb.IsBlacklisted("test-token") {
t.Error("TokenBlacklist.IsBlacklisted without Redis should return false")
// 无 Redis 时,IsBlacklisted 返回 (false, ErrBlacklistUnavailable)fail-closed:错误上抛)
revoked, err := tb.IsBlacklisted("test-token")
if revoked || !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("TokenBlacklist.IsBlacklisted without Redis = (%v, %v), want (false, ErrBlacklistUnavailable)", revoked, err)
}
}
@@ -186,10 +196,10 @@ func TestInvalidateToken(t *testing.T) {
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
// 无 Redis 时应返回 nil
// 无 Redis 时应返回 ErrBlacklistUnavailableC9a 修复:fail-closed,不再静默成功)
err := jwt.InvalidateToken(token)
if err != nil {
t.Errorf("InvalidateToken without Redis should return nil, got %v", err)
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("InvalidateToken without Redis should return ErrBlacklistUnavailable, got %v", err)
}
}
@@ -215,3 +225,354 @@ func splitToken(token string) []string {
result = append(result, token[start:])
return result
}
// ===== C9b 回归:刷新令牌撤销闭环 =====
// 回归 C9bRefreshToken 成功后,旧 token 必须被拉黑(ParseToken 返 ErrTokenRevoked),
// 新 token 可用。旧实现丢弃 Add 错误仍签发新 token,旧 token 仍有效(双有效)。
func TestRefreshTokenRevokesOldToken(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
// 刷新
newToken, err := jwt.RefreshToken(token)
if err != nil {
t.Fatalf("RefreshToken: %v", err)
}
if newToken == "" {
t.Fatal("RefreshToken should return non-empty token")
}
// 新 token 可解析
newClaims, err := jwt.ParseToken(newToken)
if err != nil {
t.Fatalf("ParseToken new token: %v", err)
}
if newClaims.Username != "testuser" {
t.Error("new token claims should match original")
}
// 旧 token 必须已被拉黑(C9b 核心)
_, err = jwt.ParseToken(token)
if !errors.Is(err, jwt.ErrTokenRevoked) {
t.Errorf("old token after refresh err = %v, want ErrTokenRevoked (C9b: old must be blacklisted)", err)
}
}
// 回归 C9bRedis 抖动(Add 失败)时 RefreshToken 必须 fail-closed,不签发新 token。
// 旧实现丢弃 Add 错误仍签发新 token → 旧 token 未拉黑、新旧双有效。
func TestRefreshTokenFailsOnRedisError(t *testing.T) {
setupTestConfig()
mr := setupMiniRedis(t)
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
// 模拟 Redis 抖动:关闭 miniredis,使 Add 的 Set 失败。
mr.Close()
_, err := jwt.RefreshToken(token)
if err == nil {
t.Fatal("RefreshToken should fail when Redis unavailable (C9b: must not issue new token)")
}
// 不应是 ErrBlacklistUnavailable(那是无 Redis 路径),而是 Add 的 Set 错误包装。
// 关键:未签发新 token,旧 token 未被拉黑(因 Add 失败)。
}
// 回归 C9a/bInvalidateToken 闭环——登出后 token 被拉黑、ParseToken 返 ErrTokenRevoked。
func TestInvalidateTokenRevokesToken(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
// 登出前可解析
if _, err := jwt.ParseToken(token); err != nil {
t.Fatalf("ParseToken before invalidate: %v", err)
}
// 登出(拉黑)
if err := jwt.InvalidateToken(token); err != nil {
t.Fatalf("InvalidateToken: %v", err)
}
// 登出后必须被拉黑
_, err := jwt.ParseToken(token)
if !errors.Is(err, jwt.ErrTokenRevoked) {
t.Errorf("ParseToken after invalidate err = %v, want ErrTokenRevoked", err)
}
}
// 回归 C9a:无 Redis 时 InvalidateTokenByID 返 ErrBlacklistUnavailablefail-closed)。
func TestInvalidateTokenByIDNoRedis(t *testing.T) {
setupTestConfig()
// 不 setupMiniRedis → tokenBlacklist 指向无 Redis 的 Manager
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) }) // 还原基线
err := jwt.InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Errorf("InvalidateTokenByID without Redis err = %v, want ErrBlacklistUnavailable", err)
}
}
// ===== P0 回归:算法混淆 / 空密钥 / 不支持算法 =====
// 回归 P0alg=none 的 token 必须被拒(jwt.WithValidMethods 固定 HMAC 族)。
// 这是算法混淆攻击的一种典型形态——攻击者去掉签名并将 alg 置为 none。
func TestParseTokenRejectsAlgNone(t *testing.T) {
setupTestConfig()
claims := jwt.Claims{UserID: 1, Username: "attacker", Role: "admin", UserType: "super_admin"}
// 构造 alg=none 的未签名 token。
noneToken := gojwt.NewWithClaims(gojwt.SigningMethodNone, claims)
signed, err := noneToken.SignedString(gojwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("构造 none token 失败: %v", err)
}
if _, err := jwt.ParseToken(signed); err == nil {
t.Error("ParseToken 必须拒绝 alg=none 的 token(算法混淆防护)")
}
}
// 回归 P0:空密钥时 GenerateToken/ParseToken 必须 fail-closed 返 ErrEmptySecret
// 杜绝以零长度 HMAC 密钥签发/校验导致任意 token 通过。
func TestEmptySecretFailsClosed(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
t.Cleanup(setupTestConfig) // 还原基线,避免污染其它测试
_, err := jwt.GenerateToken(1, "u", "admin", "admin")
if !errors.Is(err, jwt.ErrEmptySecret) {
t.Errorf("空密钥 GenerateToken err = %v, want ErrEmptySecret", err)
}
// 校验侧:任意 token 在空密钥下都不得通过。
if _, err := jwt.ParseToken("a.b.c"); err == nil {
t.Error("空密钥 ParseToken 不得通过任何 token")
}
}
// 回归 P0:配置不支持的算法(如 RS256)时 GenerateToken 必须返 ErrUnsupportedAlgorithm
// 不再静默回退 HS256。
func TestUnsupportedAlgorithmFailsClosed(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Algorithm: "RS256",
Expire: time.Hour,
}})
t.Cleanup(setupTestConfig)
_, err := jwt.GenerateToken(1, "u", "admin", "admin")
if !errors.Is(err, jwt.ErrUnsupportedAlgorithm) {
t.Errorf("RS256 GenerateToken err = %v, want ErrUnsupportedAlgorithm", err)
}
}
// 回归 P0:显式支持的 HS384 仍可正常签发与校验(确认算法固定未误伤合法 HMAC 族)。
func TestSupportedAlgorithmHS384(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Algorithm: "HS384",
Expire: time.Hour,
}})
t.Cleanup(setupTestConfig)
setupMiniRedis(t)
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
if err != nil {
t.Fatalf("HS384 GenerateToken: %v", err)
}
if _, err := jwt.ParseToken(token); err != nil {
t.Errorf("HS384 ParseToken: %v", err)
}
}
func TestParseTokenRejectsWrongIssuer(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Expire: time.Hour,
Issuer: "trusted-issuer",
}})
t.Cleanup(setupTestConfig)
claims := jwt.Claims{
UserID: 1,
Username: "attacker",
Role: "admin",
UserType: "super_admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "evil-issuer",
},
}
token := signTokenForTest(t, claims)
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
t.Errorf("ParseToken wrong issuer err = %v, want ErrTokenInvalid", err)
}
if _, err := jwt.GetClaimsFromToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
t.Errorf("GetClaimsFromToken wrong issuer err = %v, want ErrTokenInvalid", err)
}
}
func TestRefreshTokenUsesRefreshExpire(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Expire: time.Hour,
RefreshExpire: 4 * time.Hour,
}})
t.Cleanup(setupTestConfig)
setupMiniRedis(t)
token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
newToken, err := jwt.RefreshToken(token)
if err != nil {
t.Fatalf("RefreshToken: %v", err)
}
claims, err := jwt.ParseToken(newToken)
if err != nil {
t.Fatalf("ParseToken refreshed token: %v", err)
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl < 3*time.Hour || ttl > 4*time.Hour+time.Minute {
t.Errorf("refreshed token ttl = %v, want about 4h", ttl)
}
}
func TestGenerateTokenWithCustomExpiryRejectsNonPositive(t *testing.T) {
setupTestConfig()
for _, seconds := range []int{0, -1} {
if _, err := jwt.GenerateTokenWithCustomExpiry(1, "u", "admin", "admin", seconds); !errors.Is(err, jwt.ErrInvalidExpiry) {
t.Errorf("GenerateTokenWithCustomExpiry(%d) err = %v, want ErrInvalidExpiry", seconds, err)
}
}
}
func TestInvalidateTokenByIDRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
if err := jwt.InvalidateTokenByID("", time.Now().Add(time.Hour)); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("InvalidateTokenByID empty err = %v, want ErrEmptyJTI", err)
}
}
func TestRefreshTokenRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token := signTokenForTest(t, jwt.Claims{
UserID: 1,
Username: "legacy",
Role: "admin",
UserType: "admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
},
})
if _, err := jwt.RefreshToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("RefreshToken empty JTI err = %v, want ErrEmptyJTI", err)
}
}
func TestInvalidateTokenRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token := signTokenForTest(t, jwt.Claims{
UserID: 1,
Username: "legacy",
Role: "admin",
UserType: "admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
},
})
if err := jwt.InvalidateToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("InvalidateToken empty JTI err = %v, want ErrEmptyJTI", err)
}
}
func TestParseTokenEmptySecretReturnsSentinel(t *testing.T) {
setupTestConfig()
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
if err != nil {
t.Fatalf("GenerateToken before empty secret: %v", err)
}
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
t.Cleanup(setupTestConfig)
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrEmptySecret) {
t.Fatalf("ParseToken empty secret err = %v, want ErrEmptySecret", err)
}
}
func TestParseTokenBlacklistPolicy(t *testing.T) {
setupTestConfig()
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) })
token, err := jwt.GenerateToken(1, "test", "admin", "admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Fatalf("ParseToken default fail-closed should reject without Redis, err = %v, want ErrBlacklistUnavailable", err)
}
if _, err := jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen); err != nil {
t.Fatalf("ParseTokenWithBlacklistPolicy fail-open should pass without Redis: %v", err)
}
}
func TestInvalidateTokenAllowsFutureNotBeforeToken(t *testing.T) {
setupTestConfig()
mr := setupMiniRedis(t)
claims := jwt.Claims{
UserID: 1,
Username: "external",
Role: "admin",
UserType: "admin",
JTI: "future-jti",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now().Add(10 * time.Minute)),
Issuer: "xlgo",
ID: "future-jti",
},
}
token := signTokenForTest(t, claims)
if err := jwt.InvalidateToken(token); err != nil {
t.Fatalf("InvalidateToken future nbf: %v", err)
}
if !mr.Exists("jwt_bl:future-jti") {
t.Fatal("future nbf token JTI should be blacklisted")
}
}
func signTokenForTest(t *testing.T, claims jwt.Claims) string {
t.Helper()
token, err := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims).SignedString([]byte("test-secret-key-1234567890123456789012"))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return token
}
+12 -17
View File
@@ -1,6 +1,8 @@
package logger
import (
"time"
"go.uber.org/zap"
)
@@ -12,24 +14,17 @@ var Field = struct {
Bool func(key string, value bool) zap.Field
Uint func(key string, value uint) zap.Field
Float64 func(key string, value float64) zap.Field
Duration func(key string, value interface{}) zap.Field
Duration func(key string, value time.Duration) zap.Field
Error func(err error) zap.Field
}{
String: zap.String,
Int: zap.Int,
Int64: zap.Int64,
Bool: zap.Bool,
Uint: zap.Uint,
Float64: zap.Float64,
Duration: func(key string, value interface{}) zap.Field {
switch v := value.(type) {
case zap.Field:
return v
default:
return zap.Any(key, value)
}
},
String: zap.String,
Int: zap.Int,
Int64: zap.Int64,
Bool: zap.Bool,
Uint: zap.Uint,
Float64: zap.Float64,
Duration: zap.Duration,
Error: func(err error) zap.Field {
return zap.Error(err)
},
return zap.Error(err)
},
}
+444 -80
View File
@@ -1,8 +1,12 @@
package logger
import (
"errors"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -12,17 +16,250 @@ import (
)
var (
// Logger 全局日志实例
Logger *zap.Logger
sugar *zap.SugaredLogger
apiLog *zap.Logger
dbLog *zap.Logger
// 内部 atomic 存储——四个 logger 的真实源(H7 修复)。
// 所有包级函数(Info/Debug/.../APILog/DBLog/Sync)经 atomic Load 读取,
// 使请求 goroutine 不与 Init/Close 重新装配全局变量竞争。
// 原实现用 m.mu(实例锁)保护包级全局变量,锁与被保护对象作用域错配,
// 读侧无锁裸读 → 热重载 re-Init/Close 与请求日志存在数据竞争。
loggerPtr atomic.Pointer[zap.Logger]
sugarPtr atomic.Pointer[zap.SugaredLogger]
apiLogPtr atomic.Pointer[zap.Logger]
dbLogPtr atomic.Pointer[zap.Logger]
// defaultLoggerPtr 是包级 facade 使用的默认 manager 原子快照。
// 保留导出的 DefaultLogger *LogManager 作为兼容变量;SetDefaultLogManager
// 只替换这个内部指针,避免 facade 裸读/裸写导出变量。
defaultLoggerPtr atomic.Pointer[LogManager]
globalLogGeneration uint64
// globalMu 串行化包级 logger/writer 的发布与关闭。
// 不能仅依赖 LogManager.mu:多个 LogManager 实例并发 Init/Close 时,
// 实例锁无法保护 Logger/fileWriters 这类包级状态。
globalMu sync.Mutex
// Logger 全局通用日志实例(兼容别名)。Init 之前为 Nop,调用安全。
//
// Deprecated: 此导出变量由 Init/Close 在 globalMu 下同步维护,但直接读它在 re-Init/Close
// 期间非并发安全(L-E)。请改用包级函数 Info/Debug/Warn/Error/...(经 atomic 读取,
// 并发安全)。保留仅为向后兼容,未来大版本可能移除。
Logger = zap.NewNop()
// fileWriters 持有所有 lumberjack 实例引用,
// Close() 调用时显式释放文件句柄。
// 必要性:lumberjack 不依赖 GC 关闭文件,进程长跑或测试场景下
// 不显式关闭会持有句柄导致 Windows 上无法删除日志目录。
// 仅在 globalMu 下访问(Init/Close),无读侧竞争。
fileWriters []*lumberjack.Logger
)
// Init 初始化日志
func Init(cfg *config.Config) error {
// 确保日志目录存在
if err := os.MkdirAll(cfg.Log.Dir, 0755); err != nil {
func init() {
// 初始化 atomic 存储为 Nop,保证包级函数在任何时刻 Load 均非 nil。
nop := zap.NewNop()
loggerPtr.Store(nop)
sugarPtr.Store(nop.Sugar())
apiLogPtr.Store(nop)
dbLogPtr.Store(nop)
defaultLoggerPtr.Store(DefaultLogger)
}
// currentLogger 返回通用 logger 的 atomic 快照(永不 nil)。
func currentLogger() *zap.Logger {
if l := loggerPtr.Load(); l != nil {
return l
}
return zap.NewNop() // 防御:init 后不可达
}
// currentSugar 返回 sugared logger 的 atomic 快照(永不 nil)。
func currentSugar() *zap.SugaredLogger {
if s := sugarPtr.Load(); s != nil {
return s
}
return zap.NewNop().Sugar() // 防御:init 后不可达
}
// currentAPILog 返回 API logger 的 atomic 快照(永不 nil)。
func currentAPILog() *zap.Logger {
if l := apiLogPtr.Load(); l != nil {
return l
}
return zap.NewNop()
}
// currentDBLog 返回 DB logger 的 atomic 快照(永不 nil)。
func currentDBLog() *zap.Logger {
if l := dbLogPtr.Load(); l != nil {
return l
}
return zap.NewNop()
}
// LogManager 日志管理器(#10)。照 database.Manager 模式:
// 实例化 + DefaultLogger 全局默认 + 包级 facade 代理。
// 包级 Logger/sugar/apiLog/dbLog 由 Init 同步维护,下游 8 个包零改动。
type LogManager struct {
mu sync.Mutex
cfg *config.Config
// level 是所有 core 共享的 AtomicLevel,支持运行期 SetLevel 热切换(M19)。
// Init 前为 nilSetLevel 守卫之。
level zap.AtomicLevel
generation uint64
}
// DefaultSnapshot 是默认 logger 全局状态的 opaque 快照。
// App 初始化用它实现“先安装新 logger,后续失败可恢复旧 logger”的事务边界。
type DefaultSnapshot struct {
manager *LogManager
logger *zap.Logger
sugar *zap.SugaredLogger
apiLog *zap.Logger
dbLog *zap.Logger
writers []*lumberjack.Logger
generation uint64
}
// DefaultLogger 默认日志管理器,包级 facade 代理到它。
//
// Deprecated: 直接替换该导出变量(logger.DefaultLogger = m)不会更新包级 facade
// 使用的 atomic 快照,也不是并发安全的替换方式。请用 SetDefaultLogManager(m)。
// 直接调用 DefaultLogger.Init/Close/SetLevel/GetLevel 仍保留兼容,并受 LogManager
// 自身锁保护。
var DefaultLogger = NewLogManager()
// NewLogManager 创建日志管理器实例。
func NewLogManager() *LogManager { return &LogManager{} }
// GetDefaultLogManager 返回全局默认 LogManageratomic 读取,并发安全)。
func GetDefaultLogManager() *LogManager {
if m := defaultLoggerPtr.Load(); m != nil {
return m
}
if DefaultLogger != nil && defaultLoggerPtr.CompareAndSwap(nil, DefaultLogger) {
return DefaultLogger
}
m := NewLogManager()
if defaultLoggerPtr.CompareAndSwap(nil, m) {
return m
}
return defaultLoggerPtr.Load()
}
// SetLevel 运行期热切换日志级别(M19)。需先 Init;未 Init 时返回 false。
// 影响所有 coreapp/api/db/console)——它们共享同一 AtomicLevel。
func (m *LogManager) SetLevel(l zapcore.Level) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.level == (zap.AtomicLevel{}) {
return false
}
m.level.SetLevel(l)
return true
}
// GetLevel 返回当前日志级别(未 Init 返回 InfoLevel)。
func (m *LogManager) GetLevel() zapcore.Level {
m.mu.Lock()
defer m.mu.Unlock()
if m.level == (zap.AtomicLevel{}) {
return zapcore.InfoLevel
}
return m.level.Level()
}
// SetLevel 包级 facade:运行期热切换默认日志级别(M19)。未 Init 时无操作返回 false。
func SetLevel(l zapcore.Level) bool { return GetDefaultLogManager().SetLevel(l) }
// GetLevel 包级 facade:返回默认日志当前级别。
func GetLevel() zapcore.Level { return GetDefaultLogManager().GetLevel() }
// SetDefaultLogManager 提升指定 LogManager 为全局默认(atomic.Store,并发安全)。
func SetDefaultLogManager(m *LogManager) {
if m != nil {
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 的分流策略:
// - Logger(通用):写 console + logs/app.log
// - APILog() :写 console + logs/api.log
// - DBLog() :写 console + logs/database.log
//
// 关键修复(v1.0.3):旧实现把 apiCore 和 dbCore 都 Tee 进通用 Logger
// 导致每条 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 {
return err
}
@@ -42,132 +279,259 @@ func Init(cfg *config.Config) error {
EncodeCaller: zapcore.ShortCallerEncoder,
}
// 根据运行模式设置日志级别
var level zapcore.Level
// 根据运行模式设置日志级别M19:用 AtomicLevel 支持运行期 SetLevel 热切换)
level := zap.NewAtomicLevelAt(zapcore.DebugLevel)
if cfg.IsProduction() {
level = zapcore.WarnLevel
} else {
level = zapcore.DebugLevel
level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
}
// API 日志
apiWriter := &lumberjack.Logger{
Filename: filepath.Join(cfg.Log.Dir, "api.log"),
MaxSize: cfg.Log.MaxSize,
MaxBackups: cfg.Log.MaxBackups,
MaxAge: cfg.Log.MaxAge,
Compress: cfg.Log.Compress,
}
// 数据库日志
dbWriter := &lumberjack.Logger{
Filename: filepath.Join(cfg.Log.Dir, "database.log"),
MaxSize: cfg.Log.MaxSize,
MaxBackups: cfg.Log.MaxBackups,
MaxAge: cfg.Log.MaxAge,
Compress: cfg.Log.Compress,
}
// 控制台输出
jsonEncoder := zapcore.NewJSONEncoder(encoderConfig)
consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig)
consoleWriter := zapcore.AddSync(os.Stdout)
consoleCore := zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), level)
// 创建核心
apiCore := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.AddSync(apiWriter),
level,
// 通用日志独立文件,避免与 api/db 日志重复写入
appWriter := newRotatingWriter(cfg.Log, "app.log")
apiWriter := newRotatingWriter(cfg.Log, "api.log")
dbWriter := newRotatingWriter(cfg.Log, "database.log")
appCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(appWriter), level)
apiCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(apiWriter), level)
dbCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(dbWriter), level)
// 三个 logger 各写自己的文件 + console,互不 Tee
newLogger := zap.New(
zapcore.NewTee(appCore, consoleCore),
zap.AddCaller(), zap.AddCallerSkip(1),
)
newAPILog := zap.New(
zapcore.NewTee(apiCore, consoleCore),
zap.AddCaller(), zap.AddCallerSkip(1),
)
newDBLog := zap.New(
zapcore.NewTee(dbCore, consoleCore),
zap.AddCaller(), zap.AddCallerSkip(1),
)
dbCore := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.AddSync(dbWriter),
level,
)
// 全部构造成功后再原子替换全局变量,避免半初始化状态。
// 先发布新 logger,再关闭旧 writer,避免请求 goroutine 在替换窗口内
// 通过包级函数拿到仍指向已关闭 writer 的旧 logger。
// H7:四个 logger 经 atomic.Pointer Store,读侧(请求 goroutine)无锁原子 load
// fileWriters 仅在 globalMu 下访问。Logger 兼容别名同步维护。
globalMu.Lock()
oldWriters := fileWriters
globalLogGeneration++
m.generation = globalLogGeneration
loggerPtr.Store(newLogger)
sugarPtr.Store(newLogger.Sugar())
apiLogPtr.Store(newAPILog)
dbLogPtr.Store(newDBLog)
Logger = newLogger
fileWriters = []*lumberjack.Logger{appWriter, apiWriter, dbWriter}
globalMu.Unlock()
consoleCore := zapcore.NewCore(
consoleEncoder,
consoleWriter,
level,
)
// 合并核心
core := zapcore.NewTee(apiCore, dbCore, consoleCore)
// 创建日志实例
Logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
sugar = Logger.Sugar()
// 创建专用日志实例
apiLog = zap.New(apiCore, zap.AddCaller(), zap.AddCallerSkip(1))
dbLog = zap.New(dbCore, zap.AddCaller(), zap.AddCallerSkip(1))
m.level = level
m.cfg = cfg
if closePrevious {
return closeFileWriters(oldWriters)
}
return nil
}
// Sync 同步日志
func Sync() {
if Logger != nil {
_ = Logger.Sync()
// Init 包级 facade:初始化日志(代理到 DefaultLogger)。
func Init(cfg *config.Config) error {
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
func newRotatingWriter(cfg config.LogConfig, filename string) *lumberjack.Logger {
return &lumberjack.Logger{
Filename: filepath.Join(cfg.Dir, filename),
MaxSize: cfg.MaxSize,
MaxBackups: cfg.MaxBackups,
MaxAge: cfg.MaxAge,
Compress: cfg.Compress,
}
}
// closeFileWriters 关闭传入的 lumberjack writer,并聚合关闭错误。
func closeFileWriters(writers []*lumberjack.Logger) error {
var errs []error
for _, w := range writers {
if w != nil {
if err := w.Close(); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
func syncLoggers(loggers ...*zap.Logger) error {
var errs []error
for _, l := range loggers {
if l == nil {
continue
}
if err := l.Sync(); err != nil && !isHarmlessSyncError(err) {
errs = append(errs, err)
}
}
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 GetDefaultLogManager().Sync()
}
// Close 关闭日志文件句柄,重置全局 logger 为 Nop。
// 通常由 App.Shutdown 在 Sync 之后调用;测试场景需要清理临时目录时也应调用。
//
// 调用后再次写日志不会 panicfall back to nop logger),但不会写入文件。
// 如需重新启用,请再次调用 Init。
func (m *LogManager) Close() error {
m.mu.Lock()
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()
loggerPtr.Store(nop)
sugarPtr.Store(nop.Sugar())
apiLogPtr.Store(nop)
dbLogPtr.Store(nop)
Logger = nop
fileWriters = nil
globalMu.Unlock()
m.level = zap.AtomicLevel{}
m.cfg = nil
m.generation = 0
m.mu.Unlock()
return errors.Join(
syncLoggers(oldLogger, oldAPILog, oldDBLog),
closeFileWriters(oldWriters),
)
}
// Close 包级 facade:关闭日志文件句柄,重置为 Nop(代理到 DefaultLogger)。
func Close() error {
return GetDefaultLogManager().Close()
}
// isHarmlessSyncError 识别 stdout/stderr Sync 在不同平台返回的预期错误。
// 这些错误来自 console core,对真实 writer 无影响,可安全忽略。
func isHarmlessSyncError(err error) bool {
if err == nil {
return true
}
msg := err.Error()
for _, sub := range []string{
"invalid argument", // Linux stdout
"inappropriate ioctl for device", // macOS stdout
"bad file descriptor", // Windows stdout
} {
if strings.Contains(msg, sub) {
return true
}
}
return false
}
// Debug 调试日志
func Debug(msg string, fields ...zap.Field) {
Logger.Debug(msg, fields...)
currentLogger().Debug(msg, fields...)
}
// Info 信息日志
func Info(msg string, fields ...zap.Field) {
Logger.Info(msg, fields...)
currentLogger().Info(msg, fields...)
}
// Warn 警告日志
func Warn(msg string, fields ...zap.Field) {
Logger.Warn(msg, fields...)
currentLogger().Warn(msg, fields...)
}
// Error 错误日志
func Error(msg string, fields ...zap.Field) {
Logger.Error(msg, fields...)
currentLogger().Error(msg, fields...)
}
// Fatal 致命错误日志
// Fatal 致命错误日志(仅供应用层使用,框架内部禁止调用)
func Fatal(msg string, fields ...zap.Field) {
Logger.Fatal(msg, fields...)
currentLogger().Fatal(msg, fields...)
}
// Debugf 格式化调试日志
func Debugf(template string, args ...any) {
sugar.Debugf(template, args...)
currentSugar().Debugf(template, args...)
}
// Infof 格式化信息日志
func Infof(template string, args ...any) {
sugar.Infof(template, args...)
currentSugar().Infof(template, args...)
}
// Warnf 格式化警告日志
func Warnf(template string, args ...any) {
sugar.Warnf(template, args...)
currentSugar().Warnf(template, args...)
}
// Errorf 格式化错误日志
func Errorf(template string, args ...any) {
sugar.Errorf(template, args...)
currentSugar().Errorf(template, args...)
}
// Fatalf 格式化致命错误日志
// Fatalf 格式化致命错误日志(仅供应用层使用,框架内部禁止调用)
func Fatalf(template string, args ...any) {
sugar.Fatalf(template, args...)
currentSugar().Fatalf(template, args...)
}
// APILog API 专用日志
// APILog 返回 API 专用日志器(写 logs/api.log + console
func APILog() *zap.Logger {
return apiLog
return currentAPILog()
}
// DBLog 数据库专用日志
// DBLog 返回数据库专用日志器(写 logs/database.log + console
func DBLog() *zap.Logger {
return dbLog
return currentDBLog()
}
+482
View File
@@ -0,0 +1,482 @@
package logger
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// tmpCfg 构造一个指向临时目录的日志配置,用于 Init。
func tmpCfg(dir string) *config.Config {
return &config.Config{
Server: config.ServerConfig{Mode: "production"},
Log: config.LogConfig{
Dir: dir,
MaxSize: 1,
MaxBackups: 1,
MaxAge: 1,
Compress: false,
},
}
}
// TestH7ConcurrentInitCloseAndRead 验证请求 goroutine 的包级日志读路径
// 与 Init/Close 重新装配全局 logger 无数据竞争(-race)。
//
// H7 根因:原实现 Info/Error/APILog/DBLog/Sync 裸读包级 Logger/sugar/apiLog/dbLog
// 而 Init/Close 在 m.mu 下写这些变量——实例锁保护全局变量、读侧无锁 → re-Init/Close
// 与请求日志竞争。修复后读路径经 atomic.Pointer Load。
//
// 红/绿验证:将 currentLogger/currentSugar/currentAPILog/currentDBLog 临时改为
// 裸读 Logger/sugar/apiLog/dbLog(或等价地恢复包级裸读),-race 必复现 DATA RACE
// 恢复 atomic 后绿。
func TestH7ConcurrentInitCloseAndRead(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
// 确保默认 LogManager 起点干净。
_ = GetDefaultLogManager().Close()
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
stop := make(chan struct{})
var wg sync.WaitGroup
// 写者:循环 Init/Close(重新装配全局 logger)。
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = GetDefaultLogManager().Init(cfg)
_ = GetDefaultLogManager().Close()
}
}()
// 读者:并发调用读路径(包级函数 + atomic 快照读取)。
// 仅读取 logger 指针——这正是被竞态的访问;不调用 .Info/.Errorf 等写方法,
// 避免对已被 re-Init 关闭的旧 writer 触发 lumberjack 重新打开同一文件,
// 导致测试 TempDir 清理时句柄仍占用(框架不支持 re-Init 与在途写入并发,
// 该约束非 H7 范围)。
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
// 这些调用内部经 atomic Load 读取包级 logger 指针。
_ = currentLogger()
_ = currentSugar()
_ = currentAPILog()
_ = currentDBLog()
_ = APILog()
_ = DBLog()
}
}()
}
// 运行窗口足以让 race detector 采到任何竞争。
time.Sleep(120 * time.Millisecond)
close(stop)
wg.Wait()
// 收尾到 Nop,避免后续测试持有临时目录句柄。
_ = GetDefaultLogManager().Close()
}
// TestH7CurrentLoggerReflectsInitAndClose 验证 atomic 快照随 Init/Close 正确切换:
// Init 后 currentLogger 写入文件;Close 后回到 Nop(不写文件)。
func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
_ = GetDefaultLogManager().Close()
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
// Init 前:Nop,调用安全且不写文件。
before := currentLogger()
if before == nil {
t.Fatal("currentLogger nil before Init")
}
if err := GetDefaultLogManager().Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
// Init 后:currentLogger 与 Logger 兼容别名一致,且为非 Nop 实例。
after := currentLogger()
if after == nil {
t.Fatal("currentLogger nil after Init")
}
if after != Logger {
t.Error("after Init, currentLogger() != Logger (compat alias out of sync)")
}
// 写一条日志并 flush,验证落到文件(说明拿到的是真实 logger,非 Nop)。
const mark = "H7_INIT_REFLECT_xyz"
after.Info(mark)
_ = GetDefaultLogManager().Sync()
data, err := readFile(t, filepath.Join(dir, "app.log"))
if err != nil {
t.Fatalf("read app.log: %v", err)
}
if !strings.Contains(data, mark) {
t.Errorf("app.log missing mark %q after Init (got Nop?)", mark)
}
// Close 后:currentLogger 回到 Nop(与 Init 前实例不同,但同为 Nop 行为)。
if err := GetDefaultLogManager().Close(); err != nil {
t.Fatalf("Close: %v", err)
}
closed := currentLogger()
if closed == nil {
t.Fatal("currentLogger nil after Close")
}
// Nop logger 写不报错也不落盘;验证 Close 后再写不新增 mark。
closed.Info("H7_SHOULD_NOT_APPEAR_xyz")
_ = GetDefaultLogManager().Sync()
data2, _ := readFile(t, filepath.Join(dir, "app.log"))
if strings.Contains(data2, "H7_SHOULD_NOT_APPEAR_xyz") {
t.Error("wrote to file after Close (expected Nop)")
}
}
// TestH7AtomicPointersNonNil 验证 init 后四个 atomic 快照永不 nil
// 且 APILog/DBLog 返回与内部 atomic 一致的实例。
func TestH7AtomicPointersNonNil(t *testing.T) {
if loggerPtr.Load() == nil {
t.Error("loggerPtr nil after init")
}
if sugarPtr.Load() == nil {
t.Error("sugarPtr nil after init")
}
if apiLogPtr.Load() == nil {
t.Error("apiLogPtr nil after init")
}
if dbLogPtr.Load() == nil {
t.Error("dbLogPtr nil after init")
}
if APILog() != apiLogPtr.Load() {
t.Error("APILog() != apiLogPtr")
}
if DBLog() != dbLogPtr.Load() {
t.Error("DBLog() != dbLogPtr")
}
if currentLogger() != loggerPtr.Load() {
t.Error("currentLogger() != loggerPtr")
}
}
// TestH7DefaultLoggerCompatibility 锁定 M3 的兼容边界:
// DefaultLogger 仍保持 *LogManager 类型,旧代码可继续直接调用其方法;
// 包级 facade 的默认 manager 替换则必须走 SetDefaultLogManager 的 atomic 快照。
func TestH7DefaultLoggerCompatibility(t *testing.T) {
var _ *LogManager = DefaultLogger
old := GetDefaultLogManager()
t.Cleanup(func() { SetDefaultLogManager(old) })
next := NewLogManager()
SetDefaultLogManager(next)
if got := GetDefaultLogManager(); got != next {
t.Fatalf("GetDefaultLogManager() = %p, want %p", got, next)
}
}
// TestM3StaleManagerCloseDoesNotCloseCurrentLogger 回归:旧 manager 的 Close
// 不得关闭另一个 manager 后续发布的全局 logger/writer。
func TestM3StaleManagerCloseDoesNotCloseCurrentLogger(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
m1 := NewLogManager()
m2 := NewLogManager()
t.Cleanup(func() {
_ = m1.Close()
_ = m2.Close()
SetDefaultLogManager(NewLogManager())
})
if err := m1.Init(tmpCfg(dir1)); err != nil {
t.Fatalf("m1.Init: %v", err)
}
if err := m2.Init(tmpCfg(dir2)); err != nil {
t.Fatalf("m2.Init: %v", err)
}
if err := m1.Close(); err != nil {
t.Fatalf("stale m1.Close: %v", err)
}
const mark = "M3_CURRENT_LOGGER_SURVIVES_STALE_CLOSE"
currentLogger().Info(mark)
_ = syncLoggers(currentLogger())
data, err := readFile(t, filepath.Join(dir2, "app.log"))
if err != nil {
t.Fatalf("read current app.log: %v", err)
}
if !strings.Contains(data, mark) {
t.Fatal("stale manager Close closed the current logger")
}
}
// TestH7DurationFieldFix 验证 H7bField.Duration 签名改为
// func(key string, value time.Duration) zap.Field,且 key 不再被丢弃。
//
// 旧实现 func(key string, value interface{}) 在 case zap.Field 分支 return v
// 丢弃 key,签名与实现矛盾。修复后直接委托 zap.Duration(key, value)。
func TestH7DurationFieldFix(t *testing.T) {
f := Field.Duration("elapsed", 5*time.Second)
if f.Key != "elapsed" {
t.Errorf("Duration key lost: got %q, want %q (H7b regression)", f.Key, "elapsed")
}
// zap.Duration 用 zapcore.DurationType 编码,Integer 字段承载纳秒。
if f.Integer != int64(5*time.Second) {
t.Errorf("Duration value mismatch: got %d, want %d", f.Integer, int64(5*time.Second))
}
// 零值与其他字段不受影响。
f2 := Field.Duration("d0", 0)
if f2.Key != "d0" {
t.Errorf("Duration zero key lost: %q", f2.Key)
}
}
// TestH7DurationFieldSignature 编译期锁定 Field.Duration 的签名为
// func(string, time.Duration) zap.FieldH7b 修复的核心:类型安全签名)。
//
// 旧签名为 func(string, interface{}) zap.Field,其 case zap.Field 分支 return v
// 丢弃传入的 key。若回退旧签名,下方赋值将因参数类型不匹配(interface{} ≠ time.Duration
// 编译失败——即"修复前红、修复后绿"由编译器强制保证。
func TestH7DurationFieldSignature(t *testing.T) {
var _ func(string, time.Duration) zap.Field = Field.Duration
}
// readFile 读取文件内容,文件不存在时返回空串(不报错)。
func readFile(t *testing.T, path string) (string, error) {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(b), nil
}
// TestSetLevelHotSwap_M19Init 后可运行期热切换日志级别(M19)。
func TestSetLevelHotSwap_M19(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
_ = GetDefaultLogManager().Close()
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
if err := GetDefaultLogManager().Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
// Init 后(tmpCfg 为 production 模式,默认 InfoLevel)可热切换到 ErrorLevel。
before := GetDefaultLogManager().GetLevel()
if before != zapcore.InfoLevel {
t.Errorf("default level = %v, want InfoLevel (production)", before)
}
if ok := GetDefaultLogManager().SetLevel(zapcore.ErrorLevel); !ok {
t.Fatal("SetLevel after Init should return true")
}
if got := GetDefaultLogManager().GetLevel(); got != zapcore.ErrorLevel {
t.Errorf("after SetLevel, level = %v, want ErrorLevel", got)
}
// 包级 facade 同步。
SetLevel(zapcore.WarnLevel)
if got := GetLevel(); got != zapcore.WarnLevel {
t.Errorf("package GetLevel = %v, want WarnLevel", got)
}
}
// TestM3ConcurrentInitSetLevelAndClose 验证 Init/SetLevel/GetLevel/Close
// 共享同一个 manager 临界区,不再出现 m.level 锁外写与锁内读写竞争(-race)。
func TestM3ConcurrentInitSetLevelAndClose(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
m := NewLogManager()
SetDefaultLogManager(m)
t.Cleanup(func() {
_ = m.Close()
SetDefaultLogManager(NewLogManager())
})
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = m.Init(cfg)
_ = m.Close()
}
}()
for i := 0; i < 4; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
levels := []zapcore.Level{
zapcore.DebugLevel,
zapcore.InfoLevel,
zapcore.WarnLevel,
zapcore.ErrorLevel,
}
for {
select {
case <-stop:
return
default:
}
_ = m.SetLevel(levels[i%len(levels)])
_ = m.GetLevel()
}
}(i)
}
time.Sleep(120 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestM3ConcurrentSetDefaultAndFacades 验证默认 LogManager 经 atomic.Pointer
// 读写,SetDefaultLogManager 与包级 facade 并发时无裸全局指针竞态(-race)。
func TestM3ConcurrentSetDefaultAndFacades(t *testing.T) {
t.Cleanup(func() {
_ = Close()
SetDefaultLogManager(NewLogManager())
})
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
SetDefaultLogManager(NewLogManager())
}
}()
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = GetLevel()
_ = SetLevel(zapcore.WarnLevel)
_ = Sync()
_ = APILog()
_ = DBLog()
}
}()
}
time.Sleep(120 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestM3ConcurrentManagersInitClose 验证不同 LogManager 实例并发 Init/Close 时,
// 包级 Logger/fileWriters 的发布与关闭由 globalMu 串行化,不再依赖实例锁保护包级状态。
func TestM3ConcurrentManagersInitClose(t *testing.T) {
dir := t.TempDir()
cfg := tmpCfg(dir)
stop := make(chan struct{})
var wg sync.WaitGroup
managers := make([]*LogManager, 3)
for i := 0; i < 3; i++ {
managers[i] = NewLogManager()
wg.Add(1)
go func(m *LogManager) {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
SetDefaultLogManager(m)
_ = m.Init(cfg)
_ = m.Close()
}
}(managers[i])
}
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = currentLogger()
_ = currentSugar()
_ = APILog()
_ = DBLog()
}
}()
time.Sleep(120 * time.Millisecond)
close(stop)
wg.Wait()
for _, m := range managers {
_ = m.Close()
}
_ = Close()
SetDefaultLogManager(NewLogManager())
}
// TestM3InitRejectsInvalidLogConfig 验证 logger.Init 对明显非法日志配置失败,
// 避免空目录意外把 app.log 写到进程工作目录。
func TestM3InitRejectsInvalidLogConfig(t *testing.T) {
if err := NewLogManager().Init(&config.Config{}); err == nil {
t.Fatal("Init with empty log dir should fail")
}
cfg := tmpCfg(t.TempDir())
cfg.Log.MaxAge = -1
if err := NewLogManager().Init(cfg); err == nil {
t.Fatal("Init with negative MaxAge should fail")
}
}
+116 -2
View File
@@ -1,8 +1,12 @@
package logger_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
)
@@ -11,12 +15,12 @@ import (
func TestAPILog(t *testing.T) {
_ = logger.APILog()
// 未初始化时为 nil,这是预期行为
// 未初始化时为 nop logger,调用安全
}
func TestDBLog(t *testing.T) {
_ = logger.DBLog()
// 未初始化时为 nil,这是预期行为
// 未初始化时为 nop logger,调用安全
}
func TestFieldString(t *testing.T) {
@@ -64,4 +68,114 @@ func TestFieldFloat64(t *testing.T) {
func TestFieldError(t *testing.T) {
_ = logger.Field.Error(nil)
// zap.Field 是结构体,仅验证函数调用正常
}
// initWithTempDir 使用临时目录初始化 logger,返回日志目录与读取文件内容的辅助函数。
// 验证修复 #3 之后,三个 logger 各自只写自己的文件,互不串扰。
//
// 测试退出时通过 t.Cleanup 调用 logger.Close 释放文件句柄,
// 避免 Windows 上 t.TempDir 因句柄占用而清理失败。
func initWithTempDir(t *testing.T) (dir string, read func(name string) string) {
t.Helper()
dir = t.TempDir()
cfg := &config.Config{
Server: config.ServerConfig{Mode: "production"}, // 走 InfoLevel,避免 Debug 噪音
Log: config.LogConfig{
Dir: dir,
MaxSize: 10,
MaxBackups: 1,
MaxAge: 1,
Compress: false,
},
}
if err := logger.Init(cfg); err != nil {
t.Fatalf("logger.Init failed: %v", err)
}
t.Cleanup(func() { _ = logger.Close() })
read = func(name string) string {
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return ""
}
t.Fatalf("read %s: %v", path, err)
}
return string(data)
}
return dir, read
}
// TestLoggerNoCrossWriting 验证 Logger / APILog / DBLog 不会重复写入彼此的日志文件。
//
// 这是 v1.0.3 修复的核心:旧实现 Logger = Tee(apiCore, dbCore, consoleCore)
// 导致 logger.Info(...) 同时写到 api.log + database.log + console 三份。
func TestLoggerNoCrossWriting(t *testing.T) {
_, read := initWithTempDir(t)
// 三个标记字符串足够独特,分别由三个 logger 写入
const (
appMark = "MARKER_APP_LOG_ONLY_xyz"
apiMark = "MARKER_API_LOG_ONLY_xyz"
dbMark = "MARKER_DB_LOG_ONLY_xyz"
)
logger.Info(appMark)
logger.APILog().Info(apiMark)
logger.DBLog().Info(dbMark)
if err := logger.Sync(); err != nil {
t.Fatalf("Sync: %v", err)
}
app := read("app.log")
api := read("api.log")
db := read("database.log")
// 各自的日志文件必须包含自己的标记
if !strings.Contains(app, appMark) {
t.Errorf("app.log missing %q, got: %s", appMark, app)
}
if !strings.Contains(api, apiMark) {
t.Errorf("api.log missing %q, got: %s", apiMark, api)
}
if !strings.Contains(db, dbMark) {
t.Errorf("database.log missing %q, got: %s", dbMark, db)
}
// 关键:禁止串写
if strings.Contains(api, appMark) {
t.Errorf("api.log should NOT contain %q (cross-writing bug regressed)", appMark)
}
if strings.Contains(db, appMark) {
t.Errorf("database.log should NOT contain %q (cross-writing bug regressed)", appMark)
}
if strings.Contains(app, apiMark) {
t.Errorf("app.log should NOT contain %q", apiMark)
}
if strings.Contains(db, apiMark) {
t.Errorf("database.log should NOT contain %q", apiMark)
}
if strings.Contains(app, dbMark) {
t.Errorf("app.log should NOT contain %q", dbMark)
}
if strings.Contains(api, dbMark) {
t.Errorf("api.log should NOT contain %q", dbMark)
}
}
// TestLoggerInitNilConfig 验证 Init 对 nil 配置返回错误,不再 panic。
func TestLoggerInitNilConfig(t *testing.T) {
if err := logger.Init(nil); err == nil {
t.Error("Init(nil) should return error, got nil")
}
}
// TestLoggerSyncBeforeInit 验证未初始化时 Sync 不 panicglobals 仍为 Nop)。
func TestLoggerSyncBeforeInit(t *testing.T) {
// Nop logger 的 Sync 永远返回 nil
if err := logger.Sync(); err != nil {
t.Errorf("Sync on nop logger should be nil, got %v", err)
}
}
+3
View File
@@ -0,0 +1,3 @@
{"level":"warn","time":"2026-07-02T23:25:17.643+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 1/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
{"level":"warn","time":"2026-07-02T23:25:18.648+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 2/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
{"level":"warn","time":"2026-07-02T23:25:20.651+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 3/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
+136 -89
View File
@@ -17,8 +17,26 @@ const (
ContextKeyRole = "role"
// ContextKeyUserType 用户类型上下文键
ContextKeyUserType = "user_type"
// DefaultUserTypeSuperAdmin 默认超级管理员用户类型
DefaultUserTypeSuperAdmin = "super_admin"
// DefaultUserTypeAdmin 默认管理员用户类型
DefaultUserTypeAdmin = "admin"
// DefaultUserTypeStaff 默认员工用户类型
DefaultUserTypeStaff = "staff"
)
// AuthUser 当前认证用户
type AuthUser struct {
UserID uint
Username string
Role string
UserType string
}
// AuthChecker 自定义权限检查函数
type AuthChecker func(user AuthUser, c *gin.Context) bool
// AuthRequired JWT 认证中间件
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -31,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
@@ -55,27 +73,17 @@ func AuthRequired() gin.HandlerFunc {
}
}
// AdminRequired 管理员权限中间件
func AdminRequired() gin.HandlerFunc {
// RequireAuth 自定义权限中间件
func RequireAuth(checker AuthChecker, messages ...string) gin.HandlerFunc {
return func(c *gin.Context) {
userType, exists := c.Get(ContextKeyUserType)
if !exists {
response.Unauthorized(c, "请先登录")
c.Abort()
return
}
// 安全的类型断言
ut, ok := userType.(string)
user, ok := GetAuthUser(c)
if !ok {
response.Unauthorized(c, "用户信息异常")
c.Abort()
abortAuthContext(c)
return
}
if ut != "super_admin" && ut != "admin" {
response.Fail(c, "无权限访问")
c.Abort()
if checker == nil || !checker(user, c) {
abortForbidden(c, messages...)
return
}
@@ -83,89 +91,114 @@ func AdminRequired() gin.HandlerFunc {
}
}
// RequireUserTypes 用户类型权限中间件
func RequireUserTypes(userTypes ...string) gin.HandlerFunc {
allowed := make(map[string]struct{}, len(userTypes))
for _, userType := range userTypes {
allowed[userType] = struct{}{}
}
return RequireAuth(func(user AuthUser, c *gin.Context) bool {
_, ok := allowed[user.UserType]
return ok
})
}
// RequireRoles 角色权限中间件
func RequireRoles(roles ...string) gin.HandlerFunc {
allowed := make(map[string]struct{}, len(roles))
for _, role := range roles {
allowed[role] = struct{}{}
}
return RequireAuth(func(user AuthUser, c *gin.Context) bool {
_, ok := allowed[user.Role]
return ok
})
}
// AdminRequired 管理员权限中间件
func AdminRequired() gin.HandlerFunc {
return RequireUserTypes(DefaultUserTypeSuperAdmin, DefaultUserTypeAdmin)
}
// SuperAdminRequired 超级管理员权限中间件
func SuperAdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
userType, exists := c.Get(ContextKeyUserType)
if !exists {
response.Unauthorized(c, "请先登录")
c.Abort()
return
}
// 安全的类型断言
ut, ok := userType.(string)
if !ok {
response.Unauthorized(c, "用户信息异常")
c.Abort()
return
}
if ut != "super_admin" {
response.Fail(c, "需要超级管理员权限")
c.Abort()
return
}
c.Next()
}
return RequireUserTypes(DefaultUserTypeSuperAdmin)
}
// StaffRequired 员工权限中间件
func StaffRequired() gin.HandlerFunc {
return func(c *gin.Context) {
userType, exists := c.Get(ContextKeyUserType)
if !exists {
response.Unauthorized(c, "请先登录")
c.Abort()
return
}
// 安全的类型断言
ut, ok := userType.(string)
if !ok {
response.Unauthorized(c, "用户信息异常")
c.Abort()
return
}
if ut != "staff" {
response.Fail(c, "无权限访问")
c.Abort()
return
}
c.Next()
}
return RequireUserTypes(DefaultUserTypeStaff)
}
// AnyUserRequired 任意用户权限中间件(允许 admin/super_admin/staff
// AnyUserRequired 任意用户权限中间件(默认允许 admin/super_admin/staff
func AnyUserRequired() gin.HandlerFunc {
return func(c *gin.Context) {
userType, exists := c.Get(ContextKeyUserType)
if !exists {
response.Unauthorized(c, "请先登录")
c.Abort()
return
}
return RequireUserTypes(DefaultUserTypeSuperAdmin, DefaultUserTypeAdmin, DefaultUserTypeStaff)
}
// 安全的类型断言
ut, ok := userType.(string)
if !ok {
response.Unauthorized(c, "用户信息异常")
c.Abort()
return
}
// 允许所有内部用户类型
if ut != "super_admin" && ut != "admin" && ut != "staff" {
response.Fail(c, "无权限访问")
c.Abort()
return
}
c.Next()
func abortAuthContext(c *gin.Context) {
if _, exists := c.Get(ContextKeyUserID); !exists {
response.Unauthorized(c, "请先登录")
} else {
response.Unauthorized(c, "用户信息异常")
}
c.Abort()
}
func abortForbidden(c *gin.Context, messages ...string) {
if len(messages) > 0 && messages[0] != "" {
response.Fail(c, messages[0])
} else {
response.FailWithError(c, response.ErrForbidden)
}
c.Abort()
}
// GetAuthUser 从上下文获取认证用户
func GetAuthUser(c *gin.Context) (AuthUser, bool) {
userID, exists := c.Get(ContextKeyUserID)
if !exists {
return AuthUser{}, false
}
id, ok := userID.(uint)
if !ok {
return AuthUser{}, false
}
username, exists := c.Get(ContextKeyUsername)
if !exists {
return AuthUser{}, false
}
name, ok := username.(string)
if !ok {
return AuthUser{}, false
}
role, exists := c.Get(ContextKeyRole)
if !exists {
return AuthUser{}, false
}
r, ok := role.(string)
if !ok {
return AuthUser{}, false
}
userType, exists := c.Get(ContextKeyUserType)
if !exists {
return AuthUser{}, false
}
ut, ok := userType.(string)
if !ok {
return AuthUser{}, false
}
return AuthUser{
UserID: id,
Username: name,
Role: r,
UserType: ut,
}, true
}
// GetUserID 从上下文获取用户ID
@@ -196,6 +229,20 @@ func GetUsername(c *gin.Context) string {
return name
}
// GetRole 从上下文获取角色
func GetRole(c *gin.Context) string {
role, exists := c.Get(ContextKeyRole)
if !exists {
return ""
}
// 安全的类型断言
r, ok := role.(string)
if !ok {
return ""
}
return r
}
// GetUserType 从上下文获取用户类型
func GetUserType(c *gin.Context) string {
userType, exists := c.Get(ContextKeyUserType)
+198
View File
@@ -0,0 +1,198 @@
package middleware_test
import (
"net/http"
"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"
)
func performAuthMiddlewareRequest(m gin.HandlerFunc, setup func(*gin.Context)) *httptest.ResponseRecorder {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
if setup != nil {
setup(c)
}
})
r.Use(m)
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)
r.ServeHTTP(w, req)
return w
}
func setAuthUser(userType, role string) func(*gin.Context) {
return func(c *gin.Context) {
c.Set(middleware.ContextKeyUserID, uint(1))
c.Set(middleware.ContextKeyUsername, "tester")
c.Set(middleware.ContextKeyRole, role)
c.Set(middleware.ContextKeyUserType, userType)
}
}
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 {
t.Fatalf("expected status 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), `"ok":true`) {
t.Fatalf("expected request to pass, got body %s", w.Body.String())
}
}
func TestRequireUserTypesRejectsOtherTypes(t *testing.T) {
w := performAuthMiddlewareRequest(middleware.RequireUserTypes("tenant_admin"), setAuthUser("staff", "owner"))
if w.Code != http.StatusOK {
t.Fatalf("expected business error over status 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), `"code":403`) {
t.Fatalf("expected forbidden response, got body %s", w.Body.String())
}
}
func TestRequireRoles(t *testing.T) {
w := performAuthMiddlewareRequest(middleware.RequireRoles("owner"), setAuthUser("tenant_admin", "owner"))
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), `"ok":true`) {
t.Fatalf("expected request to pass, got body %s", w.Body.String())
}
}
func TestRequireAuthCustomChecker(t *testing.T) {
checker := func(user middleware.AuthUser, c *gin.Context) bool {
return user.UserID == 1 && user.UserType == "merchant" && user.Role == "owner"
}
w := performAuthMiddlewareRequest(middleware.RequireAuth(checker), setAuthUser("merchant", "owner"))
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), `"ok":true`) {
t.Fatalf("expected request to pass, got body %s", w.Body.String())
}
w = performAuthMiddlewareRequest(middleware.RequireAuth(checker, "需要商户所有者权限"), setAuthUser("merchant", "staff"))
if !strings.Contains(w.Body.String(), "需要商户所有者权限") {
t.Fatalf("expected custom forbidden message, got body %s", w.Body.String())
}
}
func TestDefaultRoleShortcuts(t *testing.T) {
tests := []struct {
name string
mw gin.HandlerFunc
userType string
wantPass bool
}{
{"admin allows super admin", middleware.AdminRequired(), middleware.DefaultUserTypeSuperAdmin, true},
{"admin allows admin", middleware.AdminRequired(), middleware.DefaultUserTypeAdmin, true},
{"admin rejects staff", middleware.AdminRequired(), middleware.DefaultUserTypeStaff, false},
{"super admin allows super admin", middleware.SuperAdminRequired(), middleware.DefaultUserTypeSuperAdmin, true},
{"super admin rejects admin", middleware.SuperAdminRequired(), middleware.DefaultUserTypeAdmin, false},
{"staff allows staff", middleware.StaffRequired(), middleware.DefaultUserTypeStaff, true},
{"any allows staff", middleware.AnyUserRequired(), middleware.DefaultUserTypeStaff, true},
{"any rejects external", middleware.AnyUserRequired(), "external", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := performAuthMiddlewareRequest(tt.mw, setAuthUser(tt.userType, "role"))
body := w.Body.String()
if tt.wantPass && !strings.Contains(body, `"ok":true`) {
t.Fatalf("expected pass, got body %s", body)
}
if !tt.wantPass && !strings.Contains(body, `"code":403`) {
t.Fatalf("expected forbidden, got body %s", body)
}
})
}
}
func TestRequireAuthRejectsMissingContext(t *testing.T) {
w := performAuthMiddlewareRequest(middleware.RequireUserTypes("admin"), nil)
if !strings.Contains(w.Body.String(), `"code":401`) || !strings.Contains(w.Body.String(), "请先登录") {
t.Fatalf("expected unauthorized login response, got body %s", w.Body.String())
}
}
func TestRequireAuthRejectsMalformedContext(t *testing.T) {
w := performAuthMiddlewareRequest(middleware.RequireUserTypes("admin"), func(c *gin.Context) {
c.Set(middleware.ContextKeyUserID, uint(1))
c.Set(middleware.ContextKeyUsername, "tester")
c.Set(middleware.ContextKeyRole, "owner")
c.Set(middleware.ContextKeyUserType, 123)
})
if !strings.Contains(w.Body.String(), `"code":401`) || !strings.Contains(w.Body.String(), "用户信息异常") {
t.Fatalf("expected malformed user response, got body %s", w.Body.String())
}
}
func TestGetAuthUser(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
setAuthUser("tenant_admin", "owner")(c)
user, ok := middleware.GetAuthUser(c)
if !ok {
t.Fatal("expected auth user")
}
if user.UserID != 1 || user.Username != "tester" || user.UserType != "tenant_admin" || user.Role != "owner" {
t.Fatalf("unexpected auth user: %+v", user)
}
if middleware.GetRole(c) != "owner" {
t.Fatalf("expected role owner, got %q", middleware.GetRole(c))
}
c.Set(middleware.ContextKeyUserID, "bad")
if _, ok := middleware.GetAuthUser(c); ok {
t.Fatal("expected malformed auth user to fail")
}
}
+100 -37
View File
@@ -2,6 +2,7 @@ package middleware
import (
"net/http"
"net/url"
"strconv"
"strings"
@@ -10,8 +11,9 @@ import (
)
// CORS 跨域中间件
// 评分: ⭐⭐⭐⭐⭐
// 理由: 支持从配置文件读取 CORS 配置,生产环境更安全
// 支持从配置文件读取 CORS 配置;遵循 W3C CORS 规范:
// 当 AllowCredentials=true 时,Access-Control-Allow-Origin 必须回显为具体 Origin
// 不能使用 "*"(浏览器会拒绝携带凭证的响应)。
func CORS() gin.HandlerFunc {
return CORSWithConfig(nil)
}
@@ -30,64 +32,71 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc {
corsConfig = &cfg.CORS
}
// 是否允许携带凭证(影响 Origin 回显策略)
allowCredentials := corsConfig != nil && corsConfig.AllowCredentials
// 获取允许的域名列表
allowedOrigins := getAllowedOrigins(cfg, corsConfig)
// 检查是否在白名单中
// 匹配 Origin
// 注意:当 allowCredentials=true 且匹配到通配符时,
// 必须回显具体 Origin 而非 "*",否则浏览器会拒绝响应。
allowedOrigin := ""
matchedWildcard := false
if origin != "" {
for _, ao := range allowedOrigins {
if ao == "*" {
allowedOrigin = "*"
matchedWildcard = true
break
}
// 支持通配符匹配(如 *.example.com
if strings.HasPrefix(ao, "*.") {
domain := ao[2:]
if strings.HasSuffix(origin, domain) {
allowedOrigin = origin
break
}
}
if ao == origin {
if matchOrigin(origin, ao) {
allowedOrigin = origin
break
}
}
}
// 如果没有匹配到白名单
// 处理通配符 + 兜底策略
if allowedOrigin == "" {
// 开发环境允许所有来源
if cfg != nil && cfg.IsDevelopment() {
allowedOrigin = "*"
} else if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
// 配置了通配符
allowedOrigin = "*"
if matchedWildcard {
if allowCredentials && origin != "" {
// AllowCredentials=true 时 spec 禁止 "*",回显具体 Origin
allowedOrigin = origin
} else {
allowedOrigin = "*"
}
} else if cfg != nil && cfg.IsDevelopment() && origin != "" && isLocalhostOrigin(origin) {
// 开发环境兜底:仅对 localhost 来源回显具体 OriginC7b 修复)。
// 旧实现无条件回显任意 Origin,若同时 AllowCredentials=true 则构成凭据型反射。
allowedOrigin = origin
}
}
// 设置 CORS 响应头
// 仅在 origin 匹配时发送 CORS 头(C7 收尾:未匹配 origin 不发 Allow-Methods/Headers 等,
// 收敛信息泄露——避免向未授权 origin 暴露 API 允许的方法/头清单)。
if allowedOrigin != "" {
c.Header("Access-Control-Allow-Origin", allowedOrigin)
// Origin 不是 "*" 时,下游缓存(CDN / 网关)必须按 Origin 区分缓存
if allowedOrigin != "*" {
c.Header("Vary", "Origin")
}
methods := corsConfig.GetAllowedMethods()
headers := corsConfig.GetAllowedHeaders()
exposedHeaders := corsConfig.GetExposedHeaders()
maxAge := corsConfig.GetMaxAge()
c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", "))
c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", "))
c.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
c.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
}
// 从配置获取允许的方法、请求头等
methods := corsConfig.GetAllowedMethods()
headers := corsConfig.GetAllowedHeaders()
exposedHeaders := corsConfig.GetExposedHeaders()
maxAge := corsConfig.GetMaxAge()
c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", "))
c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", "))
c.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
c.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
// 是否允许携带凭证
if corsConfig != nil && corsConfig.AllowCredentials {
// 仅在显式启用且 Origin 不是 "*" 时才发 Allow-Credentials
// CORS 规范:Allow-Origin: * 时禁止携带凭证)
if allowCredentials && allowedOrigin != "" && allowedOrigin != "*" {
c.Header("Access-Control-Allow-Credentials", "true")
} else {
c.Header("Access-Control-Allow-Credentials", "true") // 默认允许
}
// 处理预检请求
@@ -100,9 +109,63 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc {
}
}
// matchOrigin 判断 origin 是否匹配允许的 Origin 模式 ao。
//
// 支持三种模式(C7a 修复):
// 1. 精确匹配:ao == originscheme+host+port 全等)。
// 2. 通配子域:ao 形如 "*.example.com",匹配 example.com 的任意子域(含 a.example.com、
// a.b.example.com),但**不匹配 apex example.com 自身**,也**不匹配 notexample.com、
// evil-example.com 等后缀相同但非真实子域的域名**。旧实现用 strings.HasSuffix(origin, domain)
// 未锚定 host 边界,导致上述绕过。
// 3. 通配 apex+子域:ao 形如 "*.example.com" 经本函数仅匹配子域;若需同时允许 apex,
// 配置中需显式列出 "https://example.com"。
//
// 解析 origin 的 host 做真实子域边界判断(而非字符串后缀),杜绝 notexample.com 类绕过。
func matchOrigin(origin, ao string) bool {
if origin == "" || ao == "" {
return false
}
// 精确匹配。
if origin == ao {
return true
}
// 通配子域 *.domain。
if strings.HasPrefix(ao, "*.") {
wildcardDomain := strings.ToLower(strings.TrimSuffix(ao[2:], ".")) // 如 "example.com"
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
// 去端口、去尾点(FQDN 尾点表示 a.example.com. 应等同 a.example.com)。
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
// host 必须是 *.domain 的真实子域:host == "x." + wildcardDomain
// 且 x 非空、不含点(即直接子域)或为多级子域(a.b.example.com)。
// 关键:host 必须以 "." + wildcardDomain 结尾(锚定边界),杜绝 notexample.com。
if !strings.HasSuffix(host, "."+wildcardDomain) {
return false
}
// 排除 host == wildcardDomain 自身(apex 不由通配匹配,需显式配置)。
if host == wildcardDomain {
return false
}
return true
}
return false
}
// isLocalhostOrigin 判断 origin 是否为 localhost 来源(开发态兜底用,C7b 修复)。
// 仅允许 localhost / 127.0.0.1 / ::1 的任意端口,杜绝开发态回显任意 Origin。
func isLocalhostOrigin(origin string) bool {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
host := strings.TrimSuffix(u.Hostname(), ".")
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
// getAllowedOrigins 获取允许的域名列表
// 评分: ⭐⭐⭐⭐⭐
// 理由: 优先使用配置文件,其次使用环境变量,最后使用默认值
// 优先使用配置文件,生产环境必须显式配置,开发环境提供 localhost 兜底。
func getAllowedOrigins(cfg *config.Config, corsConfig *config.CORSConfig) []string {
// 优先使用 CORS 配置
if corsConfig != nil && len(corsConfig.AllowedOrigins) > 0 {
+94
View File
@@ -0,0 +1,94 @@
package middleware
import "testing"
// 回归 C7a:通配符子域匹配必须锚定真实子域边界,拒绝后缀相同但非子域的域名。
// 旧实现 strings.HasSuffix(origin, domain) 接受 notexample.com / evil-example.com。
func TestMatchOriginWildcardBoundary(t *testing.T) {
cases := []struct {
name string
origin string
ao string
want bool
}{
// 精确匹配
{"exact", "https://example.com", "https://example.com", true},
{"exact with port", "https://example.com:8443", "https://example.com:8443", true},
{"exact mismatch", "https://example.com", "https://other.com", false},
// 通配子域 *.example.com
{"subdomain ok", "https://a.example.com", "*.example.com", true},
{"deep subdomain ok", "https://a.b.example.com", "*.example.com", true},
{"subdomain with port ok", "https://a.example.com:3000", "*.example.com", true},
{"apex not matched by wildcard", "https://example.com", "*.example.com", false},
// C7a 核心:后缀相同但非真实子域必须拒绝
{"notexample.com rejected", "https://notexample.com", "*.example.com", false},
{"evil-example.com rejected", "https://evil-example.com", "*.example.com", false},
{"villainexample.com rejected", "https://villainexample.com", "*.example.com", false},
// 端口/协议不同的 origin host 仍按 host 判断
{"http subdomain ok", "http://a.example.com", "*.example.com", true},
// 通配仅匹配 host,scheme 不同的精确配置不被通配覆盖
{"different scheme exact not wildcard", "http://example.com", "https://example.com", false},
// 边界
{"empty origin", "", "*.example.com", false},
{"empty ao", "https://a.example.com", "", false},
{"malformed origin", "://:bad", "*.example.com", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := matchOrigin(c.origin, c.ao)
if got != c.want {
t.Errorf("matchOrigin(%q, %q) = %v, want %v", c.origin, c.ao, got, c.want)
}
})
}
}
// 回归 C7a:通配大小写不敏感(host 归一化为小写比较)。
func TestMatchOriginCaseInsensitive(t *testing.T) {
if !matchOrigin("https://A.Example.COM", "*.example.com") {
t.Error("matchOrigin should be case-insensitive on host")
}
}
// 回归 C7atrailing dot FQDNa.example.com.)应等同 a.example.com 被接受(功能正确性)。
func TestMatchOriginTrailingDot(t *testing.T) {
if !matchOrigin("https://a.example.com.", "*.example.com") {
t.Error("trailing-dot subdomain should match wildcard")
}
if !matchOrigin("https://a.example.com", "*.example.com.") {
t.Error("trailing-dot wildcard should match plain subdomain")
}
}
// 回归 C7bisLocalhostOrigin 仅允许 localhost/127.0.0.1/::1,拒绝任意其他来源。
func TestIsLocalhostOrigin(t *testing.T) {
allowed := []string{
"http://localhost:3000",
"http://localhost",
"http://127.0.0.1:8080",
"http://127.0.0.1",
"http://[::1]:4000",
"http://[::1]",
}
for _, o := range allowed {
if !isLocalhostOrigin(o) {
t.Errorf("isLocalhostOrigin(%q) = false, want true", o)
}
}
denied := []string{
"https://evil.com",
"https://localhost.evil.com", // 后缀含 localhost 但非 localhost host
"https://notlocalhost.com",
"https://example.com",
"",
"://bad",
}
for _, o := range denied {
if isLocalhostOrigin(o) {
t.Errorf("isLocalhostOrigin(%q) = true, want false", o)
}
}
}
+152 -49
View File
@@ -2,13 +2,17 @@ package middleware
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
)
const (
@@ -20,6 +24,8 @@ const (
CSRFCookieName = "csrf_token"
// CSRFFormField 表单字段名称
CSRFFormField = "_csrf"
// CSRFMaxBodyBytes JSON body 中读取 CSRF token 的最大字节数,防止 pre-auth OOM。
CSRFMaxBodyBytes = 1 << 20 // 1 MiB
)
// CSRFConfig CSRF 配置
@@ -44,6 +50,12 @@ type CSRFConfig struct {
Path string
// MaxAge Cookie 有效期(秒)
MaxAge int
// SessionCookie 为 true 时显式使用会话 CookieMaxAge=0)。
// 为保持 CSRFWithConfig(CSRFConfig{}) 的默认行为,MaxAge=0 且本字段为 false 时仍使用默认 1 小时。
SessionCookie bool
// MaxBodyBytes 从 JSON body 提取 token 时允许读取的最大字节数。
// <=0 时使用默认 1MiB。Header/Form token 不受此限制。
MaxBodyBytes int64
// ErrorFunc 错误处理函数
ErrorFunc func(c *gin.Context)
// SkipFunc 跳过检查函数
@@ -52,21 +64,25 @@ type CSRFConfig struct {
// DefaultCSRFConfig 默认 CSRF 配置
var DefaultCSRFConfig = CSRFConfig{
TokenLength: CSRFTokenLength,
HeaderName: CSRFHeaderName,
CookieName: CSRFCookieName,
FormField: CSRFFormField,
Secure: false,
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: 3600, // 1 小时
ErrorFunc: defaultCSRFError,
SkipFunc: nil,
TokenLength: CSRFTokenLength,
HeaderName: CSRFHeaderName,
CookieName: CSRFCookieName,
FormField: CSRFFormField,
Secure: false,
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: 3600, // 1 小时
MaxBodyBytes: CSRFMaxBodyBytes,
ErrorFunc: defaultCSRFError,
SkipFunc: nil,
}
// generateCSRFToken 生成 CSRF Token
func generateCSRFToken(length int) (string, error) {
if length <= 0 {
length = CSRFTokenLength
}
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
@@ -80,15 +96,13 @@ func defaultCSRFError(c *gin.Context) {
c.Abort()
}
// CSRF 创建 CSRF 中间件
func CSRF(config ...CSRFConfig) gin.HandlerFunc {
cfg := DefaultCSRFConfig
if len(config) > 0 {
cfg = config[0]
}
func csrfBodyTooLarge(c *gin.Context) {
response.Custom(c, http.StatusRequestEntityTooLarge, response.CodeFileTooLarge, "请求体过大", nil)
c.Abort()
}
// 设置默认值
if cfg.TokenLength == 0 {
func normalizeCSRFConfig(cfg CSRFConfig) CSRFConfig {
if cfg.TokenLength <= 0 {
cfg.TokenLength = CSRFTokenLength
}
if cfg.HeaderName == "" {
@@ -97,9 +111,45 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
if cfg.CookieName == "" {
cfg.CookieName = CSRFCookieName
}
if cfg.FormField == "" {
cfg.FormField = CSRFFormField
}
if cfg.Path == "" {
cfg.Path = "/"
}
if cfg.SameSite == 0 {
cfg.SameSite = DefaultCSRFConfig.SameSite
}
if cfg.MaxAge == 0 && !cfg.SessionCookie {
cfg.MaxAge = DefaultCSRFConfig.MaxAge
}
if cfg.MaxBodyBytes <= 0 {
cfg.MaxBodyBytes = CSRFMaxBodyBytes
}
if cfg.ErrorFunc == nil {
cfg.ErrorFunc = defaultCSRFError
}
return cfg
}
func pathMatchesSkip(path, prefix string) bool {
if prefix == "" {
return false
}
if prefix == "/" {
return true
}
prefix = strings.TrimRight(prefix, "/")
return path == prefix || strings.HasPrefix(path, prefix+"/")
}
// CSRF 创建 CSRF 中间件
func CSRF(config ...CSRFConfig) gin.HandlerFunc {
cfg := DefaultCSRFConfig
if len(config) > 0 {
cfg = config[0]
}
cfg = normalizeCSRFConfig(cfg)
return func(c *gin.Context) {
// 检查是否跳过
@@ -120,6 +170,7 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
cookieToken = token
// 设置 Cookie
c.SetSameSite(cfg.SameSite)
c.SetCookie(
cfg.CookieName,
token,
@@ -151,18 +202,28 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
clientToken = c.PostForm(cfg.FormField)
}
// 最后从 JSON body 获取
// 最后从 JSON body 获取
// P1 #9:用 ShouldBindBodyWith(binding.JSON) 而非 ShouldBindJSON——后者会读干
// c.Request.Body,导致下游 handler 再 ShouldBindJSON 拿到空 body(EOF)。前者把原始
// body 缓存进 gin context,下游可重复读取。
if clientToken == "" {
var body map[string]any
if err := c.ShouldBindJSON(&body); err == nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, cfg.MaxBodyBytes)
if err := c.ShouldBindBodyWith(&body, binding.JSON); err == nil {
if token, ok := body[cfg.FormField].(string); ok {
clientToken = token
}
} else {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
csrfBodyTooLarge(c)
return
}
}
}
// 验证 Token 是否匹配
if clientToken == "" || clientToken != cookieToken {
// 验证 Token 是否匹配H-7: 恒定时间比较防时序侧信道)
if len(clientToken) == 0 || subtle.ConstantTimeCompare([]byte(clientToken), []byte(cookieToken)) != 1 {
cfg.ErrorFunc(c)
return
}
@@ -187,10 +248,18 @@ func GetCSRFToken(c *gin.Context) string {
if !exists {
return ""
}
return token.(string)
// H-7: comma-ok 防裸断言 panic。csrf_token 虽由本包以 string 写入,
// 但 gin context 是共享 map,下游误写其他类型即 panic。
s, ok := token.(string)
if !ok {
return ""
}
return s
}
// CSRFToken 返回 CSRF Token 的处理器(用于 API 模式)
// CSRFToken 返回当前请求上下文中的 CSRF Token;若上下文无 Token 则现场生成一个返回。
// 适用于 Cookie/双重提交模式(与 CSRF/DoubleSubmitCookie 中间件配合),
// 不与 CSRFForAPI 配套--API 模式的可校验 Token 请用 GenerateAPIToken 颁发。
func CSRFToken(c *gin.Context) {
token := GetCSRFToken(c)
if token == "" {
@@ -213,7 +282,7 @@ func CSRFWithSkip(skipPaths []string) gin.HandlerFunc {
cfg.SkipFunc = func(c *gin.Context) bool {
path := c.Request.URL.Path
for _, p := range skipPaths {
if strings.HasPrefix(path, p) {
if pathMatchesSkip(path, p) {
return true
}
}
@@ -223,11 +292,13 @@ func CSRFWithSkip(skipPaths []string) gin.HandlerFunc {
}
// CSRFForAPI 适用于 API 的 CSRF 中间件(不使用 Cookie
// 客户端需要先调用 /csrf-token 获取 Token
// 客户端需要先调用 GenerateAPIToken 获取 Token,随后在每个非安全方法请求的
// X-CSRF-Token 头中携带。Token 单次消费(验证通过即删除)且受 TTL 约束,
// 防止重放与内存无限增长。
//
// 注意:存储为进程内内存,仅适用于单实例部署。多实例请自行用 Redis
// SETEX + GETDEL 实现等价语义。
func CSRFForAPI() gin.HandlerFunc {
tokens := make(map[string]bool)
var mu sync.RWMutex
return func(c *gin.Context) {
// 安全方法不需要验证
if isSafeMethod(c.Request.Method) {
@@ -243,12 +314,25 @@ func CSRFForAPI() gin.HandlerFunc {
return
}
// 验证 Token
mu.RLock()
valid := tokens[clientToken]
mu.RUnlock()
// 单次消费 + TTL:校验通过即删除,防止同一 token 被重放。
// 写锁内完成“查—删—过期清理”以保证原子性。
apiTokensMu.Lock()
issuedAt, ok := apiTokens[clientToken]
if ok {
delete(apiTokens, clientToken)
}
// 懒清理:map 较大时顺带淘汰过期项,避免内存无限增长。
if len(apiTokens) > 256 {
now := time.Now()
for t, at := range apiTokens {
if now.Sub(at) > apiTokenTTL {
delete(apiTokens, t)
}
}
}
apiTokensMu.Unlock()
if !valid {
if !ok || time.Since(issuedAt) > apiTokenTTL {
response.Fail(c, "CSRF Token 无效")
c.Abort()
return
@@ -259,6 +343,7 @@ func CSRFForAPI() gin.HandlerFunc {
}
// GenerateAPIToken 生成 API CSRF Token(用于 API 模式)
// 颁发的 Token 写入进程内存储,供 CSRFForAPI 校验。
func GenerateAPIToken(c *gin.Context) {
token, err := generateCSRFToken(CSRFTokenLength)
if err != nil {
@@ -266,23 +351,34 @@ func GenerateAPIToken(c *gin.Context) {
return
}
// 存储 Token(实际应用中应使用 Redis)
// 这里简化为内存存储
mu.Lock()
tokens[token] = true
mu.Unlock()
apiTokensMu.Lock()
cleanupExpiredAPITokensLocked(time.Now())
apiTokens[token] = time.Now()
apiTokensMu.Unlock()
response.Success(c, gin.H{
"csrf_token": token,
})
}
// 内存存储(用于 API 模式)
// API 模式 CSRF Token 存储:token -> 颁发时间。
// 受 apiTokensMu 保护,单次消费 + TTL(见 CSRFForAPI)。
var (
tokens = make(map[string]bool)
mu sync.RWMutex
apiTokens = make(map[string]time.Time)
apiTokensMu sync.RWMutex
)
// apiTokenTTL API 模式 CSRF Token 有效期
const apiTokenTTL = 30 * time.Minute
func cleanupExpiredAPITokensLocked(now time.Time) {
for t, at := range apiTokens {
if now.Sub(at) > apiTokenTTL {
delete(apiTokens, t)
}
}
}
// CSRFExempt 标记路由不需要 CSRF 保护
// 使用方法:在路由组上使用此中间件
func CSRFExempt() gin.HandlerFunc {
@@ -298,12 +394,16 @@ func CSRFWithExempt(config ...CSRFConfig) gin.HandlerFunc {
if len(config) > 0 {
cfg = config[0]
}
cfg = normalizeCSRFConfig(cfg)
originalSkipFunc := cfg.SkipFunc
cfg.SkipFunc = func(c *gin.Context) bool {
// 检查是否标记为 exempt
if exempt, exists := c.Get("csrf_exempt"); exists && exempt.(bool) {
return true
// 检查是否标记为 exemptP1 #9comma-ok 防裸断言 panic——gin context 为共享 map
// 下游若误以非 bool 写入 csrf_exemptexempt.(bool) 会 panic)。
if exempt, exists := c.Get("csrf_exempt"); exists {
if b, ok := exempt.(bool); ok && b {
return true
}
}
// 调用原始 SkipFunc
if originalSkipFunc != nil {
@@ -325,7 +425,10 @@ func DoubleSubmitCookie() gin.HandlerFunc {
cookieToken, err := c.Cookie(CSRFCookieName)
if err != nil || cookieToken == "" {
token, _ := generateCSRFToken(CSRFTokenLength)
c.SetCookie(CSRFCookieName, token, 3600, "/", "", false, true)
// 双重提交模式要求前端 JS 读取 cookie 并回填 X-CSRF-Token 头,
// 故 HttpOnly 必须为 false(与 CSRF() cookie 模式相反)。
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(CSRFCookieName, token, 3600, "/", "", false, false)
c.Set("csrf_token", token)
} else {
c.Set("csrf_token", cookieToken)
@@ -350,8 +453,8 @@ func DoubleSubmitCookie() gin.HandlerFunc {
return
}
// 验证 Cookie 和 Header 中的 Token 是否一致
if cookieToken != headerToken {
// 验证 Cookie 和 Header 中的 Token 是否一致H-7: 恒定时间比较防时序侧信道)
if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) != 1 {
response.Fail(c, "CSRF Token 不匹配")
c.Abort()
return
+234
View File
@@ -0,0 +1,234 @@
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
)
// 回归 C6b:TTL 过期分支。直接向包级存储注入一个“已过期”的 token,
// 断言 CSRFForAPI 拒绝它(即使该 token 从未被消费过)。
// 这条分支(time.Since(issuedAt) > apiTokenTTL)无法靠单次消费用例覆盖,
// 必须单独构造过期时间戳。
func TestCSRFForAPITTLExpiry(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRFForAPI())
r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
expiredToken := "expired-test-token"
apiTokensMu.Lock()
// 注入一个早于 TTL 的颁发时间,模拟 token 已过期。
apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second))
apiTokensMu.Unlock()
defer func() {
apiTokensMu.Lock()
delete(apiTokens, expiredToken)
apiTokensMu.Unlock()
}()
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/action", nil)
req.Header.Set("X-CSRF-Token", expiredToken)
r.ServeHTTP(w, req)
var resp response.Response
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response %q: %v", w.Body.String(), err)
}
if resp.Code == response.CodeSuccess {
t.Errorf("expired token must be rejected, got code=%d (success)", resp.Code)
}
}
// 回归 P1 #9CSRF 从 JSON body 取 token 后,下游 handler 仍能读到完整请求体。
// 修复前用 ShouldBindJSON 读干 body,下游再绑定拿到 EOF;改用 ShouldBindBodyWith 缓存 body。
func TestCSRFJSONBodyPreservedForDownstream(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF())
var gotData string
r.POST("/action", func(c *gin.Context) {
// 下游再次绑定 body,应能读到完整内容(而非被 CSRF 中间件读空)。
var payload struct {
Data string `json:"data"`
}
if err := c.ShouldBindBodyWith(&payload, binding.JSON); err != nil {
c.JSON(500, gin.H{"err": err.Error()})
return
}
gotData = payload.Data
c.JSON(200, gin.H{"status": "ok"})
})
// token 仅放在 JSON body 里(不放 header/form),触发中间件读 body 的分支。
const tok = "csrf-token-abc-1234567890"
body := `{"_csrf":"` + tok + `","data":"hello-downstream"}`
req := httptest.NewRequest("POST", "/action", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// cookie 携带同一 token,使 CSRF 校验通过。
req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: tok})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d, body=%s", w.Code, w.Body.String())
}
if gotData != "hello-downstream" {
t.Errorf("downstream read data=%q, want 'hello-downstream' (P1 #9: body must survive CSRF)", gotData)
}
}
func TestCSRFJSONBodyTooLargeRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF(CSRFConfig{MaxBodyBytes: 16}))
r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
body := `{"_csrf":"token","data":"` + strings.Repeat("x", 64) + `"}`
req := httptest.NewRequest(http.MethodPost, "/action", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: "token"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413; body=%s", w.Code, w.Body.String())
}
}
func TestCSRFNegativeTokenLengthFallsBack(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF(CSRFConfig{TokenLength: -1}))
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"token": GetCSRFToken(c)}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if len(w.Result().Cookies()) == 0 {
t.Fatal("expected csrf cookie to be set")
}
}
func TestCSRFCookieSameSiteLax(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF())
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
for _, c := range w.Result().Cookies() {
if c.Name == CSRFCookieName {
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
}
return
}
}
t.Fatal("csrf cookie not set")
}
func TestCSRFPartialConfigKeepsCookieDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRF(CSRFConfig{TokenLength: -1}))
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
for _, c := range w.Result().Cookies() {
if c.Name == CSRFCookieName {
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax for partial config", c.SameSite)
}
if c.MaxAge != DefaultCSRFConfig.MaxAge {
t.Fatalf("MaxAge = %d, want %d for partial config", c.MaxAge, DefaultCSRFConfig.MaxAge)
}
return
}
}
t.Fatal("csrf cookie not set")
}
func TestDoubleSubmitCookieSameSiteLax(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(DoubleSubmitCookie())
r.GET("/get", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/get", nil))
for _, c := range w.Result().Cookies() {
if c.Name == CSRFCookieName {
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
}
return
}
}
t.Fatal("csrf cookie not set")
}
func TestCSRFWithSkipAnchorsPathBoundary(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CSRFWithSkip([]string{"/api"}))
r.POST("/api", func(c *gin.Context) { c.Status(http.StatusNoContent) })
r.POST("/api/users", func(c *gin.Context) { c.Status(http.StatusNoContent) })
r.POST("/apix", func(c *gin.Context) { c.Status(http.StatusNoContent) })
for _, path := range []string{"/api", "/api/users"} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil))
if w.Code != http.StatusNoContent {
t.Fatalf("%s status = %d, want skipped 204", path, w.Code)
}
}
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/apix", nil))
if w.Code == http.StatusNoContent {
t.Fatal("/apix was skipped by /api prefix; want CSRF rejection")
}
}
func TestGenerateAPITokenCleansExpiredTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
expiredToken := "expired-before-issue"
apiTokensMu.Lock()
apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second))
apiTokensMu.Unlock()
defer func() {
apiTokensMu.Lock()
delete(apiTokens, expiredToken)
apiTokensMu.Unlock()
}()
r := gin.New()
r.GET("/csrf-token", GenerateAPIToken)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/csrf-token", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
apiTokensMu.RLock()
_, exists := apiTokens[expiredToken]
apiTokensMu.RUnlock()
if exists {
t.Fatal("GenerateAPIToken did not clean expired token")
}
}
+109 -46
View File
@@ -3,6 +3,8 @@ package middleware
import (
"bytes"
"io"
"net/url"
"regexp"
"strings"
"time"
@@ -43,9 +45,11 @@ func Logger() gin.HandlerFunc {
}
// LoggerWithConfig 使用自定义配置的日志中间件
// 评分: ⭐⭐⭐⭐⭐
// 理由: 可配置是否记录请求体/响应体,跳过静态资源路径,慢请求警告
func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
// 统一封顶:MaxBodyLength<=0 时回退默认值,确保请求/响应 body 捕获均有上限(防 OOM)
if cfg.MaxBodyLength <= 0 {
cfg.MaxBodyLength = DefaultLoggerConfig.MaxBodyLength
}
return func(c *gin.Context) {
// 检查是否跳过此路径
path := c.Request.URL.Path
@@ -59,26 +63,17 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
// 记录请求体(可选)
var requestBody []byte
if cfg.LogRequestBody && c.Request.Body != nil {
requestBody, _ = io.ReadAll(c.Request.Body)
// 恢复请求体供后续处理
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
// 限制记录长度
if len(requestBody) > cfg.MaxBodyLength {
requestBody = requestBody[:cfg.MaxBodyLength]
}
requestBody = readBodyBounded(c, cfg.MaxBodyLength)
}
// 记录响应体(可选)
var responseBody []byte
if cfg.LogResponseBody {
// 使用 ResponseWriter 包装器捕获响应体
blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
// 使用 ResponseWriter 包装器捕获响应体(缓冲区封顶,防止大响应 OOM
blw := &bodyLogWriter{body: bytes.NewBufferString(""), maxLen: cfg.MaxBodyLength, ResponseWriter: c.Writer}
c.Writer = blw
c.Next()
responseBody = blw.body.Bytes()
if len(responseBody) > cfg.MaxBodyLength {
responseBody = responseBody[:cfg.MaxBodyLength]
}
} else {
c.Next()
}
@@ -90,7 +85,7 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
zap.Int("status", c.Writer.Status()),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.String("query", c.Request.URL.RawQuery),
zap.String("query", filterSensitiveQuery(c.Request.URL.RawQuery)),
zap.String("ip", c.ClientIP()),
zap.Duration("latency", latency),
zap.String("user-agent", c.Request.UserAgent()),
@@ -126,32 +121,69 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
if latency > cfg.SlowRequestThreshold {
// 慢请求警告
fields = append(fields, zap.Bool("slow_request", true))
logger.APILog().Warn("Slow API Request", fields...)
logger.APILog().Warn("慢请求", fields...)
} else if status >= 500 {
logger.APILog().Error("API Request Error", fields...)
logger.APILog().Error("请求错误", fields...)
} else if status >= 400 {
logger.APILog().Warn("API Request Client Error", fields...)
logger.APILog().Warn("客户端请求错误", fields...)
} else {
logger.APILog().Info("API Request", fields...)
logger.APILog().Info("API 请求", fields...)
}
}
}
// readBodyBounded 读取请求体用于日志记录,封顶 maxLen 字节以防 OOM。
//
// 仅向内存读入最多 maxLen+1 字节(+1 用于检测截断),其余部分不读入内存;
// 通过 io.MultiReader 把「已读前缀 + 原始 body 剩余」复原为 c.Request.Body
// 因此下游处理器仍能拿到完整请求体。返回的日志副本截断为 maxLen。
func readBodyBounded(c *gin.Context, maxLen int) []byte {
if maxLen <= 0 {
maxLen = DefaultLoggerConfig.MaxBodyLength
}
// io.ReadAll 永远返回非 nil 切片(即使出错也带已读部分)。LimitReader 封顶至 maxLen+1 字节。
read, _ := io.ReadAll(io.LimitReader(c.Request.Body, int64(maxLen)+1))
// 复原完整请求体供后续处理:已读前缀 + 原始 body 剩余部分(出错时保留已读字节,下游可重试读取剩余)
c.Request.Body = io.NopCloser(io.MultiReader(bytes.NewReader(read), c.Request.Body))
// 日志副本截断到 maxLen
if len(read) > maxLen {
return read[:maxLen]
}
return read
}
// bodyLogWriter 响应体记录包装器
type bodyLogWriter struct {
gin.ResponseWriter
body *bytes.Buffer
body *bytes.Buffer
maxLen int // 缓冲区上限(字节),<=0 表示不限制
}
// Write 捕获响应体
func (w *bodyLogWriter) Write(b []byte) (int, error) {
// appendBounded 向缓冲区追加字节,但不超过 maxLen 上限,防止大响应 OOM。
func (w *bodyLogWriter) appendBounded(b []byte) {
if w.maxLen <= 0 {
w.body.Write(b)
return
}
remaining := w.maxLen - w.body.Len()
if remaining <= 0 {
return
}
if len(b) > remaining {
b = b[:remaining]
}
w.body.Write(b)
}
// Write 捕获响应体(缓冲区封顶,完整响应仍写入下游 ResponseWriter
func (w *bodyLogWriter) Write(b []byte) (int, error) {
w.appendBounded(b)
return w.ResponseWriter.Write(b)
}
// WriteString 捕获字符串响应
func (w *bodyLogWriter) WriteString(s string) (int, error) {
w.body.WriteString(s)
w.appendBounded([]byte(s))
return w.ResponseWriter.WriteString(s)
}
@@ -166,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
}
}
@@ -174,29 +206,60 @@ func shouldSkipPath(path string, cfg LoggerConfig) bool {
return false
}
// filterSensitiveFields 过滤敏感字段(密码、token等)
func filterSensitiveFields(body []byte) string {
bodyStr := string(body)
// 过滤常见敏感字段(简单字符串替换)
sensitiveFields := []string{
"password", "passwd", "pwd",
"token", "access_token", "refresh_token",
"secret", "api_key", "apikey",
"credit_card", "card_number",
func pathPrefixMatch(path, prefix string) bool {
if prefix == "" {
return false
}
cleanPrefix := strings.TrimRight(prefix, "/")
if cleanPrefix == "" {
return path == "/"
}
return path == cleanPrefix || strings.HasPrefix(path, cleanPrefix+"/")
}
for _, field := range sensitiveFields {
// 检查是否包含敏感字段
keyPattern := `"` + field + `":`
if strings.Contains(bodyStr, keyPattern) {
// 简单替换:将值替换为 [FILTERED]
// 注意:这是一个简化的实现,复杂的 JSON 可能需要更精确的处理
bodyStr = strings.ReplaceAll(bodyStr, keyPattern, keyPattern+"\"[FILTERED]\"")
// filterSensitiveFields 过滤敏感字段(密码、token等)
// sensitiveFieldsRE M1 修复:编译期正则,匹配 "key":"value" 整体并将 value 替换为 [FILTERED]。
// 支持 JSON 字符串中标准转义(\" \\ \/ \b \f \n \r \t \uXXXX)。
var sensitiveFieldsRE = regexp.MustCompile(
`"(password|passwd|pwd|token|access_token|refresh_token|secret|api_key|apikey|credit_card|card_number)"\s*:\s*"((?:[^"\\]|\\.)*)"`,
)
// filterSensitiveFields M1 修复后实现:将敏感字段的值替换为 [FILTERED],完整移除原始值。
// 对非 JSON 输入原样返回。
func filterSensitiveFields(body []byte) string {
return sensitiveFieldsRE.ReplaceAllString(string(body), `"$1":"[FILTERED]"`)
}
// sensitiveQueryKeys 需要在访问日志中脱敏的 query 参数名(小写匹配,P1 #14)。
// 与 sensitiveFieldsRE 覆盖的字段保持一致。
var sensitiveQueryKeys = map[string]struct{}{
"password": {}, "passwd": {}, "pwd": {}, "token": {},
"access_token": {}, "refresh_token": {}, "secret": {},
"api_key": {}, "apikey": {}, "credit_card": {}, "card_number": {},
}
// filterSensitiveQuery 脱敏 query 字符串中的敏感参数值(P1 #14):
// 如 ?access_token=xxx 会被记为 access_token=%5BFILTERED%5D,防止令牌等随访问日志落盘。
// 无敏感键时原样返回(不重排);解析失败时整体标记以免原文泄露。
func filterSensitiveQuery(rawQuery string) string {
if rawQuery == "" {
return ""
}
values, err := url.ParseQuery(rawQuery)
if err != nil {
return "[redacted: unparsable query]"
}
changed := false
for k := range values {
if _, ok := sensitiveQueryKeys[strings.ToLower(k)]; ok {
values[k] = []string{"[FILTERED]"}
changed = true
}
}
return bodyStr
if !changed {
return rawQuery
}
return values.Encode()
}
// LoggerForAPI API 专用日志中间件(更详细)
@@ -226,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
}
@@ -234,7 +297,7 @@ func LoggerMinimal() gin.HandlerFunc {
start := time.Now()
c.Next()
logger.APILog().Info("Request",
logger.APILog().Info("请求",
zap.Int("status", c.Writer.Status()),
zap.String("method", c.Request.Method),
zap.String("path", path),
@@ -242,4 +305,4 @@ func LoggerMinimal() gin.HandlerFunc {
zap.Duration("latency", time.Since(start)),
)
}
}
}
+275
View File
@@ -0,0 +1,275 @@
package middleware
import (
"bytes"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// newCtxWithBody 构造一个带请求体的 gin.Context,仅用于 logger 内部测试。
func newCtxWithBody(body []byte) *gin.Context {
req := httptest.NewRequest("POST", "/", bytes.NewReader(body))
c := &gin.Context{}
c.Request = req
return c
}
// TestReadBodyBounded_TruncatesLogCopy 复现 H3:日志副本必须封顶到 maxLen,
// 而不是把整个 body 读入内存后再截断(原 io.ReadAll 无上限 → OOM)。
func TestReadBodyBounded_TruncatesLogCopy(t *testing.T) {
cases := []struct {
name string
bodyLen int
maxLen int
}{
{"smaller_than_limit", 100, 1024},
{"equal_to_limit", 1024, 1024},
{"larger_than_limit", 100_000, 1024},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := bytes.Repeat([]byte("a"), tc.bodyLen)
c := newCtxWithBody(body)
got := readBodyBounded(c, tc.maxLen)
want := tc.bodyLen
if want > tc.maxLen {
want = tc.maxLen
}
if len(got) != want {
t.Fatalf("log copy len = %d, want %d (maxLen=%d, bodyLen=%d)", len(got), want, tc.maxLen, tc.bodyLen)
}
})
}
}
// TestReadBodyBounded_RestoresFullBody 是 H3 修复的闭环断言:
// 即使日志副本被截断,下游处理器仍必须能读到完整请求体(io.MultiReader 复原)。
func TestReadBodyBounded_RestoresFullBody(t *testing.T) {
const maxLen = 64
body := []byte(strings.Repeat("ABCDEFGH", 1000)) // 8000 字节,远超 maxLen
c := newCtxWithBody(body)
logCopy := readBodyBounded(c, maxLen)
// 日志副本封顶
if len(logCopy) != maxLen {
t.Fatalf("log copy len = %d, want %d", len(logCopy), maxLen)
}
// 下游必须拿到完整原始 body
restored, err := io.ReadAll(c.Request.Body)
if err != nil {
t.Fatalf("read restored body: %v", err)
}
if !bytes.Equal(restored, body) {
t.Fatalf("downstream body corrupted: got len=%d, want len=%d", len(restored), len(body))
}
}
// TestReadBodyBounded_PreservesSmallBodyExactly 小于上限时日志副本与原始一致。
func TestReadBodyBounded_PreservesSmallBodyExactly(t *testing.T) {
body := []byte(`{"user":"alice","password":"secret"}`)
c := newCtxWithBody(body)
got := readBodyBounded(c, 1024)
if !bytes.Equal(got, body) {
t.Fatalf("log copy = %q, want %q", got, body)
}
restored, _ := io.ReadAll(c.Request.Body)
if !bytes.Equal(restored, body) {
t.Fatalf("downstream body = %q, want %q", restored, body)
}
}
// TestBodyLogWriter_Bounded 复现 H3 响应侧:响应体捕获缓冲区必须封顶,
// 完整响应仍写入下游 ResponseWriter。
func TestBodyLogWriter_Bounded(t *testing.T) {
gin.SetMode(gin.TestMode)
const maxLen = 32
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: maxLen,
}
large := bytes.Repeat([]byte("Z"), 10_000)
n, err := w.Write(large)
if err != nil {
t.Fatalf("write: %v", err)
}
if n != len(large) {
t.Fatalf("Write returned %d, want %d (下游必须收到完整响应)", n, len(large))
}
if w.body.Len() > maxLen {
t.Fatalf("captured buffer len = %d, must be <= %d (OOM 防护失效)", w.body.Len(), maxLen)
}
if w.body.Len() != maxLen {
t.Fatalf("captured buffer len = %d, want exactly %d", w.body.Len(), maxLen)
}
// 下游 ResponseWriter 收到完整内容
if rec.Body.Len() != len(large) {
t.Fatalf("downstream response len = %d, want %d", rec.Body.Len(), len(large))
}
}
// TestBodyLogWriter_NoLimit maxLen<=0 时不限制(向后兼容)。
func TestBodyLogWriter_NoLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: 0,
}
data := []byte("hello world")
if _, err := w.Write(data); err != nil {
t.Fatalf("write: %v", err)
}
if !bytes.Equal(w.body.Bytes(), data) {
t.Fatalf("captured = %q, want %q", w.body.Bytes(), data)
}
}
// TestBodyLogWriter_MultiWriteAccumulation 多次小写累积不超过 maxLen,
// 下游仍收到完整拼接结果。
func TestBodyLogWriter_MultiWriteAccumulation(t *testing.T) {
gin.SetMode(gin.TestMode)
const maxLen = 32
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: maxLen,
}
chunks := [][]byte{[]byte("0123456789"), []byte("abcdefghij"), []byte("ABCDEFGHIJ"), []byte("zzzzzzzzzz")}
var downstream bytes.Buffer
for _, ch := range chunks {
n, err := w.Write(ch)
if err != nil || n != len(ch) {
t.Fatalf("Write chunk %q: n=%d err=%v", ch, n, err)
}
downstream.Write(ch)
}
if w.body.Len() > maxLen {
t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen)
}
if w.body.Len() != maxLen {
t.Fatalf("captured len = %d, want exactly %d", w.body.Len(), maxLen)
}
if !bytes.Equal(rec.Body.Bytes(), downstream.Bytes()) {
t.Fatalf("downstream = %q, want %q", rec.Body.Bytes(), downstream.Bytes())
}
}
// TestBodyLogWriter_WriteStringBounded WriteString 路径同样封顶。
func TestBodyLogWriter_WriteStringBounded(t *testing.T) {
gin.SetMode(gin.TestMode)
const maxLen = 16
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: maxLen,
}
large := strings.Repeat("S", 500)
n, err := w.WriteString(large)
if err != nil {
t.Fatalf("WriteString: %v", err)
}
if n != len(large) {
t.Fatalf("WriteString returned %d, want %d", n, len(large))
}
if w.body.Len() > maxLen {
t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen)
}
if rec.Body.Len() != len(large) {
t.Fatalf("downstream len = %d, want %d", rec.Body.Len(), len(large))
}
}
// TestFilterSensitiveQuery P1 #14query 中的敏感参数值必须脱敏,非敏感原样保留。
func TestFilterSensitiveQuery(t *testing.T) {
cases := []struct {
name string
in string
contains string // 期望结果包含
absent string // 期望结果不含(敏感原值)
}{
{"empty", "", "", ""},
{"no sensitive", "page=1&size=20", "page=1", ""},
{"access_token redacted", "access_token=supersecret123&page=1", "FILTERED", "supersecret123"},
{"password redacted", "user=alice&password=hunter2", "FILTERED", "hunter2"},
{"case insensitive key", "Token=abc.def.ghi", "FILTERED", "abc.def.ghi"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := filterSensitiveQuery(tc.in)
if tc.contains != "" && !strings.Contains(got, tc.contains) {
t.Errorf("filterSensitiveQuery(%q) = %q, want contains %q", tc.in, got, tc.contains)
}
if tc.absent != "" && strings.Contains(got, tc.absent) {
t.Errorf("filterSensitiveQuery(%q) = %q, must NOT contain sensitive value %q", tc.in, got, tc.absent)
}
})
}
}
// TestLoggerWithConfig_NormalizesMaxBodyLength MaxBodyLength<=0 时归一化为默认值,
// 确保响应侧捕获缓冲区仍有上限(H3 复审 MEDIUM:消除请求/响应侧 maxLen<=0 不对称)。
func TestLoggerWithConfig_NormalizesMaxBodyLength(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
cfg := LoggerConfig{
LogRequestBody: true,
LogResponseBody: true,
MaxBodyLength: 0, // 手滑置 0,应被归一化为默认 1024
SkipPaths: []string{},
SkipPathPrefixes: []string{},
SlowRequestThreshold: 500 * time.Millisecond,
}
r.Use(LoggerWithConfig(cfg))
r.POST("/", func(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
c.Data(200, "text/plain", body)
})
// 请求体 5000 字节(> 默认 1024),下游应仍得完整 body
reqBody := bytes.Repeat([]byte("a"), 5000)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/", bytes.NewReader(reqBody))
r.ServeHTTP(w, req)
if w.Body.Len() != len(reqBody) {
t.Fatalf("downstream body len = %d, want %d (请求体必须完整复原)", w.Body.Len(), len(reqBody))
}
if !bytes.Equal(w.Body.Bytes(), reqBody) {
t.Fatalf("downstream body corrupted")
}
}
func TestLoggerSkipPathPrefixesAnchorsBoundary_M8(t *testing.T) {
cfg := LoggerConfig{SkipPathPrefixes: []string{"/api"}}
if !shouldSkipPath("/api", cfg) {
t.Fatal("/api should be skipped")
}
if !shouldSkipPath("/api/users", cfg) {
t.Fatal("/api/users should be skipped")
}
if shouldSkipPath("/api2/users", cfg) {
t.Fatal("/api2/users should not be skipped by /api prefix")
}
}
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// #18 Prometheus 指标。三个标配:
// - http_requests_total: 请求计数(按 method/route/status 维度)
// - http_request_duration_seconds: 请求耗时直方图(按 method/route 维度)
// - http_requests_in_flight: 当前在飞请求数
var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "HTTP 请求总数。",
},
[]string{"method", "route", "status"},
)
httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP 请求耗时(秒)。",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "route"},
)
httpRequestsInFlight = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "http_requests_in_flight",
Help: "正在处理的 HTTP 请求数。",
},
)
)
// Metrics Prometheus 指标中间件(#18)。记录请求计数、耗时、在飞数。
// route 标签用 c.FullPath()(路由模板,如 /api/v1/users/:id),避免高基数。
func Metrics() gin.HandlerFunc {
return func(c *gin.Context) {
httpRequestsInFlight.Inc()
defer httpRequestsInFlight.Dec() // M2 修复:defer 保证 panic 时也递减,不依赖 Recover 中间件顺序
start := time.Now()
c.Next()
elapsed := time.Since(start).Seconds()
status := strconv.Itoa(c.Writer.Status())
route := c.FullPath()
if route == "" {
route = "not_found"
}
method := c.Request.Method
httpRequestsTotal.WithLabelValues(method, route, status).Inc()
httpRequestDuration.WithLabelValues(method, route).Observe(elapsed)
}
}
File diff suppressed because it is too large Load Diff
+422 -172
View File
@@ -2,13 +2,17 @@ package middleware
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// RateLimiter 速率限制器(内存版,单实例使用)
@@ -20,15 +24,22 @@ type RateLimiter struct {
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
nowFunc func() time.Time // 时间源(默认 time.Now,测试可注入可控时钟)
}
type visitor struct {
lastSeen time.Time
count int
// windowStart 当前固定窗口的起点。仅在新窗口开始时设置,放行时不变更(H4a 修复)。
// 旧实现每次放行都更新 lastSeen,导致 time.Since(lastSeen) > window 重置分支对持续
// 客户端永不成立、count 单调累加,稳态客户端(低于 rate)被误限流。
windowStart time.Time
count int
}
// NewRateLimiter 创建速率限制器(内存版)
// NewRateLimiter 创建速率限制器(内存版)
// rate<=0 或 window<=0 时 panic。内部启动 cleanup goroutine,调用方负责在不再使用时
// 调用 (*RateLimiter).Stop 释放,否则 goroutine 泄漏。
func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
mustValidRateLimit(rate, window)
ctx, cancel := context.WithCancel(context.Background())
limiter := &RateLimiter{
visitors: make(map[string]*visitor),
@@ -36,6 +47,7 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
window: window,
ctx: ctx,
cancel: cancel,
nowFunc: time.Now,
}
limiter.wg.Add(1)
@@ -44,32 +56,67 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
return limiter
}
// Allow 检查是否允许请求
func mustValidRateLimit(rate int, window time.Duration) {
if rate <= 0 {
panic("rate limiter: rate must be positive")
}
if window <= 0 {
panic("rate limiter: window must be positive")
}
}
// now 返回当前时间(可被测试注入 nowFunc 覆盖)。
func (rl *RateLimiter) now() time.Time {
if rl.nowFunc != nil {
return rl.nowFunc()
}
return time.Now()
}
// SetNowFunc 注入时间源(默认 time.Now),供测试用可控时钟验证窗口语义。
// 生产代码通常无需调用。
func (rl *RateLimiter) SetNowFunc(f func() time.Time) {
rl.mu.Lock()
rl.nowFunc = f
rl.mu.Unlock()
}
// Allow 检查是否允许请求。
//
// 固定窗口语义(H4a 修复):windowStart 是当前窗口起点,仅在窗口过期重置时变更;
// 放行时不再更新 windowStart。窗口内 count 达到 rate 即拒绝,窗口过期则重置为新窗口。
// 旧实现每次放行更新 lastSeen,致重置分支对持续客户端永不成立、稳态客户端被误限流。
//
// 注意:固定窗口算法允许窗口边界突发(两窗口交界处瞬时可达 2×rate)。
// 如需平滑限流(无突发),请用 Redis 版 RedisRateLimiter(滑动窗口)。
func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := rl.now()
v, exists := rl.visitors[ip]
if !exists {
rl.visitors[ip] = &visitor{
lastSeen: time.Now(),
count: 1,
windowStart: now,
count: 1,
}
return true
}
if time.Since(v.lastSeen) > rl.window {
// 窗口过期:开新窗口,count 重置为 1。
if now.Sub(v.windowStart) > rl.window {
v.windowStart = now
v.count = 1
v.lastSeen = time.Now()
return true
}
// 当前窗口内已达上限:拒绝(不更新 windowStart)。
if v.count >= rl.rate {
return false
}
// 放行:count++windowStart 不变(固定窗口语义)。
v.count++
v.lastSeen = time.Now()
return true
}
@@ -87,7 +134,8 @@ func (rl *RateLimiter) cleanupVisitors() {
case <-ticker.C:
rl.mu.Lock()
for ip, v := range rl.visitors {
if time.Since(v.lastSeen) > rl.window {
// 窗口起点超 window 未活跃即淘汰(H4awindowStart 是窗口起点,不再被放行更新)。
if rl.now().Sub(v.windowStart) > rl.window {
delete(rl.visitors, ip)
}
}
@@ -104,13 +152,23 @@ func (rl *RateLimiter) Stop() {
// ===== Redis 分布式限流器 =====
// H4c: Redis 限流器故障相关错误。
var (
// ErrRedisRateLimiterUnavailable Redis 未启用(database.GetRedis() == nil)。
// fail-closed 限流器在此情况下拒绝请求;fail-open 限流器放行。
ErrRedisRateLimiterUnavailable = errors.New("redis rate limiter: redis client unavailable")
// ErrRedisRateLimiterUnexpectedResult Redis 返回非预期的结果类型(非 int64)。
// fail-closed 限流器拒绝;fail-open 限流器放行。旧实现裸断言会 panic。
ErrRedisRateLimiterUnexpectedResult = errors.New("redis rate limiter: unexpected result type")
)
// RedisRateLimiter Redis 分布式限流器
// 评分: ⭐⭐⭐⭐⭐
// 理由: 多实例部署时限流共享,使用滑动窗口算法
type RedisRateLimiter struct {
keyPrefix string // 键名前缀
rate int // 每分钟允许的请求数
window time.Duration // 时间窗口
keyPrefix string // 键名前缀
rate int // 每分钟允许的请求数
window time.Duration // 时间窗口
failClosed atomic.Bool // H4c/H-6: Redis 错误/断言失败时是否拒绝(true=安全型 fail-closed)。atomic 以支持运行期 SetFailClosed 并发安全切换。
client *redis.Client // Phase 5: 注入的 redis clientnil 回退 database.GetRedis(),照 jwt.TokenBlacklist 模型)。App 经 WithRedisClient 注入 per-App client 实现隔离。
}
// slidingWindowLua 滑动窗口限流 Lua 脚本
@@ -137,21 +195,78 @@ else
end
`
// NewRedisRateLimiter 创建 Redis 分布式限流器
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
return &RedisRateLimiter{
keyPrefix: keyPrefix,
rate: rate,
window: window,
// RedisRateLimiterOption 配置 RedisRateLimiter 的可选策略。
type RedisRateLimiterOption func(*RedisRateLimiter)
// WithFailClosed 设置 Redis 故障时 fail-closed(拒绝请求)。
// 不传或传 false 为 fail-open(放行,兼容默认)。安全敏感场景(登录防爆破等)应传 true。
func WithFailClosed(failClosed bool) RedisRateLimiterOption {
return func(rl *RedisRateLimiter) {
rl.failClosed.Store(failClosed)
}
}
// Allow 检查是否允许请求
// 评分: ⭐⭐⭐⭐⭐
// 理由: 使用滑动窗口算法,精确限流
// WithRedisClient 注入指定 Redis 客户端(Phase 5,多 Redis/测试隔离)。
// 不传则回退 database.GetRedis()(全局)。App 可经 app.RedisClient() 取自身 client 注入。
func WithRedisClient(client *redis.Client) RedisRateLimiterOption {
return func(rl *RedisRateLimiter) {
rl.client = client
}
}
// NewRedisRateLimiter 创建 Redis 分布式限流器。
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景传 WithFailClosed(true)。
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) *RedisRateLimiter {
mustValidRateLimit(rate, window)
rl := &RedisRateLimiter{
keyPrefix: keyPrefix,
rate: rate,
window: window,
// failClosed 零值 falsefail-open,兼容默认)
}
for _, opt := range opts {
if opt != nil {
opt(rl)
}
}
return rl
}
// SetFailClosed 设置 Redis 故障时的策略:true=拒绝(安全型),false=放行(兼容默认)。
// 供已创建的限流器切换策略。并发安全(atomic.Store),可与并发 Allow 调用共存。
func (rl *RedisRateLimiter) SetFailClosed(failClosed bool) {
rl.failClosed.Store(failClosed)
}
// redisClient 返回注入的 redis client,未注入则回退 database.GetRedis()jwt 模型)。
// M-C 教训:取一次复用,避免 nil 检查与 Eval 各调一次 GetRedis() 之间的 CloseRedis 竞态。
func (rl *RedisRateLimiter) redisClient() *redis.Client {
if rl != nil && rl.client != nil {
return rl.client
}
return database.GetRedis()
}
// Allow 检查是否允许请求。
//
// H4c 修复:
// - result.(int64) 改 comma-ok,断言失败返 ErrRedisRateLimiterUnexpectedResult 而非 panic。
// - Redis 错误/断言失败时按 failClosed 策略决定:fail-closed 返 (false, err) 拒绝,
// fail-open 返 (true, err) 放行(兼容旧行为)。中间件层据此 allowed 值决定放行/拒绝,
// 不再无条件 fail-open--登录防爆破等安全场景用 fail-closed 限流器即可在 Redis 故障时拒绝。
//
// Redis 未启用(redisClient() == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable)
// fail-open 返 (true, nil)(兼容旧行为)。安全场景必须确保 Redis 已启用。
//
// Phase 5redisClient() 注入优先、全局兜底,App 经 WithRedisClient 注入 per-App client。
func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) {
if database.RedisClient == nil {
// Redis 未启用,默认允许
// M-C 修复:取一次复用。原实现 nil 检查与 Eval 各调一次 GetRedis()
// 两次之间若 CloseRedis,第二次返回 nil -> nil.Eval panic。
rdb := rl.redisClient()
if rdb == nil {
if rl.failClosed.Load() {
return false, ErrRedisRateLimiterUnavailable
}
return true, nil
}
@@ -159,18 +274,30 @@ func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool,
now := float64(time.Now().UnixMilli())
windowMs := float64(rl.window.Milliseconds())
result, err := database.RedisClient.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
result, err := rdb.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
if err != nil {
return true, err // 出错时允许请求,避免影响业务
if rl.failClosed.Load() {
return false, err
}
return true, err // 出错时允许请求,避免影响业务(兼容旧行为)
}
count := result.(int64)
// H4c: comma-ok 断言,避免 Redis 返回非 int64 时 panic。
count, ok := result.(int64)
if !ok {
if rl.failClosed.Load() {
return false, ErrRedisRateLimiterUnexpectedResult
}
return true, ErrRedisRateLimiterUnexpectedResult
}
return count == 0, nil
}
// GetCount 获取当前窗口内的请求数
func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (int64, error) {
if database.RedisClient == nil {
// M-C 修复(Phase 5:改 redisClient):取一次复用(原三次调用存在 nil-deref 窗口)。
rdb := rl.redisClient()
if rdb == nil {
return 0, nil
}
@@ -179,138 +306,287 @@ func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (in
windowStart := now - rl.window.Milliseconds()
// 移除旧记录并获取当前计数
database.RedisClient.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
return database.RedisClient.ZCard(ctx, key).Result()
rdb.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
return rdb.ZCard(ctx, key).Result()
}
// Reset 重置限流计数
func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error {
if database.RedisClient == nil {
// M-C 修复(Phase 5:改 redisClient):取一次复用(原两次调用存在 nil-deref 窗口)。
rdb := rl.redisClient()
if rdb == nil {
return nil
}
key := rl.keyPrefix + ":" + identifier
return database.RedisClient.Del(ctx, key).Err()
return rdb.Del(ctx, key).Err()
}
// ===== 全局限速器 =====
// ===== 全局限速器 RegistryPhase 5 实例化) =====
//
// 内存限流器(RateLimiter)持 cleanup goroutine,进程级共享会在多 App 下互相污染
// App A Shutdown 经 StopRateLimiters 把 App B 的 loginLimiter 也停了)。故照
// cache.CacheManager / cron.Scheduler 模式引入 RateLimitRegistry:实例化 + 全局默认
// + 包级 facade 代理,App 持自己的 Registry。
//
// 包级 LoginRateLimit() 等改为"请求时解析当前默认 Registry"懒创建限流器,使 App.Init
// swap 后请求落到 App 的 Registry(避免 pre-Init 注册到 init Registry 被 swap 丢弃)。
var (
loginLimiter *RateLimiter
apiLimiter *RateLimiter
uploadLimiter *RateLimiter
redisLimiters map[string]*RedisRateLimiter
limitersMu sync.Mutex
)
// RateLimitRegistry 持一组内存限流器,支持 per-App 隔离。
type RateLimitRegistry struct {
mu sync.Mutex
loginLimiter *RateLimiter
apiLimiter *RateLimiter
uploadLimiter *RateLimiter
customLimiters []*RateLimiter
}
// NewRateLimitRegistry 创建限流器注册表实例。
func NewRateLimitRegistry() *RateLimitRegistry {
return &RateLimitRegistry{}
}
func (r *RateLimitRegistry) loginLimiterInstance() *RateLimiter {
r.mu.Lock()
defer r.mu.Unlock()
if r.loginLimiter == nil {
r.loginLimiter = NewRateLimiter(10, time.Minute)
}
return r.loginLimiter
}
func (r *RateLimitRegistry) apiLimiterInstance() *RateLimiter {
r.mu.Lock()
defer r.mu.Unlock()
if r.apiLimiter == nil {
r.apiLimiter = NewRateLimiter(100, time.Minute)
}
return r.apiLimiter
}
func (r *RateLimitRegistry) uploadLimiterInstance() *RateLimiter {
r.mu.Lock()
defer r.mu.Unlock()
if r.uploadLimiter == nil {
r.uploadLimiter = NewRateLimiter(20, time.Minute)
}
return r.uploadLimiter
}
// registerCustom 登记自定义限流器供 Stop 统一停止(H4b)。
func (r *RateLimitRegistry) registerCustom(l *RateLimiter) {
r.mu.Lock()
r.customLimiters = append(r.customLimiters, l)
r.mu.Unlock()
}
// Init 预初始化标准限流器(可选;不调则首次请求时懒创建)。先停旧的同名限流器释放 goroutine。
func (r *RateLimitRegistry) Init() {
r.mu.Lock()
defer r.mu.Unlock()
if r.loginLimiter != nil {
r.loginLimiter.Stop()
}
if r.apiLimiter != nil {
r.apiLimiter.Stop()
}
if r.uploadLimiter != nil {
r.uploadLimiter.Stop()
}
r.loginLimiter = NewRateLimiter(10, time.Minute)
r.apiLimiter = NewRateLimiter(100, time.Minute)
r.uploadLimiter = NewRateLimiter(20, time.Minute)
}
// Stop 停止注册表中所有限流器(释放 cleanup goroutine)。幂等。
func (r *RateLimitRegistry) Stop() {
r.mu.Lock()
defer r.mu.Unlock()
if r.loginLimiter != nil {
r.loginLimiter.Stop()
r.loginLimiter = nil
}
if r.apiLimiter != nil {
r.apiLimiter.Stop()
r.apiLimiter = nil
}
if r.uploadLimiter != nil {
r.uploadLimiter.Stop()
r.uploadLimiter = nil
}
for _, l := range r.customLimiters {
l.Stop()
}
r.customLimiters = nil
}
// --- App-bound 中间件(捕获此 Registry,不查全局默认,多 App per-App 隔离) ---
//
// 与包级 LoginRateLimit() 等的区别:包级在请求时查 GetDefaultRateLimitRegistry()(多 App 下
// 仅最后 Init 的 App 是全局默认,故包级 facade 不提供 per-App 计数隔离);本组方法捕获 r,
// 始终用此 Registry 的 limiter。多 App 场景用 app.RateLimitRegistry().LoginRateLimit() 装配路由。
// LoginRateLimit 返回绑定到此 Registry 的登录限流中间件。
func (r *RateLimitRegistry) LoginRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, r.loginLimiterInstance())
}
}
// APIRateLimit 返回绑定到此 Registry 的普通 API 限流中间件。
func (r *RateLimitRegistry) APIRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, r.apiLimiterInstance())
}
}
// UploadRateLimit 返回绑定到此 Registry 的上传限流中间件。
func (r *RateLimitRegistry) UploadRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, r.uploadLimiterInstance())
}
}
// CustomRateLimit 返回绑定到此 Registry 的自定义限流中间件。limiter 在首次请求时创建并
// 登记到此 Registryr.registerCustom),由 App.Shutdown 的 registry.Stop() 收口,无泄漏。
func (r *RateLimitRegistry) CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
var (
once sync.Once
limiter *RateLimiter
)
return func(c *gin.Context) {
once.Do(func() {
limiter = NewRateLimiter(rate, window)
r.registerCustom(limiter)
})
rateLimitAllow(c, limiter)
}
}
// defaultRateLimitRegistry 全局默认 Registryatomic,照 cache/cron 模式)。
var defaultRateLimitRegistry atomic.Pointer[RateLimitRegistry]
func init() {
redisLimiters = make(map[string]*RedisRateLimiter)
defaultRateLimitRegistry.Store(NewRateLimitRegistry())
}
// InitRateLimiters 初始化限速器
func InitRateLimiters() {
limitersMu.Lock()
defer limitersMu.Unlock()
// 先停止旧的限流器
if loginLimiter != nil {
loginLimiter.Stop()
}
if apiLimiter != nil {
apiLimiter.Stop()
}
if uploadLimiter != nil {
uploadLimiter.Stop()
}
// 内存限流器(单实例)
loginLimiter = NewRateLimiter(10, time.Minute)
apiLimiter = NewRateLimiter(100, time.Minute)
uploadLimiter = NewRateLimiter(20, time.Minute)
// GetDefaultRateLimitRegistry 返回全局默认 Registry。
func GetDefaultRateLimitRegistry() *RateLimitRegistry {
return defaultRateLimitRegistry.Load()
}
// StopRateLimiters 停止所有限速器(应用关闭时调用)
func StopRateLimiters() {
limitersMu.Lock()
defer limitersMu.Unlock()
if loginLimiter != nil {
loginLimiter.Stop()
loginLimiter = nil
}
if apiLimiter != nil {
apiLimiter.Stop()
apiLimiter = nil
}
if uploadLimiter != nil {
uploadLimiter.Stop()
uploadLimiter = nil
// SwapDefaultRateLimitRegistry 置为全局默认,返回被替换的旧(照 Swap 模式)。nil 忽略,返回当前默认。
func SwapDefaultRateLimitRegistry(r *RateLimitRegistry) *RateLimitRegistry {
if r == nil {
return defaultRateLimitRegistry.Load()
}
return defaultRateLimitRegistry.Swap(r)
}
// RateLimit 通用速率限制中间件(内存版)
// rateLimitAllow 公共放行/拒绝逻辑(内存版)
func rateLimitAllow(c *gin.Context, rl *RateLimiter) {
if rl == nil {
response.Custom(c, http.StatusServiceUnavailable, response.CodeServiceUnavailable,
"限流器未初始化", nil)
c.Abort()
return
}
if !rl.Allow(c.ClientIP()) {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
}
// RateLimit 通用速率限制中间件(内存版,用户自持 limiter 时使用)。
func RateLimit(limiter *RateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !limiter.Allow(ip) {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
rateLimitAllow(c, limiter)
}
}
// LoginRateLimit 登录接口速率限制
// LoginRateLimit 登录接口速率限制。限流器在首次请求时从当前默认 Registry 懒创建,
// 故 App.Init swap 后用的是 App 的 Registry(避免 pre-Init 注册到 init Registry 被丢弃)。
func LoginRateLimit() gin.HandlerFunc {
limitersMu.Lock()
if loginLimiter == nil {
loginLimiter = NewRateLimiter(10, time.Minute)
return func(c *gin.Context) {
rateLimitAllow(c, GetDefaultRateLimitRegistry().loginLimiterInstance())
}
limiter := loginLimiter
limitersMu.Unlock()
return RateLimit(limiter)
}
// APIRateLimit 普通 API 速率限制
// APIRateLimit 普通 API 速率限制
func APIRateLimit() gin.HandlerFunc {
limitersMu.Lock()
if apiLimiter == nil {
apiLimiter = NewRateLimiter(100, time.Minute)
return func(c *gin.Context) {
rateLimitAllow(c, GetDefaultRateLimitRegistry().apiLimiterInstance())
}
limiter := apiLimiter
limitersMu.Unlock()
return RateLimit(limiter)
}
// UploadRateLimit 上传接口速率限制
// UploadRateLimit 上传接口速率限制
func UploadRateLimit() gin.HandlerFunc {
limitersMu.Lock()
if uploadLimiter == nil {
uploadLimiter = NewRateLimiter(20, time.Minute)
return func(c *gin.Context) {
rateLimitAllow(c, GetDefaultRateLimitRegistry().uploadLimiterInstance())
}
limiter := uploadLimiter
limitersMu.Unlock()
return RateLimit(limiter)
}
// CustomRateLimit 自定义速率限制(内存版)
// CustomRateLimit 自定义速率限制(内存版)。限流器在首次请求时创建并登记到当前默认
// Registry(供 Stop 统一停止,避免 cleanup goroutine 泄漏,H4b)。
func CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
limiter := NewRateLimiter(rate, window)
return RateLimit(limiter)
var (
once sync.Once
limiter *RateLimiter
)
return func(c *gin.Context) {
once.Do(func() {
limiter = NewRateLimiter(rate, window)
GetDefaultRateLimitRegistry().registerCustom(limiter)
})
rateLimitAllow(c, limiter)
}
}
// InitRateLimiters 初始化默认 Registry 的标准限流器(可选,不调则懒创建)。
func InitRateLimiters() {
GetDefaultRateLimitRegistry().Init()
}
// StopRateLimiters 停止默认 Registry 的所有限流器。注意:仅停默认 Registry--
// App 持自己的 Registry 时应在 Shutdown 调 app 自己的 StopPhase 5)。
func StopRateLimiters() {
GetDefaultRateLimitRegistry().Stop()
}
// ===== Redis 分布式限流中间件 =====
// RedisRateLimit Redis 分布式限流中间件
// 评分: ⭐⭐⭐⭐⭐
// 理由: 多实例部署时共享限流状态
// 参数: keyPrefix 键名前缀(如 "login_limit"),rate 每分钟请求数
func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
// redisLimitDecision 处理 RedisRateLimiter.Allow 的结果,按 allowed 值决定放行/拒绝。
//
// H4c: 不再无条件 fail-open。Allow 已按 limiter 的 failClosed 策略把 Redis 故障翻成
// allowed 值--fail-closed 时 allowed=false(拒绝),fail-open 时 allowed=true(放行)。
// 故 err 与 allowed 的组合语义:
// - err==nil, allowed==true -> 放行
// - err==nil, allowed==false -> 真超限,返 429response.RateLimit
// - err!=nil, allowed==false -> fail-closed 限流器在 Redis 故障下拒绝,返 503(服务不可用)
// - err!=nil, allowed==true -> fail-open 限流器在 Redis 故障下放行(兼容旧行为)
func redisLimitDecision(c *gin.Context, allowed bool, err error) {
if err != nil && !allowed {
// fail-closedRedis 故障时拒绝(防限流静默失效)。返 503 区别于真实超限的 429。
response.Custom(c, http.StatusServiceUnavailable, response.CodeServiceUnavailable,
"限流服务暂时不可用", nil)
c.Abort()
return
}
if !allowed {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
}
// RedisRateLimit Redis 分布式限流中间件。
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景(登录防爆破等)
// 传 WithFailClosed(true)Redis 故障时拒绝以防限流失效。
func RedisRateLimit(keyPrefix string, rate int, opts ...RedisRateLimiterOption) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
return func(c *gin.Context) {
identifier := c.ClientIP()
@@ -322,83 +598,57 @@ func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
// }
allowed, err := limiter.Allow(c.Request.Context(), identifier)
if err != nil {
// Redis 错误时允许请求,避免影响业务
c.Next()
return
}
if !allowed {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
redisLimitDecision(c, allowed, err)
}
}
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流
// 参数: keyPrefix 键名前缀,rate 每分钟请求数,identifierFunc 标识获取函数
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流
// 参数: keyPrefix 键名前缀,rate 1 分钟窗口内允许的请求数,identifierFunc 标识获取函数
// identifierFunc 为 nil 或返回空串时回退到 c.ClientIP()。默认 fail-open,传 WithFailClosed(true) 切换。
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string, opts ...RedisRateLimiterOption) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
return func(c *gin.Context) {
identifier := identifierFunc(c)
identifier := ""
if identifierFunc != nil {
identifier = identifierFunc(c)
}
if identifier == "" {
identifier = c.ClientIP()
}
allowed, err := limiter.Allow(c.Request.Context(), identifier)
if err != nil {
c.Next()
return
}
if !allowed {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
redisLimitDecision(c, allowed, err)
}
}
// LoginRedisRateLimit 登录接口 Redis 分布式限流
// LoginRedisRateLimit 登录接口 Redis 分布式限流fail-closed)。
//
// H4c: 登录防爆破场景必须 fail-closed--Redis 故障时若 fail-open 则限流失效、
// 攻击者可借 Redis 抖动窗口无限爆破。改为 fail-closedRedis 故障时返 503 拒绝。
func LoginRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("login_limit", 10)
return RedisRateLimit("login_limit", 10, WithFailClosed(true))
}
// APIRedisRateLimit API Redis 分布式限流
// APIRedisRateLimit API Redis 分布式限流fail-open,避免影响业务)。
func APIRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("api_limit", 100)
}
// UploadRedisRateLimit 上传接口 Redis 分布式限流
// UploadRedisRateLimit 上传接口 Redis 分布式限流fail-closed)。
// 上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效。
func UploadRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("upload_limit", 20)
return RedisRateLimit("upload_limit", 20, WithFailClosed(true))
}
// CustomRedisRateLimit 自定义 Redis 分布式限流
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, window)
// CustomRedisRateLimit 自定义 Redis 分布式限流
// 默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed。
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, window, opts...)
return func(c *gin.Context) {
identifier := c.ClientIP()
allowed, err := limiter.Allow(c.Request.Context(), identifier)
if err != nil {
c.Next()
return
}
if !allowed {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
redisLimitDecision(c, allowed, err)
}
}
}
@@ -0,0 +1,96 @@
package middleware
import (
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// TestRedisRateLimiterInjectedClientPriority 固化 Phase 5jwt 模型):
// RedisRateLimiter.redisClient() 注入优先、全局兜底。
func TestRedisRateLimiterInjectedClientPriority(t *testing.T) {
mrG := miniredis.RunT(t)
clientG := redis.NewClient(&redis.Options{Addr: mrG.Addr()})
t.Cleanup(func() { _ = clientG.Close() })
origGlobal := database.SetTestRedisClient(clientG)
t.Cleanup(func() { database.SetTestRedisClient(origGlobal) })
mrA := miniredis.RunT(t)
clientA := redis.NewClient(&redis.Options{Addr: mrA.Addr()})
t.Cleanup(func() { _ = clientA.Close() })
// 注入优先
rl := NewRedisRateLimiter("test", 10, time.Minute, WithRedisClient(clientA))
if got := rl.redisClient(); got != clientA {
t.Fatalf("injected redisClient() = %v, want clientA", got)
}
// 未注入 -> 全局兜底
rl2 := NewRedisRateLimiter("test", 10, time.Minute)
if got := rl2.redisClient(); got != clientG {
t.Fatalf("fallback redisClient() = %v, want clientG (global)", got)
}
}
// TestSwapDefaultRateLimitRegistryReturnsOld 固化 Phase 5Swap 返回被替换的旧 Registry。
func TestSwapDefaultRateLimitRegistryReturnsOld(t *testing.T) {
orig := defaultRateLimitRegistry.Load()
defer SwapDefaultRateLimitRegistry(orig)
first := NewRateLimitRegistry()
if prev := SwapDefaultRateLimitRegistry(first); prev != orig {
t.Fatalf("Swap returned %v, want orig", prev)
}
second := NewRateLimitRegistry()
if returned := SwapDefaultRateLimitRegistry(second); returned != first {
t.Fatalf("Swap returned %v, want first", returned)
}
if defaultRateLimitRegistry.Load() != second {
t.Fatal("Swap did not install second as default")
}
if got := SwapDefaultRateLimitRegistry(nil); got != second {
t.Fatalf("Swap(nil) = %v, want second (current default)", got)
}
}
// TestRateLimitRegistryStopIdempotent 固化 Phase 5Stop 幂等,重复调用不 panic。
func TestRateLimitRegistryStopIdempotent(t *testing.T) {
r := NewRateLimitRegistry()
_ = r.loginLimiterInstance()
_ = r.apiLimiterInstance()
_ = r.uploadLimiterInstance()
r.registerCustom(NewRateLimiter(5, time.Minute))
r.Stop()
r.Stop() // 幂等
}
// TestRateLimitRegistryIsolation 固化 Phase 5:两个 Registry 实例的 limiter 互相独立。
func TestRateLimitRegistryIsolation(t *testing.T) {
rA := NewRateLimitRegistry()
rB := NewRateLimitRegistry()
defer rA.Stop()
defer rB.Stop()
la := rA.loginLimiterInstance()
lb := rB.loginLimiterInstance()
if la == lb {
t.Fatal("two registries returned the same loginLimiter instance (not isolated)")
}
// rA 用尽 10 次;rB 不受影响
for i := 0; i < 10; i++ {
if !la.Allow("1.1.1.1") {
t.Fatalf("rA request %d should be allowed", i+1)
}
}
// rA 第 11 次应被拒绝(count=10 >= rate=10
if la.Allow("1.1.1.1") {
t.Fatal("rA 11th should be rejected (count reached limit)")
}
// rB 的 loginLimiter 独立计数,1.1.1.1 仍可放行
if !lb.Allow("1.1.1.1") {
t.Fatal("rB loginLimiter should be independent of rA (still allowed)")
}}
+33 -12
View File
@@ -12,8 +12,6 @@ import (
)
// Recover panic恢复中间件,捕获panic并返回统一错误响应
// 评分: ⭐⭐⭐⭐⭐
// 理由: 生产环境必备,防止panic导致服务崩溃
func Recover() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
@@ -22,7 +20,7 @@ func Recover() gin.HandlerFunc {
requestID := GetRequestID(c)
// 记录错误日志
logger.Error("Panic recovered",
logger.Error("panic 已恢复",
zap.String("request_id", requestID),
zap.String("error", fmt.Sprintf("%v", err)),
zap.String("path", c.Request.URL.Path),
@@ -30,9 +28,26 @@ func Recover() gin.HandlerFunc {
zap.String("stack", string(debug.Stack())),
)
// 返回错误响应
response.FailWithCode(c, response.CodeServerError, "服务器内部错误")
c.AbortWithStatus(http.StatusInternalServerError)
// 返回错误响应
// 必须用 response.Custom 显式写 HTTP 500FailWithCode 经 writeResp→httpStatusFor
// 在默认 ModeBusiness 下对 CodeServerError 返回 200c.JSON(200,...) 会先 flush
// 锁定状态,随后 AbortWithStatus(500) 因 w.Written()==true 沦为 no-op
// 导致客户端收到 HTTP 200 + body code:500,网关/APM 按 status 看不到 panic。
// Custom 不受 Mode 影响,直接写 500 且保留 RequestID。
//
// M-B 修复:若 panic 发生在响应已开始写出之后(流式/部分写),c.Writer.Written()==true
// gin 拒绝再次写响应,response.Custom 沦为 no-op,客户端会收到截断响应而非 500。
// 此时不再尝试写 500(无效),仅 Abort + 记录,避免无意义的二次写入。
if !c.Writer.Written() {
response.Custom(c, http.StatusInternalServerError, response.CodeServerError, "服务器内部错误", nil)
} else {
logger.Warn("panic 发生在响应已开始写入后,无法写入 500,客户端将收到截断响应",
zap.String("request_id", requestID),
zap.String("path", c.Request.URL.Path),
zap.Int("bytes_written", c.Writer.Size()),
)
}
c.Abort()
}
}()
c.Next()
@@ -40,8 +55,6 @@ func Recover() gin.HandlerFunc {
}
// RecoverWithDetail panic恢复中间件(详细版本,返回更多信息)
// 评分: ⭐⭐⭐⭐
// 理由: 开发环境使用,返回详细错误信息便于调试
// 注意: 生产环境不应使用,会暴露敏感信息
func RecoverWithDetail() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -49,7 +62,7 @@ func RecoverWithDetail() gin.HandlerFunc {
if err := recover(); err != nil {
requestID := GetRequestID(c)
logger.Error("Panic recovered",
logger.Error("panic 已恢复",
zap.String("request_id", requestID),
zap.String("error", fmt.Sprintf("%v", err)),
zap.String("path", c.Request.URL.Path),
@@ -57,9 +70,17 @@ func RecoverWithDetail() gin.HandlerFunc {
zap.String("stack", string(debug.Stack())),
)
// 开发环境返回详细错误
response.FailWithCode(c, response.CodeServerError, fmt.Sprintf("Panic: %v", err))
c.AbortWithStatus(http.StatusInternalServerError)
// 开发环境返回详细错误。同样用 Custom 显式写 500(见 Recover 注释)。
// M-B 修复:响应已写出时不重复写 500(同 Recover)。
if !c.Writer.Written() {
response.Custom(c, http.StatusInternalServerError, response.CodeServerError, fmt.Sprintf("服务器内部错误: %v", err), nil)
} else {
logger.Warn("panic 发生在响应已开始写入后,无法写入 500,客户端将收到截断响应",
zap.String("request_id", requestID),
zap.String("path", c.Request.URL.Path),
)
}
c.Abort()
}
}()
c.Next()
+27 -4
View File
@@ -5,12 +5,35 @@ import (
"github.com/gin-gonic/gin"
)
// RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪
// 评分: ⭐⭐⭐⭐⭐
// 理由: 日志追踪、问题排查必备,简洁高效
// 请求 ID 校验约束(M15 修复:不无条件信任客户端 X-Request-ID,防头注入/日志伪造)。
const (
// requestIDMaxLen 客户端传入请求 ID 的最大长度。超长视为非法并重新生成,避免日志膨胀/注入。
requestIDMaxLen = 128
)
// sanitizeRequestID 校验客户端传入的 X-Request-ID:仅允许可见 ASCII0x20-0x7e)且
// 不含 CR/LF(防 HTTP 头注入与日志换行伪造),长度不超过 requestIDMaxLen。
// 非法或为空时返回 "",由调用方重新生成。
func sanitizeRequestID(id string) string {
if len(id) == 0 || len(id) > requestIDMaxLen {
return ""
}
for i := 0; i < len(id); i++ {
c := id[i]
if c < 0x20 || c > 0x7e {
return ""
}
}
return id
}
// RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪。
//
// 客户端可经 X-Request-ID 头传入 ID 用于链路串联,但会做校验(M15 修复):
// 仅接受可见 ASCII、无换行、长度 ≤128 的值,否则忽略并重新生成——防头注入与日志伪造。
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
requestID := sanitizeRequestID(c.GetHeader("X-Request-ID"))
if requestID == "" {
requestID = utils.UUID()
}
+37
View File
@@ -0,0 +1,37 @@
package middleware
import (
"context"
"time"
"github.com/gin-gonic/gin"
)
// Timeout 请求级超时中间件(#19)。
//
// 与 http.Server.ReadTimeout(连接级)不同,这是业务级超时:
// 为每个请求的 context 设置 deadline,下游 GORM / Redis / HTTP 调用
// 走 c.Request.Context() 即可级联取消,避免单个慢请求拖垮协程。
//
// ⚠️ 软超时语义(M14 文档化):本中间件仅注入带 deadline 的 ctx,不会主动中断 handler。
// 生效前提是下游实际消费 ctx——GORM/Redis/HTTP 客户端等会响应 ctx 取消;但若 handler
// 是纯 CPU 循环、阻塞于不查 ctx 的调用、或不读 c.Request.Context(),则 deadline 到期
// 也不会停。需要硬中断的场景请配合 http.Server.WriteTimeout 或在 handler 内显式 select ctx.Done。
//
// 用法:
//
// r.Use(middleware.Timeout(5 * time.Second))
//
// d <= 0 时不启用超时(直接 Next)。
func Timeout(d time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
if d <= 0 {
c.Next()
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
+72
View File
@@ -0,0 +1,72 @@
package middleware_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/gin-gonic/gin"
)
func TestTimeoutAllowsFastHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.Timeout(time.Second))
r.GET("/ok", func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/ok", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestTimeoutCancelsSlowHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.Timeout(50 * time.Millisecond))
r.GET("/slow", func(c *gin.Context) {
select {
case <-c.Request.Context().Done():
// 超时取消生效
return
case <-time.After(time.Second):
t.Error("handler should have been cancelled by timeout")
}
})
w := httptest.NewRecorder()
done := make(chan struct{})
go func() {
r.ServeHTTP(w, httptest.NewRequest("GET", "/slow", nil))
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("request did not return in time")
}
}
func TestTimeoutZeroDisables(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.Timeout(0)) // 不启用
ctxCancelled := false
r.GET("/x", func(c *gin.Context) {
ctxCancelled = c.Request.Context() == context.Background()
_ = ctxCancelled
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/x", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}

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