Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dbbce34f5 | |||
| 96ce883d41 | |||
| 399950a9f6 | |||
| df78706dbb | |||
| 905cf7db4f | |||
| 9e11f8f007 | |||
| ffcb1a77b9 | |||
| 6730f12cd6 | |||
| 5eb3879a85 | |||
| 08ed43385d | |||
| 21021d4001 | |||
| 2529b0a074 | |||
| ca822dcadd | |||
| c64a992008 | |||
| 18eb62e2f0 | |||
| 74b65216b6 | |||
| 4ccbbfbf1b | |||
| 89721132a1 | |||
| 1ea3d8751f | |||
| 27c14499c2 | |||
| d73a46447c | |||
| 0610a159f0 | |||
| 667f0793fc | |||
| 7ca1d81f88 | |||
| 8ccfb162b8 | |||
| 9b9c5df676 | |||
| fed3e37348 | |||
| 8770e9344a | |||
| 232b16d65d | |||
| 56352fdd81 | |||
| 2d3dde99c2 |
+7
-8
@@ -1,17 +1,16 @@
|
||||
# Claude Code 协作指引(本地,不入库)
|
||||
CLAUDE.md
|
||||
|
||||
AGENTS.md
|
||||
|
||||
# 临时发版辅助文件
|
||||
gitHub_release_*.md
|
||||
|
||||
# 构建产物
|
||||
*.exe
|
||||
bin/
|
||||
claude_check_report_framework.md
|
||||
claude_check_report_module.md
|
||||
cross_review_assessment.md
|
||||
deepseek_check_report_framework.md
|
||||
deepseek_check_report_module.md
|
||||
glm_check_report_framework.md
|
||||
glm_check_report_modelue.md
|
||||
last_report.md
|
||||
md/
|
||||
.gocache/
|
||||
gpt_check_report_framework.md
|
||||
gpt_check_report_review.md
|
||||
gpt_check_report_module.md
|
||||
|
||||
+167
-5
@@ -16,7 +16,169 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_暂无未发布变更。_
|
||||
## [1.4.0] - 2026-07-12
|
||||
|
||||
> **instance+facade 一致性收尾**:cron/storage/cache/ratelimit 四个"纯全局"包按 `database.Manager`/`jwt.Manager` 模式实例化(App 持实例 + `SwapDefault*` 提升全局默认 + 包级 facade 代理),消除单进程多 App 互相污染;trace 纳入 App 生命周期(不做实例隔离,OTel 本身进程全局);清理 `WithoutWire` 空函数。
|
||||
>
|
||||
> **修复的多 App 冲突**:① App A `Shutdown` 不再把全局 cron 调度器 / 限流器停掉(B 的任务/限流不受影响);② App B `Init` 不再覆盖 App A 的 storage driver / cache redis;③ cache/ratelimit 不再硬编码 `database.GetRedis()`,可注入 per-App redis;④ `commitReplacedResources` 不再关闭"被替换的全局默认"DB/Redis manager(多 App 下那可能是另一个 App 的活跃 manager,关闭致 "redis: client is closed");⑤ trace.Close 在 `App.Shutdown` 被调用(此前从未调用,exporter 后台 goroutine/连接泄漏)。
|
||||
>
|
||||
> `go vet` + `go build` + `go test -race ./...` 全绿。本机需 `-buildvcs=false`。
|
||||
|
||||
### Breaking ⚠️
|
||||
|
||||
- **`xlgo.WithoutWire()` 删除**(app.go):自 v1.1.0 wire 包删除后遗留的空 no-op 函数,全仓无引用。直接删除,无替代;调用方删除该 Option 即可(无行为变化)。
|
||||
- **`cron.AddTask(...)` 在 `App.Init` 之前注册不再生效**(cron/cron.go + app.go):App 现持专属 `*Scheduler`,`Init` 时创建并 `SwapDefaultScheduler` 提升为全局默认。pre-Init 调用包级 `cron.AddTask` 会注册到 init 默认调度器、被 App swap 丢弃。迁移:改用 `xlgo.WithCronTask(name, schedule, handler)`(蕴含启用 cron),或 `Init` 后 `app.Scheduler().AddTask(...)`。post-Init 的包级 `cron.AddTask` 仍可用(代理到 App 调度器)。`cron` 包 `init()` 现预创建全局默认调度器(曾为懒初始化)。
|
||||
- **`middleware.StopRateLimiters()` 语义收窄**(middleware/ratelimit.go):仅停止全局默认 `RateLimitRegistry` 的限流器。App 现持专属 Registry,`Shutdown` 调 `app` 自己的 `Stop`,不再调全局 `StopRateLimiters`(避免误停其他 App)。`LoginRateLimit`/`APIRateLimit`/`UploadRateLimit`/`CustomRateLimit` 改为请求时从默认 Registry 懒创建限流器(单 App 行为不变;多 App 下各自 Registry 隔离)。
|
||||
- **`App.commitReplacedResources` 不再关闭被替换的全局 DB/Redis manager**(app.go):多 App 下"previous"可能是另一个 App 的活跃 manager,关闭致 `redis: client is closed`。单 App 下 previous 为 init() 空默认(无 client,Close 本就是 no-op),无影响。用户若手动 `database.InitRedis`/`InitDB` 后再 `App.Init`,旧 client 的关闭由用户负责(非常规组合)。
|
||||
- **`cache.redisCache` 持注入 redis client**(cache/cache.go):新增 `cache.NewRedisCacheWithRedis(client)`、`(*CacheManager).InitWithRedis(client)`、`cache.SwapDefaultCacheManager(m)`。`cache.NewRedisCache()` 仍存在(懒取全局 Redis,standalone 用法)。App 现经 `InitWithRedis(a.redisManager.Client())` 注入 per-App client。
|
||||
- **`middleware.RedisRateLimiter` 新增 `WithRedisClient(client)` 选项**(middleware/ratelimit.go):`Allow`/`GetCount`/`Reset` 改用 `rl.redisClient()`(注入优先、`database.GetRedis()` 兜底,照 `jwt.TokenBlacklist` 模型)。包级 `RedisRateLimit` 系列仍用全局 Redis;per-App 隔离用 `middleware.NewRedisRateLimiter(..., middleware.WithRedisClient(app.RedisClient()))`。
|
||||
|
||||
### Added
|
||||
|
||||
- **`xlgo.WithTrace()`**:把 OpenTelemetry trace 纳入 App 生命周期--`Init` 调 `trace.Init`(按 `config.Trace`)、装入 `trace.Middleware`、`Shutdown` 调 `trace.Close`。实际导出由 `config.Trace.Enabled` 控制。**不做实例隔离**:OTel `TracerProvider`/`Propagator` 是进程级全局单例,多 App 共享(与 OTel 设计一致)。
|
||||
- **`xlgo.WithCronTask(name, schedule, handler)`**:注册定时任务到 App 专属调度器(蕴含 `WithCron`),替代旧的全局 `cron.AddTask` pre-Run 注册模式。
|
||||
- **`xlgo.App.Scheduler()` / `Cache()` / `RedisClient()`**:分别返回 App 专属的 cron 调度器、缓存服务、Redis 客户端,供多 App 隔离场景直接操作(绕过全局 facade)。
|
||||
- **`config.TraceConfig` + `Config.Trace` 字段**:链路追踪 YAML 配置(`service_name`/`exporter_type`/`endpoint`/`insecure`/`sample_ratio`/`enabled`/`propagator` 等)。`Validate` 在 `Enabled` 时校验 `sample_ratio` 范围;导出器/传播器枚举校验委托 `trace.Init`。
|
||||
- **`cron.SwapDefaultScheduler(s)`**、**`storage.SwapDefaultStorageManager(m)`**、**`cache.SwapDefaultCacheManager(m)`**、**`middleware.SwapDefaultRateLimitRegistry(r)`**:返回被替换的旧实例,供 App Init 失败回滚(照 `database.SwapDefaultManager`/`SwapDefaultRedisManager` 模式)。
|
||||
- **`storage.StorageManager.Close()`**:闭合 Init/Close 生命周期(与 database/redis/logger manager 对称),供 `App.closeResources`(Shutdown 路径)与 `rollbackReplacedResources`(Init 失败路径)对称收口。当前 LocalStorage/OSSStorage 无 closeable 资源(`oss.Client` 未暴露 Close),故为 no-op;经 `io.Closer` 断言调用驱动,未来带连接池的驱动实现 `io.Closer` 即被自动调用,框架骨架无需改动。
|
||||
- **`middleware.RateLimitRegistry`**:持 login/api/upload/custom 内存限流器,支持 per-App 隔离。`NewRateLimitRegistry()` / `Init()` / `Stop()` / `GetDefaultRateLimitRegistry()`。
|
||||
- **`xlgo.App.RateLimitRegistry()` + App-bound 限流中间件**(middleware/ratelimit.go):`app.RateLimitRegistry().LoginRateLimit()`/`APIRateLimit()`/`UploadRateLimit()`/`CustomRateLimit(rate, window)` 返回的中间件捕获 App 自己的 Registry(请求时不查全局默认),实现多 App per-App 计数隔离。`RateLimitRegistry` 改在 `New()` 时预创建,使 getter 在 `Init` 前即可用于路由装配;`Init` 失败回滚时 `registry.Stop()` 收口 custom limiter,无泄漏。
|
||||
|
||||
### Fixed
|
||||
|
||||
- **多 App 限流器互不污染**(middleware/ratelimit.go):App A `Shutdown` 不再经全局 `StopRateLimiters` 把 App B 的 loginLimiter 置 nil(致 B 下次请求懒创建新 limiter、计数清零,稳态客户端借 A 的 Shutdown 窗口绕过限流)。各 App 持自己的 `RateLimitRegistry`。
|
||||
- **多 App cron 调度器隔离**(cron/cron.go + app.go):App A `Shutdown` 不再经 `cron.StopGlobalWithTimeout` 停掉共享全局调度器(B 的任务不受影响)。
|
||||
- **多 App storage / cache 隔离**(storage/cache + app.go):App B `Init` 不再覆盖 App A 的 storage driver / cache redis;cache 不再硬编码 `database.GetRedis()`(致 A 的缓存读到 B 的 redis)。
|
||||
- **trace exporter 泄漏**(app.go):`App.Shutdown` 现调 `trace.Close` 刷出批量 span 并释放 exporter 后台 goroutine/连接(H-14 修了 `Close` 幂等,但 App 此前从未调它)。
|
||||
- **多 App logger writer 跨 App 误杀**(app.go `commitReplacedResources`):与 `previousDB`/`previousRedis` 对齐,`commitReplacedResources` 不再 `CloseDefaultSnapshot`(旧实现会关掉被替换的全局默认 logger 的 lumberjack writers--多 App 下那可能是另一个 App 的活跃 writer,致其后续写日志失败/丢失)。单 App 下旧默认为 Nop(无 writer,不泄漏);用户手动配置 logger 后再 `App.Init` 的旧 writer 由用户负责。
|
||||
|
||||
### 多 App 边界与已知缺口
|
||||
|
||||
本次实例化收尾覆盖 cron/storage/cache/ratelimit + trace 生命周期。多 App 进程下的边界:
|
||||
|
||||
- **包级限流 facade 仅绑定最后 Init 的 App**(middleware/ratelimit.go):`LoginRateLimit`/`APIRateLimit`/`UploadRateLimit`/`CustomRateLimit`(包级)在请求时解析 `GetDefaultRateLimitRegistry()`(全局默认),多 App 下仅最后 Init 的 App 是全局默认。**已提供 per-App 计数隔离路径**:`app.RateLimitRegistry().LoginRateLimit()` 等 App-bound 中间件捕获 App 自己的 Registry(见 Added)。包级 facade 仍为单 App / backcompat 保留。
|
||||
- **`CustomRateLimit` 首请求时机**:包级 `CustomRateLimit` 的 `sync.Once` 在首请求时登记 limiter 到全局默认 Registry,首请求若在 `App.Init` swap 前会泄漏 cleanup goroutine。**App-bound 路径 `app.RateLimitRegistry().CustomRateLimit(rate, window)` 已无此问题**(登记到 App 的 Registry,由 `registry.Stop()` 收口)。HTTP server 在 Init 后才起,常规用法不触发泄漏。
|
||||
- **trace 进程全局共享**(trace/trace.go,设计限制):OTel `TracerProvider`/`Propagator` 是进程级单例,任一 App `Shutdown` 调 `trace.Close` 会关闭全局 trace 导出,影响同进程其他 App。多 App 需自行管理 trace 生命周期(不在多个 App 上同时启用 `WithTrace`)。**不可修**(OTel 自身全局设计)。
|
||||
- **`StorageManager` 无 `Close`**(storage/storage.go,经核实:oss.Client 无 Close,当前为 no-op;已加 StorageManager.Close() 闭合生命周期(见 Added)):`LocalStorage` 无 closeable 资源;`OSSStorage` 持 `*oss.Client`,但 `oss.Client` **未暴露 `Close` 方法**(aliyun-oss-go-sdk),其 HTTP 空闲连接由 OS 超时 + GC 回收。无显式资源泄漏;未来带连接池的驱动实现 io.Closer 即被 Close 自动调用。
|
||||
|
||||
## [1.3.0] - 2026-07-12
|
||||
|
||||
> 同义可失败 API 收敛 + 注释/文档一致性修复 + 前序评审收口。
|
||||
>
|
||||
> **API 收敛**(`gpt_clear_function.md` 计划):按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,收敛 cache/jwt/storage/ratelimit 四个包的历史双轨 API。自定义 `CacheService`/`Storage` 实现需同步更新签名(编译失败即迁移信号)。
|
||||
>
|
||||
> **注释/文档一致性**(`glm_note_report.md` 报告):A/B/C 三档共 23 项注释/文档修复,无运行逻辑变更。
|
||||
>
|
||||
> **前序评审收口**:M13 cron panic、M1 App 生命周期状态机、M3 logger 生命周期临界区;config 模块评审(H-config-1 Set/viper 同源、M-config-1 回调 panic 隔离、M-config-2 DB SSL/TLS、M-config-3 App 关闭停 watcher、M-config-4 Clone 守卫);database 模块评审(H-db-1 后台探活/启动 ping 经 pingWithTimeout 3s 约束)。
|
||||
>
|
||||
> 项目处于初级阶段,无下游用户,放心引入破坏性变更。`go vet` + `go build` + `go test -race ./...` 全绿。
|
||||
|
||||
### Breaking ⚠️
|
||||
|
||||
- **PostgresDSN 默认 sslmode 由 `disable` 改为 `prefer`**(M-config-2):原 `PostgresDSN` 硬编码 `sslmode=disable`,生产 DB 流量明文。新增 `DatabaseConfig.SSLMode` 字段,空值默认 `prefer`(优先加密、失败回退明文),可显式配置 `disable/allow/prefer/require/verify-ca/verify-full`(非法值在 `Validate` 阶段报错)。依赖明文 Postgres 连接的下游若因 `prefer` 回退行为受影响,请显式设置 `ssl_mode: disable`。MySQL 不受影响(用 `TLS`/`TLSRootCA`)。
|
||||
|
||||
- **cache 写操作与计数器/原始 Redis helper 在 Redis 未初始化时返回 `ErrRedisNotReady`**:`Set` / `Delete` / `DeleteByPattern` / `Incr` / `IncrBy` / `Decr` / `GetTTL` / `SetExpire` / `GetRaw` / `SetRaw` 旧行为会静默返回成功或零值,调用方容易误判缓存写入、计数器更新或过期时间设置已经生效;现在统一显式返回错误。公共接口签名不变,但依赖“未启用 Redis 时当作成功”的下游需要改为忽略 `errors.Is(err, cache.ErrRedisNotReady)` 或显式启用 Redis。
|
||||
- **`cache.WithLock` / `cache.WithLockAutoExtend` 未获取到锁时返回 `ErrLockNotAcquired`**:旧行为返回 `nil` 并跳过业务函数,调用方无法区分“业务执行成功”和“根本没有执行”。同时锁 TTL 小于 1ms、续期/重试间隔非正会返回显式错误,避免 Redis PX=0 或 `time.NewTicker(0)` 崩溃。
|
||||
- **`cache.WithLock` / `cache.WithLockAutoExtend` 的业务函数签名改为 `func(context.Context) error`**:旧签名 `func() error` 无法强制业务函数接收取消信号,容易在请求取消/超时后继续访问 DB/HTTP 等下游资源;现在框架会把调用方 ctx 传入业务函数。nil 业务函数返回新增 `ErrLockFuncNil`。
|
||||
- **`test.Request.Execute()` 改为返回 `*test.Response`**:旧返回值是 `*httptest.ResponseRecorder`,与文档示例中的 `resp.AssertOK(t)` / `resp.ParseJSON(...)` 不一致;现在 `Execute()` 返回带断言和 JSON 解析方法的包装类型。需要原始 recorder 的调用方改用新增 `ExecuteRecorder()`。
|
||||
- **`config.Get()` / `(*config.Manager).Get()` 改为返回配置副本**:旧行为暴露内部 `*Config`,调用方修改返回值会污染全局配置并可能与热重载并发读写竞态;现在返回深拷贝。需要动态替换配置的测试或工具代码请改用 `config.Set(cfg)`。
|
||||
- **`config.Set()` / `(*config.Manager).Set()` 现在返回 `error`**:非 nil 配置会先执行 `Validate()`,非法配置不会覆盖旧配置,并返回 `ErrInvalidConfig` 包装错误。旧代码可以继续忽略返回值,但建议测试和启动路径显式检查。
|
||||
- **`config.GetViper()` / `(*config.Manager).GetViper()` 改为返回 viper 快照**:旧行为暴露内部可变 `*viper.Viper`;现在修改返回对象不会影响全局配置。常规读取请使用 `GetString` / `GetInt` / `GetBool` / `GetStringMap`。
|
||||
- **未知数据库 driver 不再静默回退 MySQL**:`config.DatabaseConfig.DSN()` 对非空但未注册的 `driver` 返回空字符串,`database.Dialector` 返回初始化即失败的 Dialector;空 `driver` 仍保持默认 MySQL。下游若使用自定义数据库驱动,需先通过 `database.RegisterDialect` 或 `config.RegisterDSNBuilder` 注册。
|
||||
- **`database.InitDB` / `(*database.Manager).InitDB` / `InitDBWithReplicas` 必须显式传入 `context.Context`**:旧 API 无法取消初始化过程中的 Ping 与重试等待,shutdown 或启动失败回滚时可能长时间卡住。现在调用方必须传入生命周期 ctx;普通测试或一次性脚本可使用 `context.Background()`,App 初始化会使用 App root ctx。
|
||||
- **`database.SetDefaultManager` / `database.SetDefaultRedisManager` 会关闭被替换的旧 manager**:旧行为只做 atomic 替换,直接调用会遗留旧 DB/Redis 连接池。现在普通 Set 表示“接管全局默认资源并释放旧资源”;需要失败回滚或延迟释放旧资源的初始化流程请改用新增 `database.SwapDefaultManager` / `database.SwapDefaultRedisManager`。
|
||||
- **`jwt.ParseToken` 开始校验 issuer,`RefreshToken` 使用 `jwt.refresh_expire`**:签发者与当前配置不一致的 token 会被拒绝;刷新后的 token 过期时间优先使用 `refresh_expire`,未配置时回退 `expire`。`GenerateTokenWithCustomExpiry` 现在拒绝非正过期时间,`InvalidateTokenByID("")` 返回 `ErrEmptyJTI`。
|
||||
- **`App.Init()` 由 `sync.Once` 改为生命周期状态机**(app.go,M1):5 态 `stateCreated/Initializing/Initialized/Stopping/Stopped` + `lifecycleMu`(RWMutex) + `initMu`(Mutex)。`Shutdown` 后或 `Init` 失败后再调 `Init()` 返回新增导出错误 **`xlgo.ErrAppClosed`**(原 `sync.Once` "多次调用返回首次结果"语义不再适用——已关闭的 App 不可再 Init,需新建 App)。
|
||||
- **`App.Go()` 在 Shutdown 开始或 Init 失败后为 no-op**(app.go,M1):`state >= stateStopping` 时拒绝 `wg.Add` 直接返回,避免与 `Shutdown` 的 `wg.Wait` 竞争 `sync.WaitGroup` 契约(Add 须 happen-before Wait)。依赖"Shutdown 后仍可 Go"的下游需改用独立 goroutine。
|
||||
- **生命周期 hook 不允许重入调用 `Init`/`Shutdown`/`Run`**(app.go,M1):`initMu` 非重入,hook(OnInit/OnStart/OnReady/OnStop)内调用会自锁死锁。需在 hook 内触发关闭应改用信号通道由主流程处理。
|
||||
- **`xlgo.WithConfig(cfg)` 改为快照语义并在 `Init` 时校验**(app.go,M1):传入配置会深拷贝到 App 私有快照,调用方后续修改原 `cfg` 不再影响 App;非法配置在 `Init` 返回中文校验错误。依赖“修改原 cfg 指针动态影响 App”的下游需改为重新创建 App 或使用配置管理器。
|
||||
- **`logger.DefaultLogger = m` 直接赋值不再驱动包级 facade**(logger/logger.go,M3):为消除 `SetDefaultLogManager()` 与包级 facade 并发读取默认 manager 的裸全局指针竞态,facade 改为读取内部 atomic 快照。`logger.DefaultLogger` 仍保持 `*LogManager` 类型,旧的 `logger.DefaultLogger.Init/Close/SetLevel/GetLevel` 直接调用仍可用;替换默认 manager 请使用 `logger.SetDefaultLogManager(m)`。
|
||||
- **`logger.Init` 拒绝明显非法日志配置**(logger/logger.go,M3):空日志目录、负数 `MaxSize/MaxBackups/MaxAge` 现在直接返回错误。依赖零值日志配置启动 `WithLogger()` 的下游需显式设置 `Log.Dir` 与非负轮转参数。
|
||||
- **限流器非法配置改为 fail-fast**(middleware/ratelimit.go,M8):`NewRateLimiter` / `NewRedisRateLimiter` / `NewRedisRateLimiterFailClosed` 现在对 `rate <= 0` 或 `window <= 0` 直接 panic,避免零值窗口/零值配额静默产生不确定限流语义。下游应在配置加载阶段校验限流参数。
|
||||
- **`handler.BindJSON` 默认限制 JSON body 为 1MiB**(handler/handler.go,M6):防止入口层无上限读取请求体导致 OOM。需要更大 JSON 的接口请改用 `handler.BindJSONWithMaxBytes(c, req, maxBytes)` 显式声明上限。
|
||||
- **cron 非法任务配置改为 fail-fast**(cron/cron.go,M13):`AddTask` 拒绝 nil schedule / nil handler;`Every(<=0)`、`Daily`/`Weekly` 越界时间、非法 weekday 会 panic,避免静默生成不推进或归一化跑偏的调度。
|
||||
- **`cron.ParseCron` 非法表达式改为 fail-fast panic**(cron/cron.go,M13):旧行为会把非法表达式静默回退为每分钟执行,容易让拼写错误变成高频任务。动态输入请用 `ParseCronStrict` 处理 error;确实需要旧回退语义时改用新增 `ParseCronOrDefault`。
|
||||
- **repository 查询保护默认开启**(repository/repository.go,M5/N5):`FindAll` 默认最多返回 `DefaultFindAllLimit=1000` 条;明确需要全表扫描时改用 `FindAllUnbounded`。`FindPage*` / `QueryBuilder.Page` 会归一化 `page/pageSize` 并限制 `MaxPageSize=100`、`MaxPage=10000`。`Find*Ordered` / `QueryBuilder.Order` 只接受简单字段排序(如 `created_at DESC, id ASC`),复杂表达式/raw SQL 会返回 `ErrUnsafeOrder`;`UpdateBatch` 字段名不合法返回 `ErrUnsafeField`。
|
||||
|
||||
- **同义可失败 API 收敛:错误不再吞并**(cache/jwt/storage/ratelimit):按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,收敛历史双轨 API。详见下述分项;自定义 `CacheService`/`Storage` 实现需同步更新签名(编译失败即迁移信号)。
|
||||
|
||||
- **cache**:`CacheService.Get` / `Exists` 改为 `(bool, error)`--命中 `(true,nil)`、未命中 `(false,nil)`、Redis 未就绪/命令错误/反序列化失败返回 `(false,err)`。删除 `cache.GetE` / `cache.ExistsE` / `CacheGetter` / `CacheExistChecker` / `redisCache.GetE` / `redisCache.ExistsE`(不保留 deprecated wrapper)。新增包级 `cache.Get(ctx,key,dest) (bool,error)` / `cache.Exists(ctx,key) (bool,error)`。`GetWithPrefix` 改为 `(bool,error)`。迁移:`hit, err := cache.Get(ctx, key, &v); if err != nil { return err }; if !hit { /* miss */ }`。
|
||||
|
||||
- **jwt**:`TokenBlacklist.IsBlacklisted` 改为 `(bool, error)`,删除 `IsBlacklistedE`。`IsTokenRevoked` 改为 `(bool, error)`。`ParseToken` 默认从 **fail-open 改为 fail-closed**--黑名单后端不可检查时返回 `ErrBlacklistUnavailable` 拒绝该 Token(无 Redis 部署不再支持可靠撤销)。删除 `ParseTokenFailClosed`(主 API 已 fail-closed)。保留 `ParseTokenWithBlacklistPolicy(token, policy)` + `BlacklistPolicy`/`BlacklistFailOpen`/`BlacklistFailClosed` 供显式 fail-open(仅无 Redis 或低安全场景)。迁移:无 Redis 部署需启用 Redis,或显式 `jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen)`。
|
||||
|
||||
- **storage**:`Storage.Exists` 改为 `(bool, error)`--存在 `(true,nil)`、不存在 `(false,nil)`、未初始化/路径非法/穿越/后端错误返回 `(false,err)`。`LocalStorage.Exists` 区分 `os.IsNotExist`(not found)与其他 `os.Stat` 错误;`OSSStorage.Exists` 区分 OSS 404/NoSuchKey(not found)与鉴权/网络错误。包级 `storage.Exists` 同步签名,未初始化返回 `ErrStorageNotInitialized`。新增 `ErrReadTooLarge`:`LocalStorage.Get` / `OSSStorage.Get` 读取超 `maxReadBytes` 上限原误用 `ErrInvalidPath`(路径无效语义不贴切),改为 `ErrReadTooLarge`。迁移:`ok, err := storage.Exists(p); if err != nil { return err }; if !ok { /* not found */ }`。
|
||||
|
||||
- **ratelimit**:Redis 限流器策略收敛为配置型 API。`NewRedisRateLimiter(keyPrefix, rate, window, opts ...RedisRateLimiterOption)` 新增可变参数,`WithFailClosed(true)` 替代原 `NewRedisRateLimiterFailClosed`。`RedisRateLimit` / `CustomRedisRateLimit` / `RedisRateLimitWithIdentifier` 同步加 `opts` 参数。删除 `NewRedisRateLimiterFailClosed` / `RedisRateLimitFailClosed` / `CustomRedisRateLimitFailClosed`。`UploadRedisRateLimit` 由 fail-open 改为 **fail-closed**(上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效)。迁移:`middleware.RedisRateLimit("k", 100, middleware.WithFailClosed(true))` 替代原 `RedisRateLimitFailClosed("k", 100)`。
|
||||
|
||||
### Security 🔒
|
||||
|
||||
- **MySQL 连接支持 TLS**(M-config-2):`DatabaseConfig.TLS` 为 true 时 `MySQLDSN` 追加 `tls=true`(go-sql-driver/mysql v1.7.0 内置安全语义:系统根 CA + ServerName 自动取自 Host + 证书校验,无需注册)。配合 `DatabaseConfig.TLSRootCA`(PEM 路径)可指定私有 CA/自签证书,由 `database` 包在 `InitDB` 时 `RegisterTLSConfig` 注册命名配置(`config.MySQLTLSConfigName`);CA 不可读或非 PEM 时 fail-fast,不静默回退明文。
|
||||
- **Postgres 连接支持 sslmode 配置**(M-config-2):见 Breaking 项,默认 `prefer` 优先加密。
|
||||
|
||||
### Fixed 🐛
|
||||
|
||||
- **config `Set(cfg)` 与 viper 视图同源修复**(H-config-1):`Set` 原只更新类型化视图 `m.cfg`,`m.v`(viper)停留旧值,导致 `Get()` 与 `GetString/GetInt/GetBool/GetViper` 返回不同世界(违反 C1 单一配置源)。现在 `Set` 用 mapstructure 将 `*Config` 重建为不含 `AutomaticEnv` 的 viper 视图,保证 Get 与 GetString 同源。
|
||||
- **config 热重载回调 panic 隔离**(M-config-1):单个 `onChange` 回调 panic 原会传播致 watcher 泄漏、后续热更新静默失效。现在每个回调独立 `recover`(标准库 `log` 记录 + 堆栈),不阻断后续回调、不杀 watcher。
|
||||
- **App.Shutdown 停止 configManager watcher**(M-config-3):`closeResources` 末尾新增 `configManager.StopWatcher()`,用户对 App 的 configManager 调 `LoadWithWatch`/`StartWatcher` 后由 Shutdown 统一收口,避免关闭后遗留监听 goroutine(违反 C7)。
|
||||
- **config `Clone` 切片字段覆盖守卫**(M-config-4):新增反射测试枚举 `Config` 所有切片/map 字段,断言 `Clone` 深拷贝;新增切片字段未同步 fixture 或 `Clone` 时测试失败,形成机械守卫。
|
||||
- **config `watchLoop` 增加 ctx 逃生通道**(L-config-2):原仅靠 `w.Events` 关闭退出,与"for 消费循环须 ctx.Done"红线有张力。`StopWatcher` 改为 cancel ctx + Close watcher 双重退出。
|
||||
- **config `Validate` 连接池交叉校验**(L-config-3):`MaxOpenConns>0` 时 `MaxIdleConns>MaxOpenConns` 视为配置错误。
|
||||
- **config 哨兵错误改用 `errors.New`**(L-config-4);**`DSN()` 去除冗余 TrimSpace**(L-config-5);**`DSN/MySQLDSN/PostgresDSN/Addr` nil receiver 防御**(L-config-6)。
|
||||
- **database 后台探活/启动 ping 经 pingWithTimeout 3s 约束**(H-db-1,M11 修复不完整):`pingWithTimeout` 原只加到包级 `HealthCheck()`,未覆盖方法 `(*Manager).HealthCheck`(被后台探活 `probeOnce` master 与 `/health` 端点共用)、`probeOnce` 从库 ping、`InitDB`/`InitDBWithReplicas` 启动 ping。挂起 DB(连接活但不响应)下这些路径的 `PingContext` 无 ctx deadline 无限阻塞,致探活 goroutine 阻塞、#21 自愈冻结、`IsHealthy()` 缓存失真、启动卡死。现 4 路径统一经 `pingWithTimeout`(`healthCheckTimeout`=3s,尊重 ctx 自带更短 deadline)。**另发现并修复 gorm.Open 的 automatic ping**(gorm.go:204,ConnPool 为 `*sql.DB` 时调 `pinger.Ping()` 无超时)在 pingWithTimeout 之前就无限阻塞--`initDB`/`InitDBWithReplicas` 的 gormConfig 加 `DisableAutomaticPing: true`,框架用 pingWithTimeout 自管启动 ping。Redis 侧 `HealthCheck` 经 client `ReadTimeout`(3s) 约束(行为验证)。新增 `manager_hdb1_internal_test.go`(hungDriver 回归 pingWithTimeout/m.HealthCheck/probeOnce replica/initDB/InitDBWithReplicas 5 路径)、`redis_hdb1_internal_test.go`(挂起 Redis 回归 HealthCheck)、`app_hdb1_test.go`(App 级端到端:Init 挂起 DB 有界失败 + Shutdown 正常)。
|
||||
|
||||
- **M9 JWT issuer / refresh expiry 契约修复**:`ParseToken`、`InvalidateToken`、`GetClaimsFromToken` 统一按当前配置校验 issuer;`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键;新增 `ParseTokenFailClosed` / `ParseTokenWithBlacklistPolicy` / `TokenBlacklist.IsBlacklistedE`,默认 `ParseToken` 仍保持黑名单检查 fail-open 兼容语义,安全敏感路由可显式选择 fail-closed;解析侧配置错误现在可通过 `errors.Is` 区分 `ErrEmptySecret` / `ErrUnsupportedAlgorithm`;`InvalidateToken` 使用不校验时序的解析路径,允许提前吊销 `nbf` 在未来的外部 token。
|
||||
- **M10 分布式锁参数与取消传播修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop;`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic;`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady`;`WithLock` / `WithLockAutoExtend` 现在把调用方 ctx 传入业务函数,避免取消后业务函数继续运行。
|
||||
- **M10 cache 剩余错误语义收口**:新增 `cache.GetE` / `cache.ExistsE` 与可选 `CacheGetter` / `CacheExistChecker`,让调用方能区分 cache miss、Redis/backend 故障和反序列化错误;保留旧 `Get` / `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option,`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。
|
||||
- **M2 config 热重载生命周期修复**:`StopWatcher` 会等待已触发的 reload/回调结束;包级 `Load` / `LoadWithWatch` 只有在新配置成功加载并启动 watcher 后才替换默认 manager,失败时保留旧 watcher;`SetDefaultManager` 会停止旧 manager 的 watcher,避免全局置换后遗留 goroutine;数据库配置出现字段时会校验 driver/host/name/port,未知 driver 不再静默回退 MySQL,`database.Dialector` 对未知 driver fail-closed;MySQL DSN 转义用户名/库名,Postgres DSN 统一转义字符串字段。
|
||||
- **M4 database nil 边界修复**:`InitDB(ctx, nil)` / `InitDBWithReplicas(ctx, nil, ...)` / `InitRedis(nil)` 现在返回中文错误,不再空指针 panic;`UseMaster(nil)` / `UseReplica(nil)` / `GetDBFromContext(nil)` / `WithTx(nil, ...)` / `TxFromContext(nil)` / `TransactionWithContext(nil, ...)` / `ReadQuery(nil, ...)` / `WriteQuery(nil, ...)` / Redis health check 会把 nil context 归一化为 `context.Background()`,避免异常调用路径触发 panic;`Dialector(nil)` 安全回退到 MySQL 空 DSN。
|
||||
- **M4 database 全局置换资源释放修复**:`SetDefaultManager` / `SetDefaultRedisManager` 替换全局默认 manager 时会关闭旧 DB/Redis manager,避免包级默认资源反复置换后连接池泄漏;App 初始化改用 `SwapDefaultManager` / `SwapDefaultRedisManager` 暂存旧资源,保证失败回滚仍能恢复旧默认资源。
|
||||
- **M4 database 初始化生命周期修复**:DB 初始化与主从库初始化统一接收 ctx,主库/从库 Ping 使用 `PingContext`,重试等待改为 `select ctx.Done()/time.After`;`Manager` 用生命周期锁串行化 Init/Close,避免 shutdown 与运行期重建交错;运行期重建从库后会立即重建健康标记,探活循环也能在发现健康标记缺失时自愈;包级 `HealthCheck()` 固定读取一次默认 manager 快照。
|
||||
- **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。
|
||||
- **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost;`ValidateStruct(nil)` 直接返回 nil。
|
||||
- **M15 utils 转换语义收口**:新增 `utils.ToIntE` / `utils.ToInt64E` 返回解析错误;旧 `ToInt` / `ToInt64` 保留“失败返回 0”的兼容行为,调用方在 0 有业务含义时应迁移到严格变体。
|
||||
- **M16 测试工具、脚手架与示例闭环修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache` 与 `MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码;`examples/full` 启动时初始化 `alice/secret`,登录校验 bcrypt 哈希,创建用户也保存哈希,避免示例首次运行无法登录或传播不验密/明文密码模式;README/GUIDE 限流示例不再引用不存在的 `handler.Login` / `handler.Upload`。
|
||||
- **M16 GUIDE/test API 不一致修复**:GUIDE 测试示例不再调用不存在的 `AssertCode` / `AssertJSONKeyExists`,统一改用现有 `AssertJSONContains`,避免照文档编写测试直接编译失败。
|
||||
- **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。
|
||||
- **M8 middleware 边界收口**:`AuthRequired` 的 `Authorization` scheme 改为大小写不敏感,符合 Bearer 语义;`LoggerConfig.SkipPathPrefixes` 与 `SimpleLogger` 静态路径跳过改为路径边界匹配(`/api` 仅匹配 `/api` 和 `/api/...`,不再误跳过 `/api2`)。
|
||||
|
||||
- **M13 cron handler panic 未 recover 崩进程**(cron/cron.go):`RunTask` 与 `checkAndRun` 调度 goroutine 统一经新增 `executeTask(t)` 边界 `recover`,panic 转为 error(含 `debug.Stack` 调用栈)记入 `task.LastError` 并向上返回,不再终止进程。外侧 `defer wg.Done()`/`running` 守卫释放不受影响(recover 在边界内完成)。顺带修复 `RunTask` 手动路径此前只更 `LastRun/RunCount`、不记 `LastError` 的子问题(现与调度路径一致)。
|
||||
- **M6 response/handler 入口防护**(response/error.go,response/response.go,handler/handler.go):`FailWithError(nil)` / `FailWithDetail(nil, ...)` 回退统一服务器错误响应,不再 nil deref panic;新增 `response.DownloadReader` 支持大文件/对象存储流式下载,旧 `Download` / `DownloadWithContentType` 保持兼容并复用同一响应头逻辑。
|
||||
- **M14 trace 剩余记录补齐**(trace/trace.go):`SampleRatio` 拒绝 NaN/越界值;`Init`/`Close` 使用操作超时并在初始化失败时回滚 provider;`RecordError` / `RecordErrorToSpan` 对 nil 输入 no-op;`Middleware(serviceName)` 写入 `service.name` attribute;`X-Trace-ID` 不再输出全零 TraceID。
|
||||
- **M7 health/readiness 探活超时**(router/router.go):`HealthCheck` 新增 `Timeout` 字段,默认每个依赖检查 2s 超时;超时项返回 `"timeout"` 并使 `/health` / `/readyz` 返回 503。单个 check 同时最多一个执行中,panic 会 recover 为错误,避免 k8s/LB/监控探活被挂死依赖无限卡住或无限堆积 goroutine。
|
||||
- **M13 cron 剩余边界收口**(cron/cron.go):Stop 后再次 Start 会重建调度器 context,手动和调度执行不再收到已取消 ctx;Start/Stop 生命周期串行化,避免 Stop 等待期间重新 Start 触发 WaitGroup Add/Wait 交错。
|
||||
- **M5 repository 安全边界收口**(repository/repository.go):nil ctx 统一按 `context.Background()` 处理;`FindByIDs(nil)` 返回非 nil 空切片;`NewQueryBuilder` 复用 nil DB 明确 panic;批量空 ids 写操作 no-op;默认 `FindAll` 加上限并新增 `FindAllUnbounded`;排序/字段名白名单避免便捷 API 误接 raw SQL。
|
||||
- **M1 App 生命周期三类缺陷统一治理**(app.go):
|
||||
- **Init 失败无资源回滚** → 新增 `failAfterInit`:markStopping → cancel rootCtx → wg.Wait(10s) → cron 5s 显式超时停止 → `closeResources` 幂等关闭 db/redis/logger,回滚错误 `errors.Join` 进 `initErr` 不吞;先停 goroutine 再关资源,避免"关 DB 时探活 goroutine 仍在用"的竞态。
|
||||
- **App.Go 与 wg.Wait race** → `lifecycleMu.RLock` 包住 `wg.Add`,`Shutdown` 持写锁翻 `stateStopping`,保证 Add happens-before Wait。
|
||||
- **Shutdown 非幂等/非并发安全** → `shutdownOnce` 保证 `doShutdown` 单次执行,并发调用者返回同一 `shutdownErr`。
|
||||
- **OnStop 语义与超时** → 仅 `Init` 曾成功(`wasInitialized`)时在 `doShutdown` 开头执行;Init 失败/未 Init 时 HTTP 从未启动,OnStop 跳过。OnStop 现在受 `server.shutdown_timeout` 同一预算约束,阻塞 hook 不再无限拖住 Shutdown。
|
||||
- **资源所有权** → App 只关闭自己成功初始化过的 logger/db/redis/cron,避免一个 Init 失败的新 App 关闭同进程既有全局资源。
|
||||
- **复审补强:资源替换事务边界** → App 初始化 logger/db/redis 时先创建 App-owned manager 并保存旧默认 manager 快照;OnInit 或后续步骤失败时恢复旧默认 manager 并关闭新资源,完整成功后才释放旧资源,避免“新 App Init 失败”破坏同进程既有全局 logger/db/redis。
|
||||
- **复审补强:health/probing 绑定 App-owned manager** → App 注册的 MySQL/Redis health check 与 DB probing 使用本 App 持有的 manager,不随后续全局默认 manager 替换漂移。
|
||||
- **OnReady 早于真实监听成功** → `StartServer` 改为同步 `net.Listen` 且 TLS 证书装配成功后再启动 `Serve` 与执行 OnReady;监听/TLS 失败直接返回,不触发 ready 副作用。
|
||||
- **`server.unix_socket` 不可用** → 非空 `unix_socket` 现在走 `net.Listen("unix", path)` + `http.Server.Serve`,不再把 socket path 误传给 TCP `ListenAndServe`。
|
||||
- `Init`/`Shutdown` 经 `initMu` 串行化 doInit/doShutdown 长段,杜绝并发改资源。
|
||||
- **M3 logger 生命周期与全局 manager 并发治理**(logger/logger.go):
|
||||
- **`LogManager.Init()` 锁外写 `m.level`** → `Init` 在通过局部配置校验后持 `m.mu` 完成建目录、构造与发布新 logger,`m.level` 只在同一临界区更新;包级 `Logger/fileWriters` 发布另由 `globalMu` 串行化,避免多个 `LogManager` 实例用各自实例锁保护同一包级状态。
|
||||
- **`DefaultLogger` 裸全局指针** → 保留导出变量兼容旧代码,新增内部 atomic 默认 manager 快照与 `GetDefaultLogManager()`;包级 `Init/Close/Sync/SetLevel/GetLevel` 全部经 atomic 读取当前 manager。
|
||||
- **stale manager 关闭当前 logger** → 每次发布全局 logger 分配 generation,只有拥有当前 generation 的 `LogManager.Close()` 才能关闭当前全局 logger/writer。
|
||||
- **旧 writer 关闭顺序错误** → `Init` 先 atomic 发布新 logger,再关闭旧 lumberjack writer,避免替换窗口内包级读路径拿到指向已关闭 writer 的旧 logger。
|
||||
- **`Close()` 与 `Init()` 生命周期互相覆盖** → `Close` 在同一 manager 锁内先快照旧 logger/writer 并发布 Nop,再执行 Sync/Close;关闭错误通过 `errors.Join` 聚合返回,不再静默吞掉 lumberjack `Close` 错误。
|
||||
- **M8 middleware/CSRF 与限流边界治理**(middleware/csrf.go,middleware/ratelimit.go):
|
||||
- **CSRF JSON body 读取无上限** → 从 body 提取 `_csrf` 时使用 `http.MaxBytesReader`,默认上限 1MiB(`CSRFConfig.MaxBodyBytes` 可调),超限返回 HTTP 413,避免 pre-auth OOM;仍使用 `ShouldBindBodyWith` 保留下游重复读取 body 的能力。
|
||||
- **CSRF cookie SameSite 配置未真正写入** → `CSRF()` 与 `DoubleSubmitCookie()` 设置 cookie 前显式 `SetSameSite`,默认 Lax。
|
||||
- **CSRF 局部配置丢默认 cookie 行为** → `CSRFConfig` 归一化补齐 `Path`、`SameSite`、`MaxAge`、`FormField`、`MaxBodyBytes` 等默认值,只覆盖单个字段时不再意外丢失默认 cookie 约束。
|
||||
- **CSRF session cookie 显式配置** → 新增 `CSRFConfig.SessionCookie`,需要会话 Cookie 时设置为 `true`;为保持 `CSRFWithConfig(CSRFConfig{})` 默认 1 小时语义,`MaxAge=0` 且未设置 `SessionCookie` 时仍回退默认值。
|
||||
- **CSRF TokenLength 负数 panic/退化** → `TokenLength <= 0` 统一回退默认长度,避免配置错误导致 `make([]byte, negative)` panic。
|
||||
- **CSRF skip path 前缀误跳过** → `CSRFWithSkip([]string{"/api"})` 仅跳过 `/api` 与 `/api/...`,不再误跳过 `/apix`。
|
||||
- **API CSRF token map 只校验时清理过期 token** → `GenerateAPIToken` 颁发新 token 前同步清理过期项,避免长期只发不验场景内存增长。
|
||||
- **`RateLimit(nil)` panic** → 改为 fail-closed 返回 HTTP 503 + `CodeServiceUnavailable`,避免未初始化限流器导致请求路径 nil deref。
|
||||
- **`RedisRateLimitWithIdentifier` nil 回调 panic** → `identifierFunc == nil` 时回退 `ClientIP()`,并保留空字符串回退逻辑。
|
||||
- **gosec database 告警收口**(database/manager.go,database/redis.go):`RoundRobinPicker` 改为 mutex 保护的有界 `int` 计数器,消除 G115 整数转换告警;`RandomPicker` 改用 `crypto/rand` 选择从库,消除 G404 弱随机源告警,随机源失败时安全回退到首个从库;Redis 初始化 ping 失败时不再静默吞掉 `client.Close()` 错误。
|
||||
- **复审补强:DB/Redis 重建路径资源释放**(database/manager.go,database/redis.go):Redis 重复初始化会关闭旧 client;DB 重建/重试路径的旧连接池关闭失败不再静默丢弃,会记录 warning 供排查。
|
||||
- **gosec Close 错误收口**(utils/http.go,compress/compress.go):HTTP multipart 上传循环中的本地文件 `Close()` 错误不再静默吞掉;gzip/zip 解压输出文件关闭失败会通过 `errors.Join` 返回,并触发残留目标文件清理。
|
||||
- **gosec 兼容性/误报标注收口**(cmd/xlgo,utils/http.go,utils/crypto.go,utils/file.go,compress/compress.go):为已存在输入校验或明确调用方契约的路径、客户端请求 cookie、非安全用途 checksum、压缩源路径遍历、兼容模式 HTTP 请求添加精确 `#nosec` 理由;`NewSSRFSafeHTTPClient` / `BlockPrivateNetworks` 仍是处理不可信 URL 的推荐入口。
|
||||
- **示例参数解析错误处理**(examples/full/main.go):示例用户详情接口现在检查 `fmt.Sscanf` 错误,非法用户 ID 返回失败响应,不再静默使用零值。
|
||||
|
||||
## [1.2.0] - 2026-07-04
|
||||
|
||||
@@ -38,7 +200,7 @@ _暂无未发布变更。_
|
||||
- **`handler.GetPage` 加 page 上限 10000**(handler/handler.go,M-D):防 `?page=999999999` 产生超大 OFFSET 拖垮 DB(深分页 DoS)。超过上限钳制到 `MaxPage`,需更深遍历应改游标/keyset 分页。
|
||||
- **`utils.EqualsIgnoreCase` 改 `strings.EqualFold`**(utils/strings.go,L-C):原仅 ASCII 字节折叠,非 ASCII(如 `É`/`é`)误判为不等。现 Unicode 大小写折叠,行为更正确。
|
||||
- **`utils.ReadFile` 去 `FileExists` 前置检查**(utils/file.go,M-F):消除 TOCTOU 竞态,直接 `os.ReadFile`。文件不存在返 `*os.PathError`,调用方改用 `errors.Is(err, os.ErrNotExist)` 判断(原字符串 `"file not found"` 不再返回)。
|
||||
- **`database.DefaultManager` 类型变更**(database/manager.go,主线A 收口):由 `*Manager` 改为 `atomic.Pointer[Manager]`,消除原裸指针(外部直接 `database.DefaultManager = ...` 赋值与请求 goroutine 经 facade 读取)的数据竞争,与 `database.DefaultRedis`/`storage.DefaultStorage`/`cache.defaultCachePtr`/`jwt.defaultManager`/`config.defaultManager` 对齐——框架内包级可变全局一律 atomic.Pointer。新增 `database.SetDefaultManager(m)`(atomic.Store,并发安全)与 `database.GetDefaultManager()`(atomic 读取)。下游若直接调用 `DefaultManager.Init/Master` 等方法需改用 `InitDB`/`GetDB` 等 facade,或 `DefaultManager.Load().Init(...)`,或经 `GetDefaultManager()` 取实例;原 `database.DefaultManager = myDB` 直接赋值改为 `database.SetDefaultManager(myDB)`。
|
||||
- **`database.DefaultManager` 类型变更**(database/manager.go,主线A 收口):由 `*Manager` 改为 `atomic.Pointer[Manager]`,消除原裸指针(外部直接 `database.DefaultManager = ...` 赋值与请求 goroutine 经 facade 读取)的数据竞争,与 `database.DefaultRedis`/`storage.DefaultStorage`/`cache.defaultCachePtr`/`jwt.defaultManager`/`config.defaultManager` 对齐——框架内包级可变全局一律 atomic.Pointer。新增 `database.SetDefaultManager(m)`(并发安全,替换后关闭旧 manager)、`database.SwapDefaultManager(m)`(并发安全,返回旧 manager 且不关闭)与 `database.GetDefaultManager()`(atomic 读取)。下游若直接调用 `DefaultManager.Init/Master` 等方法需改用 `InitDB`/`GetDB` 等 facade,或 `DefaultManager.Load().Init(...)`,或经 `GetDefaultManager()` 取实例;原 `database.DefaultManager = myDB` 直接赋值改为 `database.SetDefaultManager(myDB)`。
|
||||
|
||||
### Fixed 🐛
|
||||
|
||||
@@ -223,7 +385,7 @@ _暂无未发布变更。_
|
||||
- **C12b 无重叠守卫**:`checkAndRun` 每秒 tick,长任务跨 tick 被反复 spawn 同一任务并发执行。新增 per-task `running *atomic.Bool` 守卫,`checkAndRun` 与 `RunTask` 均经 `CompareAndSwap(false,true)` 占用,正在执行则跳过/返错。
|
||||
- **C12c Interval 漂移**:`NextRun` 原在 handler 完成后以 `time.Now()` 锚定,每周期累积 handler 时长。改为 `checkAndRun` spawn 前 `task.NextRun = task.Schedule.Next(task.NextRun)`(以上次 `NextRun` 锚定),`runTask`/`RunTask` 不再更新 `NextRun`。
|
||||
- **C12d Weekly 跳周**:原 `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻,当天目标未到点被跳一周。重写为 `((day-now)+7)%7` 加天数后 `!next.After(now)` 才 +7,当天未到点返回本周、已过返回下周。
|
||||
- **C12e cron 解析缺陷**:`parseInt` 忽略非数字逐位累积,`1-5,8` 因先判 `-` 被当范围(`parseInt("5,8")=58`)、`garbage`→0 误触发、`*/garbage`→step=0 匹配全部、周日 `7` 不匹配。重写 `matchField`:列表分支独立于范围分支(先按逗号拆,每项判 `*/n`/`a-b/n`/`a-b`/单值),全用 `strconv.Atoi` 返错;weekday `7→0`,范围 `lo>hi` 环绕;歧义范围 `0-7`/`7-0` 拒绝。新增 `ParseCronStrict(expr) (*FullCronSchedule, error)` 严格校验;`ParseCron` 保留原签名,非法回退默认全 `*`。
|
||||
- **C12e cron 解析缺陷**:`parseInt` 忽略非数字逐位累积,`1-5,8` 因先判 `-` 被当范围(`parseInt("5,8")=58`)、`garbage`→0 误触发、`*/garbage`→step=0 匹配全部、周日 `7` 不匹配。重写 `matchField`:列表分支独立于范围分支(先按逗号拆,每项判 `*/n`/`a-b/n`/`a-b`/单值),全用 `strconv.Atoi` 返错;weekday `7→0`,范围 `lo>hi` 环绕;歧义范围 `0-7`/`7-0` 拒绝。新增 `ParseCronStrict(expr) (*FullCronSchedule, error)` 严格校验;当前 Unreleased 已进一步将 `ParseCron` 非法输入改为 fail-fast panic,旧版回退默认全 `*` 的兼容语义迁移到 `ParseCronOrDefault`。
|
||||
- 无 API 签名变更(`ParseCron` 仍返 `*FullCronSchedule`,`AddTask`/`RunTask`/`GetTask`/`ListTasks` 签名不变);新增 `ParseCronStrict`(非 breaking)。`Task` 新增未导出 `running` 字段(外部不可构造)。行为变更:`GetTask`/`ListTasks` 返回拷贝(修改返回值不影响内部状态);`RunTask` 占用守卫期间再次调用返"任务正在执行中"错误;长任务不再重叠;调度不漂移;Weekly 当天未到点不再跳周;cron 解析拒绝非法表达式。
|
||||
|
||||
#### C13:`trace/trace.go` opt-in 即崩 + 未实现导出器/传播器 + Middleware 不更新 c.Request(trace/trace.go)
|
||||
@@ -667,8 +829,8 @@ database.InitMySQL(cfg)
|
||||
database.InitMySQLWithReplicas(cfg, replicas)
|
||||
|
||||
// ✅ 新(驱动由 cfg.Database.Driver 决定,可以是 mysql / postgres / 自定义注册的方言)
|
||||
database.InitDB(cfg)
|
||||
database.InitDBWithReplicas(cfg, replicas)
|
||||
database.InitDB(ctx, cfg)
|
||||
database.InitDBWithReplicas(ctx, cfg, replicas)
|
||||
```
|
||||
|
||||
**为什么现在动手**:
|
||||
|
||||
@@ -334,6 +334,8 @@ mgr.SetPicker(&database.RoundRobinPicker{})
|
||||
db := mgr.FromContext(ctx)
|
||||
```
|
||||
|
||||
`database.SetDefaultManager(m)` 会把新 manager 提升为全局默认,并关闭被替换的旧 manager,避免连接池泄漏。需要失败回滚或延迟释放旧资源时,请使用 `database.SwapDefaultManager(m)` 并自行关闭其返回的旧 manager。
|
||||
|
||||
### 4.1.3 注册自定义 GORM 驱动
|
||||
|
||||
`database.RegisterDialect` 一次注册即让 `database.driver: <name>` 生效,DSN 构建器同步登记到 `config` 包:
|
||||
@@ -511,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"
|
||||
|
||||
@@ -527,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 {
|
||||
// 缓存命中
|
||||
}
|
||||
|
||||
@@ -539,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)
|
||||
@@ -560,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 分布式锁(安全增强版)
|
||||
@@ -582,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
|
||||
})
|
||||
@@ -772,9 +785,11 @@ response.FailWithDetail(c, response.ErrPasswordWrong, "连续错误3次将锁定
|
||||
```go
|
||||
// 文件下载
|
||||
response.Download(c, "report.xlsx", fileData)
|
||||
// 大文件/对象存储流式下载优先使用 DownloadReader
|
||||
|
||||
// HTML响应
|
||||
response.HTML(c, "<html>...</html>")
|
||||
// HTML 会原样输出;只传入可信或已清洗的 markup
|
||||
|
||||
// 页面跳转
|
||||
response.Redirect(c, 302, "https://example.com")
|
||||
@@ -894,16 +909,26 @@ 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次
|
||||
@@ -912,13 +937,13 @@ r.Use(middleware.CustomRateLimit(50, time.Minute)) // 每分钟50次
|
||||
r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次(fail-open:Redis 故障时放行)
|
||||
r.Use(middleware.LoginRedisRateLimit()) // 登录限流(fail-closed:Redis 故障时拒绝,防爆破)
|
||||
r.Use(middleware.APIRedisRateLimit()) // API限流(fail-open)
|
||||
r.Use(middleware.UploadRedisRateLimit()) // 上传限流(fail-open)
|
||||
r.Use(middleware.UploadRedisRateLimit()) // 上传限流(fail-closed:资源敏感,Redis 故障时拒绝)
|
||||
|
||||
// 自定义 Redis 限流
|
||||
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open
|
||||
r.Use(middleware.CustomRedisRateLimitFailClosed("sensitive", 50, time.Minute)) // fail-closed(安全场景)
|
||||
// 自定义 Redis 限流(默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed)
|
||||
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open
|
||||
r.Use(middleware.CustomRedisRateLimit("sensitive", 50, time.Minute, middleware.WithFailClosed(true))) // fail-closed(安全场景)
|
||||
|
||||
// 自定义标识限流(如按用户ID)
|
||||
// 自定义标识限流(如按用户ID,同样支持 WithFailClosed)
|
||||
r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Context) string {
|
||||
return fmt.Sprintf("user:%d", middleware.GetUserID(c))
|
||||
}))
|
||||
@@ -928,15 +953,15 @@ defer middleware.StopRateLimiters()
|
||||
```
|
||||
|
||||
> **fail-open vs fail-closed(H4c)**:Redis 限流器在 Redis 故障时有两种策略——
|
||||
> - **fail-open**(`RedisRateLimit`/`APIRedisRateLimit`/`UploadRedisRateLimit`/`CustomRedisRateLimit`,默认):Redis 故障时放行,避免影响业务,但限流静默失效。
|
||||
> - **fail-closed**(`RedisRateLimitFailClosed`/`LoginRedisRateLimit`/`CustomRedisRateLimitFailClosed`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、敏感操作)必须用 fail-closed**。
|
||||
> `RedisRateLimiter` 可经 `NewRedisRateLimiterFailClosed` 构造或 `SetFailClosed(true)` 切换策略。
|
||||
> - **fail-open**(默认):Redis 故障时放行,避免影响业务,但限流静默失效。
|
||||
> - **fail-closed**(`WithFailClosed(true)`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、上传、敏感操作)必须用 fail-closed**。
|
||||
> `RedisRateLimiter` 可经 `NewRedisRateLimiter(..., WithFailClosed(true))` 构造或 `SetFailClosed(true)` 切换策略。
|
||||
|
||||
**内存限流 vs Redis 限流:**
|
||||
- 内存限流:单实例使用,简单高效
|
||||
- Redis 限流:多实例共享,滑动窗口算法,分布式场景必需
|
||||
|
||||
### 8.4 路由架构
|
||||
### 8.5 路由架构
|
||||
|
||||
框架提供灵活的路由系统,支持模块化、版本化 API 和中间件分组。
|
||||
|
||||
@@ -1107,7 +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 字节)
|
||||
@@ -1197,6 +1222,10 @@ 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"
|
||||
|
||||
@@ -1221,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 {
|
||||
// 文件存在
|
||||
}
|
||||
```
|
||||
@@ -1516,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
|
||||
@@ -1539,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 启动与停止
|
||||
@@ -1563,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
|
||||
@@ -1660,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))
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -20,9 +20,8 @@ db := database.GetDB()
|
||||
// 同一份代码,也能注入实例 / 跑多套 / 塞 mock
|
||||
myDB := database.NewManager(cfg)
|
||||
myDB.Open(ctx) // 独立实例,不受全局影响
|
||||
database.SetDefaultManager(myDB) // 或提升为全局默认(并发安全)
|
||||
mockCache := &fakeCacheSvc{}
|
||||
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入
|
||||
database.SetDefaultManager(myDB) // 提升为全局默认,并关闭旧默认 manager
|
||||
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入自定义 CacheManager
|
||||
```
|
||||
|
||||
### 区别于一般 Gin 脚手架的几点
|
||||
@@ -276,6 +275,8 @@ defer mgr.Close()
|
||||
database.SetReplicaPicker(&database.RoundRobinPicker{})
|
||||
```
|
||||
|
||||
`database.SetDefaultManager(m)` 表示把新 manager 接管为全局默认,并关闭被替换的旧 manager,避免连接池泄漏。需要失败回滚或延迟释放旧资源的初始化流程,请使用 `database.SwapDefaultManager(m)` 并自行决定何时关闭返回的旧 manager。
|
||||
|
||||
#### 注册自定义 GORM 方言
|
||||
|
||||
通过 `database.RegisterDialect` 一次注册即可让 `database.driver: <name>` 生效,DSN 构建器会同步登记到 `config` 包,因此 `cfg.Database.DSN()` 也会识别新驱动:
|
||||
@@ -338,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()
|
||||
@@ -349,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")
|
||||
@@ -369,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)
|
||||
|
||||
@@ -394,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
|
||||
})
|
||||
@@ -404,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))
|
||||
@@ -474,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 流式响应
|
||||
@@ -513,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 {
|
||||
@@ -529,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()
|
||||
@@ -602,12 +629,18 @@ userType := middleware.GetUserType(c)
|
||||
|
||||
### 限流中间件
|
||||
|
||||
> **v1.4.0**:App `Shutdown` 自动 `registry.Stop()`(不再调全局 `StopRateLimiters`,避免误停其他 App)。多 App per-App 计数隔离用 `app.RateLimitRegistry().LoginRateLimit()` 等 App-bound 中间件。
|
||||
|
||||
```go
|
||||
// 登录限流(每分钟 10 次)
|
||||
r.POST("/login", middleware.LoginRateLimit(), handler.Login)
|
||||
r.POST("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"token": "..."})
|
||||
})
|
||||
|
||||
// 上传限流(每分钟 20 次)
|
||||
r.POST("/upload", middleware.UploadRateLimit(), handler.Upload)
|
||||
r.POST("/upload", middleware.UploadRateLimit(), func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"file": "..."})
|
||||
})
|
||||
|
||||
// 自定义限流
|
||||
r.Use(middleware.CustomRateLimit(50, time.Minute))
|
||||
@@ -820,6 +853,22 @@ docker run -d -p 8080:8080 xlgo-app:latest
|
||||
|
||||
> 完整变更历史见 [CHANGELOG.md](./CHANGELOG.md)。
|
||||
|
||||
### v1.4.0 (2026-07-12)
|
||||
|
||||
> **instance+facade 一致性收尾**:cron/storage/cache/ratelimit 实例化(App 持 per-App 实例 + `SwapDefault*` + 包级 facade 代理,照 `database.Manager`/`jwt.Manager` 模式),消除单进程多 App 互相污染;trace 纳入 App 生命周期(`WithTrace`);删除 `WithoutWire` 空函数。`go vet` + `go build` + `go test -race ./...` 全绿。完整变更见 [CHANGELOG.md](./CHANGELOG.md)。
|
||||
|
||||
主要破坏性变更(升级前必读):
|
||||
|
||||
- **`xlgo.WithoutWire()` 删除**:v1.1.0 wire 包删除后遗留的空 no-op,直接删除调用即可(无行为变化)。
|
||||
- **`cron.AddTask` 在 `App.Init` 之前注册失效**:App 持专属调度器,pre-Init 包级 `cron.AddTask` 注册到 init 默认调度器、被 App swap 丢弃。改用 `xlgo.WithCronTask(name, schedule, handler)`(或 Init 后 `app.Scheduler().AddTask`)。
|
||||
- **`middleware.StopRateLimiters` 语义收窄**:仅停默认 Registry;App `Shutdown` 调 `app` 自己的 `registry.Stop()`,不再误停其他 App 限流器。
|
||||
- **`commitReplacedResources` 不再关闭 `previousDB`/`previousRedis`/`loggerSnapshot`**:多 App 下 previous 可能是另一 App 的活跃资源(致 `redis: client is closed` / logger writer 误杀)。单 App 无影响(init 默认无资源)。
|
||||
- **cache/ratelimit redis 注入**(jwt 模型):`cache.NewRedisCacheWithRedis`/`CacheManager.InitWithRedis`;`middleware.WithRedisClient`。
|
||||
|
||||
新增:`WithTrace`/`WithCronTask`;`app.Scheduler()`/`Cache()`/`RedisClient()`/`RateLimitRegistry()`;`cron/storage/cache/middleware` 各 `SwapDefault*`;`config.TraceConfig`;`middleware.RateLimitRegistry` + App-bound 限流中间件;`storage.StorageManager.Close()`。
|
||||
|
||||
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.4.0`(⚠️ 含破坏性变更,详见 CHANGELOG 迁移说明)
|
||||
|
||||
### v1.2.0 (2026-07-04)
|
||||
|
||||
> **破坏性版本**:4 轮对抗性评审(deepseek / GLM / Claude / 终审)收口的全部 CRITICAL/HIGH/MEDIUM 修复 + 主线A 包级可变全局并发治理统一。`go vet` + `go build` + `go test -race ./...` 全绿。完整变更见 [CHANGELOG.md](./CHANGELOG.md)。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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-3:App.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 watcher(M-config-3): before=%d after=%d", before, after)
|
||||
}
|
||||
}
|
||||
@@ -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 3:WithCronTask 把任务注册到 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())
|
||||
}
|
||||
}
|
||||
@@ -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-1(App 级闭环):
|
||||
// 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-1:initDB ping 应被 3s 约束,重试 5 次应 ~20s)", elapsed)
|
||||
}
|
||||
case <-time.After(45 * time.Second):
|
||||
t.Fatalf("App.Init 在挂起 DB 上无限阻塞(H-db-1:gorm.Open automatic ping 或 initDB ping 未被超时约束)")
|
||||
}
|
||||
|
||||
// App.Init 失败后应能正常 Shutdown(failAfterInit 已回滚资源,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,无残留 goroutine(StartProbing 未启动因 Init 失败)。
|
||||
// 此处不直接断言 goroutine 数(受测试 runtime 噪声影响),靠 Shutdown 有界退出间接证明。
|
||||
_ = context.Background()
|
||||
}
|
||||
+463
@@ -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_M1:Shutdown 后 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_M1(Edge 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_M1(Edge 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 3:canary 注册到 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_M1:Init 失败后 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)
|
||||
}
|
||||
}
|
||||
@@ -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 次 429;App 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 的 loginLimiter(A 已用满)-> 首请求即 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.Stop(customLimiters 被清)。
|
||||
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() 返回 nil(rollback 已收口,无泄漏、无 panic)。
|
||||
if app.RateLimitRegistry() != nil {
|
||||
t.Fatal("a.rateLimitRegistry should be nil after Init-failure rollback")
|
||||
}
|
||||
}
|
||||
@@ -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 置 nil,App 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 429(business 模式下是 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(计数保留在 10,registryB 未被 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 tracer,trace.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 exporter,Init 后 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 goroutine(App.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)
|
||||
}
|
||||
}
|
||||
Vendored
+82
-27
@@ -7,73 +7,86 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CacheService 缓存服务接口
|
||||
type CacheService interface {
|
||||
// Get 获取缓存值,如果存在则反序列化到 dest 并返回 true
|
||||
Get(ctx context.Context, key string, dest any) bool
|
||||
// Get 获取缓存值,命中则反序列化到 dest 并返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未就绪、命令错误或反序列化失败返回 (false, err)。调用方须显式处理错误。
|
||||
Get(ctx context.Context, key string, dest any) (bool, error)
|
||||
// Set 设置缓存值
|
||||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||||
// Delete 删除缓存
|
||||
Delete(ctx context.Context, key string) error
|
||||
// DeleteByPattern 按模式删除缓存
|
||||
DeleteByPattern(ctx context.Context, pattern string) error
|
||||
// Exists 检查缓存是否存在
|
||||
Exists(ctx context.Context, key string) bool
|
||||
// Exists 检查缓存是否存在;未命中返回 (false, nil),后端错误返回 (false, err)。
|
||||
Exists(ctx context.Context, key string) (bool, error)
|
||||
}
|
||||
|
||||
// redisCache Redis 缓存实现。
|
||||
//
|
||||
// 不在构造时快照 redis.Client(M12 修复:原 NewRedisCache 构造时取 database.GetRedis(),
|
||||
// 若在 database.InitRedis 之前构造则永久 nil、即使后续 Redis 就绪也是 no-op)。
|
||||
// 改为每次操作实时取 database.GetRedis(),使"先构造后 Init Redis"的顺序也能正确工作。
|
||||
type redisCache struct{}
|
||||
// 改为每次操作实时取 redis 客户端,使"先构造后 Init Redis"的顺序也能正确工作。
|
||||
//
|
||||
// Phase 4:持有可选的注入 client(NewRedisCacheWithRedis),注入优先、否则回退全局
|
||||
// database.GetRedis()(照 jwt.TokenBlacklist.redisClient 模型)。App 经 InitWithRedis
|
||||
// 注入 per-App redisManager.Client(),使缓存走 App 自己的 Redis 而非全局(修复跨 App 串越)。
|
||||
type redisCache struct {
|
||||
injectedClient *redis.Client
|
||||
}
|
||||
|
||||
// client 返回当前 Redis 客户端(实时取,未初始化则 nil)。
|
||||
// client 返回缓存使用的 Redis 客户端:注入优先,否则回退全局 database.GetRedis()。
|
||||
func (c *redisCache) client() *redis.Client {
|
||||
if c != nil && c.injectedClient != nil {
|
||||
return c.injectedClient
|
||||
}
|
||||
return database.GetRedis()
|
||||
}
|
||||
|
||||
// NewRedisCache 创建 Redis 缓存实例
|
||||
// NewRedisCache 创建 Redis 缓存实例(懒取全局 Redis,兼容 standalone 用法)。
|
||||
func NewRedisCache() CacheService {
|
||||
return &redisCache{}
|
||||
}
|
||||
|
||||
// Get 获取缓存值
|
||||
func (c *redisCache) Get(ctx context.Context, key string, dest any) bool {
|
||||
// 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
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
|
||||
val, err := cli.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
if err != redis.Nil {
|
||||
logger.Warn("缓存获取失败", zap.String("key", key), zap.Error(err))
|
||||
if err == redis.Nil {
|
||||
return false, nil
|
||||
}
|
||||
return false
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(val), dest); err != nil {
|
||||
logger.Warn("缓存反序列化失败", zap.String("key", key), zap.Error(err))
|
||||
return false
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Set 设置缓存值
|
||||
func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return nil // Redis 未启用,跳过缓存
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
data, err := json.Marshal(value)
|
||||
@@ -94,7 +107,7 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du
|
||||
func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return nil
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
if err := cli.Del(ctx, key).Err(); err != nil {
|
||||
@@ -109,7 +122,7 @@ func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return nil
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
var cursor uint64
|
||||
@@ -148,14 +161,19 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists 检查缓存是否存在
|
||||
func (c *redisCache) Exists(ctx context.Context, key string) bool {
|
||||
// Exists 检查缓存是否存在。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未就绪返回 (false, ErrRedisNotReady);命令错误返回 (false, err)。
|
||||
func (c *redisCache) Exists(ctx context.Context, key string) (bool, error) {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return false
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
|
||||
return cli.Exists(ctx, key).Val() > 0
|
||||
n, err := cli.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// CacheManager 缓存管理器(#10)。照 database.Manager 模式:
|
||||
@@ -187,6 +205,16 @@ func SetDefaultCacheManager(m *CacheManager) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -194,6 +222,13 @@ func (m *CacheManager) Init() {
|
||||
m.svc = NewRedisCache()
|
||||
}
|
||||
|
||||
// InitWithRedis 用指定 Redis 客户端初始化缓存服务(多 Redis/测试隔离)。
|
||||
func (m *CacheManager) InitWithRedis(client *redis.Client) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.svc = NewRedisCacheWithRedis(client)
|
||||
}
|
||||
|
||||
// Set 设置缓存服务实现(用于注入 mock 或自定义实现)。
|
||||
func (m *CacheManager) Set(svc CacheService) {
|
||||
m.mu.Lock()
|
||||
@@ -222,3 +257,23 @@ func Init() {
|
||||
func GetCache() CacheService {
|
||||
return GetDefaultCache().Get()
|
||||
}
|
||||
|
||||
// Get 获取全局缓存值。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// 缓存未初始化或 Redis 错误返回 (false, err)。
|
||||
func Get(ctx context.Context, key string, dest any) (bool, error) {
|
||||
svc := GetCache()
|
||||
if svc == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
return svc.Get(ctx, key, dest)
|
||||
}
|
||||
|
||||
// Exists 检查全局缓存是否存在。未命中返回 (false, nil);缓存未初始化或
|
||||
// Redis 错误返回 (false, err)。
|
||||
func Exists(ctx context.Context, key string) (bool, error) {
|
||||
svc := GetCache()
|
||||
if svc == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
return svc.Exists(ctx, key)
|
||||
}
|
||||
|
||||
Vendored
+199
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+69
@@ -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 4:Swap 返回被替换的旧 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)
|
||||
}
|
||||
}
|
||||
Vendored
+18
-5
@@ -25,6 +25,9 @@ type KeyBuilderOption func(*KeyBuilder)
|
||||
// 示例: WithPrefix("site_a") -> 所有 key 自动添加 "site_a" 前缀
|
||||
func WithPrefix(prefix string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
if kb == nil {
|
||||
return
|
||||
}
|
||||
kb.prefix = prefix
|
||||
}
|
||||
}
|
||||
@@ -33,14 +36,20 @@ func WithPrefix(prefix string) KeyBuilderOption {
|
||||
// 示例: WithSeparator(":") -> "site_a:user:1"
|
||||
func WithSeparator(separator string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
if kb == nil {
|
||||
return
|
||||
}
|
||||
kb._separator = separator
|
||||
}
|
||||
}
|
||||
|
||||
// WithCacheType 设置缓存类型标识
|
||||
// 示例: WithCacheType("session") -> "session_site_a_user:1"
|
||||
// 示例: WithCacheType("session") -> "session:site_a:user:1"
|
||||
func WithCacheType(cacheType string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
if kb == nil {
|
||||
return
|
||||
}
|
||||
kb._cacheType = cacheType
|
||||
}
|
||||
}
|
||||
@@ -53,6 +62,9 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
|
||||
_cacheType: "cache",
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt == nil {
|
||||
continue
|
||||
}
|
||||
opt(kb)
|
||||
}
|
||||
return kb
|
||||
@@ -60,7 +72,7 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
|
||||
|
||||
// Build 构建完整键名(CK2 修复:mu 读保护)。
|
||||
// 格式: {cacheType}{separator}{prefix}{separator}{key}
|
||||
// 示例: kb.Build("user:1") -> "cache_site_a_user:1"
|
||||
// 示例: kb.Build("user:1") -> "cache:site_a:user:1"
|
||||
func (kb *KeyBuilder) Build(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
@@ -141,7 +153,7 @@ func (kb *KeyBuilder) BuildSession(key string) string {
|
||||
}
|
||||
|
||||
// BuildPattern 构建匹配模式(用于 SCAN/Keys)(CK2 修复:mu 读保护)。
|
||||
// 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*"
|
||||
// 示例: kb.BuildPattern("user:*") -> "cache:site_a:user:*"
|
||||
func (kb *KeyBuilder) BuildPattern(pattern string) string {
|
||||
return kb.Build(pattern)
|
||||
}
|
||||
@@ -258,8 +270,9 @@ func SetWithPrefix(ctx context.Context, key string, value any, ttl time.Duration
|
||||
return GetCache().Set(ctx, kb.Build(key), value, ttl)
|
||||
}
|
||||
|
||||
// GetWithPrefix 带前缀的缓存获取
|
||||
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) bool {
|
||||
// GetWithPrefix 带前缀的缓存获取。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 错误或反序列化失败返回 (false, err)。
|
||||
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) (bool, error) {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return GetCache().Get(ctx, kb.Build(key), dest)
|
||||
}
|
||||
|
||||
Vendored
+12
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+52
-19
@@ -15,8 +15,16 @@ var (
|
||||
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
|
||||
ErrLockExpired = errors.New("锁已过期")
|
||||
ErrRedisNotReady = errors.New("Redis 未初始化")
|
||||
// ErrLockNotAcquired 表示锁被其它客户端持有,业务函数未执行。
|
||||
ErrLockNotAcquired = errors.New("未获取到锁")
|
||||
// ErrInvalidLockTTL 表示锁 TTL 小于 Redis PX 支持的 1ms 粒度或非正。
|
||||
ErrInvalidLockTTL = errors.New("锁 TTL 必须大于等于 1ms")
|
||||
// ErrInvalidLockRetryInterval 表示重试或续期间隔非法。
|
||||
ErrInvalidLockRetryInterval = errors.New("锁重试/续期间隔必须大于 0")
|
||||
// ErrLockUnexpectedResult Lua 脚本返回了非预期的结果类型(C1b:裸类型断言防护)。
|
||||
ErrLockUnexpectedResult = errors.New("锁脚本返回非预期结果")
|
||||
// ErrLockFuncNil 表示分布式锁业务函数为空。
|
||||
ErrLockFuncNil = errors.New("锁业务函数不能为空")
|
||||
)
|
||||
|
||||
// toInt64 将 Lua 脚本返回值安全断言为 int64(C1b:禁止裸断言 panic)。
|
||||
@@ -29,6 +37,13 @@ func toInt64(v any) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func ttlMillis(ttl time.Duration) (int64, error) {
|
||||
if ttl < time.Millisecond {
|
||||
return 0, ErrInvalidLockTTL
|
||||
}
|
||||
return int64(ttl / time.Millisecond), nil
|
||||
}
|
||||
|
||||
// LockToken 锁令牌(用于安全释放锁)。
|
||||
//
|
||||
// 安全说明(C1d 设计局限):Token 是随机 UUID(非单调递增的 fencing token)。
|
||||
@@ -88,7 +103,10 @@ func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, er
|
||||
}
|
||||
|
||||
token := utils.UUID()
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
ttlMs, err := ttlMillis(ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := rdb.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
|
||||
if err != nil {
|
||||
@@ -148,7 +166,7 @@ func Unlock(ctx context.Context, token *LockToken) error {
|
||||
func UnlockByKey(ctx context.Context, key string) error {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
return rdb.Del(ctx, key).Err()
|
||||
}
|
||||
@@ -165,7 +183,10 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
ttlMs, err := ttlMillis(ttl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := rdb.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
|
||||
if err != nil {
|
||||
@@ -185,6 +206,9 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
|
||||
|
||||
// TryLock 尝试获取锁,失败时等待重试。重试等待响应 ctx 取消(C1c 修复)。
|
||||
func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval time.Duration, maxRetry int) (*LockToken, error) {
|
||||
if retryInterval <= 0 {
|
||||
return nil, ErrInvalidLockRetryInterval
|
||||
}
|
||||
for i := 0; i < maxRetry; i++ {
|
||||
token, err := NewLock(ctx, key, ttl)
|
||||
if err != nil {
|
||||
@@ -200,7 +224,7 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t
|
||||
case <-time.After(retryInterval):
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
return nil, ErrLockNotAcquired
|
||||
}
|
||||
|
||||
// WithLock 使用分布式锁执行函数(自动管理锁)。
|
||||
@@ -209,13 +233,16 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t
|
||||
//
|
||||
// 解锁用独立 Background ctx(C1a 一致性修复):fn 返回或 panic 后,原 ctx 可能已被
|
||||
// 调用方取消,用其解锁会失败导致锁泄漏到 TTL。fn panic 时 defer 也保证解锁执行。
|
||||
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() error) error {
|
||||
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) error) error {
|
||||
if fn == nil {
|
||||
return ErrLockFuncNil
|
||||
}
|
||||
token, err := NewLock(ctx, key, ttl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil {
|
||||
return nil // 未获取到锁,跳过执行
|
||||
return ErrLockNotAcquired
|
||||
}
|
||||
defer func() {
|
||||
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@@ -223,7 +250,7 @@ func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() erro
|
||||
_ = Unlock(unlockCtx, token)
|
||||
}()
|
||||
|
||||
return fn()
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)。
|
||||
@@ -234,13 +261,19 @@ func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() erro
|
||||
// 避免旧实现 done 无缓冲 + 子 defer close(done) + 父 done<-struct{}{} 的 send-on-closed panic
|
||||
// (ctx 取消或 ExtendLock 失败时 done 已 closed,父再 send 即 panic,Unlock 不执行、锁泄漏到 TTL)。
|
||||
// Unlock 用 context.Background() 派生超时,避免原 ctx 已取消致 Unlock 失败再泄漏。
|
||||
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func() error) error {
|
||||
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func(context.Context) error) error {
|
||||
if fn == nil {
|
||||
return ErrLockFuncNil
|
||||
}
|
||||
if extendInterval <= 0 {
|
||||
return ErrInvalidLockRetryInterval
|
||||
}
|
||||
token, err := NewLock(ctx, key, initialTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil {
|
||||
return nil // 未获取到锁,跳过执行
|
||||
return ErrLockNotAcquired
|
||||
}
|
||||
|
||||
// 父关停信号(仅父 close)与子 ack 信号(仅子 close)。
|
||||
@@ -277,8 +310,8 @@ func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duratio
|
||||
_ = Unlock(unlockCtx, token)
|
||||
}()
|
||||
|
||||
// 执行业务函数。
|
||||
err = fn()
|
||||
// 执行业务函数。fn 必须接收 ctx,避免请求取消后业务 loader/DB/HTTP 调用继续运行。
|
||||
err = fn(ctx)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -335,7 +368,7 @@ func ForceUnlock(ctx context.Context, key string) error {
|
||||
func Incr(ctx context.Context, key string) (int64, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return rdb.Incr(ctx, key).Result()
|
||||
}
|
||||
@@ -344,7 +377,7 @@ func Incr(ctx context.Context, key string) (int64, error) {
|
||||
func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return rdb.IncrBy(ctx, key, value).Result()
|
||||
}
|
||||
@@ -353,7 +386,7 @@ func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
|
||||
func Decr(ctx context.Context, key string) (int64, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return rdb.Decr(ctx, key).Result()
|
||||
}
|
||||
@@ -362,7 +395,7 @@ func Decr(ctx context.Context, key string) (int64, error) {
|
||||
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return rdb.TTL(ctx, key).Result()
|
||||
}
|
||||
@@ -371,7 +404,7 @@ func GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return false, nil
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
return rdb.Expire(ctx, key, ttl).Result()
|
||||
}
|
||||
@@ -380,7 +413,7 @@ func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error)
|
||||
func GetRaw(ctx context.Context, key string) (string, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return "", nil
|
||||
return "", ErrRedisNotReady
|
||||
}
|
||||
return rdb.Get(ctx, key).Result()
|
||||
}
|
||||
@@ -389,7 +422,7 @@ func GetRaw(ctx context.Context, key string) (string, error) {
|
||||
func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
return rdb.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+122
-10
@@ -45,11 +45,11 @@ func TestWithLockAutoExtendCtxCancelNoPanic(t *testing.T) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { panicked = recover() }()
|
||||
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func() error {
|
||||
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(runCtx context.Context) error {
|
||||
close(ran)
|
||||
// 阻塞直到 ctx 被取消,模拟长任务。
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
<-runCtx.Done()
|
||||
return runCtx.Err()
|
||||
})
|
||||
// WithLockAutoExtend 返回 fn 的错误(ctx.Err),不应 panic。
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
@@ -83,7 +83,7 @@ func TestWithLockAutoExtendNormalRelease(t *testing.T) {
|
||||
key := "c1a:normal"
|
||||
|
||||
called := false
|
||||
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func() error {
|
||||
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
@@ -107,7 +107,7 @@ func TestWithLockAutoExtendExtendsLock(t *testing.T) {
|
||||
|
||||
// initialTTL=500ms,extendInterval=100ms。fn 执行 800ms,期间应多次续期。
|
||||
// 若续期失效,锁会在 500ms 过期,另一 worker 可获取。
|
||||
err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func() error {
|
||||
err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func(context.Context) error {
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
@@ -137,7 +137,7 @@ func TestWithLockAutoExtendBlocksContender(t *testing.T) {
|
||||
|
||||
go func() {
|
||||
defer close(lockDone)
|
||||
cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func() error {
|
||||
cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func(context.Context) error {
|
||||
close(fnStarted)
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
close(fnDone)
|
||||
@@ -173,7 +173,7 @@ func TestWithLockAutoExtendFnPanicReleasesLock(t *testing.T) {
|
||||
var panicked any
|
||||
func() {
|
||||
defer func() { panicked = recover() }()
|
||||
_ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func() error {
|
||||
_ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
|
||||
time.Sleep(150 * time.Millisecond) // 让续期 ticker 至少触发一次
|
||||
panic("boom")
|
||||
})
|
||||
@@ -201,10 +201,10 @@ func TestWithLockCtxCancelReleasesLock(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_ = cache.WithLock(ctx, key, 10*time.Second, func() error {
|
||||
_ = cache.WithLock(ctx, key, 10*time.Second, func(runCtx context.Context) error {
|
||||
close(fnStarted)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
<-runCtx.Done()
|
||||
return runCtx.Err()
|
||||
})
|
||||
}()
|
||||
|
||||
@@ -218,6 +218,39 @@ func TestWithLockCtxCancelReleasesLock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLockPassesContextToFunction(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
key := "m10:ctx-aware"
|
||||
|
||||
var seen context.Context
|
||||
err := cache.WithLock(ctx, key, time.Second, func(runCtx context.Context) error {
|
||||
seen = runCtx
|
||||
cancel()
|
||||
<-runCtx.Done()
|
||||
return runCtx.Err()
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("WithLock ctx-aware err = %v, want context.Canceled", err)
|
||||
}
|
||||
if seen != ctx {
|
||||
t.Fatal("WithLock 应把调用方 ctx 传入业务函数")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLockRejectsNilFunction(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := cache.WithLock(ctx, "m10:nil-fn", time.Second, nil); !errors.Is(err, cache.ErrLockFuncNil) {
|
||||
t.Fatalf("WithLock nil fn err = %v, want ErrLockFuncNil", err)
|
||||
}
|
||||
if err := cache.WithLockAutoExtend(ctx, "m10:nil-fn-auto", time.Second, 100*time.Millisecond, nil); !errors.Is(err, cache.ErrLockFuncNil) {
|
||||
t.Fatalf("WithLockAutoExtend nil fn err = %v, want ErrLockFuncNil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== C1b:类型断言不 panic =====
|
||||
|
||||
// 回归 C1b:NewLock/Unlock/ExtendLock 在正常路径返回正确结果(Lua 脚本返 int64)。
|
||||
@@ -299,3 +332,82 @@ func TestTryLockRespectsCtxCancel(t *testing.T) {
|
||||
t.Errorf("TryLock took %v, should return shortly after ctx cancel (C1c: time.Sleep ignored ctx)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockRejectsSubMillisecondTTL(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if token, err := cache.NewLock(ctx, "m10:ttl", time.Nanosecond); !errors.Is(err, cache.ErrInvalidLockTTL) || token != nil {
|
||||
t.Fatalf("NewLock sub-ms ttl token=%v err=%v, want ErrInvalidLockTTL", token, err)
|
||||
}
|
||||
|
||||
token, err := cache.NewLock(ctx, "m10:ttl-valid", time.Second)
|
||||
if err != nil || token == nil {
|
||||
t.Fatalf("NewLock valid setup token=%v err=%v", token, err)
|
||||
}
|
||||
defer cache.Unlock(ctx, token)
|
||||
|
||||
if err := cache.ExtendLock(ctx, token, 0); !errors.Is(err, cache.ErrInvalidLockTTL) {
|
||||
t.Errorf("ExtendLock zero ttl err = %v, want ErrInvalidLockTTL", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLockAutoExtendRejectsNonPositiveInterval(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
called := false
|
||||
|
||||
err := cache.WithLockAutoExtend(context.Background(), "m10:interval", time.Second, 0, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, cache.ErrInvalidLockRetryInterval) {
|
||||
t.Fatalf("WithLockAutoExtend zero interval err = %v, want ErrInvalidLockRetryInterval", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("WithLockAutoExtend should not run fn with invalid interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLockHeldReturnsErrLockNotAcquired(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
key := "m10:not-acquired"
|
||||
|
||||
holder, err := cache.NewLock(ctx, key, time.Second)
|
||||
if err != nil || holder == nil {
|
||||
t.Fatalf("setup holder token=%v err=%v", holder, err)
|
||||
}
|
||||
defer cache.Unlock(ctx, holder)
|
||||
|
||||
called := false
|
||||
err = cache.WithLock(ctx, key, time.Second, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, cache.ErrLockNotAcquired) {
|
||||
t.Fatalf("WithLock held err = %v, want ErrLockNotAcquired", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("WithLock should not run fn when lock is held")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryLockExhaustedReturnsErrLockNotAcquired(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
key := "m10:try-exhausted"
|
||||
|
||||
holder, err := cache.NewLock(ctx, key, time.Second)
|
||||
if err != nil || holder == nil {
|
||||
t.Fatalf("setup holder token=%v err=%v", holder, err)
|
||||
}
|
||||
defer cache.Unlock(ctx, holder)
|
||||
|
||||
token, err := cache.TryLock(ctx, key, time.Second, time.Millisecond, 2)
|
||||
if !errors.Is(err, cache.ErrLockNotAcquired) {
|
||||
t.Fatalf("TryLock exhausted err = %v, want ErrLockNotAcquired", err)
|
||||
}
|
||||
if token != nil {
|
||||
t.Fatalf("TryLock exhausted token = %v, want nil", token)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+18
-16
@@ -51,33 +51,35 @@ func TestLockErrors(t *testing.T) {
|
||||
if ttl != 0 {
|
||||
t.Error("GetLockTTL should return 0 without Redis")
|
||||
}
|
||||
|
||||
if err := cache.UnlockByKey(ctx, "test_key"); !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("UnlockByKey without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrDecr(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test Incr without Redis
|
||||
// Redis 未初始化时应显式返回 ErrRedisNotReady,避免调用方误判为计数器值为 0。
|
||||
n, err := cache.Incr(ctx, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("Incr error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("Incr without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("Incr should return 0 without Redis")
|
||||
}
|
||||
|
||||
// Test IncrBy
|
||||
n, err = cache.IncrBy(ctx, "counter", 10)
|
||||
if err != nil {
|
||||
t.Errorf("IncrBy error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("IncrBy without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("IncrBy should return 0 without Redis")
|
||||
}
|
||||
|
||||
// Test Decr
|
||||
n, err = cache.Decr(ctx, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("Decr error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("Decr without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("Decr should return 0 without Redis")
|
||||
@@ -88,8 +90,8 @@ func TestSetExpire(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
ok, err := cache.SetExpire(ctx, "test_key", time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("SetExpire error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("SetExpire without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("SetExpire should return false without Redis")
|
||||
@@ -101,14 +103,14 @@ func TestGetRawSetRaw(t *testing.T) {
|
||||
|
||||
// Test SetRaw
|
||||
err := cache.SetRaw(ctx, "test_key", "test_value", time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("SetRaw error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("SetRaw without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
|
||||
// Test GetRaw
|
||||
val, err := cache.GetRaw(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("GetRaw error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("GetRaw without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if val != "" {
|
||||
t.Error("GetRaw should return empty string without Redis")
|
||||
@@ -146,4 +148,4 @@ func TestKFunctions(t *testing.T) {
|
||||
if sessionKey == "" {
|
||||
t.Error("KSession should not return empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-29
@@ -18,6 +18,7 @@ func createProject(name string) error {
|
||||
if err := validateProjectName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
// #nosec G703 -- name was validated by validateProjectName: no path separators, no "..", no leading dot, Go identifier only.
|
||||
if _, err := os.Stat(name); !os.IsNotExist(err) {
|
||||
return fmt.Errorf("目录 %s 已存在", name)
|
||||
}
|
||||
@@ -74,7 +75,9 @@ func createProject(name string) error {
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
// #nosec G301,G703 -- dir is built from validated project name plus fixed scaffold subdirectories; 0755 is intentional for generated project dirs.
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
// #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back.
|
||||
_ = os.RemoveAll(name) // P1 #21:清理半成品
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
@@ -115,6 +118,7 @@ func createProject(name string) error {
|
||||
|
||||
for path, content := range files {
|
||||
if err := renderTemplateFile(path, content, data); err != nil {
|
||||
// #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back.
|
||||
_ = os.RemoveAll(name) // P1 #21:部分失败回滚,避免留下半成品项目
|
||||
return err
|
||||
}
|
||||
@@ -135,6 +139,7 @@ func renderTemplateFile(path, content string, data TemplateData) (retErr error)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析模板 %s 失败: %w", path, err)
|
||||
}
|
||||
// #nosec G304 -- path is from createProject's files map built from validated project name plus fixed filenames.
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件 %s 失败: %w", path, err)
|
||||
@@ -168,19 +173,19 @@ func validateProjectName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidGoIdentifier 判断 s 是否为合法 Go 标识符(ASCII 范围)。
|
||||
// isValidGoIdentifier 判断 s 是否为脚手架接受的 ASCII 标识符:字母开头,后续允许字母、数字、下划线。
|
||||
func isValidGoIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_'
|
||||
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
isNum := r >= '0' && r <= '9'
|
||||
if i == 0 {
|
||||
if !isLetter {
|
||||
return false
|
||||
}
|
||||
} else if !isLetter && !isNum {
|
||||
} else if !isLetter && !isNum && r != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -202,17 +207,11 @@ func validateModulePath(module string) error {
|
||||
}
|
||||
|
||||
func makeFile(fileType, name string) error {
|
||||
// 文件名小写,但保留原分隔用于多词;标识符须为合法 Go 标识符(仅字母数字下划线)。
|
||||
// 将连字符/空格等转为下划线后再 Title,避免 "my-thing" → "My-Thing" 生成非法标识符(M20)。
|
||||
name = strings.ToLower(name)
|
||||
// P1 #21:拒绝含路径分隔符的名称,避免生成到目标目录之外。
|
||||
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name)
|
||||
if err := validateMakeName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
identBase := sanitizeIdent(name)
|
||||
caser := cases.Title(language.English)
|
||||
nameTitle := caser.String(strings.ReplaceAll(identBase, "_", " "))
|
||||
nameTitle = strings.ReplaceAll(nameTitle, " ", "") // 拼回 CamelCase
|
||||
name = strings.ToLower(name)
|
||||
nameTitle := makeNameTitle(name)
|
||||
|
||||
switch fileType {
|
||||
case "handler":
|
||||
@@ -228,24 +227,28 @@ func makeFile(fileType, name string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeIdent 把 name 中的非字母数字字符替换为下划线,生成合法 Go 标识符基串(M20)。
|
||||
// 如 "my-thing" → "my_thing",后续 Title 后得到 "MyThing"。
|
||||
func sanitizeIdent(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
} else if r >= 'A' && r <= 'Z' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
// validateMakeName 校验 xlgo make 的资源名,避免路径穿越或生成不可编译的 Go 代码。
|
||||
func validateMakeName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("名称不能为空")
|
||||
}
|
||||
s := strings.Trim(b.String(), "_")
|
||||
if s == "" {
|
||||
return "xlgo"
|
||||
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name)
|
||||
}
|
||||
return s
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return fmt.Errorf("名称不能以 . 开头: %q", name)
|
||||
}
|
||||
if !isValidGoIdentifier(name) {
|
||||
return fmt.Errorf("名称 %q 必须是合法标识符:须以字母开头,仅含字母、数字、下划线", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeNameTitle 把 snake_case 名称转为导出的 CamelCase 类型名。
|
||||
func makeNameTitle(name string) string {
|
||||
caser := cases.Title(language.English)
|
||||
nameTitle := caser.String(strings.ReplaceAll(name, "_", " "))
|
||||
return strings.ReplaceAll(nameTitle, " ", "")
|
||||
}
|
||||
|
||||
func createHandler(name, nameTitle string) error {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
dir := filepath.Dir(path)
|
||||
// #nosec G301,G703 -- path is built by makeFile from fixed scaffold directories and a name that rejects separators/".."; 0755 is intentional for generated dirs.
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// #nosec G306,G703 -- path is built by makeFile from fixed scaffold directories and a validated name; 0644 is intentional for generated source files.
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
@@ -20,6 +22,7 @@ func writeFile(path, content string) error {
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
// #nosec G703 -- path is built by makeFile from fixed scaffold directories and a name that rejects separators/"..".
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true
|
||||
|
||||
@@ -176,7 +176,9 @@ func GzipDecompressFileWithOptions(src, dst string, opts DecompressOptions) (err
|
||||
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 dst,避免被拒炸弹遗留残片;
|
||||
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
|
||||
defer func() {
|
||||
dstFile.Close()
|
||||
if cerr := dstFile.Close(); cerr != nil {
|
||||
err = errors.Join(err, cerr)
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(dst)
|
||||
}
|
||||
@@ -252,7 +254,7 @@ func Zip(zipPath string, paths []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// #nosec G304 -- path 为 Walk 遍历调用方源路径产生,非不可信输入
|
||||
// #nosec G304,G122 -- path 为 Walk 遍历调用方源路径产生,非不可信输入;压缩场景接受 symlink 跟随语义。
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -298,7 +300,7 @@ func UnzipWithOptions(zipPath, dstDir string, opts DecompressOptions) error {
|
||||
// 用绝对路径作目标锚定根,避免相对路径 + `..` 组合绕过前缀校验(C5a)。
|
||||
absDst, err := filepath.Abs(dstDir)
|
||||
if err != nil {
|
||||
absDst = dstDir
|
||||
return fmt.Errorf("resolve unzip destination failed: %w", err)
|
||||
}
|
||||
absDst = filepath.Clean(absDst)
|
||||
|
||||
@@ -365,7 +367,9 @@ func unzipFile(file *zip.File, absDst string, entryLimit, totalLimit, accrued in
|
||||
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 target,避免被拒炸弹遗留残片;
|
||||
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
|
||||
defer func() {
|
||||
dstFile.Close()
|
||||
if cerr := dstFile.Close(); cerr != nil {
|
||||
err = errors.Join(err, cerr)
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(target)
|
||||
}
|
||||
|
||||
+329
-90
@@ -1,9 +1,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -16,7 +20,9 @@ import (
|
||||
|
||||
// 配置错误
|
||||
var (
|
||||
ErrConfigNotLoaded = fmt.Errorf("配置未加载")
|
||||
// L-config-4:无格式化的哨兵错误用 errors.New(fmt.Errorf 无动词等价但语义上后者暗示格式化)。
|
||||
ErrConfigNotLoaded = errors.New("配置未加载")
|
||||
ErrInvalidConfig = errors.New("配置非法")
|
||||
)
|
||||
|
||||
// Config 全局配置结构体
|
||||
@@ -31,6 +37,7 @@ type Config struct {
|
||||
Upload UploadConfig `mapstructure:"upload"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
Trace TraceConfig `mapstructure:"trace"`
|
||||
}
|
||||
|
||||
// Clone 返回 Config 的深拷贝(M-G 修复)。
|
||||
@@ -39,8 +46,7 @@ type Config struct {
|
||||
// 类型白名单、Storage.Local/OSS 上传策略的扩展名/MIME 白名单)深拷贝底层数组,使返回值
|
||||
// 可被调用方安全修改(含 append/sort/改元素)而不污染框架内部配置、不与其他读者竞态。
|
||||
//
|
||||
// 用于需要可变配置副本的场景。热路径的 Get() 为零分配仍返回内部只读指针——需要改配置时
|
||||
// 用 Clone() 或 Load()(Load 内部已返回 Clone)。
|
||||
// 用于需要可变配置副本的场景。Load/Get/回调均返回 Clone,避免调用方误改全局配置。
|
||||
func (c *Config) Clone() *Config {
|
||||
if c == nil {
|
||||
return nil
|
||||
@@ -72,6 +78,34 @@ func cloneStrings(s []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringAnyMap(in map[string]any) map[string]any {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = cloneAny(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAny(v any) any {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
return cloneStringAnyMap(x)
|
||||
case []any:
|
||||
out := make([]any, len(x))
|
||||
for i, item := range x {
|
||||
out[i] = cloneAny(item)
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return cloneStrings(x)
|
||||
default:
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||
// AppConfig 应用配置
|
||||
// 使用场景:
|
||||
// - 缓存键名前缀: cache:{site_name}:user:1
|
||||
@@ -133,7 +167,7 @@ type TLSConfig struct {
|
||||
|
||||
// ServerConfig 服务配置
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"` // 绑定地址,空=监听所有接口(0.0.0.0);"127.0.0.1"=仅本机;内网IP=绑定指定网卡
|
||||
Host string `mapstructure:"host"` // 绑定地址,空=监听所有接口(0.0.0.0);"127.0.0.1"=仅本机;内网IP=绑定指定网卡
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // development 或 production
|
||||
ReadTimeout time.Duration `mapstructure:"read_timeout"` // 读超时,如 "15s"
|
||||
@@ -142,7 +176,7 @@ type ServerConfig struct {
|
||||
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` // 优雅关闭超时,如 "30s"
|
||||
MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 最大请求头字节数
|
||||
TLS TLSConfig `mapstructure:"tls"`
|
||||
UnixSocket string `mapstructure:"unix_socket"` // 非空时优先于 Port,监听 unix socket
|
||||
UnixSocket string `mapstructure:"unix_socket"` // 非空时优先于 Port,监听 unix socket
|
||||
ResponseMode string `mapstructure:"response_mode"` // business(默认) 或 rest,见 response.SetMode
|
||||
}
|
||||
|
||||
@@ -201,6 +235,12 @@ const (
|
||||
DriverPostgres = "postgres"
|
||||
)
|
||||
|
||||
// MySQLTLSConfigName 是 database 包为 MySQL 私有 CA TLS 注册的命名配置名(M-config-2)。
|
||||
// 当 DatabaseConfig.TLS=true 且 TLSRootCA 非空时,MySQLDSN 追加 tls=<本常量>,
|
||||
// 由 database 包在 InitDB 时通过 go-sql-driver/mysql.RegisterTLSConfig 注册自定义 *tls.Config。
|
||||
// TLS=true 但 TLSRootCA 为空时则用内置 tls=true(系统根 CA),无需注册。
|
||||
const MySQLTLSConfigName = "xlgo-mysql"
|
||||
|
||||
// DSNBuilder 根据 DatabaseConfig 生成连接字符串
|
||||
type DSNBuilder func(*DatabaseConfig) string
|
||||
|
||||
@@ -282,44 +322,102 @@ type DatabaseConfig struct {
|
||||
HealthCheckInterval time.Duration `mapstructure:"health_check_interval"`
|
||||
// HealthCheckFailureThreshold 连续探活失败多少次标记不健康(#21)。0 表示用默认 3
|
||||
HealthCheckFailureThreshold int `mapstructure:"health_check_failure_threshold"`
|
||||
// SSLMode PostgreSQL sslmode(disable/allow/prefer/require/verify-ca/verify-full)。
|
||||
// 空时默认 "prefer"(M-config-2:原硬编码 disable 已改为默认 prefer,优先加密、失败回退明文)。
|
||||
// 该字段仅对 PostgreSQL 生效;MySQL 用 TLS/TLSRootCA。
|
||||
SSLMode string `mapstructure:"ssl_mode"`
|
||||
// TLS 是否对 MySQL 启用 TLS。true 时 MySQLDSN 追加 tls=true(内置:系统根 CA + 证书校验,
|
||||
// ServerName 自动取自 Host)。配合 TLSRootCA 可指定私有 CA(M-config-2)。
|
||||
TLS bool `mapstructure:"tls"`
|
||||
// TLSRootCA MySQL TLS 自定义 CA 证书 PEM 路径(用于私有 CA/自签证书)。
|
||||
// 非空时 MySQLDSN 改用 tls=MySQLTLSConfigName,由 database 包在 InitDB 时注册命名 TLS 配置
|
||||
// (ServerName 取自 Host)。该命名配置仅覆盖「replica DSN host 与主库 Host 相同」的单 host 集群;
|
||||
// 多 host replicas + 私有 CA 时须为每个 replica host 注册不同名 TLS 配置并自建 replica DSN(框架
|
||||
// MySQLDSN 硬编码 MySQLTLSConfigName,不适用),详见 database.ensureMySQLTLSRegistered 注释。
|
||||
// 空时用内置 tls=true(系统根 CA)。仅 MySQL 生效。
|
||||
TLSRootCA string `mapstructure:"tls_root_ca"`
|
||||
}
|
||||
|
||||
// DSN 根据驱动返回连接字符串。设置了 CustomDSN 时优先返回 CustomDSN;
|
||||
// 未指定 Driver 时按 MySQL 处理(向后兼容)。
|
||||
// 若驱动通过 RegisterDSNBuilder 注册过自定义构建器,则使用注册的构建器。
|
||||
// DSN 根据驱动返回连接字符串。设置了 CustomDSN 时优先返回 CustomDSN。
|
||||
// 未指定 Driver 时按 MySQL 处理;非空但未注册的 Driver 返回空字符串,
|
||||
// 避免把拼写错误静默当作 MySQL 连接,正常加载路径会由 Validate 提前报错。
|
||||
func (c *DatabaseConfig) DSN() string {
|
||||
if c == nil { // L-config-6:防御 nil receiver
|
||||
return ""
|
||||
}
|
||||
if c.CustomDSN != "" {
|
||||
return c.CustomDSN
|
||||
}
|
||||
if builder, ok := LookupDSNBuilder(c.Driver); ok {
|
||||
driver := strings.TrimSpace(c.Driver)
|
||||
if driver == "" { // L-config-5:去除原重复的 TrimSpace(driver)
|
||||
driver = DriverMySQL
|
||||
}
|
||||
if builder, ok := LookupDSNBuilder(driver); ok {
|
||||
return builder(c)
|
||||
}
|
||||
// 未注册时回退到 MySQL(保持向后兼容)
|
||||
return c.MySQLDSN()
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c DatabaseConfig) isConfigured() bool {
|
||||
return strings.TrimSpace(c.Driver) != "" ||
|
||||
strings.TrimSpace(c.Host) != "" ||
|
||||
c.Port != 0 ||
|
||||
strings.TrimSpace(c.User) != "" ||
|
||||
strings.TrimSpace(c.Password) != "" ||
|
||||
strings.TrimSpace(c.Name) != "" ||
|
||||
strings.TrimSpace(c.CustomDSN) != ""
|
||||
}
|
||||
|
||||
// MySQLDSN 返回 MySQL 连接字符串。
|
||||
// 密码经 url.QueryEscape 转义,避免含 @/:/空格 等特殊字符破坏 DSN(M9)。
|
||||
// loc 由 Timezone 配置,空则默认 "Local"(向后兼容)。
|
||||
// TLS=true 时追加 tls 参数(M-config-2):TLSRootCA 为空用内置 tls=true(系统根 CA + 证书校验);
|
||||
// TLSRootCA 非空用 tls=MySQLTLSConfigName(database 包注册的私有 CA 命名配置)。
|
||||
func (c *DatabaseConfig) MySQLDSN() string {
|
||||
if c == nil { // L-config-6
|
||||
return ""
|
||||
}
|
||||
loc := c.Timezone
|
||||
if loc == "" {
|
||||
loc = "Local"
|
||||
}
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s",
|
||||
c.User, url.QueryEscape(c.Password), c.Host, c.Port, c.Name, url.QueryEscape(loc))
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s",
|
||||
url.QueryEscape(c.User), url.QueryEscape(c.Password), c.Host, c.Port, url.PathEscape(c.Name), url.QueryEscape(loc))
|
||||
if c.TLS {
|
||||
if strings.TrimSpace(c.TLSRootCA) == "" {
|
||||
dsn += "&tls=true"
|
||||
} else {
|
||||
dsn += "&tls=" + MySQLTLSConfigName
|
||||
}
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// PostgresDSN 返回 PostgreSQL 连接字符串。
|
||||
// 密码经单引号转义(内嵌单引号翻倍),避免含空格/引号/反斜杠破坏 key=value DSN(M9)。
|
||||
// 字符串字段统一用单引号包裹并转义,避免含空格/引号/反斜杠破坏 key=value DSN。
|
||||
// TimeZone 由 Timezone 配置,空则默认 "Asia/Shanghai"(向后兼容)。
|
||||
// sslmode 由 SSLMode 配置,空则默认 "prefer"(M-config-2:原硬编码 disable 改为 prefer,优先加密)。
|
||||
func (c *DatabaseConfig) PostgresDSN() string {
|
||||
if c == nil { // L-config-6
|
||||
return ""
|
||||
}
|
||||
tz := c.Timezone
|
||||
if tz == "" {
|
||||
tz = "Asia/Shanghai"
|
||||
}
|
||||
return fmt.Sprintf("host=%s port=%d user=%s password='%s' dbname=%s sslmode=disable TimeZone=%s",
|
||||
c.Host, c.Port, c.User, strings.ReplaceAll(c.Password, "'", "''"), c.Name, tz)
|
||||
sslmode := strings.TrimSpace(c.SSLMode)
|
||||
if sslmode == "" {
|
||||
sslmode = "prefer"
|
||||
}
|
||||
// sslmode 不加引号(与原 sslmode=disable 格式一致);SSLMode 已被 Validate 限定为固定枚举,无注入风险。
|
||||
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s",
|
||||
postgresQuote(c.Host), c.Port, postgresQuote(c.User), postgresQuote(c.Password), postgresQuote(c.Name), sslmode, postgresQuote(tz))
|
||||
}
|
||||
|
||||
func postgresQuote(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `'`, `\'`)
|
||||
return "'" + s + "'"
|
||||
}
|
||||
|
||||
// RedisConfig Redis 配置
|
||||
@@ -332,6 +430,9 @@ type RedisConfig struct {
|
||||
|
||||
// Addr 返回 Redis 地址
|
||||
func (c *RedisConfig) Addr() string {
|
||||
if c == nil { // L-config-6
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
}
|
||||
|
||||
@@ -424,6 +525,26 @@ type CORSConfig struct {
|
||||
MaxAge int `mapstructure:"max_age"` // 预检请求缓存时间(秒)
|
||||
}
|
||||
|
||||
// TraceConfig 链路追踪配置(OpenTelemetry)。由 App 在 WithTrace 时读取并调 trace.Init。
|
||||
//
|
||||
// 语义说明:OTel 的 TracerProvider / TextMapPropagator 本身是进程级全局单例
|
||||
// (otel.SetTracerProvider 全局生效),故 trace 不做 per-App 实例隔离--多 App 进程
|
||||
// 共享同一 OTel 全局状态,这与 OTel 自身设计一致。App 仅负责把 trace 纳入生命周期
|
||||
// (Init/Close)与装入 Middleware,不提供实例隔离。
|
||||
//
|
||||
// Enabled=false(默认)时 trace.Init 安装 Noop tracer,Middleware 不 panic、不导出。
|
||||
type TraceConfig struct {
|
||||
ServiceName string `mapstructure:"service_name"` // 服务名(空则回退 cfg.App.Name)
|
||||
ServiceVersion string `mapstructure:"service_version"` // 服务版本
|
||||
Environment string `mapstructure:"environment"` // 运行环境
|
||||
ExporterType string `mapstructure:"exporter_type"` // otlp-http(默认) / otlp-grpc / stdout
|
||||
Endpoint string `mapstructure:"endpoint"` // OTLP collector 地址
|
||||
Insecure bool `mapstructure:"insecure"` // 明文(无 TLS)连接 collector
|
||||
SampleRatio float64 `mapstructure:"sample_ratio"` // 采样比例 0.0-1.0
|
||||
Enabled bool `mapstructure:"enabled"` // 是否启用导出
|
||||
Propagator string `mapstructure:"propagator"` // w3c(默认) / b3 / jaeger
|
||||
}
|
||||
|
||||
// GetAllowedOrigins 获取允许的域名列表
|
||||
func (c *CORSConfig) GetAllowedOrigins() []string {
|
||||
if c == nil || len(c.AllowedOrigins) == 0 {
|
||||
@@ -476,6 +597,10 @@ type Manager struct {
|
||||
watcher *fsnotify.Watcher
|
||||
// watchDone 在监听 goroutine 退出时被 close,供 StopWatcher 等待退出确认。
|
||||
watchDone chan struct{}
|
||||
// watchCancel 取消 watchLoop 的 ctx(L-config-2)。watchLoop 同时监听 ctx.Done 与 w.Events,
|
||||
// 提供 fsnotify 致命错误且 Events 未关闭时的逃生通道,避免监听 goroutine 永驻。
|
||||
// StopWatcher 时 cancel + Close(w) 双重退出保障。
|
||||
watchCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// defaultManager 是包级默认管理器(C10a)。改用 atomic.Pointer 保护读写,
|
||||
@@ -499,6 +624,49 @@ func newViper(configPath string) *viper.Viper {
|
||||
return v
|
||||
}
|
||||
|
||||
func cloneViper(src *viper.Viper, configPath string) *viper.Viper {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
cp := newViper(configPath)
|
||||
if err := cp.MergeConfigMap(src.AllSettings()); err != nil {
|
||||
return nil
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// configToMap 将 *Config 按 mapstructure tag 转为嵌套 map[string]any(H-config-1)。
|
||||
// mapstructure struct->map 会递归嵌套结构体为 map,切片/map 字段原样保留,
|
||||
// 用于在 Set(cfg) 后重建 m.v,使 GetString/GetInt/GetBool/GetViper 与 Get() 读到同一配置。
|
||||
func configToMap(cfg *Config) map[string]any {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]any{}
|
||||
if err := mapstructure.Decode(cfg, &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// viperFromConfig 由 *Config 重建一个不含 AutomaticEnv 的 viper(H-config-1)。
|
||||
// 用于 Set(cfg) 后同步 m.v:不启用 AutomaticEnv 是为了确保 GetString 等只读取 cfg 派生的值,
|
||||
// 与 Get()(返回 m.cfg 即调用方传入的 cfg)严格同源,避免 env 覆盖造成二者的二次分裂。
|
||||
// 保留 SetConfigFile(m.path) 以便后续 Reload 从文件重读。
|
||||
//
|
||||
// 已知差异(Duration 字段):mapstructure struct->map 将 time.Duration 原样保留为 time.Duration,
|
||||
// 故 GetString("jwt.expire") 在 Set 后返回 Duration.String() 格式(如 "24h0m0s"),与文件加载路径
|
||||
// 返回的原始字符串(如 "24h")字面不同。二者语义一致(均可 ParseDuration),typed view(Get().JWT.Expire)
|
||||
// 与 GetDuration 在两条路径下完全一致。Duration 字段应经 typed view 或 GetDuration 读取,勿用 GetString 字面比较。
|
||||
func viperFromConfig(configPath string, cfg *Config) *viper.Viper {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
if m := configToMap(cfg); m != nil {
|
||||
_ = v.MergeConfigMap(m)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// unmarshalConfig 将 viper 解析到 Config,启用 string→time.Duration decode hook,
|
||||
// 使 ServerConfig/JWTConfig 的 Duration 字段可写 "24h"/"15s" 等字符串。
|
||||
func unmarshalConfig(v *viper.Viper, cfg *Config) error {
|
||||
@@ -594,31 +762,34 @@ func (m *Manager) StartWatcher() error {
|
||||
}
|
||||
m.watcher = w
|
||||
m.watchDone = make(chan struct{})
|
||||
// L-config-2:为 watchLoop 创建可取消 ctx,作为 w.Events 关闭之外的逃生通道。
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.watchCancel = cancel
|
||||
target := filepath.Base(m.path)
|
||||
done := m.watchDone
|
||||
m.mu.Unlock()
|
||||
|
||||
go m.watchLoop(w, target, done)
|
||||
go m.watchLoop(ctx, w, target, done)
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchLoop 是文件监听 goroutine 主体。文件变更经去抖后调用 reload;
|
||||
// watcher 被 Close(Events 通道关闭)时退出并 close done。
|
||||
// done 由 StartWatcher 在锁内捕获传入,避免本 goroutine 读取 m.watchDone 字段
|
||||
// 与 StopWatcher 写入竞争。
|
||||
func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct{}) {
|
||||
// watcher 被 Close(Events 通道关闭)或 ctx 被 cancel(L-config-2)时退出并 close done。
|
||||
// done 与 ctx 均由 StartWatcher 在锁内捕获传入,避免本 goroutine 读取 m.watchDone /
|
||||
// m.watchCancel 字段与 StopWatcher 写入竞争。ctx 提供 fsnotify 致命错误下的逃生通道。
|
||||
func (m *Manager) watchLoop(ctx context.Context, w *fsnotify.Watcher, target string, done chan struct{}) {
|
||||
defer close(done)
|
||||
const debounce = 200 * time.Millisecond
|
||||
var timer *time.Timer
|
||||
// P1 #16:退出时停掉未触发的去抖 timer,避免 StopWatcher 之后 AfterFunc 仍
|
||||
// reload() 一个调用方认为已停止的 manager。
|
||||
defer func() {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}()
|
||||
timer := time.NewTimer(time.Hour)
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
defer timer.Stop()
|
||||
var timerC <-chan time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done(): // L-config-2:StopWatcher cancel 时退出,不单靠 w.Events 关闭
|
||||
return
|
||||
case ev, ok := <-w.Events:
|
||||
if !ok {
|
||||
return
|
||||
@@ -631,25 +802,30 @@ func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct
|
||||
continue
|
||||
}
|
||||
// 去抖:合并编辑器/工具的连续写事件,仅最后一次触发重载。
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
if !timer.Stop() && timerC != nil {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer = time.AfterFunc(debounce, func() {
|
||||
// reload 内部对非法配置保留旧配置(C10b),错误被忽略——
|
||||
// 监听路径无法向上传播错误,保留旧配置即正确语义。
|
||||
_ = m.reload()
|
||||
})
|
||||
timer.Reset(debounce)
|
||||
timerC = timer.C
|
||||
case _, ok := <-w.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// 非致命错误:继续监听。
|
||||
case <-timerC:
|
||||
timerC = nil
|
||||
// reload 内部对非法配置保留旧配置(C10b),错误被忽略——
|
||||
// 监听路径无法向上传播错误,保留旧配置即正确语义。
|
||||
_ = m.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopWatcher 停止配置文件监听并释放 watcher(C10d)。幂等。
|
||||
// 关闭 fsnotify watcher → Events 通道关闭 → watchLoop 退出 → 等待 watchDone。
|
||||
// cancel ctx 与关闭 fsnotify watcher 双重退出(L-config-2),等待 watchDone 确认 goroutine 退出。
|
||||
func (m *Manager) StopWatcher() {
|
||||
if m == nil {
|
||||
return
|
||||
@@ -657,57 +833,120 @@ func (m *Manager) StopWatcher() {
|
||||
m.mu.Lock()
|
||||
w := m.watcher
|
||||
done := m.watchDone
|
||||
cancel := m.watchCancel
|
||||
m.watcher = nil
|
||||
m.watchDone = nil
|
||||
m.watchCancel = nil
|
||||
m.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel() // L-config-2:先 cancel 让 watchLoop 经 ctx.Done 退出
|
||||
}
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
_ = w.Close()
|
||||
_ = w.Close() // 再 Close watcher 让 w.Events 关闭,双重保障
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// Get 获取配置。
|
||||
//
|
||||
// 返回的是 Manager 内部持有的配置指针(共享),调用方**必须视为只读**:
|
||||
// 修改返回值的标量或切片元素会污染全局配置并与其他读取 goroutine 竞态。需要可变副本时
|
||||
// 用 Clone()(返回深拷贝)或 Load()(重载并返回深拷贝)。
|
||||
//
|
||||
// M-G:热路径(jwt 鉴权等每请求调用)保持返回内部指针以零分配,可变副本走 Clone()。
|
||||
// 框架自身的 reload 创建新 Config 并替换 m.cfg 指针,不改写旧 Config 对象,故 Get()
|
||||
// 返回的旧指针在 reload 后仍指向一致的(旧)快照,reload 不引入对旧快照的竞态。
|
||||
// Get 获取配置副本。返回值可由调用方自由修改,不会污染 Manager 内部配置。
|
||||
func (m *Manager) Get() *Config {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.cfg
|
||||
return m.cfg.Clone()
|
||||
}
|
||||
|
||||
// GetViper 获取 viper 实例
|
||||
// GetViper 获取 viper 的只读快照。
|
||||
//
|
||||
// 返回值不是 Manager 内部 viper 指针;调用方修改该快照不会影响全局配置。
|
||||
// 需要扩展配置读取时优先使用 GetString/GetInt/GetBool 等包级 helper。
|
||||
func (m *Manager) GetViper() *viper.Viper {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.v
|
||||
return cloneViper(m.v, m.path)
|
||||
}
|
||||
|
||||
// Set 手动设置配置
|
||||
func (m *Manager) Set(cfg *Config) {
|
||||
// GetString 获取字符串配置。
|
||||
func (m *Manager) GetString(key string) string {
|
||||
if m == nil {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.v == nil {
|
||||
return ""
|
||||
}
|
||||
return m.v.GetString(key)
|
||||
}
|
||||
|
||||
// GetInt 获取整数配置。
|
||||
func (m *Manager) GetInt(key string) int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.v == nil {
|
||||
return 0
|
||||
}
|
||||
return m.v.GetInt(key)
|
||||
}
|
||||
|
||||
// GetBool 获取布尔配置。
|
||||
func (m *Manager) GetBool(key string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.v == nil {
|
||||
return false
|
||||
}
|
||||
return m.v.GetBool(key)
|
||||
}
|
||||
|
||||
// GetStringMap 获取字符串映射配置副本。
|
||||
func (m *Manager) GetStringMap(key string) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.v == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneStringAnyMap(m.v.GetStringMap(key))
|
||||
}
|
||||
|
||||
// Set 手动设置配置。非 nil 配置会先 Validate,并以深拷贝形式保存。
|
||||
// H-config-1:非 nil 配置同时用其重建 m.v(不含 AutomaticEnv),使 Get() 与
|
||||
// GetString/GetInt/GetBool/GetViper 读到同一配置世界,消除原"Set 只更新类型化视图、
|
||||
// viper 视图停留在旧值"的静默分裂(违反 C1 单一配置源)。
|
||||
func (m *Manager) Set(cfg *Config) error {
|
||||
if m == nil {
|
||||
return ErrConfigNotLoaded
|
||||
}
|
||||
if cfg != nil {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return errors.Join(ErrInvalidConfig, err)
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cfg = cfg
|
||||
m.cfg = cfg.Clone()
|
||||
if cfg == nil {
|
||||
m.v = nil
|
||||
} else {
|
||||
m.v = viperFromConfig(m.path, cfg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload 重新加载配置文件。读取、解析、校验(C10b)任一步失败均保留旧配置并返回错误;
|
||||
@@ -749,8 +988,17 @@ func (m *Manager) reload() error {
|
||||
|
||||
// M-G:回调传入 newCfg 的深拷贝,避免回调修改切片字段与 Get() 读者(持有 &newCfg)竞态。
|
||||
// 回调为 onChange 通知语义,应观察而非改写配置;改写副本不影响内部 m.cfg。
|
||||
// M-config-1:每个回调独立 recover,单个回调 panic 不得杀掉 watcher 或阻断后续回调。
|
||||
// config 是叶子包(logger 依赖 config),不能用框架 zap,故用标准库 log 记录 + 堆栈。
|
||||
for _, cb := range cbs {
|
||||
cb(newCfg.Clone())
|
||||
func(cb func(*Config)) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("config: 配置变更回调 panic(已隔离,继续后续回调): %v\n%s", r, debug.Stack())
|
||||
}
|
||||
}()
|
||||
cb(newCfg.Clone())
|
||||
}(cb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -760,39 +1008,42 @@ func (m *Manager) reload() error {
|
||||
// 都 Store,遗留一个已 StartWatcher 的 manager 无引用可停(goroutine 泄漏)。
|
||||
var pkgLoadMu sync.Mutex
|
||||
|
||||
// Load 加载配置文件(C3.5 修复:替换前停止旧 Manager 的 watcher,防止 goroutine 泄漏)。
|
||||
// P1 #8:全程持 pkgLoadMu 串行化,消除与并发 Load/LoadWithWatch 的 TOCTOU。
|
||||
// Load 加载配置文件。
|
||||
// P1 #8:全程持 pkgLoadMu 串行化。新配置加载成功后才替换默认 Manager 并停止旧 watcher;
|
||||
// 加载失败会保留旧 Manager 与旧 watcher,避免一次错误配置导致热更新链路断掉。
|
||||
func Load(configPath string) (*Config, error) {
|
||||
pkgLoadMu.Lock()
|
||||
defer pkgLoadMu.Unlock()
|
||||
if old := defaultManager.Load(); old != nil {
|
||||
old.StopWatcher()
|
||||
}
|
||||
m := NewManager(configPath)
|
||||
cfg, err := m.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
old := defaultManager.Load()
|
||||
defaultManager.Store(m)
|
||||
if old != nil && old != m {
|
||||
old.StopWatcher()
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadWithWatch 加载配置文件并启用热更新(C3.5 修复:替换前停止旧 watcher)。
|
||||
// LoadWithWatch 加载配置文件并启用热更新。
|
||||
// P1 #8:全程持 pkgLoadMu 串行化,且新 manager 在其 watcher 成功启动后才置换为默认;
|
||||
// 启动失败则停掉半启动的 watcher 并不置换,避免遗留孤儿 watcher。
|
||||
// 启动失败则保留旧 Manager 与旧 watcher,并停掉新 manager 可能半启动的 watcher。
|
||||
func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) {
|
||||
pkgLoadMu.Lock()
|
||||
defer pkgLoadMu.Unlock()
|
||||
if old := defaultManager.Load(); old != nil {
|
||||
old.StopWatcher()
|
||||
}
|
||||
m := NewManager(configPath)
|
||||
cfg, err := m.LoadWithWatch(onChange)
|
||||
if err != nil {
|
||||
m.StopWatcher() // 清理可能已半启动的 watcher,避免孤儿 goroutine
|
||||
return nil, err
|
||||
}
|
||||
old := defaultManager.Load()
|
||||
defaultManager.Store(m)
|
||||
if old != nil && old != m {
|
||||
old.StopWatcher()
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -821,9 +1072,9 @@ func GetViper() *viper.Viper {
|
||||
return defaultManager.Load().GetViper()
|
||||
}
|
||||
|
||||
// Set 手动设置配置(用于测试或动态修改)
|
||||
func Set(cfg *Config) {
|
||||
defaultManager.Load().Set(cfg)
|
||||
// Set 手动设置配置(用于测试或动态修改)。非 nil 配置会先校验并复制。
|
||||
func Set(cfg *Config) error {
|
||||
return defaultManager.Load().Set(cfg)
|
||||
}
|
||||
|
||||
// Reload 重新加载配置文件
|
||||
@@ -837,48 +1088,36 @@ func Reload() error {
|
||||
// 传入 nil 表示重置为空管理器。
|
||||
//
|
||||
// C10a:经 atomic.Pointer.Store 原子置换,消除与并发读取(Get 等)的数据竞争。
|
||||
// 置换后会停止旧 Manager 的 watcher,避免全局默认 manager 切换后遗留热更新 goroutine。
|
||||
func SetDefaultManager(m *Manager) {
|
||||
old := defaultManager.Load()
|
||||
if m == nil {
|
||||
defaultManager.Store(NewManager(""))
|
||||
return
|
||||
m = NewManager("")
|
||||
}
|
||||
defaultManager.Store(m)
|
||||
if old != nil && old != m {
|
||||
old.StopWatcher()
|
||||
}
|
||||
}
|
||||
|
||||
// GetString 获取字符串配置
|
||||
func GetString(key string) string {
|
||||
v := GetViper()
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.GetString(key)
|
||||
return defaultManager.Load().GetString(key)
|
||||
}
|
||||
|
||||
// GetInt 获取整数配置
|
||||
func GetInt(key string) int {
|
||||
v := GetViper()
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return v.GetInt(key)
|
||||
return defaultManager.Load().GetInt(key)
|
||||
}
|
||||
|
||||
// GetBool 获取布尔配置
|
||||
func GetBool(key string) bool {
|
||||
v := GetViper()
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return v.GetBool(key)
|
||||
return defaultManager.Load().GetBool(key)
|
||||
}
|
||||
|
||||
// GetStringMap 获取字符串映射配置
|
||||
func GetStringMap(key string) map[string]any {
|
||||
v := GetViper()
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return v.GetStringMap(key)
|
||||
return defaultManager.Load().GetStringMap(key)
|
||||
}
|
||||
|
||||
// IsDevelopment 是否开发环境
|
||||
|
||||
@@ -329,3 +329,171 @@ func TestStartWatcherIdempotent(t *testing.T) {
|
||||
}
|
||||
m.StopWatcher()
|
||||
}
|
||||
|
||||
func TestStopWatcherWaitsInFlightReload(t *testing.T) {
|
||||
p := writeConfig(t, "c10_stop_wait.yaml", validConfigYAML(8091))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
m.RegisterCallback(func(*config.Config) {
|
||||
close(entered)
|
||||
<-release
|
||||
})
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(p, []byte(validConfigYAML(8092)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("热更新回调未触发")
|
||||
}
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
m.StopWatcher()
|
||||
close(stopped)
|
||||
}()
|
||||
select {
|
||||
case <-stopped:
|
||||
t.Fatal("StopWatcher 不应在 reload 回调结束前返回")
|
||||
case <-time.After(120 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("StopWatcher 未等待到 reload 回调结束")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithWatchFailureKeepsOldWatcher(t *testing.T) {
|
||||
p := writeConfig(t, "c10_keep_old.yaml", validConfigYAML(8093))
|
||||
defer os.Remove(p)
|
||||
|
||||
changes := make(chan int, 4)
|
||||
if _, err := config.LoadWithWatch(p, func(c *config.Config) {
|
||||
changes <- c.Server.Port
|
||||
}); err != nil {
|
||||
t.Fatalf("LoadWithWatch old: %v", err)
|
||||
}
|
||||
defer config.StopWatcher()
|
||||
|
||||
missing := filepath.Join(filepath.Dir(p), "missing.yaml")
|
||||
if _, err := config.LoadWithWatch(missing, nil); err == nil {
|
||||
t.Fatal("加载缺失配置应失败")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(p, []byte(validConfigYAML(8094)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
select {
|
||||
case port := <-changes:
|
||||
if port != 8094 {
|
||||
t.Fatalf("旧 watcher 回调端口错误: %d", port)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("加载失败后旧 watcher 不应被停止")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultManagerStopsOldWatcher(t *testing.T) {
|
||||
oldPath := writeConfig(t, "c10_old_default.yaml", validConfigYAML(8095))
|
||||
newPath := writeConfig(t, "c10_new_default.yaml", validConfigYAML(8096))
|
||||
defer os.Remove(oldPath)
|
||||
defer os.Remove(newPath)
|
||||
|
||||
oldManager := config.NewManager(oldPath)
|
||||
if _, err := oldManager.Load(); err != nil {
|
||||
t.Fatalf("old Load: %v", err)
|
||||
}
|
||||
changes := make(chan int, 4)
|
||||
oldManager.RegisterCallback(func(c *config.Config) {
|
||||
changes <- c.Server.Port
|
||||
})
|
||||
if err := oldManager.StartWatcher(); err != nil {
|
||||
t.Fatalf("old StartWatcher: %v", err)
|
||||
}
|
||||
config.SetDefaultManager(oldManager)
|
||||
|
||||
newManager := config.NewManager(newPath)
|
||||
if _, err := newManager.Load(); err != nil {
|
||||
t.Fatalf("new Load: %v", err)
|
||||
}
|
||||
config.SetDefaultManager(newManager)
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
if err := os.WriteFile(oldPath, []byte(validConfigYAML(8097)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile old: %v", err)
|
||||
}
|
||||
select {
|
||||
case port := <-changes:
|
||||
t.Fatalf("旧 watcher 已停止,不应收到端口 %d", port)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetAndViperReturnCopies(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{Name: "copy", Env: "dev"},
|
||||
CORS: config.CORSConfig{
|
||||
AllowedOrigins: []string{"https://a.example.com"},
|
||||
},
|
||||
}
|
||||
if err := config.Set(cfg); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
cfg.App.Name = "mutated-input"
|
||||
cfg.CORS.AllowedOrigins[0] = "https://mutated-input.example.com"
|
||||
got := config.Get()
|
||||
if got.App.Name != "copy" {
|
||||
t.Fatalf("Set 应保存副本,实际 App.Name=%q", got.App.Name)
|
||||
}
|
||||
if got.CORS.AllowedOrigins[0] != "https://a.example.com" {
|
||||
t.Fatalf("Set 应深拷贝切片,实际 origin=%q", got.CORS.AllowedOrigins[0])
|
||||
}
|
||||
|
||||
got.App.Name = "mutated-get"
|
||||
got.CORS.AllowedOrigins[0] = "https://mutated-get.example.com"
|
||||
gotAgain := config.Get()
|
||||
if gotAgain.App.Name != "copy" || gotAgain.CORS.AllowedOrigins[0] != "https://a.example.com" {
|
||||
t.Fatalf("Get 应返回副本,实际 %+v", gotAgain)
|
||||
}
|
||||
|
||||
if err := config.Set(&config.Config{Server: config.ServerConfig{Port: 99999}}); err == nil {
|
||||
t.Fatal("Set 非法配置应返回错误")
|
||||
}
|
||||
if got := config.Get().App.Name; got != "copy" {
|
||||
t.Fatalf("Set 非法配置不应覆盖旧配置,实际 App.Name=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetViperReturnsSnapshot(t *testing.T) {
|
||||
p := writeConfig(t, "c10_viper_snapshot.yaml", validConfigYAML(8098))
|
||||
defer os.Remove(p)
|
||||
|
||||
if _, err := config.Load(p); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
v := config.GetViper()
|
||||
if v == nil {
|
||||
t.Fatal("GetViper returned nil")
|
||||
}
|
||||
v.Set("app.name", "mutated")
|
||||
if got := config.GetString("app.name"); got != "c10" {
|
||||
t.Fatalf("GetViper 应返回快照,不应污染内部 viper,实际 app.name=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// TestSetKeepsViperInSync_Hconfig1 固化 H-config-1:Set(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-test(H-config-1 同源)", v)
|
||||
}
|
||||
if v := config.GetInt("server.port"); v != 7777 {
|
||||
t.Errorf("GetInt(server.port) = %d, want 7777(H-config-1)", v)
|
||||
}
|
||||
if v := config.GetBool("app.debug"); v != true {
|
||||
t.Errorf("GetBool(app.debug) = %v, want true(H-config-1)", v)
|
||||
}
|
||||
if v := config.GetString("app.env"); v != "prod" {
|
||||
t.Errorf("GetString(app.env) = %q, want prod(H-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 24h(Duration 同源应读 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 TLS:TLS=false 无 tls 参数;
|
||||
// TLS=true 无 CA 用内置 tls=true;TLS=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-3:MaxOpenConns>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-2:TLSRootCA 需配合 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-6:DSN/MySQLDSN/PostgresDSN/Addr 对 nil receiver 安全。
|
||||
func TestDSNAddrNilReceiver_Lconfig6(t *testing.T) {
|
||||
var db *config.DatabaseConfig
|
||||
if v := db.DSN(); v != "" {
|
||||
t.Errorf("nil DatabaseConfig.DSN() = %q, want empty", v)
|
||||
}
|
||||
if v := db.MySQLDSN(); v != "" {
|
||||
t.Errorf("nil MySQLDSN() = %q, want empty", v)
|
||||
}
|
||||
if v := db.PostgresDSN(); v != "" {
|
||||
t.Errorf("nil PostgresDSN() = %q, want empty", v)
|
||||
}
|
||||
var r *config.RedisConfig
|
||||
if v := r.Addr(); v != "" {
|
||||
t.Errorf("nil RedisConfig.Addr() = %q, want empty", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloneDeepCopiesAllSliceFields_Mconfig4 固化 M-config-4:反射枚举 Config 所有切片/map 字段,
|
||||
// 断言 Clone 深拷贝(不同底层数组/map)。fixture 必须填充每个切片字段--新增切片字段若未在 Clone
|
||||
// 中处理、或未在 fixture 中填充,本测试都会失败,形成机械守卫。
|
||||
func TestCloneDeepCopiesAllSliceFields_Mconfig4(t *testing.T) {
|
||||
original := allSlicesPopulatedConfig()
|
||||
clone := original.Clone()
|
||||
|
||||
var check func(o, c reflect.Value, path string)
|
||||
check = func(o, c reflect.Value, path string) {
|
||||
for o.Kind() == reflect.Pointer {
|
||||
o = o.Elem()
|
||||
}
|
||||
for c.Kind() == reflect.Pointer {
|
||||
c = c.Elem()
|
||||
}
|
||||
if o.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
for i := 0; i < o.NumField(); i++ {
|
||||
of := o.Field(i)
|
||||
cf := c.Field(i)
|
||||
p := path + "." + o.Type().Field(i).Name
|
||||
switch of.Kind() {
|
||||
case reflect.Pointer, reflect.Struct:
|
||||
check(of, cf, p)
|
||||
case reflect.Slice:
|
||||
if of.IsNil() || of.Len() == 0 {
|
||||
t.Errorf("M-config-4: fixture 未填充切片 %s(新增切片字段须同步 fixture 与 Clone)", p)
|
||||
continue
|
||||
}
|
||||
if cf.IsNil() || cf.Len() != of.Len() {
|
||||
t.Errorf("M-config-4: Clone 后切片 %s 为 nil 或长度不一致", p)
|
||||
continue
|
||||
}
|
||||
if of.Pointer() == cf.Pointer() {
|
||||
t.Errorf("M-config-4: Clone 未深拷贝切片 %s(共享底层数组)", p)
|
||||
}
|
||||
case reflect.Map:
|
||||
if of.IsNil() || of.Len() == 0 {
|
||||
t.Errorf("M-config-4: fixture 未填充 map %s", p)
|
||||
continue
|
||||
}
|
||||
if of.Pointer() == cf.Pointer() {
|
||||
t.Errorf("M-config-4: Clone 未深拷贝 map %s(共享 map)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
check(reflect.ValueOf(original), reflect.ValueOf(clone), "Config")
|
||||
}
|
||||
|
||||
// allSlicesPopulatedConfig 返回一个填充了所有切片字段的 Config,供 Clone 覆盖断言使用。
|
||||
// 新增任何切片/map 字段到 Config 或其子结构体时,必须同步在此填充,否则
|
||||
// TestCloneDeepCopiesAllSliceFields_Mconfig4 会以"未填充"失败提醒。
|
||||
func allSlicesPopulatedConfig() *config.Config {
|
||||
return &config.Config{
|
||||
CORS: config.CORSConfig{
|
||||
AllowedOrigins: []string{"https://a.example.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedHeaders: []string{"X-Test"},
|
||||
ExposedHeaders: []string{"X-Exp"},
|
||||
},
|
||||
Upload: config.UploadConfig{
|
||||
AllowedImageTypes: []string{"image/jpeg"},
|
||||
AllowedVideoTypes: []string{"video/mp4"},
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Upload: config.UploadPolicy{
|
||||
AllowedExts: []string{".jpg"},
|
||||
AllowedMIMEs: []string{"image/jpeg"},
|
||||
},
|
||||
},
|
||||
OSS: config.OSSStorageConfig{
|
||||
Upload: config.UploadPolicy{
|
||||
AllowedExts: []string{".png"},
|
||||
AllowedMIMEs: []string{"image/png"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
+25
-5
@@ -102,7 +102,7 @@ func TestDatabaseConfigPostgresDSN(t *testing.T) {
|
||||
}
|
||||
|
||||
dsn := db.DSN()
|
||||
expected := "host=localhost port=5432 user=postgres password='password' dbname=testdb sslmode=disable TimeZone=Asia/Shanghai"
|
||||
expected := "host='localhost' port=5432 user='postgres' password='password' dbname='testdb' sslmode=prefer TimeZone='Asia/Shanghai'"
|
||||
if dsn != expected {
|
||||
t.Errorf("Postgres DSN = %s, want %s", dsn, expected)
|
||||
}
|
||||
@@ -113,6 +113,26 @@ func TestDatabaseConfigPostgresDSN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseConfigUnknownDriverDoesNotFallback(t *testing.T) {
|
||||
db := config.DatabaseConfig{
|
||||
Driver: "no-such-driver",
|
||||
Host: "localhost",
|
||||
Port: 3306,
|
||||
User: "root",
|
||||
Password: "password",
|
||||
Name: "testdb",
|
||||
}
|
||||
|
||||
if dsn := db.DSN(); dsn != "" {
|
||||
t.Fatalf("未知 driver 不应静默回退 MySQL,实际 DSN=%q", dsn)
|
||||
}
|
||||
|
||||
cfg := &config.Config{Database: db}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "database.driver") {
|
||||
t.Fatalf("未知 driver 应在 Validate 阶段报错,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatabaseConfigDSNPasswordEscape_M9:含特殊字符的密码须被转义,不破坏 DSN。
|
||||
func TestDatabaseConfigDSNPasswordEscape_M9(t *testing.T) {
|
||||
// MySQL:密码含 @/:/空格 → url.QueryEscape
|
||||
@@ -140,13 +160,13 @@ func TestDatabaseConfigDSNPasswordEscape_M9(t *testing.T) {
|
||||
Name: "testdb",
|
||||
}
|
||||
pdsn := pg.PostgresDSN()
|
||||
if !strings.Contains(pdsn, "password='p''ord'") {
|
||||
if !strings.Contains(pdsn, `password='p\'ord'`) {
|
||||
t.Errorf("Postgres DSN password not escaped: %s", pdsn)
|
||||
}
|
||||
|
||||
// Timezone 可配置
|
||||
pg2 := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n", Timezone: "UTC"}
|
||||
if !strings.Contains(pg2.PostgresDSN(), "TimeZone=UTC") {
|
||||
if !strings.Contains(pg2.PostgresDSN(), "TimeZone='UTC'") {
|
||||
t.Errorf("Postgres DSN should honor Timezone=UTC: %s", pg2.PostgresDSN())
|
||||
}
|
||||
mysql2 := config.DatabaseConfig{Driver: config.DriverMySQL, Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", Timezone: "UTC"}
|
||||
@@ -350,7 +370,7 @@ func TestConfigManagerIsolation(t *testing.T) {
|
||||
func TestConfigSet(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{
|
||||
Name: "Manual",
|
||||
Name: "Manual",
|
||||
SiteName: "manual_site",
|
||||
},
|
||||
}
|
||||
@@ -418,4 +438,4 @@ func TestUploadConfig(t *testing.T) {
|
||||
if upload.MaxFileSize != 10 {
|
||||
t.Error("UploadConfig failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-5
@@ -39,17 +39,40 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Database:仅当配置了 driver 时校验(未启用 mysql 的项目可留空)
|
||||
if strings.TrimSpace(c.Database.Driver) != "" {
|
||||
if c.Database.Host == "" {
|
||||
// Database:出现任一数据库字段时视为启用;driver 为空按 MySQL 兼容处理。
|
||||
if c.Database.isConfigured() {
|
||||
driver := strings.TrimSpace(c.Database.Driver)
|
||||
if driver != "" {
|
||||
if _, ok := LookupDSNBuilder(driver); !ok {
|
||||
problems = append(problems, fmt.Sprintf("database.driver 未注册: %s", driver))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Host) == "" {
|
||||
problems = append(problems, "database.host 启用数据库后必填")
|
||||
}
|
||||
if c.Database.Name == "" {
|
||||
if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Name) == "" {
|
||||
problems = append(problems, "database.name 启用数据库后必填")
|
||||
}
|
||||
if c.Database.Port <= 0 || c.Database.Port > 65535 {
|
||||
if strings.TrimSpace(c.Database.CustomDSN) == "" && (c.Database.Port <= 0 || c.Database.Port > 65535) {
|
||||
problems = append(problems, fmt.Sprintf("database.port 超出范围(1-65535): %d", c.Database.Port))
|
||||
}
|
||||
// L-config-3:连接池配置交叉校验。MaxOpenConns>0 时 MaxIdleConns 不应超过它,
|
||||
// 否则 database/sql 会按 MaxOpenConns 截断空闲连接,配置意图与实际不符。
|
||||
if c.Database.MaxOpenConns > 0 && c.Database.MaxIdleConns > c.Database.MaxOpenConns {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"database.max_idle_conns(%d) 不应大于 max_open_conns(%d)", c.Database.MaxIdleConns, c.Database.MaxOpenConns))
|
||||
}
|
||||
// M-config-2:Postgres 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-2:TLSRootCA 仅在 TLS=true 时生效,单独配置而无 tls:true 视为配置不一致。
|
||||
if strings.TrimSpace(c.Database.TLSRootCA) != "" && !c.Database.TLS {
|
||||
problems = append(problems, "database.tls_root_ca 需配合 tls: true 才生效")
|
||||
}
|
||||
}
|
||||
|
||||
// Redis:仅当配置了 host 时校验
|
||||
@@ -59,6 +82,14 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Trace:仅当启用时校验采样比例。ExporterType/Propagator 的枚举校验委托 trace.Init
|
||||
// (随 OTel 版本可能扩展,避免 config 与 trace 双源枚举漂移)。
|
||||
if c.Trace.Enabled {
|
||||
if c.Trace.SampleRatio < 0 || c.Trace.SampleRatio > 1 {
|
||||
problems = append(problems, fmt.Sprintf("trace.sample_ratio 超出范围(0.0-1.0): %v", c.Trace.SampleRatio))
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("配置校验失败: %s", strings.Join(problems, "; "))
|
||||
}
|
||||
@@ -67,3 +98,12 @@ func (c *Config) Validate() error {
|
||||
|
||||
// validDuration 校验 Duration 非负(0 表示未配置/用默认,合法)。
|
||||
func validDuration(d time.Duration) bool { return d >= 0 }
|
||||
|
||||
// validPostgresSSLMode 校验 PostgreSQL sslmode 取值(M-config-2)。
|
||||
func validPostgresSSLMode(s string) bool {
|
||||
switch s {
|
||||
case "disable", "allow", "prefer", "require", "verify-ca", "verify-full":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -43,6 +43,24 @@ func TestValidateJWTSecretAbsentSkipped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTraceSampleRatioWhenEnabled(t *testing.T) {
|
||||
c := validBase()
|
||||
c.Trace.Enabled = true
|
||||
c.Trace.SampleRatio = 1.5
|
||||
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "trace.sample_ratio") {
|
||||
t.Fatalf("expected trace.sample_ratio error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTraceDisabledSkipsRatio(t *testing.T) {
|
||||
c := validBase()
|
||||
// Enabled=false 时即使 SampleRatio 非法也不校验(trace 未启用)
|
||||
c.Trace.SampleRatio = 1.5
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatalf("unexpected error when trace disabled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabaseMissingHost(t *testing.T) {
|
||||
c := validBase()
|
||||
c.Database.Driver = "mysql"
|
||||
|
||||
@@ -21,10 +21,15 @@ var writeMu sync.Mutex
|
||||
type Level int32
|
||||
|
||||
const (
|
||||
// LevelDebug 调试级别(最低)。
|
||||
LevelDebug Level = iota
|
||||
// LevelInfo 普通信息。
|
||||
LevelInfo
|
||||
// LevelSuccess 成功信息。
|
||||
LevelSuccess
|
||||
// LevelWarn 警告。
|
||||
LevelWarn
|
||||
// LevelError 错误(最高常规级别)。
|
||||
LevelError
|
||||
|
||||
// LevelSilent 完全静默:所有调用都不输出
|
||||
|
||||
+147
-20
@@ -3,6 +3,7 @@ package cron
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -39,19 +40,57 @@ type Schedule interface {
|
||||
Next(now time.Time) time.Time
|
||||
}
|
||||
|
||||
func validateSchedule(schedule Schedule) {
|
||||
switch s := schedule.(type) {
|
||||
case *IntervalSchedule:
|
||||
validateInterval(s.Interval)
|
||||
case *DailySchedule:
|
||||
validateClock(s.Hour, s.Minute, "Daily")
|
||||
case *WeeklySchedule:
|
||||
validateWeekday(s.Day)
|
||||
validateClock(s.Hour, s.Minute, "Weekly")
|
||||
}
|
||||
}
|
||||
|
||||
func validateInterval(interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
panic("cron: interval must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
func validateClock(hour, minute int, name string) {
|
||||
if hour < 0 || hour > 23 {
|
||||
panic(fmt.Sprintf("cron: %s hour must be in [0,23]", name))
|
||||
}
|
||||
if minute < 0 || minute > 59 {
|
||||
panic(fmt.Sprintf("cron: %s minute must be in [0,59]", name))
|
||||
}
|
||||
}
|
||||
|
||||
func validateWeekday(day time.Weekday) {
|
||||
if day < time.Sunday || day > time.Saturday {
|
||||
panic("cron: Weekly day must be in [Sunday,Saturday]")
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduler 调度器
|
||||
type Scheduler struct {
|
||||
tasks map[string]*Task
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
running bool
|
||||
tasks map[string]*Task
|
||||
mu sync.RWMutex
|
||||
lifecycleMu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
running bool
|
||||
}
|
||||
|
||||
func newSchedulerContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithCancel(context.Background())
|
||||
}
|
||||
|
||||
// NewScheduler 创建调度器
|
||||
func NewScheduler() *Scheduler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := newSchedulerContext()
|
||||
return &Scheduler{
|
||||
tasks: make(map[string]*Task),
|
||||
ctx: ctx,
|
||||
@@ -61,6 +100,14 @@ func NewScheduler() *Scheduler {
|
||||
|
||||
// AddTask 添加任务
|
||||
func (s *Scheduler) AddTask(name string, schedule Schedule, handler TaskHandler) *Task {
|
||||
if schedule == nil {
|
||||
panic("cron: AddTask requires a non-nil schedule")
|
||||
}
|
||||
if handler == nil {
|
||||
panic("cron: AddTask requires a non-nil handler")
|
||||
}
|
||||
validateSchedule(schedule)
|
||||
|
||||
task := &Task{
|
||||
Name: name,
|
||||
Schedule: schedule,
|
||||
@@ -139,6 +186,27 @@ func (s *Scheduler) ListTasks() []*Task {
|
||||
return tasks
|
||||
}
|
||||
|
||||
// executeTask 在 exec 边界运行 task handler;recover 捕获 panic 转为 error(含调用栈),
|
||||
// 防止调度 goroutine 内未 recover 的 panic 终止整个进程(M13)。
|
||||
// 命名返回值让 defer 在 panic 时改写 err。两侧调用点(RunTask / checkAndRun goroutine)
|
||||
// 共用此边界,panic 一律转为 error 记入 LastError 并向上返回,不破坏 running 守卫与 wg.Done
|
||||
// (recover 在本函数内部完成,外侧 defer 仍正常执行)。
|
||||
func (s *Scheduler) executeTask(t *Task) (err error) {
|
||||
s.mu.RLock()
|
||||
ctx := s.ctx
|
||||
s.mu.RUnlock()
|
||||
return s.executeTaskWithContext(ctx, t)
|
||||
}
|
||||
|
||||
func (s *Scheduler) executeTaskWithContext(ctx context.Context, t *Task) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("cron task %q panic recovered: %v\n%s", t.Name, r, debug.Stack())
|
||||
}
|
||||
}()
|
||||
return t.Handler(ctx)
|
||||
}
|
||||
|
||||
// RunTask 立即运行任务(手动触发,同步返回 handler 错误)。
|
||||
//
|
||||
// 占用 per-task running 守卫,与调度循环互斥,防止同一任务重叠执行(C12b)。
|
||||
@@ -166,11 +234,12 @@ func (s *Scheduler) RunTask(name string) error {
|
||||
}
|
||||
}()
|
||||
|
||||
err := task.Handler(s.ctx)
|
||||
err := s.executeTask(task)
|
||||
|
||||
s.mu.Lock()
|
||||
task.LastRun = time.Now()
|
||||
task.RunCount++
|
||||
task.LastError = err // M13: 手动路径也记 LastError(与调度路径对齐)
|
||||
s.mu.Unlock()
|
||||
|
||||
return err
|
||||
@@ -178,16 +247,25 @@ func (s *Scheduler) RunTask(name string) error {
|
||||
|
||||
// Start 启动调度器
|
||||
func (s *Scheduler) Start() {
|
||||
s.lifecycleMu.Lock()
|
||||
defer s.lifecycleMu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
s.ctx, s.cancel = newSchedulerContext()
|
||||
default:
|
||||
}
|
||||
ctx := s.ctx
|
||||
s.running = true
|
||||
s.wg.Add(1)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.run()
|
||||
go s.run(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止调度器并无限等待在跑任务退出(要求 handler 尊重 ctx.Done)。
|
||||
@@ -200,15 +278,19 @@ func (s *Scheduler) Stop() {
|
||||
// 返回 true 表示所有任务已退出;false 表示超时(仍有任务未响应 ctx.Done 而运行)。
|
||||
// timeout<=0 等价于无限等待(同 Stop)。幂等:未运行时直接返回 true。
|
||||
func (s *Scheduler) StopWithTimeout(timeout time.Duration) bool {
|
||||
s.lifecycleMu.Lock()
|
||||
defer s.lifecycleMu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
if !s.running {
|
||||
s.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
s.running = false
|
||||
cancel := s.cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
s.cancel()
|
||||
cancel()
|
||||
if timeout <= 0 {
|
||||
s.wg.Wait()
|
||||
return true
|
||||
@@ -224,7 +306,7 @@ func (s *Scheduler) StopWithTimeout(timeout time.Duration) bool {
|
||||
}
|
||||
|
||||
// run 运行调度循环
|
||||
func (s *Scheduler) run() {
|
||||
func (s *Scheduler) run(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
@@ -232,10 +314,10 @@ func (s *Scheduler) run() {
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.checkAndRun()
|
||||
s.checkAndRun(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,10 +338,23 @@ func (s *Scheduler) run() {
|
||||
// 一定能等到本批全部 goroutine。收集阶段已 CAS 占用守卫并推进 NextRun,故 spawn 必须
|
||||
// 执行(否则守卫不释放、NextRun 已推进却未跑)。spawn 用 s.ctx——Stop 后 ctx 已取消,
|
||||
// handler 收到 canceled ctx 应及时退出。
|
||||
func (s *Scheduler) checkAndRun() {
|
||||
func (s *Scheduler) checkAndRun(ctxs ...context.Context) {
|
||||
now := time.Now()
|
||||
var ctx context.Context
|
||||
enforceRunning := len(ctxs) > 0 && ctxs[0] != nil
|
||||
if len(ctxs) > 0 && ctxs[0] != nil {
|
||||
ctx = ctxs[0]
|
||||
} else {
|
||||
s.mu.RLock()
|
||||
ctx = s.ctx
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if enforceRunning && (!s.running || ctx.Err() != nil) {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
var due []*Task
|
||||
for _, task := range s.tasks {
|
||||
if !task.Enabled || task.NextRun.IsZero() || !now.After(task.NextRun) {
|
||||
@@ -285,7 +380,7 @@ func (s *Scheduler) checkAndRun() {
|
||||
}
|
||||
}()
|
||||
|
||||
err := t.Handler(s.ctx)
|
||||
err := s.executeTaskWithContext(ctx, t)
|
||||
|
||||
s.mu.Lock()
|
||||
t.LastRun = time.Now()
|
||||
@@ -313,6 +408,7 @@ func (s *IntervalSchedule) Next(now time.Time) time.Time {
|
||||
|
||||
// Every 每隔指定时间运行
|
||||
func Every(interval time.Duration) *IntervalSchedule {
|
||||
validateInterval(interval)
|
||||
return &IntervalSchedule{Interval: interval}
|
||||
}
|
||||
|
||||
@@ -333,6 +429,7 @@ func (s *DailySchedule) Next(now time.Time) time.Time {
|
||||
|
||||
// Daily 每日指定时间运行
|
||||
func Daily(hour, minute int) *DailySchedule {
|
||||
validateClock(hour, minute, "Daily")
|
||||
return &DailySchedule{Hour: hour, Minute: minute}
|
||||
}
|
||||
|
||||
@@ -361,6 +458,8 @@ func (s *WeeklySchedule) Next(now time.Time) time.Time {
|
||||
|
||||
// Weekly 每周指定时间运行
|
||||
func Weekly(day time.Weekday, hour, minute int) *WeeklySchedule {
|
||||
validateWeekday(day)
|
||||
validateClock(hour, minute, "Weekly")
|
||||
return &WeeklySchedule{Day: day, Hour: hour, Minute: minute}
|
||||
}
|
||||
|
||||
@@ -522,8 +621,21 @@ func parseCronRange(s string, min, max int) (int, int, error) {
|
||||
// "0 0 1 * *" - 每月1号凌晨
|
||||
// "0 0 * * 0" - 每周日凌晨
|
||||
//
|
||||
// 非法表达式回退默认全 "*"(每分钟执行)。需要严格校验请用 ParseCronStrict。
|
||||
// 非法表达式会 panic(fail-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
|
||||
}
|
||||
@@ -672,11 +784,16 @@ func Cron(minute, hour string) *CronSchedule {
|
||||
return &CronSchedule{Minute: minute, Hour: hour}
|
||||
}
|
||||
|
||||
// 全局调度器。用 atomic.Pointer 懒初始化,使 StopGlobalWithTimeout 能安全 peek
|
||||
// 是否已创建而不触发创建,也消除原 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 {
|
||||
if s := globalScheduler.Load(); s != nil {
|
||||
return s
|
||||
@@ -688,6 +805,16 @@ func GetScheduler() *Scheduler {
|
||||
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 添加任务到全局调度器
|
||||
func AddTask(name string, schedule Schedule, handler TaskHandler) *Task {
|
||||
return GetScheduler().AddTask(name, schedule, handler)
|
||||
|
||||
+18
-9
@@ -264,15 +264,15 @@ func TestC12eParseCronStrict(t *testing.T) {
|
||||
{"0 12 * * *", true},
|
||||
{"*/15 * * * *", true},
|
||||
{"0 9-17 * * 1-5", true},
|
||||
{"0 0 1 * 7", true}, // 周日 7 合法
|
||||
{"0 0 1 * 7", true}, // 周日 7 合法
|
||||
{"0 0 * * 0-7", false},
|
||||
{"invalid", false},
|
||||
{"1-5,8 0 * * *", true},
|
||||
{"60 0 * * *", false}, // 分钟越界
|
||||
{"0 25 * * *", false}, // 小时越界
|
||||
{"0 0 0 * *", false}, // 日越界
|
||||
{"0 0 * 13 *", false}, // 月越界
|
||||
{"0 0 * * 9", false}, // 周越界
|
||||
{"60 0 * * *", false}, // 分钟越界
|
||||
{"0 25 * * *", false}, // 小时越界
|
||||
{"0 0 0 * *", false}, // 日越界
|
||||
{"0 0 * 13 *", false}, // 月越界
|
||||
{"0 0 * * 9", false}, // 周越界
|
||||
{"garbage 0 * * *", false},
|
||||
{"*/0 * * * *", false}, // step=0 非法
|
||||
}
|
||||
@@ -287,11 +287,20 @@ func TestC12eParseCronStrict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestC12eParseCronFallback 验证 ParseCron 非法回退默认全 *(保持原行为)。
|
||||
// TestC12eParseCronFallback verifies ParseCron now fails fast on invalid input.
|
||||
func TestC12eParseCronFallback(t *testing.T) {
|
||||
s := cron.ParseCron("invalid")
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("ParseCron(invalid) should panic")
|
||||
}
|
||||
}()
|
||||
_ = cron.ParseCron("invalid")
|
||||
}
|
||||
|
||||
func TestC12eParseCronOrDefaultFallback(t *testing.T) {
|
||||
s := cron.ParseCronOrDefault("invalid")
|
||||
if s.Minute != "*" || s.Hour != "*" || s.Day != "*" || s.Month != "*" || s.Weekday != "*" {
|
||||
t.Errorf("ParseCron(invalid) should fall back to all-*, got %+v", s)
|
||||
t.Errorf("ParseCronOrDefault(invalid) should fall back to all-*, got %+v", s)
|
||||
}
|
||||
// 合法表达式不回退。
|
||||
s = cron.ParseCron("1-5,8 0 * * *")
|
||||
|
||||
@@ -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
@@ -232,10 +232,14 @@ func TestParseCron(t *testing.T) {
|
||||
}
|
||||
|
||||
// 无效表达式返回默认
|
||||
schedule = cron.ParseCron("invalid")
|
||||
if schedule.Minute != "*" || schedule.Hour != "*" {
|
||||
t.Error("ParseCron invalid should return default")
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("ParseCron invalid should panic")
|
||||
}
|
||||
}()
|
||||
_ = cron.ParseCron("invalid")
|
||||
}()
|
||||
}
|
||||
|
||||
func TestFullCronScheduleMatch(t *testing.T) {
|
||||
@@ -261,4 +265,4 @@ func TestFullCronScheduleMatch(t *testing.T) {
|
||||
if next.Minute() != 10 || next.Hour() != 10 {
|
||||
t.Errorf("Next after 10:07 should be 10:10, got %v", next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-11
@@ -1,6 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// 内置驱动常量(更多驱动可通过 RegisterDialect 扩展)
|
||||
@@ -29,8 +32,8 @@ type DialectSpec struct {
|
||||
Aliases []string
|
||||
// Dialector 由 DSN 构造 GORM Dialector
|
||||
Dialector DialectorFactory
|
||||
// DSN 由 DatabaseConfig 拼接连接字符串。可选——
|
||||
// 不提供时使用 cfg.MySQLDSN() 兜底(适合自定义驱动通过 CustomDSN 指定连接串的场景)
|
||||
// DSN 由 DatabaseConfig 拼接连接字符串。可选。
|
||||
// 不提供时仅 CustomDSN 能直接生效;需要由配置字段拼接连接串的自定义驱动应显式提供该函数。
|
||||
DSN config.DSNBuilder
|
||||
}
|
||||
|
||||
@@ -92,24 +95,61 @@ func RegisteredDialects() []string {
|
||||
}
|
||||
|
||||
// Dialector 根据配置返回 GORM Dialector。
|
||||
// 驱动由 cfg.Database.Driver 决定,未指定或未注册时按 MySQL 兜底(向后兼容)。
|
||||
// 驱动由 cfg.Database.Driver 决定;未指定时默认 MySQL,非空但未注册时返回会初始化失败的
|
||||
// Dialector,避免拼写错误静默回退到 MySQL。
|
||||
func Dialector(cfg *config.Config) gorm.Dialector {
|
||||
if cfg == nil {
|
||||
logger.Warn("database: 配置为空,回退到 MySQL 空 DSN")
|
||||
return mysql.Open("")
|
||||
}
|
||||
return dialectorForDSN(cfg.Database.Driver, cfg.Database.DSN())
|
||||
}
|
||||
|
||||
// dialectorForDSN 根据驱动名和 DSN 返回 Dialector
|
||||
func dialectorForDSN(driver, dsn string) gorm.Dialector {
|
||||
if f, ok := LookupDialect(driver); ok {
|
||||
normalized := normalizeDriver(driver)
|
||||
if normalized == "" {
|
||||
normalized = DriverMySQL
|
||||
}
|
||||
if f, ok := LookupDialect(normalized); ok {
|
||||
return f(dsn)
|
||||
}
|
||||
// 未注册时回退到 MySQL,与 config.DSN() 的回退保持一致。
|
||||
// 拼写错误的驱动名(如 "mysq"/"postgrs")会静默回退 MySQL,导致连接错误难排查(M10),
|
||||
// 故在此告警一次,提示用户驱动名未注册。
|
||||
logger.Warnf("database: 驱动 %q 未注册,回退到 MySQL(已注册: %s);若是拼写错误请在配置中修正 driver",
|
||||
normalizeDriver(driver), strings.Join(RegisteredDialects(), ", "))
|
||||
return mysql.Open(dsn)
|
||||
logger.Warnf("database: 驱动 %q 未注册(已注册: %s),拒绝静默回退到 MySQL;请修正配置或先注册方言",
|
||||
normalized, strings.Join(RegisteredDialects(), ", "))
|
||||
return errorDialector{
|
||||
name: "invalid",
|
||||
err: fmt.Errorf("数据库驱动未注册: %s", normalized),
|
||||
}
|
||||
}
|
||||
|
||||
type errorDialector struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
func (d errorDialector) Name() string { return d.name }
|
||||
|
||||
func (d errorDialector) Initialize(*gorm.DB) error {
|
||||
if d.err == nil {
|
||||
return errors.New("数据库驱动未注册")
|
||||
}
|
||||
return d.err
|
||||
}
|
||||
|
||||
func (d errorDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
|
||||
|
||||
func (d errorDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
|
||||
func (d errorDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
|
||||
func (d errorDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
|
||||
|
||||
func (d errorDialector) QuoteTo(writer clause.Writer, str string) {
|
||||
_, _ = writer.WriteString(str)
|
||||
}
|
||||
|
||||
func (d errorDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
|
||||
// normalizeDriver 规范化驱动名(小写、去空白)
|
||||
func normalizeDriver(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
@@ -124,7 +164,7 @@ func driverDescription(driver string) string {
|
||||
if _, ok := LookupDialect(key); ok {
|
||||
return key
|
||||
}
|
||||
return fmt.Sprintf("%s (unregistered, fallback=%s)", key, DriverMySQL)
|
||||
return fmt.Sprintf("%s (unregistered)", key)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
+161
-36
@@ -2,10 +2,11 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -30,6 +31,13 @@ const (
|
||||
dbModeReplica = "replica"
|
||||
)
|
||||
|
||||
func normalizeContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ReplicaPicker 从库选择策略
|
||||
type ReplicaPicker interface {
|
||||
Pick(replicas []*gorm.DB) *gorm.DB
|
||||
@@ -37,7 +45,8 @@ type ReplicaPicker interface {
|
||||
|
||||
// RoundRobinPicker 轮询选择从库
|
||||
type RoundRobinPicker struct {
|
||||
counter uint64
|
||||
mu sync.Mutex
|
||||
counter int
|
||||
}
|
||||
|
||||
// Pick 轮询选择一个从库
|
||||
@@ -45,14 +54,17 @@ func (p *RoundRobinPicker) Pick(replicas []*gorm.DB) *gorm.DB {
|
||||
if len(replicas) == 0 {
|
||||
return nil
|
||||
}
|
||||
n := atomic.AddUint64(&p.counter, 1)
|
||||
return replicas[int(n-1)%len(replicas)]
|
||||
p.mu.Lock()
|
||||
idx := p.counter % len(replicas)
|
||||
p.counter = (idx + 1) % len(replicas)
|
||||
p.mu.Unlock()
|
||||
return replicas[idx]
|
||||
}
|
||||
|
||||
// RandomPicker 随机选择从库。
|
||||
//
|
||||
// D2 注释:rand.Intn 自 Go 1.20 起(go.dev/issue/54899)内部使用 per-goroutine
|
||||
// 随机源,并发安全无需额外同步。本模块 go.mod 声明 go 1.25.0,满足此要求。
|
||||
// 使用 crypto/rand 生成随机索引,并发安全且不可预测。len(replicas)<=0 返回 nil;
|
||||
// crypto/rand 失败(极罕见,如熵池耗尽)时回退到 replicas[0],保证可用性。
|
||||
type RandomPicker struct{}
|
||||
|
||||
// Pick 随机选择一个从库
|
||||
@@ -60,7 +72,11 @@ func (p *RandomPicker) Pick(replicas []*gorm.DB) *gorm.DB {
|
||||
if len(replicas) == 0 {
|
||||
return nil
|
||||
}
|
||||
return replicas[rand.Intn(len(replicas))]
|
||||
n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(len(replicas))))
|
||||
if err != nil {
|
||||
return replicas[0]
|
||||
}
|
||||
return replicas[int(n.Int64())]
|
||||
}
|
||||
|
||||
// Manager 数据库管理器,持有主库与从库连接实例
|
||||
@@ -70,6 +86,9 @@ type Manager struct {
|
||||
replicas []*gorm.DB
|
||||
picker ReplicaPicker
|
||||
mu sync.Mutex
|
||||
// opMu 串行化 InitDB / InitDBWithReplicas / Close 这类资源生命周期操作。
|
||||
// 避免关闭已开始后初始化又发布新连接,或初始化发布后被并发 Close 置空。
|
||||
opMu sync.Mutex
|
||||
|
||||
// #21 健康自愈
|
||||
healthy atomic.Bool // 主库是否健康
|
||||
@@ -190,6 +209,15 @@ func (m *Manager) initReplicaHealth() {
|
||||
m.replicaHealthSet = true
|
||||
}
|
||||
|
||||
// ensureReplicaHealthLocked 按当前 replicas 重建健康标记。调用方须持有 m.mu。
|
||||
func (m *Manager) ensureReplicaHealthLocked() {
|
||||
m.replicaHealthy = make([]atomic.Bool, len(m.replicas))
|
||||
for i := range m.replicaHealthy {
|
||||
m.replicaHealthy[i].Store(true)
|
||||
}
|
||||
m.replicaHealthSet = true
|
||||
}
|
||||
|
||||
// resetReplicaHealth 清空从库健康标记,使下次 initReplicaHealth 按新 replicas 长度重建。
|
||||
// 重建从库(InitDBWithReplicas)/Close 前必须调用,避免健康切片与新 replicas 长度错位(C11a)。
|
||||
// 调用方须持有 m.mu。
|
||||
@@ -203,6 +231,7 @@ func (m *Manager) resetReplicaHealth() {
|
||||
// 周期 ping 主库,连续失败达阈值后标记不健康(IsHealthy=false);
|
||||
// 同时 ping 各从库,失败则从读流量剔除,恢复后自动重新纳入。
|
||||
func (m *Manager) StartProbing(ctx context.Context) {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.initReplicaHealth()
|
||||
|
||||
cfg := m.getCfg() // P1 #11:锁内快照,避免与 InitDB 写 m.cfg 竞态
|
||||
@@ -255,6 +284,9 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) {
|
||||
m.mu.Lock()
|
||||
replicas := make([]*gorm.DB, len(m.replicas))
|
||||
copy(replicas, m.replicas)
|
||||
if len(replicas) > 0 && !m.replicaHealthSet {
|
||||
m.ensureReplicaHealthLocked()
|
||||
}
|
||||
healthSet := m.replicaHealthSet
|
||||
replicaHealthy := m.replicaHealthy // 快照切片头,避免与 resetReplicaHealth 写竞争
|
||||
m.mu.Unlock()
|
||||
@@ -272,7 +304,8 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
// M11(H-db-1):从库探活 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞探活 goroutine。
|
||||
if err := pingWithTimeout(sqlDB, ctx); err != nil {
|
||||
if i < len(replicaHealthy) && replicaHealthy[i].Load() {
|
||||
logger.Warnf("数据库从库 #%d 探活失败,暂时剔除读流量: %v", i, err)
|
||||
}
|
||||
@@ -289,6 +322,7 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) {
|
||||
|
||||
// FromContext 根据上下文选择数据库
|
||||
func (m *Manager) FromContext(ctx context.Context) *gorm.DB {
|
||||
ctx = normalizeContext(ctx)
|
||||
mode, ok := ctx.Value(dbModeContextKey{}).(string)
|
||||
if !ok {
|
||||
return m.Replica()
|
||||
@@ -309,7 +343,7 @@ func (m *Manager) Open(ctx context.Context) error {
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
return m.InitDB(cfg)
|
||||
return m.InitDB(ctx, cfg)
|
||||
}
|
||||
|
||||
// OpenWithReplicas 打开主库与从库连接
|
||||
@@ -318,7 +352,7 @@ func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) er
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
return m.InitDBWithReplicas(cfg, replicaDSNs)
|
||||
return m.InitDBWithReplicas(ctx, cfg, replicaDSNs)
|
||||
}
|
||||
|
||||
// closeDB 关闭 gorm.DB 底层连接池。nil 或未初始化(无 ConnPool)时返回 nil,不 panic。
|
||||
@@ -334,9 +368,17 @@ func closeDB(db *gorm.DB) error {
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
func warnCloseDB(db *gorm.DB, context string) {
|
||||
if err := closeDB(db); err != nil {
|
||||
logger.Warnf("%s: %v", context, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭主库与全部从库连接,并重置从库健康状态。
|
||||
// 字段置空在锁内完成(保证新读取得到 nil),实际关闭在锁外执行避免持锁阻塞。
|
||||
func (m *Manager) Close() error {
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
m.mu.Lock()
|
||||
master := m.master
|
||||
replicas := m.replicas
|
||||
@@ -358,8 +400,13 @@ func (m *Manager) Close() error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查,主库不可达时返回错误
|
||||
// HealthCheck 健康检查,主库不可达时返回错误。
|
||||
//
|
||||
// M11 完整覆盖(H-db-1 修复):ping 经 pingWithTimeout 受 healthCheckTimeout(3s) 约束,
|
||||
// 使后台探活(probeOnce)与 /health 端点(app.go 经本方法)都不会被挂起 DB(连接活但不
|
||||
// 响应)无限阻塞。ctx 自带更短 deadline 时优先尊重 ctx。
|
||||
func (m *Manager) HealthCheck(ctx context.Context) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.mu.Lock()
|
||||
db := m.master
|
||||
m.mu.Unlock()
|
||||
@@ -370,7 +417,7 @@ func (m *Manager) HealthCheck(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.PingContext(ctx)
|
||||
return pingWithTimeout(sqlDB, ctx)
|
||||
}
|
||||
|
||||
// DefaultManager 默认数据库管理器,包级 facade 代理到它。
|
||||
@@ -389,11 +436,28 @@ func init() {
|
||||
DefaultManager.Store(NewManager(nil))
|
||||
}
|
||||
|
||||
// SetDefaultManager 提升指定 Manager 为全局默认(主线A 修复:atomic.Store,并发安全)。
|
||||
// SwapDefaultManager 将指定 Manager 置为全局默认,并返回被替换的旧 Manager。
|
||||
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存。
|
||||
// nil 被忽略,以防 facade Load 到 nil panic。
|
||||
func SwapDefaultManager(m *Manager) *Manager {
|
||||
if m == nil {
|
||||
return DefaultManager.Load()
|
||||
}
|
||||
return DefaultManager.Swap(m)
|
||||
}
|
||||
|
||||
// SetDefaultManager 提升指定 Manager 为全局默认,并关闭被替换的旧 Manager。
|
||||
// 用于多实例场景或测试注入。nil 被忽略以防 facade Load 到 nil panic。
|
||||
// 若调用方需要保留旧 Manager 用于回滚,请使用 SwapDefaultManager。
|
||||
func SetDefaultManager(m *Manager) {
|
||||
if m != nil {
|
||||
DefaultManager.Store(m)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
old := SwapDefaultManager(m)
|
||||
if old != nil && old != m {
|
||||
if err := old.Close(); err != nil {
|
||||
logger.Warnf("关闭被替换的旧数据库 manager 失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,8 +468,22 @@ func GetDefaultManager() *Manager {
|
||||
return DefaultManager.Load()
|
||||
}
|
||||
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
|
||||
func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。
|
||||
// ctx 控制 Ping 与重试等待;调用方取消 ctx 时初始化会尽快返回。
|
||||
func (m *Manager) InitDB(ctx context.Context, cfg *config.Config) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
return m.initDB(ctx, cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("数据库初始化已取消: %w", err)
|
||||
}
|
||||
m.setCfg(cfg) // P1 #11:锁内写入,避免与 StartProbing/Open 读竞态
|
||||
|
||||
// GORM 日志配置
|
||||
@@ -418,6 +496,17 @@ func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormLogLevel),
|
||||
// H-db-1:禁用 gorm.Open 的自动 Ping(gorm.go:204,ConnPool 为 *sql.DB 时会调
|
||||
// pinger.Ping() 无超时)。挂起 DB(连接活但不响应)下该 Ping 无 ctx deadline 无限阻塞,
|
||||
// 发生在 initDB 的 pingWithTimeout 之前,使启动卡死。框架改用 pingWithTimeout(3s)自管
|
||||
// 启动 ping,故禁用 gorm 无超时自动 ping。InitDBWithReplicas 的 replica 路径同理。
|
||||
DisableAutomaticPing: true,
|
||||
}
|
||||
|
||||
// M-config-2:MySQL 启用 TLS 且配置自定义 CA 时,注册命名 TLS 配置,使 DSN 中 tls=<name> 生效。
|
||||
// 失败 fail-fast 返回错误,绝不静默回退明文连接。非 MySQL / 未配 CA 为 no-op。
|
||||
if err := ensureMySQLTLSRegistered(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 重试配置
|
||||
@@ -426,6 +515,9 @@ func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
|
||||
var lastErr error
|
||||
for i := range maxRetries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("数据库初始化已取消: %w", err)
|
||||
}
|
||||
// 连接主库:先打开到局部变量,仅 Ping 成功后才安装为 m.master,
|
||||
// 避免 Ping 失败时下轮覆盖 m.master 泄漏旧池(C11b)。
|
||||
db, err := gorm.Open(Dialector(cfg), gormConfig)
|
||||
@@ -439,7 +531,7 @@ func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
_ = closeDB(db) // C11b: 关闭刚打开的池,避免下轮泄漏
|
||||
warnCloseDB(db, "关闭刚打开的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮泄漏
|
||||
} else {
|
||||
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
|
||||
@@ -448,14 +540,15 @@ func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime)
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err == nil {
|
||||
// M11(H-db-1):启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 致 InitDB 重试失败而非无限阻塞。
|
||||
if err := pingWithTimeout(sqlDB, ctx); err == nil {
|
||||
// 成功:安装为新主库,关闭旧主库池(重建路径覆盖前先释放旧资源,C11b)
|
||||
m.mu.Lock()
|
||||
old := m.master
|
||||
m.master = db
|
||||
m.mu.Unlock()
|
||||
m.healthy.Store(true) // Ping 通过才标记健康(#21)
|
||||
_ = closeDB(old)
|
||||
warnCloseDB(old, "关闭旧数据库主库连接池失败")
|
||||
logger.Info("数据库主库连接成功",
|
||||
zap.String("driver", driverDescription(cfg.Database.Driver)),
|
||||
zap.String("host", cfg.Database.Host),
|
||||
@@ -464,13 +557,20 @@ func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
} else {
|
||||
// Ping 失败(如服务端暂时不可达)视作可重试
|
||||
lastErr = err
|
||||
_ = closeDB(db) // C11b: 关闭刚打开的池,避免下轮覆盖泄漏
|
||||
warnCloseDB(db, "关闭 Ping 失败的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮覆盖泄漏
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Warnf("数据库连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, lastErr)
|
||||
time.Sleep(retryDelay)
|
||||
if i == maxRetries-1 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("数据库初始化已取消: %w", ctx.Err())
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
retryDelay *= 2
|
||||
if retryDelay > 30*time.Second {
|
||||
retryDelay = 30 * time.Second
|
||||
@@ -527,9 +627,15 @@ func replicaMaxOpenConns(masterMax int) int {
|
||||
|
||||
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
|
||||
// replicaDSNs: 从库连接字符串列表(需与主库驱动匹配)
|
||||
func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error {
|
||||
func (m *Manager) InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
// 先初始化主库
|
||||
if err := m.InitDB(cfg); err != nil {
|
||||
if err := m.initDB(ctx, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -540,7 +646,7 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
|
||||
m.resetReplicaHealth()
|
||||
m.mu.Unlock()
|
||||
for _, r := range oldReplicas {
|
||||
_ = closeDB(r)
|
||||
warnCloseDB(r, "关闭旧数据库从库连接池失败")
|
||||
}
|
||||
|
||||
// 初始化从库
|
||||
@@ -554,11 +660,19 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormLogLevel),
|
||||
// H-db-1:禁用 gorm.Open 自动 Ping(同 initDB),框架用 pingWithTimeout 自管 replica 启动 ping。
|
||||
DisableAutomaticPing: true,
|
||||
}
|
||||
|
||||
// 先构建到局部切片,全部成功后再安装,避免部分构建期间外部读到中间态
|
||||
var newReplicas []*gorm.DB
|
||||
for i, dsn := range replicaDSNs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
for _, r := range newReplicas {
|
||||
warnCloseDB(r, "关闭已打开的数据库从库连接池失败")
|
||||
}
|
||||
return fmt.Errorf("数据库从库初始化已取消: %w", err)
|
||||
}
|
||||
replicaDB, err := gorm.Open(dialectorForDSN(cfg.Database.Driver, dsn), gormConfig)
|
||||
if err != nil {
|
||||
logger.Warnf("数据库从库 %d 连接失败: %v", i+1, err)
|
||||
@@ -568,7 +682,7 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
|
||||
sqlDB, err := replicaDB.DB()
|
||||
if err != nil {
|
||||
logger.Warnf("数据库从库 %d 获取连接池失败: %v", i+1, err)
|
||||
_ = closeDB(replicaDB) // C11c: 关闭刚打开的池避免泄漏
|
||||
warnCloseDB(replicaDB, "关闭刚打开的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -581,9 +695,10 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
|
||||
sqlDB.SetMaxOpenConns(replicaMaxOpenConns(cfg.Database.MaxOpenConns))
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
// M11(H-db-1):从库启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞 InitDBWithReplicas。
|
||||
if err := pingWithTimeout(sqlDB, ctx); err != nil {
|
||||
logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err)
|
||||
_ = closeDB(replicaDB) // C11c: 关闭刚打开的池避免泄漏
|
||||
warnCloseDB(replicaDB, "关闭 Ping 失败的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -593,20 +708,21 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
|
||||
|
||||
m.mu.Lock()
|
||||
m.replicas = newReplicas
|
||||
m.ensureReplicaHealthLocked()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
|
||||
func InitDB(cfg *config.Config) error {
|
||||
return DefaultManager.Load().InitDB(cfg)
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。
|
||||
func InitDB(ctx context.Context, cfg *config.Config) error {
|
||||
return DefaultManager.Load().InitDB(ctx, cfg)
|
||||
}
|
||||
|
||||
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
|
||||
func InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error {
|
||||
return DefaultManager.Load().InitDBWithReplicas(cfg, replicaDSNs)
|
||||
func InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error {
|
||||
return DefaultManager.Load().InitDBWithReplicas(ctx, cfg, replicaDSNs)
|
||||
}
|
||||
|
||||
// GetReadDB 获取读库实例(按策略选择从库)
|
||||
@@ -636,11 +752,13 @@ func SetReplicaPicker(p ReplicaPicker) {
|
||||
|
||||
// UseMaster 强制使用主库(用于事务或需要实时数据的场景)
|
||||
func UseMaster(ctx context.Context) context.Context {
|
||||
ctx = normalizeContext(ctx)
|
||||
return context.WithValue(ctx, dbModeContextKey{}, dbModeMaster)
|
||||
}
|
||||
|
||||
// UseReplica 强制使用从库(用于报表查询等场景)
|
||||
func UseReplica(ctx context.Context) context.Context {
|
||||
ctx = normalizeContext(ctx)
|
||||
return context.WithValue(ctx, dbModeContextKey{}, dbModeReplica)
|
||||
}
|
||||
|
||||
@@ -662,6 +780,7 @@ func GetDBFromContext(ctx context.Context) *gorm.DB {
|
||||
//
|
||||
// 注意:tx 仅在该 ctx 的生命周期内有效;事务提交/回滚后不得再用该 ctx 携带的 tx。
|
||||
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
|
||||
ctx = normalizeContext(ctx)
|
||||
if tx == nil {
|
||||
return ctx
|
||||
}
|
||||
@@ -670,6 +789,7 @@ func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
|
||||
|
||||
// TxFromContext 取出 ctx 携带的外层事务;无则返回 nil。
|
||||
func TxFromContext(ctx context.Context) *gorm.DB {
|
||||
ctx = normalizeContext(ctx)
|
||||
if tx, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
|
||||
return tx
|
||||
}
|
||||
@@ -704,6 +824,7 @@ func Transaction(fn func(tx *gorm.DB) error) error {
|
||||
|
||||
// TransactionWithContext 带上下文的事务操作
|
||||
func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
db := DefaultManager.Load().Master()
|
||||
if db == nil {
|
||||
return errors.New("数据库未初始化")
|
||||
@@ -711,8 +832,9 @@ func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) err
|
||||
return db.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
// ReadQuery 读查询(自动路由到从库)
|
||||
// ReadQuery 读查询。遵循 ctx 中的数据库路由标记;未指定时默认走从库。
|
||||
func ReadQuery(ctx context.Context, model any, query string, args ...any) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
db := GetDBFromContext(ctx)
|
||||
if db == nil {
|
||||
return errors.New("数据库未初始化")
|
||||
@@ -724,6 +846,7 @@ func ReadQuery(ctx context.Context, model any, query string, args ...any) error
|
||||
// 注意:命名沿用历史,实际用 .Find() 扫描结果集(读取语义),并非写操作——
|
||||
// 强制主库是为了读到刚写入的最新数据(read-your-writes)。命名误导见 M11。
|
||||
func WriteQuery(ctx context.Context, model any, query string, args ...any) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
db := DefaultManager.Load().Master()
|
||||
if db == nil {
|
||||
return errors.New("数据库未初始化")
|
||||
@@ -736,6 +859,7 @@ const healthCheckTimeout = 3 * time.Second
|
||||
|
||||
// pingWithTimeout 带超时的 ping,ctx 由调用方传入时优先尊重其 deadline。
|
||||
func pingWithTimeout(sqlDB *sql.DB, parent context.Context) error {
|
||||
parent = normalizeContext(parent)
|
||||
ctx, cancel := context.WithTimeout(parent, healthCheckTimeout)
|
||||
defer cancel()
|
||||
return sqlDB.PingContext(ctx)
|
||||
@@ -745,9 +869,10 @@ func pingWithTimeout(sqlDB *sql.DB, parent context.Context) error {
|
||||
func HealthCheck() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
ctx := context.Background()
|
||||
m := DefaultManager.Load()
|
||||
|
||||
// 检查主库
|
||||
if master := DefaultManager.Load().Master(); master != nil {
|
||||
if master := m.Master(); master != nil {
|
||||
sqlDB, err := master.DB()
|
||||
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
|
||||
result["master"] = true
|
||||
@@ -759,7 +884,7 @@ func HealthCheck() map[string]bool {
|
||||
}
|
||||
|
||||
// 检查从库
|
||||
for i, replica := range DefaultManager.Load().Replicas() {
|
||||
for i, replica := range m.Replicas() {
|
||||
if replica != nil {
|
||||
sqlDB, err := replica.DB()
|
||||
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
|
||||
|
||||
@@ -2,13 +2,17 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -131,6 +135,25 @@ func TestC11ReplicaHealthResetOnRebuild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4ProbeOnceReinitializesReplicaHealthAfterRebuild(t *testing.T) {
|
||||
m := &Manager{picker: &RandomPicker{}}
|
||||
m.mu.Lock()
|
||||
m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
|
||||
m.resetReplicaHealth()
|
||||
m.mu.Unlock()
|
||||
|
||||
m.probeOnce(context.Background(), 1)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.replicaHealthSet {
|
||||
t.Fatal("probeOnce 应在从库重建后自动重建健康标记")
|
||||
}
|
||||
if len(m.replicaHealthy) != 2 {
|
||||
t.Fatalf("健康标记长度应与从库数量一致,实际 %d", len(m.replicaHealthy))
|
||||
}
|
||||
}
|
||||
|
||||
// TestC11ReplicaHealthStaleWithoutReset 反向验证:不调 resetReplicaHealth 时
|
||||
// initReplicaHealth 早返回,健康切片长度不随新 replicas 变化(复现 C11a 缺陷根因)。
|
||||
func TestC11ReplicaHealthStaleWithoutReset(t *testing.T) {
|
||||
@@ -241,3 +264,141 @@ func TestC11HealthCheckLockedRead(t *testing.T) {
|
||||
t.Fatal("expected error for gorm.DB without ConnPool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultManagerClosesReplacedManager(t *testing.T) {
|
||||
old := NewManager(nil)
|
||||
old.mu.Lock()
|
||||
old.master = sentinelDB()
|
||||
old.replicas = []*gorm.DB{sentinelDB()}
|
||||
old.replicaHealthy = make([]atomic.Bool, 1)
|
||||
old.replicaHealthy[0].Store(true)
|
||||
old.replicaHealthSet = true
|
||||
old.mu.Unlock()
|
||||
old.healthy.Store(true)
|
||||
|
||||
orig := SwapDefaultManager(old)
|
||||
t.Cleanup(func() {
|
||||
current := SwapDefaultManager(orig)
|
||||
if current != nil && current != orig {
|
||||
_ = current.Close()
|
||||
}
|
||||
})
|
||||
|
||||
next := NewManager(nil)
|
||||
SetDefaultManager(next)
|
||||
if GetDefaultManager() != next {
|
||||
t.Fatal("SetDefaultManager 应安装新的默认 manager")
|
||||
}
|
||||
|
||||
old.mu.Lock()
|
||||
defer old.mu.Unlock()
|
||||
if old.master != nil || old.replicas != nil || old.replicaHealthy != nil || old.replicaHealthSet {
|
||||
t.Fatal("SetDefaultManager 应关闭并清空被替换的旧 manager,避免连接池泄漏")
|
||||
}
|
||||
if old.healthy.Load() {
|
||||
t.Fatal("SetDefaultManager 关闭旧 manager 后健康状态应为 false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapDefaultManagerPreservesReplacedManager(t *testing.T) {
|
||||
old := NewManager(nil)
|
||||
old.mu.Lock()
|
||||
old.master = sentinelDB()
|
||||
old.replicas = []*gorm.DB{sentinelDB()}
|
||||
old.mu.Unlock()
|
||||
|
||||
orig := SwapDefaultManager(old)
|
||||
t.Cleanup(func() {
|
||||
current := SwapDefaultManager(orig)
|
||||
if current != nil && current != orig {
|
||||
_ = current.Close()
|
||||
}
|
||||
_ = old.Close()
|
||||
})
|
||||
|
||||
next := NewManager(nil)
|
||||
previous := SwapDefaultManager(next)
|
||||
if previous != old {
|
||||
t.Fatal("SwapDefaultManager 应返回被替换的旧 manager")
|
||||
}
|
||||
if GetDefaultManager() != next {
|
||||
t.Fatal("SwapDefaultManager 应安装新的默认 manager")
|
||||
}
|
||||
|
||||
old.mu.Lock()
|
||||
defer old.mu.Unlock()
|
||||
if old.master == nil || len(old.replicas) != 1 {
|
||||
t.Fatal("SwapDefaultManager 不应关闭旧 manager,旧资源需可用于失败回滚")
|
||||
}
|
||||
}
|
||||
|
||||
type transientDialector struct{}
|
||||
|
||||
func (d transientDialector) Name() string { return "m4_transient" }
|
||||
|
||||
func (d transientDialector) Initialize(*gorm.DB) error {
|
||||
return errors.New("temporary connection failure")
|
||||
}
|
||||
|
||||
func (d transientDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
|
||||
|
||||
func (d transientDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
|
||||
func (d transientDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
|
||||
func (d transientDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
|
||||
|
||||
func (d transientDialector) QuoteTo(writer clause.Writer, str string) {
|
||||
_, _ = writer.WriteString(str)
|
||||
}
|
||||
|
||||
func (d transientDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
|
||||
func TestM4InitDBHonorsContextDuringRetrySleep(t *testing.T) {
|
||||
const driver = "m4_transient_retry"
|
||||
RegisterDialect(DialectSpec{
|
||||
Name: driver,
|
||||
Dialector: func(string) gorm.Dialector { return transientDialector{} },
|
||||
DSN: func(*config.DatabaseConfig) string { return "m4://retry" },
|
||||
})
|
||||
|
||||
m := NewManager(nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
err := m.InitDB(ctx, &config.Config{Database: config.DatabaseConfig{Driver: driver}})
|
||||
if err == nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("InitDB 应返回 context.Canceled,实际: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("InitDB 不应等待完整 retry sleep,耗时 %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4CloseWaitsForLifecycleOperation(t *testing.T) {
|
||||
m := NewManager(nil)
|
||||
m.opMu.Lock()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = m.Close()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Close 不应越过正在执行的生命周期操作")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
m.opMu.Unlock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("释放生命周期锁后 Close 未返回")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-1:ping 路径须经 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 返回;超时则 fail(H-db-1:ping 应被
|
||||
// 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 超过上限 %v(H-db-1:应被 pingWithTimeout 3s 约束)", elapsed, max)
|
||||
}
|
||||
return err
|
||||
case <-time.After(max + 2*time.Second):
|
||||
t.Fatalf("ping 路径在挂起 DB 上无限阻塞(H-db-1:pingWithTimeout 未覆盖该路径)")
|
||||
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 返回超时错误,不无限阻塞。该路径被后台探活
|
||||
// probeOnce(master)与 /health 端点(app.go 经 dbm.HealthCheck)共用。
|
||||
// 修复前 m.HealthCheck 用裸 sqlDB.PingContext(ctx),Background ctx 无 deadline -> 无限阻塞。
|
||||
func TestManagerHealthCheckBoundsHungDB_Hdb1(t *testing.T) {
|
||||
m := NewManager(nil)
|
||||
m.master = newHungGormDB(t)
|
||||
err := assertBoundedPing(t, func() error {
|
||||
return m.HealthCheck(context.Background())
|
||||
}, healthCheckTimeout+1*time.Second)
|
||||
if err == nil {
|
||||
t.Fatalf("挂起主库的 HealthCheck 应返回超时错误,got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeOnceReplicaBoundsHungDB_Hdb1 回归 H-db-1 从库探活路径:probeOnce 对挂起从库
|
||||
// 经 pingWithTimeout ~3s 返回,不无限阻塞探活 goroutine(#21 自愈不冻结)。master 置 nil
|
||||
// 使 HealthCheck 快速返回"未初始化",让 probeOnce 只在从库 ping 路径耗时。
|
||||
// 修复前 probeOnce 从库用裸 sqlDB.PingContext(ctx),Background ctx 无 deadline -> 无限阻塞。
|
||||
func TestProbeOnceReplicaBoundsHungDB_Hdb1(t *testing.T) {
|
||||
m := NewManager(nil)
|
||||
m.master = nil // master 路径快速失败,集中测从库 ping 超时
|
||||
m.replicas = []*gorm.DB{newHungGormDB(t)}
|
||||
m.replicaHealthSet = true
|
||||
m.replicaHealthy = make([]atomic.Bool, 1)
|
||||
m.replicaHealthy[0].Store(true)
|
||||
|
||||
_ = assertBoundedPing(t, func() error {
|
||||
m.probeOnce(context.Background(), 3)
|
||||
return nil
|
||||
}, healthCheckTimeout+1*time.Second)
|
||||
|
||||
// 挂起从库应被标记不健康(剔除读流量,#21 自愈生效)
|
||||
if m.replicaHealthy[0].Load() {
|
||||
t.Errorf("挂起从库应被标记不健康(replicaHealthy=false),got true")
|
||||
}
|
||||
}
|
||||
|
||||
// hungDialector 让 gorm.Open 成功并注入挂起 *sql.DB 作为 ConnPool,使 initDB 的
|
||||
// db.DB() 返回挂起 *sql.DB、pingWithTimeout 触发。用于回归启动 ping 路径。
|
||||
type hungDialector struct{ sqlDB *sql.DB }
|
||||
|
||||
func (d hungDialector) Name() string { return "hdb1_hung" }
|
||||
func (d hungDialector) Initialize(db *gorm.DB) error { db.Config.ConnPool = d.sqlDB; return nil }
|
||||
func (d hungDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
|
||||
func (d hungDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
func (d hungDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
func (d hungDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
|
||||
func (d hungDialector) QuoteTo(w clause.Writer, s string) { _, _ = w.WriteString(s) }
|
||||
func (d hungDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
|
||||
// registerHungDialect 注册挂起 dialect(幂等)。
|
||||
func registerHungDialect(t *testing.T) {
|
||||
t.Helper()
|
||||
RegisterDialect(DialectSpec{
|
||||
Name: "hdb1_hung",
|
||||
Dialector: func(string) gorm.Dialector { return hungDialector{sqlDB: newHungSqlDB(t)} },
|
||||
DSN: func(*config.DatabaseConfig) string { return "hdb1_hung://" },
|
||||
})
|
||||
}
|
||||
|
||||
// hungCfg 构造用挂起 dialect 的 config。
|
||||
func hungCfg() *config.Config {
|
||||
return &config.Config{Database: config.DatabaseConfig{Driver: "hdb1_hung"}}
|
||||
}
|
||||
|
||||
// TestInitDBBoundsHungDB_Hdb1 回归 H-db-1 启动 master ping 路径:InitDB(Background) 对
|
||||
// 挂起主库的 ping 经 pingWithTimeout 3s 失败,5 次重试后总耗时 ~15s 返回错误,不无限阻塞。
|
||||
// 修复前 initDB 用裸 sqlDB.PingContext(ctx),Background 无 deadline -> 首次 ping 无限阻塞。
|
||||
// 用 ctx 在首次 ping 超时后取消加速(避免 15s 全跑),同时证明 ctx 仍可中断重试循环。
|
||||
func TestInitDBBoundsHungDB_Hdb1(t *testing.T) {
|
||||
registerHungDialect(t)
|
||||
m := NewManager(nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// 4s 后 cancel:首次 ping 经 pingWithTimeout ~3s 超时返回错误,进入重试前 ctx 已取消,
|
||||
// initDB 在重试循环的 ctx.Err() 检查处快速返回。若修复前裸 PingContext(Background)
|
||||
// 首次即无限阻塞,cancel 无法中断 -> assertBoundedPing 超时 fail。
|
||||
go func() { time.Sleep(4 * time.Second); cancel() }()
|
||||
|
||||
_ = assertBoundedPing(t, func() error {
|
||||
return m.InitDB(ctx, hungCfg())
|
||||
}, 6*time.Second)
|
||||
}
|
||||
|
||||
// TestInitDBWithReplicasBoundsHungDB_Hdb1 回归 H-db-1 启动 replica ping 路径:
|
||||
// InitDBWithReplicas 对挂起 replica DSN 的 ping 经 pingWithTimeout 失败(replica 被 continue
|
||||
// 跳过),主库经挂起 dialect 同样 ping 失败。整流程有界返回,不无限阻塞。
|
||||
// 修复前 replica 启动 ping 用裸 PingContext -> 挂起 replica 无限阻塞 InitDBWithReplicas。
|
||||
func TestInitDBWithReplicasBoundsHungDB_Hdb1(t *testing.T) {
|
||||
registerHungDialect(t)
|
||||
m := NewManager(nil)
|
||||
|
||||
// 用可取消 ctx 限制总时长;主库挂起 ping ~3s 失败后 InitDBWithReplicas 直接返回错误
|
||||
// (主库失败不进入 replica 初始化)。此处验证主库 ping 路径有界即可覆盖启动 ping 约束。
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(4 * time.Second); cancel() }()
|
||||
|
||||
err := assertBoundedPing(t, func() error {
|
||||
return m.InitDBWithReplicas(ctx, hungCfg(), []string{"hdb1_hung://replica"})
|
||||
}, 6*time.Second)
|
||||
if err == nil {
|
||||
t.Fatalf("挂起主库的 InitDBWithReplicas 应返回错误,got nil")
|
||||
}
|
||||
}
|
||||
+71
-10
@@ -2,6 +2,7 @@ package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
@@ -111,6 +112,62 @@ func TestDefaultManagerHealthCheckWithoutInit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilContextHelpersDoNotPanic(t *testing.T) {
|
||||
_ = database.CloseAll()
|
||||
|
||||
if ctx := database.UseMaster(nil); ctx == nil {
|
||||
t.Fatal("UseMaster(nil) 应返回可用 context")
|
||||
}
|
||||
if ctx := database.UseReplica(nil); ctx == nil {
|
||||
t.Fatal("UseReplica(nil) 应返回可用 context")
|
||||
}
|
||||
if db := database.GetDBFromContext(nil); db != nil {
|
||||
t.Fatal("未初始化数据库时 GetDBFromContext(nil) 应返回 nil")
|
||||
}
|
||||
if ctx := database.WithTx(nil, nil); ctx == nil {
|
||||
t.Fatal("WithTx(nil, nil) 应返回可用 context")
|
||||
}
|
||||
if tx := database.TxFromContext(nil); tx != nil {
|
||||
t.Fatal("TxFromContext(nil) 应返回 nil")
|
||||
}
|
||||
if err := database.TransactionWithContext(nil, func(tx *gorm.DB) error { return nil }); err == nil {
|
||||
t.Fatal("未初始化数据库时 TransactionWithContext(nil) 应返回错误")
|
||||
}
|
||||
if err := database.ReadQuery(nil, &struct{}{}, "1=1"); err == nil {
|
||||
t.Fatal("未初始化数据库时 ReadQuery(nil) 应返回错误")
|
||||
}
|
||||
if err := database.WriteQuery(nil, &struct{}{}, "1=1"); err == nil {
|
||||
t.Fatal("未初始化数据库时 WriteQuery(nil) 应返回错误")
|
||||
}
|
||||
if got := database.HealthCheck(); !got["master"] {
|
||||
// HealthCheck 内部使用 background context;未初始化时 master=false 即可。
|
||||
return
|
||||
}
|
||||
t.Fatal("未初始化数据库时 HealthCheck master 不应为 true")
|
||||
}
|
||||
|
||||
func TestNilConfigInitializationReturnsError(t *testing.T) {
|
||||
mgr := database.NewManager(nil)
|
||||
if err := mgr.InitDB(context.Background(), nil); err == nil {
|
||||
t.Fatal("InitDB(nil) 应返回错误")
|
||||
}
|
||||
if err := mgr.InitDBWithReplicas(context.Background(), nil, nil); err == nil {
|
||||
t.Fatal("InitDBWithReplicas(nil) 应返回错误")
|
||||
}
|
||||
if err := database.InitDB(context.Background(), nil); err == nil {
|
||||
t.Fatal("包级 InitDB(nil) 应返回错误")
|
||||
}
|
||||
if err := database.InitDBWithReplicas(context.Background(), nil, nil); err == nil {
|
||||
t.Fatal("包级 InitDBWithReplicas(nil) 应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialectorNilConfigDoesNotPanic(t *testing.T) {
|
||||
if d := database.Dialector(nil); d == nil {
|
||||
t.Fatal("Dialector(nil) 应返回兜底 dialector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialectorSelectsByDriver(t *testing.T) {
|
||||
mysqlCfg := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: config.DriverMySQL, Host: "localhost", Port: 3306,
|
||||
@@ -149,14 +206,14 @@ func TestDialectorSelectsByDriver(t *testing.T) {
|
||||
// stubDialector 是一个用于测试 RegisterDialect 的占位 Dialector。
|
||||
type stubDialector struct{ name string }
|
||||
|
||||
func (s stubDialector) Name() string { return s.name }
|
||||
func (s stubDialector) Initialize(_ *gorm.DB) error { return nil }
|
||||
func (s stubDialector) Migrator(db *gorm.DB) gorm.Migrator { return nil }
|
||||
func (s stubDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
func (s stubDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
func (s stubDialector) Name() string { return s.name }
|
||||
func (s stubDialector) Initialize(_ *gorm.DB) error { return nil }
|
||||
func (s stubDialector) Migrator(db *gorm.DB) gorm.Migrator { return nil }
|
||||
func (s stubDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
func (s stubDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
func (s stubDialector) BindVarTo(writer clause.Writer, _ *gorm.Statement, _ any) {}
|
||||
func (s stubDialector) QuoteTo(writer clause.Writer, str string) { _, _ = writer.WriteString(str) }
|
||||
func (s stubDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
func (s stubDialector) QuoteTo(writer clause.Writer, str string) { _, _ = writer.WriteString(str) }
|
||||
func (s stubDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
|
||||
func TestRegisterDialectAndCustomDriver(t *testing.T) {
|
||||
const driver = "stubdb"
|
||||
@@ -190,13 +247,17 @@ func TestRegisterDialectAndCustomDriver(t *testing.T) {
|
||||
t.Fatalf("expected DSN built by registered builder, got %q", dsn)
|
||||
}
|
||||
|
||||
// 未知驱动回退到 mysql
|
||||
// 未知驱动应 fail-closed,避免拼写错误静默连向 MySQL。
|
||||
unknownCfg := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: "no-such-driver", Host: "localhost", Port: 3306,
|
||||
User: "root", Password: "pass", Name: "db",
|
||||
}}
|
||||
if name := database.Dialector(unknownCfg).Name(); name != "mysql" {
|
||||
t.Fatalf("expected fallback to mysql for unknown driver, got %q", name)
|
||||
d := database.Dialector(unknownCfg)
|
||||
if name := d.Name(); name != "invalid" {
|
||||
t.Fatalf("expected invalid dialector for unknown driver, got %q", name)
|
||||
}
|
||||
if _, err := gorm.Open(d, &gorm.Config{}); err == nil || !strings.Contains(err.Error(), "数据库驱动未注册") {
|
||||
t.Fatalf("未知 driver 应在 GORM 初始化时报错,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-14
@@ -2,6 +2,7 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -37,37 +38,76 @@ func init() {
|
||||
// NewRedisManager 创建 Redis 管理器实例。
|
||||
func NewRedisManager() *RedisManager { return &RedisManager{} }
|
||||
|
||||
// SetDefaultRedisManager 提升指定 RedisManager 为全局默认,后续包级 facade 走它。
|
||||
// 用于多实例场景或测试注入 mock。并发安全(atomic.Store)。
|
||||
func SetDefaultRedisManager(m *RedisManager) {
|
||||
if m != nil {
|
||||
DefaultRedis.Store(m)
|
||||
// SwapDefaultRedisManager 将指定 RedisManager 置为全局默认,并返回被替换的旧 Manager。
|
||||
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存。
|
||||
// nil 被忽略,以防 facade Load 到 nil panic。
|
||||
func SwapDefaultRedisManager(m *RedisManager) *RedisManager {
|
||||
if m == nil {
|
||||
return DefaultRedis.Load()
|
||||
}
|
||||
return DefaultRedis.Swap(m)
|
||||
}
|
||||
|
||||
// SetDefaultRedisManager 提升指定 RedisManager 为全局默认,并关闭被替换的旧 Manager。
|
||||
// 用于多实例场景或测试注入 mock。并发安全。若调用方需要保留旧 Manager
|
||||
// 用于回滚,请使用 SwapDefaultRedisManager。
|
||||
func SetDefaultRedisManager(m *RedisManager) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
old := SwapDefaultRedisManager(m)
|
||||
if old != nil && old != m {
|
||||
if err := old.Close(); err != nil {
|
||||
logger.Warnf("关闭被替换的旧 Redis manager 失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultRedisManager 返回当前默认 Redis 管理器。
|
||||
func GetDefaultRedisManager() *RedisManager {
|
||||
return DefaultRedis.Load()
|
||||
}
|
||||
|
||||
// newRedisClient 构造带 D7 超时(Dial 5s / Read 3s / Write 3s)的 redis.Client。
|
||||
// 由 Init 与测试共用,确保所有 client 实例一致具备超时约束(挂起 Redis 不无限阻塞)。
|
||||
func newRedisClient(addr, password string, db int) *redis.Client {
|
||||
return redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: db,
|
||||
DialTimeout: 5 * time.Second, // D7 修复:连接超时
|
||||
ReadTimeout: 3 * time.Second, // D7 修复:读超时(约束 HealthCheck Ping 不被挂起 Redis 阻塞)
|
||||
WriteTimeout: 3 * time.Second, // D7 修复:写超时
|
||||
})
|
||||
}
|
||||
|
||||
// Init 初始化 Redis 连接并 ping 验证。
|
||||
func (m *RedisManager) Init(cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("Redis 配置未设置")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr(),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
DialTimeout: 5 * time.Second, // D7 修复:连接超时
|
||||
ReadTimeout: 3 * time.Second, // D7 修复:读超时
|
||||
WriteTimeout: 3 * time.Second, // D7 修复:写超时
|
||||
})
|
||||
client := newRedisClient(cfg.Redis.Addr(), cfg.Redis.Password, cfg.Redis.DB)
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(pingCtx).Err(); err != nil {
|
||||
client.Close()
|
||||
if cerr := client.Close(); cerr != nil {
|
||||
return errors.Join(fmt.Errorf("Redis 连接失败: %w", err), fmt.Errorf("Redis 关闭失败: %w", cerr))
|
||||
}
|
||||
return fmt.Errorf("Redis 连接失败: %w", err)
|
||||
}
|
||||
|
||||
old := m.client
|
||||
m.cfg = cfg
|
||||
m.client = client
|
||||
if old != nil {
|
||||
if err := old.Close(); err != nil {
|
||||
logger.Warnf("关闭旧 Redis 连接失败: %v", err)
|
||||
}
|
||||
}
|
||||
logger.Info("Redis 连接成功", zap.String("addr", cfg.Redis.Addr()))
|
||||
return nil
|
||||
}
|
||||
@@ -104,6 +144,7 @@ func (m *RedisManager) setClientForTest(c *redis.Client) *redis.Client {
|
||||
|
||||
// HealthCheck Redis 健康检查。
|
||||
func (m *RedisManager) HealthCheck(ctx context.Context) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.mu.Lock()
|
||||
client := m.client
|
||||
m.mu.Unlock()
|
||||
|
||||
@@ -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(3s,redis.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 下应在 ~3s(ReadTimeout)失败返回错误。
|
||||
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)
|
||||
}
|
||||
@@ -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,旧资源需可用于失败回滚")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,21 @@ func TestHealthCheckRedisWithoutInit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisNilConfigReturnsError(t *testing.T) {
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(prev) })
|
||||
|
||||
if err := database.NewRedisManager().Init(nil); err == nil {
|
||||
t.Fatal("RedisManager.Init(nil) 应返回错误")
|
||||
}
|
||||
if err := database.InitRedis(nil); err == nil {
|
||||
t.Fatal("包级 InitRedis(nil) 应返回错误")
|
||||
}
|
||||
if err := database.HealthCheckRedis(nil); err == nil {
|
||||
t.Fatal("未初始化 Redis 时 HealthCheckRedis(nil) 应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultRedisConcurrentSetAndGet C-1/H-4 回归:并发 SetDefaultRedisManager
|
||||
// 与 GetRedis/CloseRedis/HealthCheckRedis facade 读取不应触发数据竞争。
|
||||
// 修复前 DefaultRedis 是裸 *Redis.Pointer,SetDefaultRedisManager 无锁写、
|
||||
|
||||
@@ -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/mysql(M-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 到 RootCAs,ServerName 取自 Host,MinVersion=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=MySQLTLSConfigName,ServerName 一致,握手通过。
|
||||
// - 多 host replicas + 私有 CA:replica DSN 的 host 与主库 Host 不同时,本注册的 ServerName(主库
|
||||
// host)与 replica 证书 SAN 不匹配,握手失败。此场景须用户为每个 replica host 注册**不同名**的
|
||||
// TLS 配置(各自 ServerName=该 replica host)并自行构造对应 replica DSN(tls=<该名>);框架
|
||||
// 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
|
||||
}
|
||||
@@ -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)+ 无 TLS:no-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-2:CA 文件不可读或非 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 配置应为非 nil(CA 已注册)")
|
||||
}
|
||||
if mc.TLS.ServerName != "127.0.0.1" {
|
||||
t.Errorf("TLS ServerName = %q, want 127.0.0.1", mc.TLS.ServerName)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
# xlgo 文档
|
||||
|
||||
| 文档 | 位置 | 说明 |
|
||||
|---|---|---|
|
||||
| README.md | 仓库根 | 快速开始、功能概览 |
|
||||
| GUIDE.md | 仓库根 | 完整使用指南 |
|
||||
| CHANGELOG.md | 仓库根 | 变更日志(遵循 Keep a Changelog) |
|
||||
| docs/plans/ | 本目录 | 历史版本规划与体检报告归档 |
|
||||
|
||||
## docs/plans/ 归档文档
|
||||
|
||||
这些是各版本开发时的规划/评审文档,**反映当时状态**,代码可能已演进,阅读时请对照 CHANGELOG 确认现状。
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| Version_Update_Plan_v1.0.2.md | v1.0.2 更新计划(已完成) |
|
||||
| Version_v1.0.2_report.md | v1.0.2 体检报告,含 #1-#30 改进建议与版本路线图 |
|
||||
| v2.0-review.md | 更早期的 v2.0 评估报告 |
|
||||
|
||||
> v1.0.2 体检报告中的 #1-#30 改进项,完成情况见根目录 [CHANGELOG.md](../CHANGELOG.md) 各版本章节。
|
||||
@@ -236,7 +236,7 @@ cache.KSession("sid") // -> "session:user_api:sid"
|
||||
cache.Lock(ctx, key, ttl)
|
||||
cache.Unlock(ctx, key)
|
||||
cache.TryLock(ctx, key, ttl, retry, maxRetry)
|
||||
cache.WithLock(ctx, key, ttl, fn) // 自动管理锁
|
||||
cache.WithLock(ctx, key, ttl, func(context.Context) error) // 自动管理锁
|
||||
|
||||
// 计数器
|
||||
cache.Incr(ctx, key)
|
||||
@@ -482,4 +482,4 @@ go test ./cache/... ./handler/... ./middleware/...
|
||||
|
||||
*评估日期:2026-04-29*
|
||||
*评估版本:v2.0.0*
|
||||
*优化内容:新增5个包 + 增强5个包 + 配置扩展 + CLI重构 = 约120个函数*
|
||||
*优化内容:新增5个包 + 增强5个包 + 配置扩展 + CLI重构 = 约120个函数*
|
||||
|
||||
+10
-2
@@ -24,7 +24,7 @@ go run ./examples/minimal
|
||||
go run ./examples/full
|
||||
```
|
||||
|
||||
启动后自动迁移 user 表。接口:
|
||||
启动后自动迁移 user 表,并初始化示例用户 `alice/secret`(密码以 bcrypt 哈希保存)。接口:
|
||||
|
||||
| 方法 | 路径 | 说明 | 认证 |
|
||||
|---|---|---|---|
|
||||
@@ -39,4 +39,12 @@ curl -X POST http://localhost:8082/api/v1/login \
|
||||
-d '{"username":"alice","password":"secret"}'
|
||||
```
|
||||
|
||||
> 示例代码为演示用途,密码明文存储、未做参数校验,生产环境请使用 bcrypt 哈希密码并配合 `validation` 包校验入参。
|
||||
创建用户示例:
|
||||
```bash
|
||||
curl -X POST http://localhost:8082/api/v1/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"username":"bob","password":"s3cret"}'
|
||||
```
|
||||
|
||||
> 示例代码为演示用途,已演示 bcrypt 密码哈希与登录校验;生产环境仍需配合 `validation` 包完善入参校验、密码强度策略与用户注册流程。
|
||||
|
||||
+61
-11
@@ -8,7 +8,9 @@
|
||||
//
|
||||
// POST /api/v1/login {"username":"alice","password":"secret"} → 返回 token
|
||||
// GET /api/v1/users/:id (需 Authorization: Bearer <token>)
|
||||
// POST /api/v1/users (创建用户)
|
||||
// POST /api/v1/users (需 Authorization: Bearer <token>,创建用户)
|
||||
//
|
||||
// 示例会在启动时初始化 alice/secret,密码以 bcrypt 哈希保存。
|
||||
//
|
||||
// 运行:
|
||||
//
|
||||
@@ -16,6 +18,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
@@ -26,6 +30,7 @@ import (
|
||||
"github.com/EthanCodeCraft/xlgo-core/repository"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/EthanCodeCraft/xlgo-core/router"
|
||||
"github.com/EthanCodeCraft/xlgo-core/validation"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -34,7 +39,7 @@ import (
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;size:64" json:"username"`
|
||||
Password string `gorm:"size:128" json:"-"` // 实际项目应存 bcrypt 哈希
|
||||
Password string `gorm:"size:128" json:"-"` // bcrypt 哈希,永不返回给客户端
|
||||
UserType string `gorm:"size:32" json:"user_type"`
|
||||
}
|
||||
|
||||
@@ -46,11 +51,14 @@ func main() {
|
||||
xlgo.WithModels(&User{}),
|
||||
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
|
||||
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
|
||||
xlgo.WithHook(xlgo.Hook{
|
||||
Name: "seed-example-user",
|
||||
OnInit: func(*xlgo.App) error {
|
||||
return ensureExampleUser(context.Background())
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// 初始化 user repository(App.Init 之后 master DB 才可用,这里在 registerRoutes 里延迟拿)
|
||||
_ = app
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Printf("启动失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -82,10 +90,13 @@ func login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 示例简化:查询用户,不校验密码哈希
|
||||
u, err := userRepo.FindOne(c.Request.Context(), "username = ?", req.Username)
|
||||
if err != nil {
|
||||
response.Fail(c, "用户不存在")
|
||||
response.Fail(c, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
if !validation.CheckPassword(u.Password, req.Password) {
|
||||
response.Fail(c, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,7 +112,10 @@ func getUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// 示例简化:直接用 repo 查询,实际应转 uint
|
||||
var uid uint
|
||||
fmt.Sscanf(id, "%d", &uid)
|
||||
if _, err := fmt.Sscanf(id, "%d", &uid); err != nil {
|
||||
response.Fail(c, "用户ID无效")
|
||||
return
|
||||
}
|
||||
u, err := userRepo.FindByID(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
response.NotFound(c, "用户不存在")
|
||||
@@ -111,15 +125,51 @@ func getUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
func createUser(c *gin.Context) {
|
||||
var u User
|
||||
if err := c.ShouldBindJSON(&u); err != nil {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
UserType string `json:"user_type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
u.UserType = "user"
|
||||
|
||||
hash, err := validation.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
response.ServerError(c, "密码加密失败")
|
||||
return
|
||||
}
|
||||
u := User{
|
||||
Username: req.Username,
|
||||
Password: hash,
|
||||
UserType: req.UserType,
|
||||
}
|
||||
if u.UserType == "" {
|
||||
u.UserType = "user"
|
||||
}
|
||||
if err := userRepo.Create(c.Request.Context(), &u); err != nil {
|
||||
response.Fail(c, "创建失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, u)
|
||||
}
|
||||
|
||||
func ensureExampleUser(ctx context.Context) error {
|
||||
_, err := userRepo.FindOne(ctx, "username = ?", "alice")
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
hash, err := validation.HashPassword("secret")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return userRepo.Create(ctx, &User{
|
||||
Username: "alice",
|
||||
Password: hash,
|
||||
UserType: "user",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 黑名单走 Redis(ParseToken 默认 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 状态应为 200,got %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)
|
||||
}
|
||||
@@ -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->logger(M1)。...供 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 返回 min;max<min 自动交换"),但 `RandInt`(第 39 行)与 `RandInt64`(第 55 行)没说。同问题影响 2 个函数。
|
||||
|
||||
**建议修改为**:
|
||||
```go
|
||||
// RandInt 返回 [min, max) 范围内的随机整数。min==max 时返回 min;max<min 自动交换。
|
||||
```
|
||||
|
||||
### 10. examples/full/main.go:63 - `_ = app` 死代码 + 误导注释
|
||||
|
||||
```go
|
||||
// 初始化 user repository(App.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+ 个覆盖接口密集/注释最详尽/中间件/示例/工具各类型,初筛"无问题"裁定整体可信。
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -10,6 +11,11 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DefaultMaxJSONBodyBytes 是 BindJSON 的默认请求体上限。
|
||||
// 入口层无上限读 JSON 会让任意 handler 暴露 OOM 面;需要更大请求体的业务可改用
|
||||
// BindJSONWithMaxBytes 显式声明上限。
|
||||
const DefaultMaxJSONBodyBytes int64 = 1 << 20 // 1 MiB
|
||||
|
||||
// HealthCheck 健康检查
|
||||
// @Summary 健康检查
|
||||
// @Description 检查服务是否正常运行
|
||||
@@ -28,6 +34,16 @@ func HealthCheck(c *gin.Context) {
|
||||
|
||||
// BindJSON 绑定 JSON 请求
|
||||
func BindJSON(c *gin.Context, req any) error {
|
||||
return BindJSONWithMaxBytes(c, req, DefaultMaxJSONBodyBytes)
|
||||
}
|
||||
|
||||
// BindJSONWithMaxBytes 绑定 JSON 请求,并限制最大 body 大小。
|
||||
// maxBytes<=0 时返回明确错误,避免 http.MaxBytesReader 的非正上限产生不可读语义。
|
||||
func BindJSONWithMaxBytes(c *gin.Context, req any, maxBytes int64) error {
|
||||
if maxBytes <= 0 {
|
||||
return errors.New("handler: max JSON body bytes must be positive")
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
|
||||
return c.ShouldBindJSON(req)
|
||||
}
|
||||
|
||||
|
||||
+29
-5
@@ -62,8 +62,8 @@ func TestQueryInt(t *testing.T) {
|
||||
expected int
|
||||
}{
|
||||
{"?page=10", 10},
|
||||
{"?page=abc", 1}, // 无效返回默认
|
||||
{"", 1}, // 无参数返回默认
|
||||
{"?page=abc", 1}, // 无效返回默认
|
||||
{"", 1}, // 无参数返回默认
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -185,9 +185,9 @@ func TestGetPage(t *testing.T) {
|
||||
expectedSize int
|
||||
}{
|
||||
{"?page=2&page_size=50", 2, 50},
|
||||
{"?page=0&page_size=0", 1, 20}, // 边界修正
|
||||
{"?page=0&page_size=0", 1, 20}, // 边界修正
|
||||
{"?page=-1&page_size=200", 1, 100}, // 超限修正
|
||||
{"", 1, 20}, // 默认值
|
||||
{"", 1, 20}, // 默认值
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -387,4 +387,28 @@ func TestBindJSON(t *testing.T) {
|
||||
if w.Code != 200 {
|
||||
t.Errorf("BindJSON valid status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindJSONWithMaxBytesRejectsOversizedBody_M6(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.POST("/test", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := handler.BindJSONWithMaxBytes(c, &req, 8); err == nil {
|
||||
c.JSON(http.StatusOK, req)
|
||||
return
|
||||
}
|
||||
handler.BadRequest(c, "绑定失败")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"name":"body-too-large"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
body := decodeBody(t, w)
|
||||
if body.Code != response.CodeFail {
|
||||
t.Fatalf("oversized BindJSON code = %d, want %d", body.Code, response.CodeFail)
|
||||
}
|
||||
}
|
||||
|
||||
+147
-67
@@ -53,6 +53,10 @@ var (
|
||||
// 本实现仅支持 HMAC 族(HS256/HS384/HS512);RS256 等非对称算法暂不支持,
|
||||
// 不再静默回退 HS256——避免用户误以为在用非对称算法、实则 HMAC,并助长算法混淆攻击。
|
||||
ErrUnsupportedAlgorithm = errors.New("jwt: 不支持的签名算法(仅支持 HS256/HS384/HS512)")
|
||||
// ErrInvalidExpiry 过期时间非法。签发非正过期时间的 token 会立即失效或产生不可预期会话语义。
|
||||
ErrInvalidExpiry = errors.New("jwt: 过期时间必须大于 0")
|
||||
// ErrEmptyJTI 空 JTI 无法匹配任何正常 token,却会写入 jwt_bl: 这种永不命中的黑名单键。
|
||||
ErrEmptyJTI = errors.New("jwt: jti 不能为空")
|
||||
)
|
||||
|
||||
// validMethods 允许的签名算法名(HMAC 族)。ParseWithClaims 传 jwt.WithValidMethods 固定算法,
|
||||
@@ -114,6 +118,18 @@ func (tb *TokenBlacklist) redisClient() *redis.Client {
|
||||
// 注:ctx 超时只约束命令往返,不影响 Set 的服务端 TTL(ttl 可远大于 1s)。
|
||||
const blacklistOpTimeout = 1 * time.Second
|
||||
|
||||
// BlacklistPolicy 控制解析 Token 时遇到黑名单查询错误的处理策略。
|
||||
// ParseToken 默认 BlacklistFailClosed(黑名单不可检查即拒绝);
|
||||
// 需显式 fail-open(仅无 Redis 或低安全场景)用 ParseTokenWithBlacklistPolicy(..., BlacklistFailOpen)。
|
||||
type BlacklistPolicy int
|
||||
|
||||
const (
|
||||
// BlacklistFailOpen 黑名单查询出错时放行 Token(仅用于无 Redis 或低安全场景,非默认)。
|
||||
BlacklistFailOpen BlacklistPolicy = iota
|
||||
// BlacklistFailClosed 黑名单查询出错时拒绝 Token(默认;ParseToken 即用此策略)。
|
||||
BlacklistFailClosed
|
||||
)
|
||||
|
||||
// blacklistCtx 创建带超时的 context 用于黑名单 Redis 操作(M-A)。
|
||||
func blacklistCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), blacklistOpTimeout)
|
||||
@@ -125,6 +141,10 @@ func blacklistCtx() (context.Context, context.CancelFunc) {
|
||||
// 无 Redis 时返回 ErrBlacklistUnavailable(C9a 修复):让调用方(RefreshToken/InvalidateToken)
|
||||
// 感知黑名单不可用并 fail-closed,避免无 Redis 时静默成功致撤销失效。
|
||||
func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
|
||||
if strings.TrimSpace(jti) == "" {
|
||||
return ErrEmptyJTI
|
||||
}
|
||||
|
||||
client := tb.redisClient()
|
||||
if client == nil {
|
||||
// Redis 未启用,黑名单不可用——fail-closed 让调用方决策。
|
||||
@@ -144,26 +164,23 @@ func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
|
||||
return client.Set(ctx, key, "1", ttl).Err()
|
||||
}
|
||||
|
||||
// IsBlacklisted 检查 JTI 是否在黑名单中
|
||||
func (tb *TokenBlacklist) IsBlacklisted(jti string) bool {
|
||||
// IsBlacklisted 检查 JTI 是否在黑名单中。
|
||||
// 命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未启用或不可达返回 (false, ErrBlacklistUnavailable),由调用方决定 fail-open/fail-closed。
|
||||
func (tb *TokenBlacklist) IsBlacklisted(jti string) (bool, error) {
|
||||
client := tb.redisClient()
|
||||
if client == nil {
|
||||
// Redis 未启用,不检查黑名单
|
||||
return false
|
||||
return false, ErrBlacklistUnavailable
|
||||
}
|
||||
|
||||
ctx, cancel := blacklistCtx()
|
||||
defer cancel()
|
||||
key := fmt.Sprintf("jwt_bl:%s", jti)
|
||||
// M-A 修复:显式处理 Redis 错误(原 .Val() 吞错致故障被静默当"未拉黑")。
|
||||
// 错误时保持 fail-open(返 false),与"无 Redis 部署可用"的固有局限一致,但记录告警
|
||||
// 便于运维感知 Redis 故障。安全敏感场景必须启用 Redis(见 ErrBlacklistUnavailable 注释)。
|
||||
n, err := client.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
logger.Warn("jwt 黑名单检查失败,fail-open 放行", zap.String("jti", jti), zap.Error(err))
|
||||
return false
|
||||
return false, err
|
||||
}
|
||||
return n > 0
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// Manager JWT 管理器(#10)。持有独立的 TokenBlacklist,
|
||||
@@ -230,7 +247,17 @@ func (m *Manager) Blacklist() *TokenBlacklist {
|
||||
// GenerateToken 生成 JWT Token
|
||||
func GenerateToken(userID uint, username, role, userType string) (string, error) {
|
||||
cfg := config.Get()
|
||||
var expiry time.Duration
|
||||
if cfg != nil {
|
||||
expiry = cfg.JWT.Expire
|
||||
}
|
||||
return generateTokenWithExpiry(cfg, userID, username, role, userType, expiry)
|
||||
}
|
||||
|
||||
func generateTokenWithExpiry(cfg *config.Config, userID uint, username, role, userType string, expiry time.Duration) (string, error) {
|
||||
if expiry <= 0 {
|
||||
return "", ErrInvalidExpiry
|
||||
}
|
||||
// P0:先校验密钥非空与算法受支持(fail-closed)。secretKey 亦守卫 cfg==nil,
|
||||
// 通过后 cfg 保证非空,后续访问 cfg.JWT.* 安全。
|
||||
key, err := secretKey(cfg)
|
||||
@@ -255,7 +282,7 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
|
||||
UserType: userType,
|
||||
JTI: jti,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.JWT.Expire)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: issuerOrDefault(cfg.JWT.Issuer),
|
||||
@@ -270,39 +297,10 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
|
||||
// GenerateTokenWithCustomExpiry 生成带自定义过期时间的 Token
|
||||
func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
// P0:先校验密钥非空与算法受支持(fail-closed,见 GenerateToken)。
|
||||
key, err := secretKey(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
if expireSeconds <= 0 {
|
||||
return "", ErrInvalidExpiry
|
||||
}
|
||||
method, err := signingMethod(cfg.JWT.Algorithm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jti, err := generateJTI()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
UserType: userType,
|
||||
JTI: jti,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireSeconds) * time.Second)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: issuerOrDefault(cfg.JWT.Issuer),
|
||||
ID: jti,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(method, claims)
|
||||
return token.SignedString(key)
|
||||
return generateTokenWithExpiry(cfg, userID, username, role, userType, time.Duration(expireSeconds)*time.Second)
|
||||
}
|
||||
|
||||
// issuerOrDefault 返回配置的 issuer,未配置时回退 "xlgo"。
|
||||
@@ -329,29 +327,86 @@ func signingMethod(algorithm string) (jwt.SigningMethod, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT Token
|
||||
func parseOptions(cfg *config.Config) []jwt.ParserOption {
|
||||
issuer := "xlgo"
|
||||
if cfg != nil {
|
||||
issuer = issuerOrDefault(cfg.JWT.Issuer)
|
||||
}
|
||||
return []jwt.ParserOption{
|
||||
jwt.WithValidMethods(validMethods),
|
||||
jwt.WithIssuer(issuer),
|
||||
}
|
||||
}
|
||||
|
||||
func validateIssuer(cfg *config.Config, claims *Claims) error {
|
||||
want := "xlgo"
|
||||
if cfg != nil {
|
||||
want = issuerOrDefault(cfg.JWT.Issuer)
|
||||
}
|
||||
if claims == nil || claims.Issuer != want {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapParseTokenError(err error) error {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return ErrTokenExpired
|
||||
}
|
||||
if errors.Is(err, jwt.ErrTokenMalformed) {
|
||||
return ErrTokenMalformed
|
||||
}
|
||||
if errors.Is(err, jwt.ErrTokenNotValidYet) {
|
||||
return ErrTokenNotValidYet
|
||||
}
|
||||
if errors.Is(err, ErrEmptySecret) {
|
||||
return ErrEmptySecret
|
||||
}
|
||||
if errors.Is(err, ErrUnsupportedAlgorithm) {
|
||||
return ErrUnsupportedAlgorithm
|
||||
}
|
||||
return fmt.Errorf("%w: %w", ErrTokenInvalid, err)
|
||||
}
|
||||
|
||||
func checkTokenBlacklist(claims *Claims, policy BlacklistPolicy) error {
|
||||
if claims == nil || claims.JTI == "" {
|
||||
return nil
|
||||
}
|
||||
revoked, err := currentBlacklist().IsBlacklisted(claims.JTI)
|
||||
if err != nil {
|
||||
if policy == BlacklistFailClosed {
|
||||
return err
|
||||
}
|
||||
logger.Warn("JWT 黑名单检查失败,fail-open 策略放行 token", zap.String("jti", claims.JTI), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if revoked {
|
||||
return ErrTokenRevoked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT Token。默认 fail-closed:黑名单后端不可检查时返回
|
||||
// ErrBlacklistUnavailable,拒绝该 Token。需要显式 fail-open(仅无 Redis 或低安全场景)
|
||||
// 请用 ParseTokenWithBlacklistPolicy(token, BlacklistFailOpen)。
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailClosed)
|
||||
}
|
||||
|
||||
// ParseTokenWithBlacklistPolicy 使用显式黑名单查询策略解析 JWT Token。
|
||||
func ParseTokenWithBlacklistPolicy(tokenString string, policy BlacklistPolicy) (*Claims, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), jwt.WithValidMethods(validMethods))
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), parseOptions(cfg)...)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
if errors.Is(err, jwt.ErrTokenMalformed) {
|
||||
return nil, ErrTokenMalformed
|
||||
}
|
||||
if errors.Is(err, jwt.ErrTokenNotValidYet) {
|
||||
return nil, ErrTokenNotValidYet
|
||||
}
|
||||
return nil, ErrTokenInvalid
|
||||
return nil, mapParseTokenError(err)
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
// 使用 JTI 检查黑名单(更高效)
|
||||
if claims.JTI != "" && currentBlacklist().IsBlacklisted(claims.JTI) {
|
||||
return nil, ErrTokenRevoked
|
||||
if err := checkTokenBlacklist(claims, policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -363,15 +418,22 @@ func ParseToken(tokenString string) (*Claims, error) {
|
||||
func InvalidateToken(tokenString string) error {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), jwt.WithValidMethods(validMethods))
|
||||
opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation())
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...)
|
||||
|
||||
if err != nil {
|
||||
// Token 无效或已过期,无需加入黑名单
|
||||
// Token 签名/格式/issuer 无效,无需加入黑名单。
|
||||
return nil
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok {
|
||||
if claims.JTI != "" && claims.ExpiresAt != nil {
|
||||
if err := validateIssuer(cfg, claims); err != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(claims.JTI) == "" {
|
||||
return ErrEmptyJTI
|
||||
}
|
||||
if claims.ExpiresAt != nil {
|
||||
return currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time)
|
||||
}
|
||||
}
|
||||
@@ -382,6 +444,9 @@ func InvalidateToken(tokenString string) error {
|
||||
// InvalidateTokenByID 直接通过 JTI 使 Token 失效
|
||||
// 参数: jti JWT ID,expiry 过期时间
|
||||
func InvalidateTokenByID(jti string, expiry time.Time) error {
|
||||
if strings.TrimSpace(jti) == "" {
|
||||
return ErrEmptyJTI
|
||||
}
|
||||
return currentBlacklist().Add(jti, expiry)
|
||||
}
|
||||
|
||||
@@ -397,13 +462,24 @@ func RefreshToken(tokenString string) (string, error) {
|
||||
}
|
||||
|
||||
// 将旧 Token 加入黑名单;失败则不签发新 token(C9b:禁止吞 Add 错误)。
|
||||
if claims.JTI != "" && claims.ExpiresAt != nil {
|
||||
if strings.TrimSpace(claims.JTI) == "" {
|
||||
return "", ErrEmptyJTI
|
||||
}
|
||||
if claims.ExpiresAt != nil {
|
||||
if err := currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time); err != nil {
|
||||
return "", fmt.Errorf("刷新令牌失败:旧令牌撤销失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return GenerateToken(claims.UserID, claims.Username, claims.Role, claims.UserType)
|
||||
cfg := config.Get()
|
||||
var expiry time.Duration
|
||||
if cfg != nil {
|
||||
expiry = cfg.JWT.RefreshExpire
|
||||
}
|
||||
if expiry <= 0 && cfg != nil {
|
||||
expiry = cfg.JWT.Expire
|
||||
}
|
||||
return generateTokenWithExpiry(cfg, claims.UserID, claims.Username, claims.Role, claims.UserType, expiry)
|
||||
}
|
||||
|
||||
// GetJTI 从 Token 中提取 JTI(不验证签名)
|
||||
@@ -421,8 +497,9 @@ func GetJTI(tokenString string) (string, error) {
|
||||
return "", ErrTokenInvalid
|
||||
}
|
||||
|
||||
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
|
||||
func IsTokenRevoked(jti string) bool {
|
||||
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)。
|
||||
// 返回 (是否撤销, 错误);黑名单后端不可用时返回 (false, ErrBlacklistUnavailable)。
|
||||
func IsTokenRevoked(jti string) (bool, error) {
|
||||
return currentBlacklist().IsBlacklisted(jti)
|
||||
}
|
||||
|
||||
@@ -431,16 +508,19 @@ func IsTokenRevoked(jti string) bool {
|
||||
func GetClaimsFromToken(tokenString string) (*Claims, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg),
|
||||
jwt.WithValidMethods(validMethods), jwt.WithoutClaimsValidation())
|
||||
opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation())
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok {
|
||||
if err := validateIssuer(cfg, claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestC9cConcurrentSetDefaultAndRead(t *testing.T) {
|
||||
default:
|
||||
}
|
||||
_, _ = ParseToken(token)
|
||||
_ = IsTokenRevoked("some-jti")
|
||||
_, _ = IsTokenRevoked("some-jti")
|
||||
_ = InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
|
||||
_ = currentManager()
|
||||
}
|
||||
|
||||
+198
-6
@@ -17,7 +17,7 @@ func setupTestConfig() {
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
|
||||
Expire: time.Hour, // 1小时
|
||||
Expire: time.Hour, // 1小时
|
||||
},
|
||||
}
|
||||
config.Set(cfg)
|
||||
@@ -44,6 +44,7 @@ func TestGenerateToken(t *testing.T) {
|
||||
|
||||
func TestParseToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
setupMiniRedis(t)
|
||||
|
||||
// 先生成 token
|
||||
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
@@ -93,11 +94,13 @@ func TestParseTokenWrongSecret(t *testing.T) {
|
||||
// 修改 secret
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "different-secret",
|
||||
Secret: "different-secret-key-12345678901234567890",
|
||||
Expire: 3600,
|
||||
},
|
||||
}
|
||||
config.Set(cfg)
|
||||
if err := config.Set(cfg); err != nil {
|
||||
t.Fatalf("Set wrong secret config: %v", err)
|
||||
}
|
||||
|
||||
// 应该解析失败
|
||||
_, err := jwt.ParseToken(token)
|
||||
@@ -181,9 +184,10 @@ func TestTokenBlacklist(t *testing.T) {
|
||||
t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err)
|
||||
}
|
||||
|
||||
// 无 Redis 时,IsBlacklisted 仍返回 false(验证侧 fail-open 是无 Redis 部署的固有局限)
|
||||
if tb.IsBlacklisted("test-token") {
|
||||
t.Error("TokenBlacklist.IsBlacklisted without Redis should return false")
|
||||
// 无 Redis 时,IsBlacklisted 返回 (false, ErrBlacklistUnavailable)(fail-closed:错误上抛)
|
||||
revoked, err := tb.IsBlacklisted("test-token")
|
||||
if revoked || !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Errorf("TokenBlacklist.IsBlacklisted without Redis = (%v, %v), want (false, ErrBlacklistUnavailable)", revoked, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +378,7 @@ func TestSupportedAlgorithmHS384(t *testing.T) {
|
||||
Expire: time.Hour,
|
||||
}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
setupMiniRedis(t)
|
||||
|
||||
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
|
||||
if err != nil {
|
||||
@@ -384,3 +389,190 @@ func TestSupportedAlgorithmHS384(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenRejectsWrongIssuer(t *testing.T) {
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
Expire: time.Hour,
|
||||
Issuer: "trusted-issuer",
|
||||
}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
|
||||
claims := jwt.Claims{
|
||||
UserID: 1,
|
||||
Username: "attacker",
|
||||
Role: "admin",
|
||||
UserType: "super_admin",
|
||||
RegisteredClaims: gojwt.RegisteredClaims{
|
||||
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: gojwt.NewNumericDate(time.Now()),
|
||||
NotBefore: gojwt.NewNumericDate(time.Now()),
|
||||
Issuer: "evil-issuer",
|
||||
},
|
||||
}
|
||||
token := signTokenForTest(t, claims)
|
||||
|
||||
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
|
||||
t.Errorf("ParseToken wrong issuer err = %v, want ErrTokenInvalid", err)
|
||||
}
|
||||
if _, err := jwt.GetClaimsFromToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
|
||||
t.Errorf("GetClaimsFromToken wrong issuer err = %v, want ErrTokenInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenUsesRefreshExpire(t *testing.T) {
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
Expire: time.Hour,
|
||||
RefreshExpire: 4 * time.Hour,
|
||||
}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
setupMiniRedis(t)
|
||||
|
||||
token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
newToken, err := jwt.RefreshToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshToken: %v", err)
|
||||
}
|
||||
claims, err := jwt.ParseToken(newToken)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken refreshed token: %v", err)
|
||||
}
|
||||
ttl := time.Until(claims.ExpiresAt.Time)
|
||||
if ttl < 3*time.Hour || ttl > 4*time.Hour+time.Minute {
|
||||
t.Errorf("refreshed token ttl = %v, want about 4h", ttl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTokenWithCustomExpiryRejectsNonPositive(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
for _, seconds := range []int{0, -1} {
|
||||
if _, err := jwt.GenerateTokenWithCustomExpiry(1, "u", "admin", "admin", seconds); !errors.Is(err, jwt.ErrInvalidExpiry) {
|
||||
t.Errorf("GenerateTokenWithCustomExpiry(%d) err = %v, want ErrInvalidExpiry", seconds, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateTokenByIDRejectsEmptyJTI(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
if err := jwt.InvalidateTokenByID("", time.Now().Add(time.Hour)); !errors.Is(err, jwt.ErrEmptyJTI) {
|
||||
t.Errorf("InvalidateTokenByID empty err = %v, want ErrEmptyJTI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenRejectsEmptyJTI(t *testing.T) {
|
||||
setupTestConfig()
|
||||
setupMiniRedis(t)
|
||||
|
||||
token := signTokenForTest(t, jwt.Claims{
|
||||
UserID: 1,
|
||||
Username: "legacy",
|
||||
Role: "admin",
|
||||
UserType: "admin",
|
||||
RegisteredClaims: gojwt.RegisteredClaims{
|
||||
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: gojwt.NewNumericDate(time.Now()),
|
||||
NotBefore: gojwt.NewNumericDate(time.Now()),
|
||||
Issuer: "xlgo",
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := jwt.RefreshToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
|
||||
t.Errorf("RefreshToken empty JTI err = %v, want ErrEmptyJTI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateTokenRejectsEmptyJTI(t *testing.T) {
|
||||
setupTestConfig()
|
||||
setupMiniRedis(t)
|
||||
|
||||
token := signTokenForTest(t, jwt.Claims{
|
||||
UserID: 1,
|
||||
Username: "legacy",
|
||||
Role: "admin",
|
||||
UserType: "admin",
|
||||
RegisteredClaims: gojwt.RegisteredClaims{
|
||||
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: gojwt.NewNumericDate(time.Now()),
|
||||
NotBefore: gojwt.NewNumericDate(time.Now()),
|
||||
Issuer: "xlgo",
|
||||
},
|
||||
})
|
||||
|
||||
if err := jwt.InvalidateToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
|
||||
t.Errorf("InvalidateToken empty JTI err = %v, want ErrEmptyJTI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenEmptySecretReturnsSentinel(t *testing.T) {
|
||||
setupTestConfig()
|
||||
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken before empty secret: %v", err)
|
||||
}
|
||||
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
|
||||
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrEmptySecret) {
|
||||
t.Fatalf("ParseToken empty secret err = %v, want ErrEmptySecret", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenBlacklistPolicy(t *testing.T) {
|
||||
setupTestConfig()
|
||||
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
|
||||
t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) })
|
||||
|
||||
token, err := jwt.GenerateToken(1, "test", "admin", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Fatalf("ParseToken default fail-closed should reject without Redis, err = %v, want ErrBlacklistUnavailable", err)
|
||||
}
|
||||
if _, err := jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen); err != nil {
|
||||
t.Fatalf("ParseTokenWithBlacklistPolicy fail-open should pass without Redis: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateTokenAllowsFutureNotBeforeToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
mr := setupMiniRedis(t)
|
||||
|
||||
claims := jwt.Claims{
|
||||
UserID: 1,
|
||||
Username: "external",
|
||||
Role: "admin",
|
||||
UserType: "admin",
|
||||
JTI: "future-jti",
|
||||
RegisteredClaims: gojwt.RegisteredClaims{
|
||||
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: gojwt.NewNumericDate(time.Now()),
|
||||
NotBefore: gojwt.NewNumericDate(time.Now().Add(10 * time.Minute)),
|
||||
Issuer: "xlgo",
|
||||
ID: "future-jti",
|
||||
},
|
||||
}
|
||||
token := signTokenForTest(t, claims)
|
||||
|
||||
if err := jwt.InvalidateToken(token); err != nil {
|
||||
t.Fatalf("InvalidateToken future nbf: %v", err)
|
||||
}
|
||||
if !mr.Exists("jwt_bl:future-jti") {
|
||||
t.Fatal("future nbf token JTI should be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
func signTokenForTest(t *testing.T, claims jwt.Claims) string {
|
||||
t.Helper()
|
||||
token, err := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims).SignedString([]byte("test-secret-key-1234567890123456789012"))
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
+193
-36
@@ -26,9 +26,20 @@ var (
|
||||
apiLogPtr atomic.Pointer[zap.Logger]
|
||||
dbLogPtr atomic.Pointer[zap.Logger]
|
||||
|
||||
// defaultLoggerPtr 是包级 facade 使用的默认 manager 原子快照。
|
||||
// 保留导出的 DefaultLogger *LogManager 作为兼容变量;SetDefaultLogManager
|
||||
// 只替换这个内部指针,避免 facade 裸读/裸写导出变量。
|
||||
defaultLoggerPtr atomic.Pointer[LogManager]
|
||||
globalLogGeneration uint64
|
||||
|
||||
// globalMu 串行化包级 logger/writer 的发布与关闭。
|
||||
// 不能仅依赖 LogManager.mu:多个 LogManager 实例并发 Init/Close 时,
|
||||
// 实例锁无法保护 Logger/fileWriters 这类包级状态。
|
||||
globalMu sync.Mutex
|
||||
|
||||
// Logger 全局通用日志实例(兼容别名)。Init 之前为 Nop,调用安全。
|
||||
//
|
||||
// Deprecated: 此导出变量由 Init/Close 在 m.mu 下同步维护,但直接读它在 re-Init/Close
|
||||
// Deprecated: 此导出变量由 Init/Close 在 globalMu 下同步维护,但直接读它在 re-Init/Close
|
||||
// 期间非并发安全(L-E)。请改用包级函数 Info/Debug/Warn/Error/...(经 atomic 读取,
|
||||
// 并发安全)。保留仅为向后兼容,未来大版本可能移除。
|
||||
Logger = zap.NewNop()
|
||||
@@ -37,7 +48,7 @@ var (
|
||||
// Close() 调用时显式释放文件句柄。
|
||||
// 必要性:lumberjack 不依赖 GC 关闭文件,进程长跑或测试场景下
|
||||
// 不显式关闭会持有句柄导致 Windows 上无法删除日志目录。
|
||||
// 仅在 m.mu 下访问(Init/Close/closeFileWriters),无读侧竞争。
|
||||
// 仅在 globalMu 下访问(Init/Close),无读侧竞争。
|
||||
fileWriters []*lumberjack.Logger
|
||||
)
|
||||
|
||||
@@ -48,6 +59,7 @@ func init() {
|
||||
sugarPtr.Store(nop.Sugar())
|
||||
apiLogPtr.Store(nop)
|
||||
dbLogPtr.Store(nop)
|
||||
defaultLoggerPtr.Store(DefaultLogger)
|
||||
}
|
||||
|
||||
// currentLogger 返回通用 logger 的 atomic 快照(永不 nil)。
|
||||
@@ -90,15 +102,48 @@ type LogManager struct {
|
||||
cfg *config.Config
|
||||
// level 是所有 core 共享的 AtomicLevel,支持运行期 SetLevel 热切换(M19)。
|
||||
// Init 前为 nil,SetLevel 守卫之。
|
||||
level zap.AtomicLevel
|
||||
level zap.AtomicLevel
|
||||
generation uint64
|
||||
}
|
||||
|
||||
// DefaultSnapshot 是默认 logger 全局状态的 opaque 快照。
|
||||
// App 初始化用它实现“先安装新 logger,后续失败可恢复旧 logger”的事务边界。
|
||||
type DefaultSnapshot struct {
|
||||
manager *LogManager
|
||||
logger *zap.Logger
|
||||
sugar *zap.SugaredLogger
|
||||
apiLog *zap.Logger
|
||||
dbLog *zap.Logger
|
||||
writers []*lumberjack.Logger
|
||||
generation uint64
|
||||
}
|
||||
|
||||
// DefaultLogger 默认日志管理器,包级 facade 代理到它。
|
||||
//
|
||||
// Deprecated: 直接替换该导出变量(logger.DefaultLogger = m)不会更新包级 facade
|
||||
// 使用的 atomic 快照,也不是并发安全的替换方式。请用 SetDefaultLogManager(m)。
|
||||
// 直接调用 DefaultLogger.Init/Close/SetLevel/GetLevel 仍保留兼容,并受 LogManager
|
||||
// 自身锁保护。
|
||||
var DefaultLogger = NewLogManager()
|
||||
|
||||
// NewLogManager 创建日志管理器实例。
|
||||
func NewLogManager() *LogManager { return &LogManager{} }
|
||||
|
||||
// GetDefaultLogManager 返回全局默认 LogManager(atomic 读取,并发安全)。
|
||||
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。
|
||||
// 影响所有 core(app/api/db/console)——它们共享同一 AtomicLevel。
|
||||
func (m *LogManager) SetLevel(l zapcore.Level) bool {
|
||||
@@ -122,18 +167,66 @@ func (m *LogManager) GetLevel() zapcore.Level {
|
||||
}
|
||||
|
||||
// SetLevel 包级 facade:运行期热切换默认日志级别(M19)。未 Init 时无操作返回 false。
|
||||
func SetLevel(l zapcore.Level) bool { return DefaultLogger.SetLevel(l) }
|
||||
func SetLevel(l zapcore.Level) bool { return GetDefaultLogManager().SetLevel(l) }
|
||||
|
||||
// GetLevel 包级 facade:返回默认日志当前级别。
|
||||
func GetLevel() zapcore.Level { return DefaultLogger.GetLevel() }
|
||||
func GetLevel() zapcore.Level { return GetDefaultLogManager().GetLevel() }
|
||||
|
||||
// SetDefaultLogManager 提升指定 LogManager 为全局默认。
|
||||
// SetDefaultLogManager 提升指定 LogManager 为全局默认(atomic.Store,并发安全)。
|
||||
func SetDefaultLogManager(m *LogManager) {
|
||||
if m != nil {
|
||||
DefaultLogger = m
|
||||
defaultLoggerPtr.Store(m)
|
||||
}
|
||||
}
|
||||
|
||||
// SnapshotDefault 返回默认 logger 全局状态快照。
|
||||
// 快照仅用于 App 初始化回滚;调用方成功提交后必须 CloseDefaultSnapshot 释放旧 writers。
|
||||
func SnapshotDefault() *DefaultSnapshot {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
return &DefaultSnapshot{
|
||||
manager: GetDefaultLogManager(),
|
||||
logger: currentLogger(),
|
||||
sugar: currentSugar(),
|
||||
apiLog: currentAPILog(),
|
||||
dbLog: currentDBLog(),
|
||||
writers: append([]*lumberjack.Logger(nil), fileWriters...),
|
||||
generation: globalLogGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreDefaultSnapshot 恢复 SnapshotDefault 捕获的默认 logger 状态,并关闭当前新 writers。
|
||||
func RestoreDefaultSnapshot(s *DefaultSnapshot) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
globalMu.Lock()
|
||||
newLogger := currentLogger()
|
||||
newAPILog := currentAPILog()
|
||||
newDBLog := currentDBLog()
|
||||
newWriters := fileWriters
|
||||
loggerPtr.Store(s.logger)
|
||||
sugarPtr.Store(s.sugar)
|
||||
apiLogPtr.Store(s.apiLog)
|
||||
dbLogPtr.Store(s.dbLog)
|
||||
Logger = s.logger
|
||||
fileWriters = append([]*lumberjack.Logger(nil), s.writers...)
|
||||
globalLogGeneration = s.generation
|
||||
globalMu.Unlock()
|
||||
if s.manager != nil {
|
||||
SetDefaultLogManager(s.manager)
|
||||
}
|
||||
return errors.Join(syncLoggers(newLogger, newAPILog, newDBLog), closeFileWriters(newWriters))
|
||||
}
|
||||
|
||||
// CloseDefaultSnapshot 关闭 SnapshotDefault 捕获的旧 writers;用于新 logger 已提交成功后释放旧资源。
|
||||
func CloseDefaultSnapshot(s *DefaultSnapshot) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.Join(syncLoggers(s.logger, s.apiLog, s.dbLog), closeFileWriters(s.writers))
|
||||
}
|
||||
|
||||
// Init 初始化日志。
|
||||
//
|
||||
// 三个 logger 的分流策略:
|
||||
@@ -145,9 +238,25 @@ func SetDefaultLogManager(m *LogManager) {
|
||||
// 导致每条 logger.Info(...) 都会同时落到 api.log + database.log + console
|
||||
// 三份,磁盘占用翻倍且分流形同虚设。新实现通用 Logger 只走独立的 app.log。
|
||||
func (m *LogManager) Init(cfg *config.Config) error {
|
||||
return m.init(cfg, true)
|
||||
}
|
||||
|
||||
// InitPreservingPrevious 初始化并发布 logger,但暂不关闭被替换的旧 writers。
|
||||
// App.Init 使用该方法配合 SnapshotDefault,在后续 hook 失败时可恢复旧 logger。
|
||||
func (m *LogManager) InitPreservingPrevious(cfg *config.Config) error {
|
||||
return m.init(cfg, false)
|
||||
}
|
||||
|
||||
func (m *LogManager) init(cfg *config.Config, closePrevious bool) error {
|
||||
if cfg == nil {
|
||||
return errors.New("logger: 配置为空")
|
||||
}
|
||||
if err := validateLogConfig(cfg.Log); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 确保日志目录存在(0750:owner+group 可访问,与 storage 目录权限一致)
|
||||
if err := os.MkdirAll(cfg.Log.Dir, 0o750); err != nil {
|
||||
@@ -175,8 +284,6 @@ func (m *LogManager) Init(cfg *config.Config) error {
|
||||
if cfg.IsProduction() {
|
||||
level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
|
||||
}
|
||||
// 记录到 manager,供 SetLevel/GetLevel 热切换(H7:在 m.mu 下写入)。
|
||||
m.level = level
|
||||
|
||||
jsonEncoder := zapcore.NewJSONEncoder(encoderConfig)
|
||||
consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||||
@@ -205,27 +312,51 @@ func (m *LogManager) Init(cfg *config.Config) error {
|
||||
zap.AddCaller(), zap.AddCallerSkip(1),
|
||||
)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// 全部构造成功后再原子替换全局变量,避免半初始化状态。
|
||||
// 同时关闭旧 writer 释放句柄(重复 Init 场景,主要服务于测试)。
|
||||
// 先发布新 logger,再关闭旧 writer,避免请求 goroutine 在替换窗口内
|
||||
// 通过包级函数拿到仍指向已关闭 writer 的旧 logger。
|
||||
// H7:四个 logger 经 atomic.Pointer Store,读侧(请求 goroutine)无锁原子 load;
|
||||
// fileWriters 仅在 m.mu 下访问。Logger 兼容别名同步维护。
|
||||
closeFileWriters()
|
||||
// fileWriters 仅在 globalMu 下访问。Logger 兼容别名同步维护。
|
||||
globalMu.Lock()
|
||||
oldWriters := fileWriters
|
||||
globalLogGeneration++
|
||||
m.generation = globalLogGeneration
|
||||
loggerPtr.Store(newLogger)
|
||||
sugarPtr.Store(newLogger.Sugar())
|
||||
apiLogPtr.Store(newAPILog)
|
||||
dbLogPtr.Store(newDBLog)
|
||||
Logger = newLogger
|
||||
fileWriters = []*lumberjack.Logger{appWriter, apiWriter, dbWriter}
|
||||
globalMu.Unlock()
|
||||
|
||||
m.level = level
|
||||
m.cfg = cfg
|
||||
|
||||
if closePrevious {
|
||||
return closeFileWriters(oldWriters)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Init 包级 facade:初始化日志(代理到 DefaultLogger)。
|
||||
func Init(cfg *config.Config) error {
|
||||
return DefaultLogger.Init(cfg)
|
||||
return GetDefaultLogManager().Init(cfg)
|
||||
}
|
||||
|
||||
func validateLogConfig(cfg config.LogConfig) error {
|
||||
if strings.TrimSpace(cfg.Dir) == "" {
|
||||
return errors.New("logger: 日志目录为空")
|
||||
}
|
||||
if cfg.MaxSize < 0 {
|
||||
return errors.New("logger: MaxSize 不能为负数")
|
||||
}
|
||||
if cfg.MaxBackups < 0 {
|
||||
return errors.New("logger: MaxBackups 不能为负数")
|
||||
}
|
||||
if cfg.MaxAge < 0 {
|
||||
return errors.New("logger: MaxAge 不能为负数")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newRotatingWriter 创建带 lumberjack 滚动归档的 writer
|
||||
@@ -239,24 +370,22 @@ func newRotatingWriter(cfg config.LogConfig, filename string) *lumberjack.Logger
|
||||
}
|
||||
}
|
||||
|
||||
// closeFileWriters 关闭并清空当前持有的 lumberjack writer
|
||||
func closeFileWriters() {
|
||||
for _, w := range fileWriters {
|
||||
// closeFileWriters 关闭传入的 lumberjack writer,并聚合关闭错误。
|
||||
func closeFileWriters(writers []*lumberjack.Logger) error {
|
||||
var errs []error
|
||||
for _, w := range writers {
|
||||
if w != nil {
|
||||
_ = w.Close()
|
||||
if err := w.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
fileWriters = nil
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Sync 同步全部 logger 缓冲到底层 writer。
|
||||
//
|
||||
// 注意:在 Windows / 部分 *nix 平台上对 stdout/stderr 调用 Sync 会返回
|
||||
// "invalid argument" / "inappropriate ioctl for device",属于 zap 已知行为,
|
||||
// 这里把这类错误识别并忽略,只返回真实的写入失败。
|
||||
func (m *LogManager) Sync() error {
|
||||
func syncLoggers(loggers ...*zap.Logger) error {
|
||||
var errs []error
|
||||
for _, l := range []*zap.Logger{currentLogger(), currentAPILog(), currentDBLog()} {
|
||||
for _, l := range loggers {
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
@@ -267,9 +396,18 @@ func (m *LogManager) Sync() error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Sync 同步全部 logger 缓冲到底层 writer。
|
||||
//
|
||||
// 注意:在 Windows / 部分 *nix 平台上对 stdout/stderr 调用 Sync 会返回
|
||||
// "invalid argument" / "inappropriate ioctl for device",属于 zap 已知行为,
|
||||
// 这里把这类错误识别并忽略,只返回真实的写入失败。
|
||||
func (m *LogManager) Sync() error {
|
||||
return syncLoggers(currentLogger(), currentAPILog(), currentDBLog())
|
||||
}
|
||||
|
||||
// Sync 包级 facade:同步全部 logger 缓冲(代理到 DefaultLogger)。
|
||||
func Sync() error {
|
||||
return DefaultLogger.Sync()
|
||||
return GetDefaultLogManager().Sync()
|
||||
}
|
||||
|
||||
// Close 关闭日志文件句柄,重置全局 logger 为 Nop。
|
||||
@@ -278,10 +416,20 @@ func Sync() error {
|
||||
// 调用后再次写日志不会 panic(fall back to nop logger),但不会写入文件。
|
||||
// 如需重新启用,请再次调用 Init。
|
||||
func (m *LogManager) Close() error {
|
||||
syncErr := m.Sync()
|
||||
|
||||
m.mu.Lock()
|
||||
closeFileWriters()
|
||||
globalMu.Lock()
|
||||
if m.generation == 0 || m.generation != globalLogGeneration {
|
||||
m.level = zap.AtomicLevel{}
|
||||
m.cfg = nil
|
||||
m.generation = 0
|
||||
globalMu.Unlock()
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
oldLogger := currentLogger()
|
||||
oldAPILog := currentAPILog()
|
||||
oldDBLog := currentDBLog()
|
||||
oldWriters := fileWriters
|
||||
|
||||
// H7:重置为 Nop 经 atomic Store,与读侧一致;Logger 兼容别名同步。
|
||||
nop := zap.NewNop()
|
||||
@@ -290,14 +438,23 @@ func (m *LogManager) Close() error {
|
||||
apiLogPtr.Store(nop)
|
||||
dbLogPtr.Store(nop)
|
||||
Logger = nop
|
||||
fileWriters = nil
|
||||
globalMu.Unlock()
|
||||
|
||||
m.level = zap.AtomicLevel{}
|
||||
m.cfg = nil
|
||||
m.generation = 0
|
||||
m.mu.Unlock()
|
||||
|
||||
return syncErr
|
||||
return errors.Join(
|
||||
syncLoggers(oldLogger, oldAPILog, oldDBLog),
|
||||
closeFileWriters(oldWriters),
|
||||
)
|
||||
}
|
||||
|
||||
// Close 包级 facade:关闭日志文件句柄,重置为 Nop(代理到 DefaultLogger)。
|
||||
func Close() error {
|
||||
return DefaultLogger.Close()
|
||||
return GetDefaultLogManager().Close()
|
||||
}
|
||||
|
||||
// isHarmlessSyncError 识别 stdout/stderr Sync 在不同平台返回的预期错误。
|
||||
@@ -308,9 +465,9 @@ func isHarmlessSyncError(err error) bool {
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, sub := range []string{
|
||||
"invalid argument", // Linux stdout
|
||||
"inappropriate ioctl for device", // macOS stdout
|
||||
"bad file descriptor", // Windows stdout
|
||||
"invalid argument", // Linux stdout
|
||||
"inappropriate ioctl for device", // macOS stdout
|
||||
"bad file descriptor", // Windows stdout
|
||||
} {
|
||||
if strings.Contains(msg, sub) {
|
||||
return true
|
||||
|
||||
@@ -42,9 +42,9 @@ func TestH7ConcurrentInitCloseAndRead(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := tmpCfg(dir)
|
||||
|
||||
// 确保 DefaultLogger 起点干净。
|
||||
_ = DefaultLogger.Close()
|
||||
t.Cleanup(func() { _ = DefaultLogger.Close() })
|
||||
// 确保默认 LogManager 起点干净。
|
||||
_ = GetDefaultLogManager().Close()
|
||||
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
@@ -59,8 +59,8 @@ func TestH7ConcurrentInitCloseAndRead(t *testing.T) {
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = DefaultLogger.Init(cfg)
|
||||
_ = DefaultLogger.Close()
|
||||
_ = GetDefaultLogManager().Init(cfg)
|
||||
_ = GetDefaultLogManager().Close()
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestH7ConcurrentInitCloseAndRead(t *testing.T) {
|
||||
wg.Wait()
|
||||
|
||||
// 收尾到 Nop,避免后续测试持有临时目录句柄。
|
||||
_ = DefaultLogger.Close()
|
||||
_ = GetDefaultLogManager().Close()
|
||||
}
|
||||
|
||||
// TestH7CurrentLoggerReflectsInitAndClose 验证 atomic 快照随 Init/Close 正确切换:
|
||||
@@ -105,8 +105,8 @@ func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := tmpCfg(dir)
|
||||
|
||||
_ = DefaultLogger.Close()
|
||||
t.Cleanup(func() { _ = DefaultLogger.Close() })
|
||||
_ = GetDefaultLogManager().Close()
|
||||
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
|
||||
|
||||
// Init 前:Nop,调用安全且不写文件。
|
||||
before := currentLogger()
|
||||
@@ -114,7 +114,7 @@ func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
|
||||
t.Fatal("currentLogger nil before Init")
|
||||
}
|
||||
|
||||
if err := DefaultLogger.Init(cfg); err != nil {
|
||||
if err := GetDefaultLogManager().Init(cfg); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
|
||||
// 写一条日志并 flush,验证落到文件(说明拿到的是真实 logger,非 Nop)。
|
||||
const mark = "H7_INIT_REFLECT_xyz"
|
||||
after.Info(mark)
|
||||
_ = DefaultLogger.Sync()
|
||||
_ = GetDefaultLogManager().Sync()
|
||||
|
||||
data, err := readFile(t, filepath.Join(dir, "app.log"))
|
||||
if err != nil {
|
||||
@@ -141,7 +141,7 @@ func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
|
||||
}
|
||||
|
||||
// Close 后:currentLogger 回到 Nop(与 Init 前实例不同,但同为 Nop 行为)。
|
||||
if err := DefaultLogger.Close(); err != nil {
|
||||
if err := GetDefaultLogManager().Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
closed := currentLogger()
|
||||
@@ -150,7 +150,7 @@ func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
|
||||
}
|
||||
// Nop logger 写不报错也不落盘;验证 Close 后再写不新增 mark。
|
||||
closed.Info("H7_SHOULD_NOT_APPEAR_xyz")
|
||||
_ = DefaultLogger.Sync()
|
||||
_ = GetDefaultLogManager().Sync()
|
||||
data2, _ := readFile(t, filepath.Join(dir, "app.log"))
|
||||
if strings.Contains(data2, "H7_SHOULD_NOT_APPEAR_xyz") {
|
||||
t.Error("wrote to file after Close (expected Nop)")
|
||||
@@ -183,6 +183,57 @@ func TestH7AtomicPointersNonNil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestH7DefaultLoggerCompatibility 锁定 M3 的兼容边界:
|
||||
// DefaultLogger 仍保持 *LogManager 类型,旧代码可继续直接调用其方法;
|
||||
// 包级 facade 的默认 manager 替换则必须走 SetDefaultLogManager 的 atomic 快照。
|
||||
func TestH7DefaultLoggerCompatibility(t *testing.T) {
|
||||
var _ *LogManager = DefaultLogger
|
||||
|
||||
old := GetDefaultLogManager()
|
||||
t.Cleanup(func() { SetDefaultLogManager(old) })
|
||||
|
||||
next := NewLogManager()
|
||||
SetDefaultLogManager(next)
|
||||
if got := GetDefaultLogManager(); got != next {
|
||||
t.Fatalf("GetDefaultLogManager() = %p, want %p", got, next)
|
||||
}
|
||||
}
|
||||
|
||||
// TestM3StaleManagerCloseDoesNotCloseCurrentLogger 回归:旧 manager 的 Close
|
||||
// 不得关闭另一个 manager 后续发布的全局 logger/writer。
|
||||
func TestM3StaleManagerCloseDoesNotCloseCurrentLogger(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
m1 := NewLogManager()
|
||||
m2 := NewLogManager()
|
||||
t.Cleanup(func() {
|
||||
_ = m1.Close()
|
||||
_ = m2.Close()
|
||||
SetDefaultLogManager(NewLogManager())
|
||||
})
|
||||
|
||||
if err := m1.Init(tmpCfg(dir1)); err != nil {
|
||||
t.Fatalf("m1.Init: %v", err)
|
||||
}
|
||||
if err := m2.Init(tmpCfg(dir2)); err != nil {
|
||||
t.Fatalf("m2.Init: %v", err)
|
||||
}
|
||||
if err := m1.Close(); err != nil {
|
||||
t.Fatalf("stale m1.Close: %v", err)
|
||||
}
|
||||
|
||||
const mark = "M3_CURRENT_LOGGER_SURVIVES_STALE_CLOSE"
|
||||
currentLogger().Info(mark)
|
||||
_ = syncLoggers(currentLogger())
|
||||
data, err := readFile(t, filepath.Join(dir2, "app.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("read current app.log: %v", err)
|
||||
}
|
||||
if !strings.Contains(data, mark) {
|
||||
t.Fatal("stale manager Close closed the current logger")
|
||||
}
|
||||
}
|
||||
|
||||
// TestH7DurationFieldFix 验证 H7b:Field.Duration 签名改为
|
||||
// func(key string, value time.Duration) zap.Field,且 key 不再被丢弃。
|
||||
//
|
||||
@@ -232,22 +283,22 @@ func readFile(t *testing.T, path string) (string, error) {
|
||||
func TestSetLevelHotSwap_M19(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := tmpCfg(dir)
|
||||
_ = DefaultLogger.Close()
|
||||
t.Cleanup(func() { _ = DefaultLogger.Close() })
|
||||
_ = GetDefaultLogManager().Close()
|
||||
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
|
||||
|
||||
if err := DefaultLogger.Init(cfg); err != nil {
|
||||
if err := GetDefaultLogManager().Init(cfg); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
// Init 后(tmpCfg 为 production 模式,默认 InfoLevel)可热切换到 ErrorLevel。
|
||||
before := DefaultLogger.GetLevel()
|
||||
before := GetDefaultLogManager().GetLevel()
|
||||
if before != zapcore.InfoLevel {
|
||||
t.Errorf("default level = %v, want InfoLevel (production)", before)
|
||||
}
|
||||
if ok := DefaultLogger.SetLevel(zapcore.ErrorLevel); !ok {
|
||||
if ok := GetDefaultLogManager().SetLevel(zapcore.ErrorLevel); !ok {
|
||||
t.Fatal("SetLevel after Init should return true")
|
||||
}
|
||||
if got := DefaultLogger.GetLevel(); got != zapcore.ErrorLevel {
|
||||
if got := GetDefaultLogManager().GetLevel(); got != zapcore.ErrorLevel {
|
||||
t.Errorf("after SetLevel, level = %v, want ErrorLevel", got)
|
||||
}
|
||||
|
||||
@@ -257,3 +308,175 @@ func TestSetLevelHotSwap_M19(t *testing.T) {
|
||||
t.Errorf("package GetLevel = %v, want WarnLevel", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestM3ConcurrentInitSetLevelAndClose 验证 Init/SetLevel/GetLevel/Close
|
||||
// 共享同一个 manager 临界区,不再出现 m.level 锁外写与锁内读写竞争(-race)。
|
||||
func TestM3ConcurrentInitSetLevelAndClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := tmpCfg(dir)
|
||||
m := NewLogManager()
|
||||
SetDefaultLogManager(m)
|
||||
t.Cleanup(func() {
|
||||
_ = m.Close()
|
||||
SetDefaultLogManager(NewLogManager())
|
||||
})
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = m.Init(cfg)
|
||||
_ = m.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
levels := []zapcore.Level{
|
||||
zapcore.DebugLevel,
|
||||
zapcore.InfoLevel,
|
||||
zapcore.WarnLevel,
|
||||
zapcore.ErrorLevel,
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = m.SetLevel(levels[i%len(levels)])
|
||||
_ = m.GetLevel()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestM3ConcurrentSetDefaultAndFacades 验证默认 LogManager 经 atomic.Pointer
|
||||
// 读写,SetDefaultLogManager 与包级 facade 并发时无裸全局指针竞态(-race)。
|
||||
func TestM3ConcurrentSetDefaultAndFacades(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
_ = Close()
|
||||
SetDefaultLogManager(NewLogManager())
|
||||
})
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
SetDefaultLogManager(NewLogManager())
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = GetLevel()
|
||||
_ = SetLevel(zapcore.WarnLevel)
|
||||
_ = Sync()
|
||||
_ = APILog()
|
||||
_ = DBLog()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestM3ConcurrentManagersInitClose 验证不同 LogManager 实例并发 Init/Close 时,
|
||||
// 包级 Logger/fileWriters 的发布与关闭由 globalMu 串行化,不再依赖实例锁保护包级状态。
|
||||
func TestM3ConcurrentManagersInitClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := tmpCfg(dir)
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
managers := make([]*LogManager, 3)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
managers[i] = NewLogManager()
|
||||
wg.Add(1)
|
||||
go func(m *LogManager) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
SetDefaultLogManager(m)
|
||||
_ = m.Init(cfg)
|
||||
_ = m.Close()
|
||||
}
|
||||
}(managers[i])
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = currentLogger()
|
||||
_ = currentSugar()
|
||||
_ = APILog()
|
||||
_ = DBLog()
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
for _, m := range managers {
|
||||
_ = m.Close()
|
||||
}
|
||||
_ = Close()
|
||||
SetDefaultLogManager(NewLogManager())
|
||||
}
|
||||
|
||||
// TestM3InitRejectsInvalidLogConfig 验证 logger.Init 对明显非法日志配置失败,
|
||||
// 避免空目录意外把 app.log 写到进程工作目录。
|
||||
func TestM3InitRejectsInvalidLogConfig(t *testing.T) {
|
||||
if err := NewLogManager().Init(&config.Config{}); err == nil {
|
||||
t.Fatal("Init with empty log dir should fail")
|
||||
}
|
||||
|
||||
cfg := tmpCfg(t.TempDir())
|
||||
cfg.Log.MaxAge = -1
|
||||
if err := NewLogManager().Init(cfg); err == nil {
|
||||
t.Fatal("Init with negative MaxAge should fail")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ func AuthRequired() gin.HandlerFunc {
|
||||
|
||||
// 解析 Bearer Token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
response.Unauthorized(c, "认证格式错误")
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -38,6 +41,41 @@ func setAuthUser(userType, role string) func(*gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredAcceptsCaseInsensitiveBearer_M8(t *testing.T) {
|
||||
setupMiddlewareMiniRedis(t)
|
||||
if err := config.Set(&config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
Expire: time.Hour,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set config: %v", err)
|
||||
}
|
||||
token, err := jwt.GenerateToken(1, "tester", "owner", "tenant_admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(middleware.AuthRequired())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "bearer "+token)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"ok":true`) {
|
||||
t.Fatalf("expected request to pass, got body %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireUserTypes(t *testing.T) {
|
||||
w := performAuthMiddlewareRequest(middleware.RequireUserTypes("tenant_admin"), setAuthUser("tenant_admin", "owner"))
|
||||
if w.Code != http.StatusOK {
|
||||
|
||||
+89
-21
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -23,6 +24,8 @@ const (
|
||||
CSRFCookieName = "csrf_token"
|
||||
// CSRFFormField 表单字段名称
|
||||
CSRFFormField = "_csrf"
|
||||
// CSRFMaxBodyBytes JSON body 中读取 CSRF token 的最大字节数,防止 pre-auth OOM。
|
||||
CSRFMaxBodyBytes = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
// CSRFConfig CSRF 配置
|
||||
@@ -47,6 +50,12 @@ type CSRFConfig struct {
|
||||
Path string
|
||||
// MaxAge Cookie 有效期(秒)
|
||||
MaxAge int
|
||||
// SessionCookie 为 true 时显式使用会话 Cookie(MaxAge=0)。
|
||||
// 为保持 CSRFWithConfig(CSRFConfig{}) 的默认行为,MaxAge=0 且本字段为 false 时仍使用默认 1 小时。
|
||||
SessionCookie bool
|
||||
// MaxBodyBytes 从 JSON body 提取 token 时允许读取的最大字节数。
|
||||
// <=0 时使用默认 1MiB。Header/Form token 不受此限制。
|
||||
MaxBodyBytes int64
|
||||
// ErrorFunc 错误处理函数
|
||||
ErrorFunc func(c *gin.Context)
|
||||
// SkipFunc 跳过检查函数
|
||||
@@ -55,21 +64,25 @@ type CSRFConfig struct {
|
||||
|
||||
// DefaultCSRFConfig 默认 CSRF 配置
|
||||
var DefaultCSRFConfig = CSRFConfig{
|
||||
TokenLength: CSRFTokenLength,
|
||||
HeaderName: CSRFHeaderName,
|
||||
CookieName: CSRFCookieName,
|
||||
FormField: CSRFFormField,
|
||||
Secure: false,
|
||||
HTTPOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
MaxAge: 3600, // 1 小时
|
||||
ErrorFunc: defaultCSRFError,
|
||||
SkipFunc: nil,
|
||||
TokenLength: CSRFTokenLength,
|
||||
HeaderName: CSRFHeaderName,
|
||||
CookieName: CSRFCookieName,
|
||||
FormField: CSRFFormField,
|
||||
Secure: false,
|
||||
HTTPOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
MaxAge: 3600, // 1 小时
|
||||
MaxBodyBytes: CSRFMaxBodyBytes,
|
||||
ErrorFunc: defaultCSRFError,
|
||||
SkipFunc: nil,
|
||||
}
|
||||
|
||||
// generateCSRFToken 生成 CSRF Token
|
||||
func generateCSRFToken(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
length = CSRFTokenLength
|
||||
}
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
@@ -83,15 +96,13 @@ func defaultCSRFError(c *gin.Context) {
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
// CSRF 创建 CSRF 中间件
|
||||
func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
cfg := DefaultCSRFConfig
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
}
|
||||
func csrfBodyTooLarge(c *gin.Context) {
|
||||
response.Custom(c, http.StatusRequestEntityTooLarge, response.CodeFileTooLarge, "请求体过大", nil)
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if cfg.TokenLength == 0 {
|
||||
func normalizeCSRFConfig(cfg CSRFConfig) CSRFConfig {
|
||||
if cfg.TokenLength <= 0 {
|
||||
cfg.TokenLength = CSRFTokenLength
|
||||
}
|
||||
if cfg.HeaderName == "" {
|
||||
@@ -100,9 +111,45 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
if cfg.CookieName == "" {
|
||||
cfg.CookieName = CSRFCookieName
|
||||
}
|
||||
if cfg.FormField == "" {
|
||||
cfg.FormField = CSRFFormField
|
||||
}
|
||||
if cfg.Path == "" {
|
||||
cfg.Path = "/"
|
||||
}
|
||||
if cfg.SameSite == 0 {
|
||||
cfg.SameSite = DefaultCSRFConfig.SameSite
|
||||
}
|
||||
if cfg.MaxAge == 0 && !cfg.SessionCookie {
|
||||
cfg.MaxAge = DefaultCSRFConfig.MaxAge
|
||||
}
|
||||
if cfg.MaxBodyBytes <= 0 {
|
||||
cfg.MaxBodyBytes = CSRFMaxBodyBytes
|
||||
}
|
||||
if cfg.ErrorFunc == nil {
|
||||
cfg.ErrorFunc = defaultCSRFError
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func pathMatchesSkip(path, prefix string) bool {
|
||||
if prefix == "" {
|
||||
return false
|
||||
}
|
||||
if prefix == "/" {
|
||||
return true
|
||||
}
|
||||
prefix = strings.TrimRight(prefix, "/")
|
||||
return path == prefix || strings.HasPrefix(path, prefix+"/")
|
||||
}
|
||||
|
||||
// CSRF 创建 CSRF 中间件
|
||||
func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
cfg := DefaultCSRFConfig
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
}
|
||||
cfg = normalizeCSRFConfig(cfg)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// 检查是否跳过
|
||||
@@ -123,6 +170,7 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
cookieToken = token
|
||||
|
||||
// 设置 Cookie
|
||||
c.SetSameSite(cfg.SameSite)
|
||||
c.SetCookie(
|
||||
cfg.CookieName,
|
||||
token,
|
||||
@@ -160,10 +208,17 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
// body 缓存进 gin context,下游可重复读取。
|
||||
if clientToken == "" {
|
||||
var body map[string]any
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, cfg.MaxBodyBytes)
|
||||
if err := c.ShouldBindBodyWith(&body, binding.JSON); err == nil {
|
||||
if token, ok := body[cfg.FormField].(string); ok {
|
||||
clientToken = token
|
||||
}
|
||||
} else {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
csrfBodyTooLarge(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +257,9 @@ func GetCSRFToken(c *gin.Context) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// CSRFToken 返回 CSRF Token 的处理器(用于 API 模式)
|
||||
// CSRFToken 返回当前请求上下文中的 CSRF Token;若上下文无 Token 则现场生成一个返回。
|
||||
// 适用于 Cookie/双重提交模式(与 CSRF/DoubleSubmitCookie 中间件配合),
|
||||
// 不与 CSRFForAPI 配套--API 模式的可校验 Token 请用 GenerateAPIToken 颁发。
|
||||
func CSRFToken(c *gin.Context) {
|
||||
token := GetCSRFToken(c)
|
||||
if token == "" {
|
||||
@@ -225,7 +282,7 @@ func CSRFWithSkip(skipPaths []string) gin.HandlerFunc {
|
||||
cfg.SkipFunc = func(c *gin.Context) bool {
|
||||
path := c.Request.URL.Path
|
||||
for _, p := range skipPaths {
|
||||
if strings.HasPrefix(path, p) {
|
||||
if pathMatchesSkip(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -295,6 +352,7 @@ func GenerateAPIToken(c *gin.Context) {
|
||||
}
|
||||
|
||||
apiTokensMu.Lock()
|
||||
cleanupExpiredAPITokensLocked(time.Now())
|
||||
apiTokens[token] = time.Now()
|
||||
apiTokensMu.Unlock()
|
||||
|
||||
@@ -313,6 +371,14 @@ var (
|
||||
// apiTokenTTL API 模式 CSRF Token 有效期
|
||||
const apiTokenTTL = 30 * time.Minute
|
||||
|
||||
func cleanupExpiredAPITokensLocked(now time.Time) {
|
||||
for t, at := range apiTokens {
|
||||
if now.Sub(at) > apiTokenTTL {
|
||||
delete(apiTokens, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CSRFExempt 标记路由不需要 CSRF 保护
|
||||
// 使用方法:在路由组上使用此中间件
|
||||
func CSRFExempt() gin.HandlerFunc {
|
||||
@@ -328,6 +394,7 @@ func CSRFWithExempt(config ...CSRFConfig) gin.HandlerFunc {
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
}
|
||||
cfg = normalizeCSRFConfig(cfg)
|
||||
|
||||
originalSkipFunc := cfg.SkipFunc
|
||||
cfg.SkipFunc = func(c *gin.Context) bool {
|
||||
@@ -360,6 +427,7 @@ func DoubleSubmitCookie() gin.HandlerFunc {
|
||||
token, _ := generateCSRFToken(CSRFTokenLength)
|
||||
// 双重提交模式要求前端 JS 读取 cookie 并回填 X-CSRF-Token 头,
|
||||
// 故 HttpOnly 必须为 false(与 CSRF() cookie 模式相反)。
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(CSRFCookieName, token, 3600, "/", "", false, false)
|
||||
c.Set("csrf_token", token)
|
||||
} else {
|
||||
|
||||
@@ -87,3 +87,148 @@ func TestCSRFJSONBodyPreservedForDownstream(t *testing.T) {
|
||||
t.Errorf("downstream read data=%q, want 'hello-downstream' (P1 #9: body must survive CSRF)", gotData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFJSONBodyTooLargeRejected(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRF(CSRFConfig{MaxBodyBytes: 16}))
|
||||
r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||||
|
||||
body := `{"_csrf":"token","data":"` + strings.Repeat("x", 64) + `"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: "token"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want 413; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFNegativeTokenLengthFallsBack(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRF(CSRFConfig{TokenLength: -1}))
|
||||
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"token": GetCSRFToken(c)}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(w.Result().Cookies()) == 0 {
|
||||
t.Fatal("expected csrf cookie to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFCookieSameSiteLax(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRF())
|
||||
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == CSRFCookieName {
|
||||
if c.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("csrf cookie not set")
|
||||
}
|
||||
|
||||
func TestCSRFPartialConfigKeepsCookieDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRF(CSRFConfig{TokenLength: -1}))
|
||||
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == CSRFCookieName {
|
||||
if c.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax for partial config", c.SameSite)
|
||||
}
|
||||
if c.MaxAge != DefaultCSRFConfig.MaxAge {
|
||||
t.Fatalf("MaxAge = %d, want %d for partial config", c.MaxAge, DefaultCSRFConfig.MaxAge)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("csrf cookie not set")
|
||||
}
|
||||
|
||||
func TestDoubleSubmitCookieSameSiteLax(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(DoubleSubmitCookie())
|
||||
r.GET("/get", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/get", nil))
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == CSRFCookieName {
|
||||
if c.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("csrf cookie not set")
|
||||
}
|
||||
|
||||
func TestCSRFWithSkipAnchorsPathBoundary(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRFWithSkip([]string{"/api"}))
|
||||
r.POST("/api", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
r.POST("/api/users", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
r.POST("/apix", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
|
||||
for _, path := range []string{"/api", "/api/users"} {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("%s status = %d, want skipped 204", path, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/apix", nil))
|
||||
if w.Code == http.StatusNoContent {
|
||||
t.Fatal("/apix was skipped by /api prefix; want CSRF rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAPITokenCleansExpiredTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
expiredToken := "expired-before-issue"
|
||||
apiTokensMu.Lock()
|
||||
apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second))
|
||||
apiTokensMu.Unlock()
|
||||
defer func() {
|
||||
apiTokensMu.Lock()
|
||||
delete(apiTokens, expiredToken)
|
||||
apiTokensMu.Unlock()
|
||||
}()
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/csrf-token", GenerateAPIToken)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/csrf-token", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
apiTokensMu.RLock()
|
||||
_, exists := apiTokens[expiredToken]
|
||||
apiTokensMu.RUnlock()
|
||||
if exists {
|
||||
t.Fatal("GenerateAPIToken did not clean expired token")
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -198,7 +198,7 @@ func shouldSkipPath(path string, cfg LoggerConfig) bool {
|
||||
|
||||
// 检查路径前缀
|
||||
for _, prefix := range cfg.SkipPathPrefixes {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
if pathPrefixMatch(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,17 @@ func shouldSkipPath(path string, cfg LoggerConfig) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func pathPrefixMatch(path, prefix string) bool {
|
||||
if prefix == "" {
|
||||
return false
|
||||
}
|
||||
cleanPrefix := strings.TrimRight(prefix, "/")
|
||||
if cleanPrefix == "" {
|
||||
return path == "/"
|
||||
}
|
||||
return path == cleanPrefix || strings.HasPrefix(path, cleanPrefix+"/")
|
||||
}
|
||||
|
||||
// filterSensitiveFields 过滤敏感字段(密码、token等)
|
||||
// sensitiveFieldsRE M1 修复:编译期正则,匹配 "key":"value" 整体并将 value 替换为 [FILTERED]。
|
||||
// 支持 JSON 字符串中标准转义(\" \\ \/ \b \f \n \r \t \uXXXX)。
|
||||
@@ -278,7 +289,7 @@ func LoggerMinimal() gin.HandlerFunc {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 跳过健康检查和静态资源
|
||||
if path == "/health" || strings.HasPrefix(path, "/public") || strings.HasPrefix(path, "/static") {
|
||||
if path == "/health" || pathPrefixMatch(path, "/public") || pathPrefixMatch(path, "/static") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -294,4 +305,4 @@ func LoggerMinimal() gin.HandlerFunc {
|
||||
zap.Duration("latency", time.Since(start)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,3 +260,16 @@ func TestLoggerWithConfig_NormalizesMaxBodyLength(t *testing.T) {
|
||||
t.Fatalf("downstream body corrupted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerSkipPathPrefixesAnchorsBoundary_M8(t *testing.T) {
|
||||
cfg := LoggerConfig{SkipPathPrefixes: []string{"/api"}}
|
||||
if !shouldSkipPath("/api", cfg) {
|
||||
t.Fatal("/api should be skipped")
|
||||
}
|
||||
if !shouldSkipPath("/api/users", cfg) {
|
||||
t.Fatal("/api/users should be skipped")
|
||||
}
|
||||
if shouldSkipPath("/api2/users", cfg) {
|
||||
t.Fatal("/api2/users should not be skipped by /api prefix")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1094,6 +1094,55 @@ func TestRateLimiterBurstCappedAtRate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertPanic(t *testing.T, name string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatalf("%s did not panic", name)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
func TestRateLimiterRejectsInvalidConfig(t *testing.T) {
|
||||
assertPanic(t, "memory limiter zero rate", func() {
|
||||
_ = middleware.NewRateLimiter(0, time.Minute)
|
||||
})
|
||||
assertPanic(t, "memory limiter zero window", func() {
|
||||
_ = middleware.NewRateLimiter(1, 0)
|
||||
})
|
||||
assertPanic(t, "redis limiter zero rate", func() {
|
||||
_ = middleware.NewRedisRateLimiter("bad", 0, time.Minute)
|
||||
})
|
||||
assertPanic(t, "redis fail-closed limiter zero window", func() {
|
||||
_ = middleware.NewRedisRateLimiter("bad", 1, 0, middleware.WithFailClosed(true))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRateLimiterStopIdempotent(t *testing.T) {
|
||||
limiter := middleware.NewRateLimiter(1, time.Minute)
|
||||
limiter.Stop()
|
||||
limiter.Stop()
|
||||
}
|
||||
|
||||
func TestRateLimitNilLimiterFailsClosed(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.RateLimit(nil))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("RateLimit(nil) status = %d, want 503; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := respCode(t, w); got != response.CodeServiceUnavailable {
|
||||
t.Fatalf("RateLimit(nil) code = %d, want %d", got, response.CodeServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimit(t *testing.T) {
|
||||
middleware.InitRateLimiters()
|
||||
defer middleware.StopRateLimiters()
|
||||
@@ -1238,6 +1287,32 @@ func TestCustomRedisRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisRateLimitWithIdentifierNilFuncUsesClientIP(t *testing.T) {
|
||||
setupMiddlewareMiniRedis(t)
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.RedisRateLimitWithIdentifier("nil_identifier", 1, nil))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
req1 := httptest.NewRequest("GET", "/test", nil)
|
||||
req1.RemoteAddr = "192.0.2.10:12345"
|
||||
r.ServeHTTP(w1, req1)
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("first request status = %d, want 200; body=%s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("GET", "/test", nil)
|
||||
req2.RemoteAddr = "192.0.2.10:12345"
|
||||
r.ServeHTTP(w2, req2)
|
||||
if got := respCode(t, w2); got != response.CodeRateLimit {
|
||||
t.Fatalf("second request code = %d, want %d; status=%d body=%s", got, response.CodeRateLimit, w2.Code, w2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisRateLimiterGetCount(t *testing.T) {
|
||||
limiter := middleware.NewRedisRateLimiter("test_count", 10, time.Minute)
|
||||
|
||||
@@ -1296,7 +1371,7 @@ func TestRedisRateLimiterFailClosedNoRedis(t *testing.T) {
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
limiter := middleware.NewRedisRateLimiterFailClosed("test", 10, time.Minute)
|
||||
limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute, middleware.WithFailClosed(true))
|
||||
allowed, err := limiter.Allow(context.Background(), "1.2.3.4")
|
||||
if allowed {
|
||||
t.Error("fail-closed no-redis should deny")
|
||||
@@ -1322,7 +1397,7 @@ func TestRedisRateLimiterFailClosedOnRedisError(t *testing.T) {
|
||||
}
|
||||
|
||||
// fail-closed:正常时放行。
|
||||
closedLimiter := middleware.NewRedisRateLimiterFailClosed("test_closed", 10, time.Minute)
|
||||
closedLimiter := middleware.NewRedisRateLimiter("test_closed", 10, time.Minute, middleware.WithFailClosed(true))
|
||||
allowed, err = closedLimiter.Allow(context.Background(), "1.2.3.4")
|
||||
if err != nil {
|
||||
t.Fatalf("fail-closed normal err = %v", err)
|
||||
@@ -1356,7 +1431,7 @@ func TestRedisRateLimitFailClosedMiddlewareReturns503(t *testing.T) {
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.RedisRateLimitFailClosed("login_limit", 10))
|
||||
r.Use(middleware.RedisRateLimit("login_limit", 10, middleware.WithFailClosed(true)))
|
||||
r.GET("/login", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1470,4 +1545,4 @@ func TestRedisRateLimiterSetFailClosed(t *testing.T) {
|
||||
} else if !errors.Is(err, middleware.ErrRedisRateLimiterUnavailable) {
|
||||
t.Errorf("after SetFailClosed err = %v, want ErrRedisRateLimiterUnavailable", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+290
-163
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RateLimiter 速率限制器(内存版,单实例使用)
|
||||
@@ -34,8 +35,11 @@ type visitor struct {
|
||||
count int
|
||||
}
|
||||
|
||||
// NewRateLimiter 创建速率限制器(内存版)
|
||||
// NewRateLimiter 创建速率限制器(内存版)。
|
||||
// rate<=0 或 window<=0 时 panic。内部启动 cleanup goroutine,调用方负责在不再使用时
|
||||
// 调用 (*RateLimiter).Stop 释放,否则 goroutine 泄漏。
|
||||
func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
|
||||
mustValidRateLimit(rate, window)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
limiter := &RateLimiter{
|
||||
visitors: make(map[string]*visitor),
|
||||
@@ -52,6 +56,15 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
|
||||
return limiter
|
||||
}
|
||||
|
||||
func mustValidRateLimit(rate int, window time.Duration) {
|
||||
if rate <= 0 {
|
||||
panic("rate limiter: rate must be positive")
|
||||
}
|
||||
if window <= 0 {
|
||||
panic("rate limiter: window must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
// now 返回当前时间(可被测试注入 nowFunc 覆盖)。
|
||||
func (rl *RateLimiter) now() time.Time {
|
||||
if rl.nowFunc != nil {
|
||||
@@ -151,10 +164,11 @@ var (
|
||||
|
||||
// RedisRateLimiter Redis 分布式限流器
|
||||
type RedisRateLimiter struct {
|
||||
keyPrefix string // 键名前缀
|
||||
rate int // 每分钟允许的请求数
|
||||
window time.Duration // 时间窗口
|
||||
failClosed atomic.Bool // H4c/H-6: Redis 错误/断言失败时是否拒绝(true=安全型 fail-closed)。atomic 以支持运行期 SetFailClosed 并发安全切换。
|
||||
keyPrefix string // 键名前缀
|
||||
rate int // 每分钟允许的请求数
|
||||
window time.Duration // 时间窗口
|
||||
failClosed atomic.Bool // H4c/H-6: Redis 错误/断言失败时是否拒绝(true=安全型 fail-closed)。atomic 以支持运行期 SetFailClosed 并发安全切换。
|
||||
client *redis.Client // Phase 5: 注入的 redis client(nil 回退 database.GetRedis(),照 jwt.TokenBlacklist 模型)。App 经 WithRedisClient 注入 per-App client 实现隔离。
|
||||
}
|
||||
|
||||
// slidingWindowLua 滑动窗口限流 Lua 脚本
|
||||
@@ -181,26 +195,40 @@ else
|
||||
end
|
||||
`
|
||||
|
||||
// NewRedisRateLimiter 创建 Redis 分布式限流器(默认 fail-open:Redis 错误时放行,避免影响业务)。
|
||||
// 安全敏感场景(如登录防爆破)应使用 NewRedisRateLimiterFailClosed,Redis 故障时拒绝以防限流失效。
|
||||
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
|
||||
return &RedisRateLimiter{
|
||||
// RedisRateLimiterOption 配置 RedisRateLimiter 的可选策略。
|
||||
type RedisRateLimiterOption func(*RedisRateLimiter)
|
||||
|
||||
// WithFailClosed 设置 Redis 故障时 fail-closed(拒绝请求)。
|
||||
// 不传或传 false 为 fail-open(放行,兼容默认)。安全敏感场景(登录防爆破等)应传 true。
|
||||
func WithFailClosed(failClosed bool) RedisRateLimiterOption {
|
||||
return func(rl *RedisRateLimiter) {
|
||||
rl.failClosed.Store(failClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 零值 false(fail-open,兼容默认)
|
||||
}
|
||||
}
|
||||
|
||||
// NewRedisRateLimiterFailClosed 创建安全型 Redis 分布式限流器(fail-closed):
|
||||
// Redis 不可用/错误/返回非预期类型时拒绝请求,避免限流静默失效(防爆破场景必备)。
|
||||
func NewRedisRateLimiterFailClosed(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
|
||||
rl := &RedisRateLimiter{
|
||||
keyPrefix: keyPrefix,
|
||||
rate: rate,
|
||||
window: window,
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(rl)
|
||||
}
|
||||
}
|
||||
rl.failClosed.Store(true)
|
||||
return rl
|
||||
}
|
||||
|
||||
@@ -210,20 +238,31 @@ 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 故障时拒绝。
|
||||
// 不再无条件 fail-open--登录防爆破等安全场景用 fail-closed 限流器即可在 Redis 故障时拒绝。
|
||||
//
|
||||
// Redis 未启用(database.GetRedis() == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable),
|
||||
// Redis 未启用(redisClient() == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable),
|
||||
// fail-open 返 (true, nil)(兼容旧行为)。安全场景必须确保 Redis 已启用。
|
||||
//
|
||||
// Phase 5:redisClient() 注入优先、全局兜底,App 经 WithRedisClient 注入 per-App client。
|
||||
func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) {
|
||||
// M-C 修复:GetRedis() 仅取一次复用。原实现 nil 检查与 Eval 各调一次 GetRedis(),
|
||||
// 两次之间若 CloseRedis,第二次返回 nil → nil.Eval panic。
|
||||
rdb := database.GetRedis()
|
||||
// M-C 修复:取一次复用。原实现 nil 检查与 Eval 各调一次 GetRedis(),
|
||||
// 两次之间若 CloseRedis,第二次返回 nil -> nil.Eval panic。
|
||||
rdb := rl.redisClient()
|
||||
if rdb == nil {
|
||||
if rl.failClosed.Load() {
|
||||
return false, ErrRedisRateLimiterUnavailable
|
||||
@@ -256,8 +295,8 @@ func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool,
|
||||
|
||||
// GetCount 获取当前窗口内的请求数
|
||||
func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (int64, error) {
|
||||
// M-C 修复:GetRedis() 取一次复用(原三次调用存在 nil-deref 窗口)。
|
||||
rdb := database.GetRedis()
|
||||
// M-C 修复(Phase 5:改 redisClient):取一次复用(原三次调用存在 nil-deref 窗口)。
|
||||
rdb := rl.redisClient()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -273,8 +312,8 @@ func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (in
|
||||
|
||||
// Reset 重置限流计数
|
||||
func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error {
|
||||
// M-C 修复:GetRedis() 取一次复用(原两次调用存在 nil-deref 窗口)。
|
||||
rdb := database.GetRedis()
|
||||
// M-C 修复(Phase 5:改 redisClient):取一次复用(原两次调用存在 nil-deref 窗口)。
|
||||
rdb := rl.redisClient()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -283,132 +322,237 @@ func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error
|
||||
return rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ===== 全局限速器 =====
|
||||
// ===== 全局限速器 Registry(Phase 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 (
|
||||
// RateLimitRegistry 持一组内存限流器,支持 per-App 隔离。
|
||||
type RateLimitRegistry struct {
|
||||
mu sync.Mutex
|
||||
loginLimiter *RateLimiter
|
||||
apiLimiter *RateLimiter
|
||||
uploadLimiter *RateLimiter
|
||||
customLimiters []*RateLimiter // H4b: CustomRateLimit 创建的限流器登记表,供 StopRateLimiters/InitRateLimiters 统一停止,避免 cleanup goroutine 泄漏
|
||||
limitersMu sync.Mutex
|
||||
)
|
||||
|
||||
// drainCustomLimiters 取出已登记的自定义限流器并清空登记表。调用方须持有 limitersMu。
|
||||
func drainCustomLimiters() []*RateLimiter {
|
||||
drained := customLimiters
|
||||
customLimiters = nil
|
||||
return drained
|
||||
customLimiters []*RateLimiter
|
||||
}
|
||||
|
||||
// InitRateLimiters 初始化限速器
|
||||
func InitRateLimiters() {
|
||||
limitersMu.Lock()
|
||||
defer limitersMu.Unlock()
|
||||
// NewRateLimitRegistry 创建限流器注册表实例。
|
||||
func NewRateLimitRegistry() *RateLimitRegistry {
|
||||
return &RateLimitRegistry{}
|
||||
}
|
||||
|
||||
// 先停止旧的限流器(含自定义),释放 cleanup goroutine。
|
||||
// 持锁期间调 Stop() 安全:Stop→wg.Wait 等待的 cleanupVisitors 取的是 limiter 自身的 rl.mu,
|
||||
// 非 limitersMu,无死锁;全程持锁避免与 LoginRateLimit 等懒初始化路径交错致覆盖泄漏。
|
||||
if loginLimiter != nil {
|
||||
loginLimiter.Stop()
|
||||
func (r *RateLimitRegistry) loginLimiterInstance() *RateLimiter {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.loginLimiter == nil {
|
||||
r.loginLimiter = NewRateLimiter(10, time.Minute)
|
||||
}
|
||||
if apiLimiter != nil {
|
||||
apiLimiter.Stop()
|
||||
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)
|
||||
}
|
||||
if uploadLimiter != nil {
|
||||
uploadLimiter.Stop()
|
||||
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)
|
||||
}
|
||||
for _, l := range drainCustomLimiters() {
|
||||
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()
|
||||
}
|
||||
|
||||
// 内存限流器(单实例)
|
||||
loginLimiter = NewRateLimiter(10, time.Minute)
|
||||
apiLimiter = NewRateLimiter(100, time.Minute)
|
||||
uploadLimiter = NewRateLimiter(20, time.Minute)
|
||||
r.customLimiters = nil
|
||||
}
|
||||
|
||||
// StopRateLimiters 停止所有限速器(应用关闭时调用)
|
||||
func StopRateLimiters() {
|
||||
limitersMu.Lock()
|
||||
defer limitersMu.Unlock()
|
||||
// --- App-bound 中间件(捕获此 Registry,不查全局默认,多 App per-App 隔离) ---
|
||||
//
|
||||
// 与包级 LoginRateLimit() 等的区别:包级在请求时查 GetDefaultRateLimitRegistry()(多 App 下
|
||||
// 仅最后 Init 的 App 是全局默认,故包级 facade 不提供 per-App 计数隔离);本组方法捕获 r,
|
||||
// 始终用此 Registry 的 limiter。多 App 场景用 app.RateLimitRegistry().LoginRateLimit() 装配路由。
|
||||
|
||||
if loginLimiter != nil {
|
||||
loginLimiter.Stop()
|
||||
loginLimiter = nil
|
||||
}
|
||||
if apiLimiter != nil {
|
||||
apiLimiter.Stop()
|
||||
apiLimiter = nil
|
||||
}
|
||||
if uploadLimiter != nil {
|
||||
uploadLimiter.Stop()
|
||||
uploadLimiter = nil
|
||||
}
|
||||
for _, l := range drainCustomLimiters() {
|
||||
l.Stop()
|
||||
// LoginRateLimit 返回绑定到此 Registry 的登录限流中间件。
|
||||
func (r *RateLimitRegistry) LoginRateLimit() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rateLimitAllow(c, r.loginLimiterInstance())
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimit 通用速率限制中间件(内存版)
|
||||
// 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 在首次请求时创建并
|
||||
// 登记到此 Registry(r.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 全局默认 Registry(atomic,照 cache/cron 模式)。
|
||||
var defaultRateLimitRegistry atomic.Pointer[RateLimitRegistry]
|
||||
|
||||
func init() {
|
||||
defaultRateLimitRegistry.Store(NewRateLimitRegistry())
|
||||
}
|
||||
|
||||
// GetDefaultRateLimitRegistry 返回全局默认 Registry。
|
||||
func GetDefaultRateLimitRegistry() *RateLimitRegistry {
|
||||
return defaultRateLimitRegistry.Load()
|
||||
}
|
||||
|
||||
// SwapDefaultRateLimitRegistry 置为全局默认,返回被替换的旧(照 Swap 模式)。nil 忽略,返回当前默认。
|
||||
func SwapDefaultRateLimitRegistry(r *RateLimitRegistry) *RateLimitRegistry {
|
||||
if r == nil {
|
||||
return defaultRateLimitRegistry.Load()
|
||||
}
|
||||
return defaultRateLimitRegistry.Swap(r)
|
||||
}
|
||||
|
||||
// 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 自定义速率限制(内存版)
|
||||
//
|
||||
// 创建的限流器登记入包级 customLimiters 表,StopRateLimiters / InitRateLimiters
|
||||
// 会统一停止其 cleanup goroutine(H4b 修复:原实现每次路由构造创建 limiter 无句柄,
|
||||
// StopRateLimiters 不感知 → cleanup goroutine 泄漏)。
|
||||
// CustomRateLimit 自定义速率限制(内存版)。限流器在首次请求时创建并登记到当前默认
|
||||
// Registry(供 Stop 统一停止,避免 cleanup goroutine 泄漏,H4b)。
|
||||
func CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
|
||||
limiter := NewRateLimiter(rate, window)
|
||||
limitersMu.Lock()
|
||||
customLimiters = append(customLimiters, limiter)
|
||||
limitersMu.Unlock()
|
||||
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 自己的 Stop(Phase 5)。
|
||||
func StopRateLimiters() {
|
||||
GetDefaultRateLimitRegistry().Stop()
|
||||
}
|
||||
|
||||
// ===== Redis 分布式限流中间件 =====
|
||||
@@ -416,12 +560,12 @@ func CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
|
||||
// redisLimitDecision 处理 RedisRateLimiter.Allow 的结果,按 allowed 值决定放行/拒绝。
|
||||
//
|
||||
// H4c: 不再无条件 fail-open。Allow 已按 limiter 的 failClosed 策略把 Redis 故障翻成
|
||||
// allowed 值——fail-closed 时 allowed=false(拒绝),fail-open 时 allowed=true(放行)。
|
||||
// allowed 值--fail-closed 时 allowed=false(拒绝),fail-open 时 allowed=true(放行)。
|
||||
// 故 err 与 allowed 的组合语义:
|
||||
// - err==nil, allowed==true → 放行
|
||||
// - err==nil, allowed==false → 真超限,返 429(response.RateLimit)
|
||||
// - err!=nil, allowed==false → fail-closed 限流器在 Redis 故障下拒绝,返 503(服务不可用)
|
||||
// - err!=nil, allowed==true → fail-open 限流器在 Redis 故障下放行(兼容旧行为)
|
||||
// - err==nil, allowed==true -> 放行
|
||||
// - err==nil, allowed==false -> 真超限,返 429(response.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-closed:Redis 故障时拒绝(防限流静默失效)。返 503 区别于真实超限的 429。
|
||||
@@ -438,11 +582,11 @@ func redisLimitDecision(c *gin.Context, allowed bool, err error) {
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// RedisRateLimit Redis 分布式限流中间件(fail-open:Redis 故障时放行,避免影响业务)。
|
||||
// 参数: keyPrefix 键名前缀(如 "login_limit"),rate 每分钟请求数
|
||||
// 安全敏感场景(登录防爆破等)应使用 RedisRateLimitFailClosed,Redis 故障时拒绝以防限流失效。
|
||||
func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
|
||||
// RedisRateLimit Redis 分布式限流中间件。
|
||||
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景(登录防爆破等)
|
||||
// 传 WithFailClosed(true),Redis 故障时拒绝以防限流失效。
|
||||
func RedisRateLimit(keyPrefix string, rate int, opts ...RedisRateLimiterOption) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
@@ -458,25 +602,17 @@ func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RedisRateLimitFailClosed 安全型 Redis 分布式限流中间件(fail-closed):
|
||||
// Redis 故障时拒绝(503),避免限流静默失效。用于登录防爆破等安全场景。
|
||||
func RedisRateLimitFailClosed(keyPrefix string, rate int) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiterFailClosed(keyPrefix, rate, time.Minute)
|
||||
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流。
|
||||
// 参数: keyPrefix 键名前缀,rate 1 分钟窗口内允许的请求数,identifierFunc 标识获取函数。
|
||||
// identifierFunc 为 nil 或返回空串时回退到 c.ClientIP()。默认 fail-open,传 WithFailClosed(true) 切换。
|
||||
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string, opts ...RedisRateLimiterOption) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
allowed, err := limiter.Allow(c.Request.Context(), identifier)
|
||||
redisLimitDecision(c, allowed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流(fail-open)。
|
||||
// 参数: keyPrefix 键名前缀,rate 每分钟请求数,identifierFunc 标识获取函数
|
||||
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := identifierFunc(c)
|
||||
identifier := ""
|
||||
if identifierFunc != nil {
|
||||
identifier = identifierFunc(c)
|
||||
}
|
||||
if identifier == "" {
|
||||
identifier = c.ClientIP()
|
||||
}
|
||||
@@ -488,10 +624,10 @@ func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc fun
|
||||
|
||||
// LoginRedisRateLimit 登录接口 Redis 分布式限流(fail-closed)。
|
||||
//
|
||||
// H4c: 登录防爆破场景必须 fail-closed——Redis 故障时若 fail-open 则限流失效、
|
||||
// H4c: 登录防爆破场景必须 fail-closed--Redis 故障时若 fail-open 则限流失效、
|
||||
// 攻击者可借 Redis 抖动窗口无限爆破。改为 fail-closed:Redis 故障时返 503 拒绝。
|
||||
func LoginRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimitFailClosed("login_limit", 10)
|
||||
return RedisRateLimit("login_limit", 10, WithFailClosed(true))
|
||||
}
|
||||
|
||||
// APIRedisRateLimit API Redis 分布式限流(fail-open,避免影响业务)。
|
||||
@@ -499,14 +635,16 @@ func APIRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimit("api_limit", 100)
|
||||
}
|
||||
|
||||
// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-open)。
|
||||
// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-closed)。
|
||||
// 上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效。
|
||||
func UploadRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimit("upload_limit", 20)
|
||||
return RedisRateLimit("upload_limit", 20, WithFailClosed(true))
|
||||
}
|
||||
|
||||
// CustomRedisRateLimit 自定义 Redis 分布式限流(fail-open)。
|
||||
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, window)
|
||||
// CustomRedisRateLimit 自定义 Redis 分布式限流。
|
||||
// 默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed。
|
||||
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, window, opts...)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
@@ -514,14 +652,3 @@ func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration) gin.
|
||||
redisLimitDecision(c, allowed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// CustomRedisRateLimitFailClosed 自定义安全型 Redis 分布式限流(fail-closed)。
|
||||
func CustomRedisRateLimitFailClosed(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiterFailClosed(keyPrefix, rate, window)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
allowed, err := limiter.Allow(c.Request.Context(), identifier)
|
||||
redisLimitDecision(c, allowed, err)
|
||||
}
|
||||
}
|
||||
@@ -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 5(jwt 模型):
|
||||
// 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 5:Swap 返回被替换的旧 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 5:Stop 幂等,重复调用不 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)")
|
||||
}}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
// BaseModel 基础模型。CreatedAt/UpdatedAt 不显式指定 type,由 GORM 按驱动选默认
|
||||
// 时间列类型(MySQL 通常为 datetime(0) 或 timestamp,保留亚秒精度)。
|
||||
// 时间列类型(MySQL 通常为 datetime(6),保留亚秒精度)。
|
||||
type BaseModel struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
+212
-15
@@ -2,11 +2,30 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultFindAllLimit caps FindAll to avoid accidental full-table scans.
|
||||
DefaultFindAllLimit = 1000
|
||||
// DefaultPageSize is used when pageSize<=0.
|
||||
DefaultPageSize = 20
|
||||
// MaxPageSize caps page queries to avoid Limit(0)/huge-limit footguns.
|
||||
MaxPageSize = 100
|
||||
// MaxPage caps deep-offset queries; deeper traversal should use keyset pagination.
|
||||
MaxPage = 10000
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsafeOrder = errors.New("repository: unsafe order clause")
|
||||
ErrUnsafeField = errors.New("repository: unsafe field name")
|
||||
)
|
||||
|
||||
// BaseRepository 基础仓库接口
|
||||
type BaseRepository[T any] interface {
|
||||
// FindByID 根据 ID 查询
|
||||
@@ -15,7 +34,7 @@ type BaseRepository[T any] interface {
|
||||
Create(ctx context.Context, model *T) error
|
||||
// Update 更新记录
|
||||
Update(ctx context.Context, model *T) error
|
||||
// Delete 删除记录(软删除)
|
||||
// Delete 删除记录(T 含软删除字段时为软删除,否则为硬删除;详见 BaseRepo.Delete)
|
||||
Delete(ctx context.Context, id uint) error
|
||||
// FindByIDs 批量查询
|
||||
FindByIDs(ctx context.Context, ids []uint) ([]T, error)
|
||||
@@ -43,12 +62,26 @@ func NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T] {
|
||||
return &BaseRepo[T]{db: db}
|
||||
}
|
||||
|
||||
// readConn 返回读连接:外层 ctx 事务 > 本 repo 事务 > 读写分离路由 > r.db 回退。
|
||||
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息。
|
||||
func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB {
|
||||
func normalizeContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (r *BaseRepo[T]) requireDB() *gorm.DB {
|
||||
if r.db == nil {
|
||||
panic("repository.BaseRepo: db is nil. Use NewBaseRepo with a valid *gorm.DB")
|
||||
}
|
||||
return r.db
|
||||
}
|
||||
|
||||
// readConn 返回读连接:先校验 repo 构造时注入的 r.db 非 nil,再按
|
||||
// 外层 ctx 事务 > 本 repo 事务 > 读写分离路由 > r.db 回退。
|
||||
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息,即使 ctx 携带外层事务也要求 repo 构造合法。
|
||||
func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB {
|
||||
ctx = normalizeContext(ctx)
|
||||
base := r.requireDB()
|
||||
if tx := database.TxFromContext(ctx); tx != nil {
|
||||
return tx.WithContext(ctx)
|
||||
}
|
||||
@@ -58,16 +91,16 @@ func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB {
|
||||
if gdb := database.GetDBFromContext(ctx); gdb != nil {
|
||||
return gdb.WithContext(ctx)
|
||||
}
|
||||
return r.db.WithContext(ctx)
|
||||
return base.WithContext(ctx)
|
||||
}
|
||||
|
||||
// writeConn 返回写连接:外层 ctx 事务 > 本 repo 事务 > 主库 > r.db 回退。
|
||||
// writeConn 返回写连接:先校验 repo 构造时注入的 r.db 非 nil,再按
|
||||
// 外层 ctx 事务 > 本 repo 事务 > 主库 > r.db 回退。
|
||||
// 写操作始终走主库,不路由到只读从库。
|
||||
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息。
|
||||
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息,即使 ctx 携带外层事务也要求 repo 构造合法。
|
||||
func (r *BaseRepo[T]) writeConn(ctx context.Context) *gorm.DB {
|
||||
if r.db == nil {
|
||||
panic("repository.BaseRepo: db is nil. Use NewBaseRepo with a valid *gorm.DB")
|
||||
}
|
||||
ctx = normalizeContext(ctx)
|
||||
base := r.requireDB()
|
||||
if tx := database.TxFromContext(ctx); tx != nil {
|
||||
return tx.WithContext(ctx)
|
||||
}
|
||||
@@ -77,7 +110,7 @@ func (r *BaseRepo[T]) writeConn(ctx context.Context) *gorm.DB {
|
||||
if mdb := database.GetWriteDB(); mdb != nil {
|
||||
return mdb.WithContext(ctx)
|
||||
}
|
||||
return r.db.WithContext(ctx)
|
||||
return base.WithContext(ctx)
|
||||
}
|
||||
|
||||
// FindByID 根据 ID 查询
|
||||
@@ -110,8 +143,8 @@ func (r *BaseRepo[T]) Update(ctx context.Context, model *T) error {
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// repo.UpdateFields(ctx, &User{Name: "new"}, "name") // 仅更新 name
|
||||
// repo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // 显式置零
|
||||
// repo.UpdateFields(ctx, &User{Name: "new"}) // struct:仅更新非零字段 name
|
||||
// repo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map:显式置零,带条件
|
||||
func (r *BaseRepo[T]) UpdateFields(ctx context.Context, model any, conds ...any) error {
|
||||
db := r.writeConn(ctx).Model(new(T))
|
||||
if len(conds) > 0 {
|
||||
@@ -135,13 +168,23 @@ func (r *BaseRepo[T]) HardDelete(ctx context.Context, id uint) error {
|
||||
|
||||
// FindByIDs 批量查询
|
||||
func (r *BaseRepo[T]) FindByIDs(ctx context.Context, ids []uint) ([]T, error) {
|
||||
if len(ids) == 0 {
|
||||
return []T{}, nil
|
||||
}
|
||||
var models []T
|
||||
err := r.readConn(ctx).Where("id IN ?", ids).Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// FindAll 查询所有记录
|
||||
// FindAll 查询记录,默认最多返回 DefaultFindAllLimit 条,避免误用导致全表扫描。
|
||||
// 需要明确全量扫描时使用 FindAllUnbounded。
|
||||
func (r *BaseRepo[T]) FindAll(ctx context.Context) ([]T, error) {
|
||||
return r.FindLimited(ctx, DefaultFindAllLimit)
|
||||
}
|
||||
|
||||
// FindAllUnbounded 查询所有记录(无默认 limit)。
|
||||
// 仅用于明确需要全表扫描的后台任务/小表场景;在线请求优先使用分页或 FindAll。
|
||||
func (r *BaseRepo[T]) FindAllUnbounded(ctx context.Context) ([]T, error) {
|
||||
var models []T
|
||||
err := r.readConn(ctx).Find(&models).Error
|
||||
return models, err
|
||||
@@ -196,6 +239,9 @@ func (r *BaseRepo[T]) FindWhere(ctx context.Context, query string, args ...any)
|
||||
// (ctx, order, query string, args ...any),与 FindWhere 等同类方法统一为变长 args。
|
||||
// Go 语法要求变长参数必须为最后一个,故 order 前置于 query。
|
||||
func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, order, query string, args ...any) ([]T, error) {
|
||||
if err := validateOrderClause(order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var models []T
|
||||
err := r.readConn(ctx).Where(query, args...).Order(order).Find(&models).Error
|
||||
return models, err
|
||||
@@ -203,6 +249,9 @@ func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, order, query string,
|
||||
|
||||
// FindOrdered 查询并排序
|
||||
func (r *BaseRepo[T]) FindOrdered(ctx context.Context, order string, limit int) ([]T, error) {
|
||||
if err := validateOrderClause(order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var models []T
|
||||
query := r.readConn(ctx).Order(order)
|
||||
if limit > 0 {
|
||||
@@ -214,6 +263,9 @@ func (r *BaseRepo[T]) FindOrdered(ctx context.Context, order string, limit int)
|
||||
|
||||
// FindLimited 查询指定数量记录
|
||||
func (r *BaseRepo[T]) FindLimited(ctx context.Context, limit int) ([]T, error) {
|
||||
if limit <= 0 {
|
||||
return []T{}, nil
|
||||
}
|
||||
var models []T
|
||||
err := r.readConn(ctx).Limit(limit).Find(&models).Error
|
||||
return models, err
|
||||
@@ -229,8 +281,25 @@ type PageResult[T any] struct {
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > MaxPage {
|
||||
page = MaxPage
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
if pageSize > MaxPageSize {
|
||||
pageSize = MaxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
// pageOffset 计算 (page-1)*pageSize,负值归零。
|
||||
func pageOffset(page, pageSize int) int {
|
||||
page, pageSize = normalizePage(page, pageSize)
|
||||
offset := (page - 1) * pageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
@@ -248,6 +317,7 @@ func pageOffset(page, pageSize int) int {
|
||||
func (r *BaseRepo[T]) FindPage(ctx context.Context, page, pageSize int) (*PageResult[T], error) {
|
||||
var models []T
|
||||
var total int64
|
||||
page, pageSize = normalizePage(page, pageSize)
|
||||
offset := pageOffset(page, pageSize)
|
||||
err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Model(new(T)).Count(&total).Error; e != nil {
|
||||
@@ -263,8 +333,12 @@ func (r *BaseRepo[T]) FindPage(ctx context.Context, page, pageSize int) (*PageRe
|
||||
|
||||
// FindPageOrdered 分页查询并排序(count+list 单事务,H6d)
|
||||
func (r *BaseRepo[T]) FindPageOrdered(ctx context.Context, page, pageSize int, order string) (*PageResult[T], error) {
|
||||
if err := validateOrderClause(order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var models []T
|
||||
var total int64
|
||||
page, pageSize = normalizePage(page, pageSize)
|
||||
offset := pageOffset(page, pageSize)
|
||||
err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Model(new(T)).Count(&total).Error; e != nil {
|
||||
@@ -282,6 +356,7 @@ func (r *BaseRepo[T]) FindPageOrdered(ctx context.Context, page, pageSize int, o
|
||||
func (r *BaseRepo[T]) FindPageWhere(ctx context.Context, page, pageSize int, query string, args ...any) (*PageResult[T], error) {
|
||||
var models []T
|
||||
var total int64
|
||||
page, pageSize = normalizePage(page, pageSize)
|
||||
offset := pageOffset(page, pageSize)
|
||||
err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Model(new(T)).Where(query, args...).Count(&total).Error; e != nil {
|
||||
@@ -301,8 +376,12 @@ func (r *BaseRepo[T]) FindPageWhere(ctx context.Context, page, pageSize int, que
|
||||
// (ctx, page, pageSize, order, query string, args ...any),与 FindPageWhere 统一为变长 args。
|
||||
// Go 语法要求变长参数必须为最后一个,故 order 前置于 query。
|
||||
func (r *BaseRepo[T]) FindPageWhereOrdered(ctx context.Context, page, pageSize int, order, query string, args ...any) (*PageResult[T], error) {
|
||||
if err := validateOrderClause(order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var models []T
|
||||
var total int64
|
||||
page, pageSize = normalizePage(page, pageSize)
|
||||
offset := pageOffset(page, pageSize)
|
||||
err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Model(new(T)).Where(query, args...).Count(&total).Error; e != nil {
|
||||
@@ -325,16 +404,28 @@ func (r *BaseRepo[T]) CreateBatch(ctx context.Context, models []T) error {
|
||||
|
||||
// UpdateBatch 批量更新(指定字段)
|
||||
func (r *BaseRepo[T]) UpdateBatch(ctx context.Context, ids []uint, field string, value any) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateIdentifier(field); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.writeConn(ctx).Model(new(T)).Where("id IN ?", ids).Update(field, value).Error
|
||||
}
|
||||
|
||||
// DeleteBatch 批量删除
|
||||
func (r *BaseRepo[T]) DeleteBatch(ctx context.Context, ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.writeConn(ctx).Delete(new(T), ids).Error
|
||||
}
|
||||
|
||||
// HardDeleteBatch 批量硬删除
|
||||
func (r *BaseRepo[T]) HardDeleteBatch(ctx context.Context, ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.writeConn(ctx).Unscoped().Delete(new(T), ids).Error
|
||||
}
|
||||
|
||||
@@ -364,16 +455,28 @@ func (r *BaseRepo[T]) SoftDeleteColumn() string {
|
||||
|
||||
// Restore 恢复软删除记录
|
||||
func (r *BaseRepo[T]) Restore(ctx context.Context, id uint) error {
|
||||
if err := validateIdentifier(r.SoftDeleteColumn()); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.writeConn(ctx).Model(new(T)).Unscoped().Where("id = ?", id).Update(r.SoftDeleteColumn(), nil).Error
|
||||
}
|
||||
|
||||
// RestoreBatch 批量恢复软删除记录
|
||||
func (r *BaseRepo[T]) RestoreBatch(ctx context.Context, ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateIdentifier(r.SoftDeleteColumn()); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.writeConn(ctx).Model(new(T)).Unscoped().Where("id IN ?", ids).Update(r.SoftDeleteColumn(), nil).Error
|
||||
}
|
||||
|
||||
// FindDeleted 查询已软删除的记录
|
||||
func (r *BaseRepo[T]) FindDeleted(ctx context.Context) ([]T, error) {
|
||||
if err := validateIdentifier(r.SoftDeleteColumn()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var models []T
|
||||
err := r.readConn(ctx).Unscoped().Where(r.SoftDeleteColumn() + " IS NOT NULL").Find(&models).Error
|
||||
return models, err
|
||||
@@ -425,11 +528,12 @@ func (r *BaseRepo[T]) WithTransaction(ctx context.Context, fn func(txRepo *BaseR
|
||||
type QueryBuilder[T any] struct {
|
||||
db *gorm.DB
|
||||
limit int
|
||||
err error
|
||||
}
|
||||
|
||||
// NewQueryBuilder 创建查询构建器
|
||||
func (r *BaseRepo[T]) NewQueryBuilder() *QueryBuilder[T] {
|
||||
return &QueryBuilder[T]{db: r.db.Model(new(T))}
|
||||
return &QueryBuilder[T]{db: r.requireDB().Model(new(T))}
|
||||
}
|
||||
|
||||
// Where 添加条件
|
||||
@@ -446,12 +550,22 @@ func (qb *QueryBuilder[T]) Or(query string, args ...any) *QueryBuilder[T] {
|
||||
|
||||
// Order 设置排序
|
||||
func (qb *QueryBuilder[T]) Order(order string) *QueryBuilder[T] {
|
||||
if qb.err != nil {
|
||||
return qb
|
||||
}
|
||||
if err := validateOrderClause(order); err != nil {
|
||||
qb.err = err
|
||||
return qb
|
||||
}
|
||||
qb.db = qb.db.Order(order)
|
||||
return qb
|
||||
}
|
||||
|
||||
// Limit 设置数量限制
|
||||
func (qb *QueryBuilder[T]) Limit(limit int) *QueryBuilder[T] {
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
qb.limit = limit
|
||||
qb.db = qb.db.Limit(limit)
|
||||
return qb
|
||||
@@ -470,6 +584,10 @@ func (qb *QueryBuilder[T]) clone() *gorm.DB {
|
||||
|
||||
// Find 执行查询
|
||||
func (qb *QueryBuilder[T]) Find(ctx context.Context) ([]T, error) {
|
||||
if qb.err != nil {
|
||||
return nil, qb.err
|
||||
}
|
||||
ctx = normalizeContext(ctx)
|
||||
var models []T
|
||||
err := qb.clone().WithContext(ctx).Find(&models).Error
|
||||
return models, err
|
||||
@@ -477,6 +595,10 @@ func (qb *QueryBuilder[T]) Find(ctx context.Context) ([]T, error) {
|
||||
|
||||
// First 执行查询并返回第一条
|
||||
func (qb *QueryBuilder[T]) First(ctx context.Context) (*T, error) {
|
||||
if qb.err != nil {
|
||||
return nil, qb.err
|
||||
}
|
||||
ctx = normalizeContext(ctx)
|
||||
var model T
|
||||
err := qb.clone().WithContext(ctx).First(&model).Error
|
||||
if err != nil {
|
||||
@@ -488,6 +610,10 @@ func (qb *QueryBuilder[T]) First(ctx context.Context) (*T, error) {
|
||||
// Count 执行统计。
|
||||
// 克隆并剥离 Limit/Offset(H6e),避免先前 Limit()/Offset() 残留截断统计行数。
|
||||
func (qb *QueryBuilder[T]) Count(ctx context.Context) (int64, error) {
|
||||
if qb.err != nil {
|
||||
return 0, qb.err
|
||||
}
|
||||
ctx = normalizeContext(ctx)
|
||||
var count int64
|
||||
err := qb.clone().Limit(-1).Offset(-1).WithContext(ctx).Count(&count).Error
|
||||
return count, err
|
||||
@@ -498,8 +624,13 @@ func (qb *QueryBuilder[T]) Count(ctx context.Context) (int64, error) {
|
||||
// 注意:与 FindPage 不同,Page 的 count+list 不包单事务(QueryBuilder 为轻量构建器);
|
||||
// 需要 total/items 快照一致请用 BaseRepo.FindPage。
|
||||
func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageResult[T], error) {
|
||||
if qb.err != nil {
|
||||
return nil, qb.err
|
||||
}
|
||||
ctx = normalizeContext(ctx)
|
||||
var models []T
|
||||
var total int64
|
||||
page, pageSize = normalizePage(page, pageSize)
|
||||
|
||||
// count 克隆并剥离 Limit/Offset
|
||||
countDB := qb.clone().Limit(-1).Offset(-1)
|
||||
@@ -519,3 +650,69 @@ func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageR
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateIdentifier(field string) error {
|
||||
if !isIdentifierPath(field) {
|
||||
return ErrUnsafeField
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isIdentifierPath(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(s, ".")
|
||||
for _, part := range parts {
|
||||
if !isIdentifier(part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
if i == 0 {
|
||||
if r != '_' && !unicode.IsLetter(r) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r != '_' && !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateOrderClause(order string) error {
|
||||
order = strings.TrimSpace(order)
|
||||
if order == "" {
|
||||
return ErrUnsafeOrder
|
||||
}
|
||||
for _, raw := range strings.Split(order, ",") {
|
||||
item := strings.TrimSpace(raw)
|
||||
if item == "" {
|
||||
return ErrUnsafeOrder
|
||||
}
|
||||
fields := strings.Fields(item)
|
||||
if len(fields) == 0 || len(fields) > 2 {
|
||||
return ErrUnsafeOrder
|
||||
}
|
||||
if !isIdentifierPath(fields[0]) {
|
||||
return ErrUnsafeOrder
|
||||
}
|
||||
if len(fields) == 2 {
|
||||
dir := strings.ToUpper(fields[1])
|
||||
if dir != "ASC" && dir != "DESC" {
|
||||
return ErrUnsafeOrder
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -403,7 +403,7 @@ func setupH6Manager(t *testing.T) (masterFile, replicaFile string) {
|
||||
cfg.Database.Driver = "sqlite"
|
||||
cfg.Database.CustomDSN = masterFile
|
||||
|
||||
if err := database.InitDBWithReplicas(cfg, []string{replicaFile}); err != nil {
|
||||
if err := database.InitDBWithReplicas(context.Background(), cfg, []string{replicaFile}); err != nil {
|
||||
t.Fatalf("InitDBWithReplicas: %v", err)
|
||||
}
|
||||
if err := database.GetWriteDB().AutoMigrate(&h6User{}); err != nil {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func seedM5Users(t *testing.T, repo *BaseRepo[h6User], n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
if err := repo.Create(context.Background(), &h6User{Name: fmt.Sprintf("m5-%04d", i), Age: i}); err != nil {
|
||||
t.Fatalf("seed user %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5NilContextFallsBackToBackground(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
|
||||
u := &h6User{Name: "nil-ctx"}
|
||||
if err := repo.Create(nil, u); err != nil {
|
||||
t.Fatalf("Create(nil ctx): %v", err)
|
||||
}
|
||||
got, err := repo.FindByID(nil, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID(nil ctx): %v", err)
|
||||
}
|
||||
if got.Name != "nil-ctx" {
|
||||
t.Fatalf("got %q, want nil-ctx", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5UnsafeOrderRejected(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
}{
|
||||
{"FindOrdered", func() error {
|
||||
_, err := repo.FindOrdered(ctx, "name desc; drop table h6_users", 10)
|
||||
return err
|
||||
}},
|
||||
{"FindWhereOrdered", func() error {
|
||||
_, err := repo.FindWhereOrdered(ctx, "name desc --", "name <> ?", "")
|
||||
return err
|
||||
}},
|
||||
{"FindPageOrdered", func() error {
|
||||
_, err := repo.FindPageOrdered(ctx, 1, 10, "lower(name) desc")
|
||||
return err
|
||||
}},
|
||||
{"FindPageWhereOrdered", func() error {
|
||||
_, err := repo.FindPageWhereOrdered(ctx, 1, 10, "name collate nocase", "name <> ?", "")
|
||||
return err
|
||||
}},
|
||||
{"QueryBuilder", func() error {
|
||||
_, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Find(ctx)
|
||||
return err
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := tc.fn(); !errors.Is(err, ErrUnsafeOrder) {
|
||||
t.Fatalf("err = %v, want ErrUnsafeOrder", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5QueryBuilderUnsafeOrderPropagatesToAllTerminators(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
seedM5Users(t, repo, 1)
|
||||
|
||||
if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Find(ctx); !errors.Is(err, ErrUnsafeOrder) {
|
||||
t.Fatalf("Find err = %v, want ErrUnsafeOrder", err)
|
||||
}
|
||||
if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").First(ctx); !errors.Is(err, ErrUnsafeOrder) {
|
||||
t.Fatalf("First err = %v, want ErrUnsafeOrder", err)
|
||||
}
|
||||
if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Count(ctx); !errors.Is(err, ErrUnsafeOrder) {
|
||||
t.Fatalf("Count err = %v, want ErrUnsafeOrder", err)
|
||||
}
|
||||
if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Page(ctx, 1, 10); !errors.Is(err, ErrUnsafeOrder) {
|
||||
t.Fatalf("Page err = %v, want ErrUnsafeOrder", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5SafeOrderStillWorks(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
if err := repo.Create(ctx, &h6User{Name: "b"}); err != nil {
|
||||
t.Fatalf("Create b: %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, &h6User{Name: "a"}); err != nil {
|
||||
t.Fatalf("Create a: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.FindOrdered(ctx, "name ASC, id DESC", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("FindOrdered safe order: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Name != "a" || got[1].Name != "b" {
|
||||
t.Fatalf("unexpected order: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5UpdateBatchRejectsUnsafeFieldAndEmptyIDsNoop(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
u := &h6User{Name: "field", Age: 1}
|
||||
if err := repo.Create(ctx, u); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.UpdateBatch(ctx, []uint{u.ID}, "age = age + 1", 2); !errors.Is(err, ErrUnsafeField) {
|
||||
t.Fatalf("unsafe field err = %v, want ErrUnsafeField", err)
|
||||
}
|
||||
if err := repo.UpdateBatch(ctx, nil, "age = age + 1", 2); err != nil {
|
||||
t.Fatalf("empty ids should noop before field validation, got %v", err)
|
||||
}
|
||||
if err := repo.UpdateBatch(ctx, []uint{u.ID}, "age", 2); err != nil {
|
||||
t.Fatalf("safe field UpdateBatch: %v", err)
|
||||
}
|
||||
got, _ := repo.FindByID(ctx, u.ID)
|
||||
if got.Age != 2 {
|
||||
t.Fatalf("age = %d, want 2", got.Age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5FindByIDsEmptyReturnsEmptySlice(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
got, err := repo.FindByIDs(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByIDs empty: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("FindByIDs empty should return non-nil empty slice")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5FindAllAppliesDefaultLimit(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
seedM5Users(t, repo, DefaultFindAllLimit+5)
|
||||
|
||||
got, err := repo.FindAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FindAll: %v", err)
|
||||
}
|
||||
if len(got) != DefaultFindAllLimit {
|
||||
t.Fatalf("FindAll len = %d, want default limit %d", len(got), DefaultFindAllLimit)
|
||||
}
|
||||
|
||||
all, err := repo.FindAllUnbounded(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FindAllUnbounded: %v", err)
|
||||
}
|
||||
if len(all) != DefaultFindAllLimit+5 {
|
||||
t.Fatalf("FindAllUnbounded len = %d, want %d", len(all), DefaultFindAllLimit+5)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5PaginationBounds(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
seedM5Users(t, repo, MaxPageSize+5)
|
||||
|
||||
p, err := repo.FindPage(context.Background(), -10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPage default bounds: %v", err)
|
||||
}
|
||||
if p.Page != 1 || p.PageSize != DefaultPageSize || len(p.Items) != DefaultPageSize {
|
||||
t.Fatalf("page defaults = page:%d size:%d items:%d, want 1/%d/%d",
|
||||
p.Page, p.PageSize, len(p.Items), DefaultPageSize, DefaultPageSize)
|
||||
}
|
||||
|
||||
p, err = repo.FindPage(context.Background(), 1, MaxPageSize+100)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPage max size: %v", err)
|
||||
}
|
||||
if p.PageSize != MaxPageSize || len(p.Items) != MaxPageSize {
|
||||
t.Fatalf("page max = size:%d items:%d, want %d/%d", p.PageSize, len(p.Items), MaxPageSize, MaxPageSize)
|
||||
}
|
||||
|
||||
p, err = repo.FindPage(context.Background(), MaxPage+1, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPage max page: %v", err)
|
||||
}
|
||||
if p.Page != MaxPage {
|
||||
t.Fatalf("page = %d, want MaxPage %d", p.Page, MaxPage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5QueryBuilderNilDBPanicsClearly(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](nil)
|
||||
defer func() {
|
||||
if rec := recover(); rec == nil {
|
||||
t.Fatal("NewQueryBuilder with nil db should panic")
|
||||
}
|
||||
}()
|
||||
_ = repo.NewQueryBuilder()
|
||||
}
|
||||
|
||||
func TestM5QueryBuilderNilContextAndPageBounds(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
seedM5Users(t, repo, 25)
|
||||
|
||||
count, err := repo.NewQueryBuilder().Where("created_at <= ?", time.Now().Add(time.Hour)).Count(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Count nil ctx: %v", err)
|
||||
}
|
||||
if count != 25 {
|
||||
t.Fatalf("count = %d, want 25", count)
|
||||
}
|
||||
|
||||
p, err := repo.NewQueryBuilder().Page(nil, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Page nil ctx: %v", err)
|
||||
}
|
||||
if p.PageSize != DefaultPageSize || len(p.Items) != DefaultPageSize {
|
||||
t.Fatalf("QueryBuilder page size/items = %d/%d, want %d/%d",
|
||||
p.PageSize, len(p.Items), DefaultPageSize, DefaultPageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5DeleteBatchEmptyIDsNoop(t *testing.T) {
|
||||
db := newDryRunDB(t)
|
||||
repo := NewBaseRepo[pageCountModel](db)
|
||||
|
||||
if err := repo.DeleteBatch(context.Background(), nil); err != nil {
|
||||
t.Fatalf("DeleteBatch empty ids: %v", err)
|
||||
}
|
||||
if got := db.Statement.SQL.String(); got != "" {
|
||||
t.Fatalf("DeleteBatch empty ids should not issue SQL, got %s", got)
|
||||
}
|
||||
if err := repo.HardDeleteBatch(context.Background(), nil); err != nil {
|
||||
t.Fatalf("HardDeleteBatch empty ids: %v", err)
|
||||
}
|
||||
if got := db.Statement.SQL.String(); got != "" {
|
||||
t.Fatalf("HardDeleteBatch empty ids should not issue SQL, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM5FindAllUsesLimitSQL(t *testing.T) {
|
||||
db := newDryRunDB(t)
|
||||
repo := NewBaseRepo[pageCountModel](db)
|
||||
var models []pageCountModel
|
||||
result := repo.readConn(context.Background()).Limit(DefaultFindAllLimit).Find(&models)
|
||||
sql := strings.ToUpper(result.Statement.SQL.String())
|
||||
if !strings.Contains(sql, "LIMIT") {
|
||||
t.Fatalf("FindAll default path should include LIMIT, got %s", sql)
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,9 @@ func (e *Error) Error() string {
|
||||
//
|
||||
// H-10 修复:返回新 *Error 拷贝,不 mutate 接收者。预定义 Err*(如 ErrNotFound)是
|
||||
// 包级共享指针,原实现 e.Detail = detail 会污染全局且并发调用存在数据竞争。
|
||||
//
|
||||
// nil 接收者:返回 &Error{Detail: detail}(Code=0/Message=""),不 panic;调用方应避免
|
||||
// 在 nil 上调用本方法以拿到无 Code/Message 的半成品错误。
|
||||
func (e *Error) WithDetail(detail string) *Error {
|
||||
if e == nil {
|
||||
return &Error{Detail: detail}
|
||||
@@ -107,6 +110,13 @@ func (e *Error) WithDetail(detail string) *Error {
|
||||
// P1 #15:仅当 detailExposed()(默认 true;App 生产环境置 false)时才向客户端输出 Detail,
|
||||
// 避免内部错误细节在生产泄露。
|
||||
func (e *Error) ToResponse() Response {
|
||||
if e == nil {
|
||||
return Response{
|
||||
Code: CodeServerError,
|
||||
Msg: ErrServerError.Message,
|
||||
Data: nil,
|
||||
}
|
||||
}
|
||||
if e.Detail != "" && detailExposed() {
|
||||
return Response{
|
||||
Code: e.Code,
|
||||
@@ -211,6 +221,10 @@ func FailWithError(c *gin.Context, err *Error) {
|
||||
// FailWithDetail 使用预定义错误并添加详细信息。
|
||||
// P1 #15:detail 仅在 detailExposed()(默认 true;App 生产环境置 false)时输出给客户端。
|
||||
func FailWithDetail(c *gin.Context, err *Error, detail string) {
|
||||
if err == nil {
|
||||
FailWithError(c, nil)
|
||||
return
|
||||
}
|
||||
if detailExposed() {
|
||||
writeResp(c, err.Code, err.Message, gin.H{"detail": detail})
|
||||
return
|
||||
|
||||
+26
-9
@@ -1,10 +1,11 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -126,22 +127,38 @@ func asciiFallbackName(filename string) string {
|
||||
}
|
||||
|
||||
// Download 文件下载响应
|
||||
// Compatibility note: for large files or object-storage streams, prefer
|
||||
// DownloadReader so the whole object is not buffered in []byte first.
|
||||
func Download(c *gin.Context, filename string, data []byte) {
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", contentDisposition(filename))
|
||||
c.Header("Content-Length", utils.ToString(len(data)))
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
DownloadReader(c, filename, "application/octet-stream", int64(len(data)), bytesReader(data))
|
||||
}
|
||||
|
||||
// DownloadWithContentType 文件下载(自定义Content-Type)
|
||||
func DownloadWithContentType(c *gin.Context, filename string, contentType string, data []byte) {
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Header("Content-Disposition", contentDisposition(filename))
|
||||
c.Header("Content-Length", utils.ToString(len(data)))
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
DownloadReader(c, filename, contentType, int64(len(data)), bytesReader(data))
|
||||
}
|
||||
|
||||
// DownloadReader 流式下载响应。
|
||||
//
|
||||
// 对大文件/对象存储下载优先使用此函数,避免先把完整内容读入 []byte 驻留内存。
|
||||
// contentLength 传 -1 表示未知长度;Gin 会省略 Content-Length。
|
||||
func DownloadReader(c *gin.Context, filename, contentType string, contentLength int64, r io.Reader) {
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
headers := map[string]string{
|
||||
"Content-Disposition": contentDisposition(filename),
|
||||
}
|
||||
c.DataFromReader(http.StatusOK, contentLength, contentType, r, headers)
|
||||
}
|
||||
|
||||
// bytesReader 避免在公开 API 中暴露 bytes 包细节,同时让旧 []byte API 复用流式写路径。
|
||||
func bytesReader(data []byte) io.Reader {
|
||||
return bytes.NewReader(data)
|
||||
}
|
||||
|
||||
// HTML HTML内容响应
|
||||
// Security note: HTML writes raw markup and does not escape untrusted input.
|
||||
func HTML(c *gin.Context, data string) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, data)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package response_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
@@ -85,6 +87,34 @@ func TestFailWithCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailWithErrorNilDoesNotPanic_M6(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("request_id", "req-m6")
|
||||
c.Next()
|
||||
})
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.FailWithError(c, nil)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("FailWithError(nil) status = %d, want 200 in business mode", w.Code)
|
||||
}
|
||||
var body response.Response
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.Code != response.CodeServerError {
|
||||
t.Fatalf("code = %d, want %d", body.Code, response.CodeServerError)
|
||||
}
|
||||
if body.RequestID != "req-m6" {
|
||||
t.Fatalf("request_id = %q, want req-m6", body.RequestID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorized(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
@@ -242,6 +272,29 @@ func TestDownloadWithContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadReaderStreamsContent_M6(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.DownloadReader(c, "big.txt", "text/plain", 11, strings.NewReader("hello world"))
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("DownloadReader status = %d, want 200", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); !contains(got, "text/plain") {
|
||||
t.Fatalf("Content-Type = %q, want text/plain", got)
|
||||
}
|
||||
if got := w.Header().Get("Content-Length"); got != "11" {
|
||||
t.Fatalf("Content-Length = %q, want 11", got)
|
||||
}
|
||||
if got := w.Body.String(); got != "hello world" {
|
||||
t.Fatalf("body = %q, want hello world", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTML(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
@@ -343,4 +396,4 @@ func contains(s, sub string) bool {
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/router"
|
||||
)
|
||||
@@ -60,6 +62,81 @@ func TestRegisterHealthRouteWithFailingCheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHealthRouteTimesOutStuckCheck_M7(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
router.RegisterHealthRoute(r, router.HealthCheck{
|
||||
Name: "stuck",
|
||||
Timeout: 10 * time.Millisecond,
|
||||
Check: func(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
start := time.Now()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", w.Code)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("health check took %s, want bounded timeout", elapsed)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"stuck":"timeout"`) {
|
||||
t.Fatalf("body = %s, want timeout status", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHealthRouteRecoversCheckPanic_M7(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
router.RegisterHealthRoute(r, router.HealthCheck{
|
||||
Name: "panic",
|
||||
Check: func(context.Context) error {
|
||||
panic("boom")
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"panic":"error"`) {
|
||||
t.Fatalf("body = %s, want panic check error status", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHealthRouteLimitsNonCooperativeCheckConcurrency_M7(t *testing.T) {
|
||||
var started atomic.Int32
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
|
||||
r := setupTestRouter()
|
||||
router.RegisterHealthRoute(r, router.HealthCheck{
|
||||
Name: "stuck",
|
||||
Timeout: 10 * time.Millisecond,
|
||||
Check: func(context.Context) error {
|
||||
started.Add(1)
|
||||
<-release
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("request %d status = %d, want 503", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
if got := started.Load(); got != 1 {
|
||||
t.Fatalf("check started %d times, want 1 while first non-cooperative check is still running", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDefaultRoutes(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
router.RegisterDefaultRoutes(r)
|
||||
|
||||
+355
-46
@@ -2,23 +2,118 @@ package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
var routerWarnf atomic.Value // stores func(string, ...any)
|
||||
|
||||
func warnf(format string, args ...any) {
|
||||
if fn, ok := routerWarnf.Load().(func(string, ...any)); ok && fn != nil {
|
||||
fn(format, args...)
|
||||
return
|
||||
}
|
||||
logger.Warnf(format, args...)
|
||||
}
|
||||
|
||||
func isDuplicateRoutePanic(msg, path string) bool {
|
||||
if strings.Contains(msg, "already registered") {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(msg, "conflicts with existing wildcard") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, fmt.Sprintf("new path '%s'", path)) &&
|
||||
strings.Contains(msg, fmt.Sprintf("existing prefix '%s'", path))
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查项
|
||||
type HealthCheck struct {
|
||||
Name string
|
||||
Check func(context.Context) error
|
||||
Disabled bool
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultHealthCheckTimeout bounds each dependency check so /health and /readyz
|
||||
// do not hang behind a stuck database/client call.
|
||||
const DefaultHealthCheckTimeout = 2 * time.Second
|
||||
|
||||
type healthCheckRunner struct {
|
||||
check HealthCheck
|
||||
running chan struct{}
|
||||
}
|
||||
|
||||
func newHealthCheckRunners(checks []HealthCheck) []healthCheckRunner {
|
||||
runners := make([]healthCheckRunner, len(checks))
|
||||
for i, check := range checks {
|
||||
runners[i] = healthCheckRunner{
|
||||
check: check,
|
||||
running: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
return runners
|
||||
}
|
||||
|
||||
func healthCheckTimeout(check HealthCheck) time.Duration {
|
||||
if check.Timeout > 0 {
|
||||
return check.Timeout
|
||||
}
|
||||
return DefaultHealthCheckTimeout
|
||||
}
|
||||
|
||||
func healthCheckStatusFromContext(err error) string {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "timeout"
|
||||
}
|
||||
return "error"
|
||||
}
|
||||
|
||||
func (r healthCheckRunner) run(ctx context.Context) (string, error) {
|
||||
check := r.check
|
||||
checkCtx, cancel := context.WithTimeout(ctx, healthCheckTimeout(check))
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case r.running <- struct{}{}:
|
||||
case <-checkCtx.Done():
|
||||
return healthCheckStatusFromContext(checkCtx.Err()), checkCtx.Err()
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
<-r.running
|
||||
}()
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
errCh <- fmt.Errorf("health check %q panic recovered: %v\n%s", check.Name, rec, debug.Stack())
|
||||
}
|
||||
}()
|
||||
errCh <- check.Check(checkCtx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil {
|
||||
return "ok", nil
|
||||
}
|
||||
return healthCheckStatusFromContext(err), err
|
||||
case <-checkCtx.Done():
|
||||
return healthCheckStatusFromContext(checkCtx.Err()), checkCtx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// registerGETOnce 幂等注册 GET 路由(H8d 收尾:消除 defaultModule 与 Register* 系列
|
||||
@@ -35,20 +130,40 @@ type HealthCheck struct {
|
||||
//
|
||||
// 注册期单线程调用,无并发问题。
|
||||
func registerGETOnce(r gin.IRoutes, path string, h gin.HandlerFunc) {
|
||||
if eng, ok := r.(*gin.Engine); ok {
|
||||
if r == nil {
|
||||
warnf("router: skip GET %q because routes is nil", path)
|
||||
return
|
||||
}
|
||||
if h == nil {
|
||||
warnf("router: skip GET %q because handler is nil", path)
|
||||
return
|
||||
}
|
||||
switch route := r.(type) {
|
||||
case *gin.Engine:
|
||||
eng := route
|
||||
if eng == nil {
|
||||
warnf("router: skip GET %q because engine is nil", path)
|
||||
return
|
||||
}
|
||||
for _, ri := range eng.Routes() {
|
||||
if ri.Method == http.MethodGet && ri.Path == path {
|
||||
warnf("router: duplicate GET %q skipped", path)
|
||||
return
|
||||
}
|
||||
}
|
||||
eng.GET(path, h)
|
||||
return
|
||||
case *gin.RouterGroup:
|
||||
if route == nil {
|
||||
warnf("router: skip GET %q because router group is nil", path)
|
||||
return
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
msg := fmt.Sprint(rec)
|
||||
if strings.Contains(msg, "already registered") ||
|
||||
strings.Contains(msg, "conflicts with existing wildcard") {
|
||||
if isDuplicateRoutePanic(msg, path) {
|
||||
warnf("router: duplicate GET %q skipped: %v", path, rec)
|
||||
return // 重复路由,静默跳过
|
||||
}
|
||||
panic(rec) // 非重复路由 panic,原样抛出
|
||||
@@ -59,14 +174,15 @@ func registerGETOnce(r gin.IRoutes, path string, h gin.HandlerFunc) {
|
||||
|
||||
// runHealthChecks 执行所有检查项,返回总体状态、HTTP code 与逐项结果。
|
||||
// 无检查项时视为健康(用于 /livez 与无依赖场景)。
|
||||
func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, map[string]string) {
|
||||
if len(checks) == 0 {
|
||||
func runHealthChecks(ctx context.Context, runners []healthCheckRunner) (string, int, map[string]string) {
|
||||
if len(runners) == 0 {
|
||||
return "ok", http.StatusOK, nil
|
||||
}
|
||||
status := "ok"
|
||||
code := http.StatusOK
|
||||
result := make(map[string]string, len(checks))
|
||||
for _, check := range checks {
|
||||
result := make(map[string]string, len(runners))
|
||||
for _, runner := range runners {
|
||||
check := runner.check
|
||||
if check.Name == "" {
|
||||
continue
|
||||
}
|
||||
@@ -74,13 +190,13 @@ func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, ma
|
||||
result[check.Name] = "disabled"
|
||||
continue
|
||||
}
|
||||
if err := check.Check(ctx); err != nil {
|
||||
result[check.Name] = "error"
|
||||
checkStatus, err := runner.run(ctx)
|
||||
result[check.Name] = checkStatus
|
||||
if err != nil {
|
||||
status = "error"
|
||||
code = http.StatusServiceUnavailable
|
||||
continue
|
||||
}
|
||||
result[check.Name] = "ok"
|
||||
}
|
||||
return status, code, result
|
||||
}
|
||||
@@ -90,8 +206,9 @@ func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, ma
|
||||
// 并附逐项结果。RegisterHealthRoute / RegisterReadinessRoute / defaultModule
|
||||
// 均委托此实现,避免三个 /health 行为与响应体不一致。
|
||||
func healthHandler(checks []HealthCheck) gin.HandlerFunc {
|
||||
runners := newHealthCheckRunners(checks)
|
||||
return func(c *gin.Context) {
|
||||
status, code, result := runHealthChecks(c.Request.Context(), checks)
|
||||
status, code, result := runHealthChecks(c.Request.Context(), runners)
|
||||
if result == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"status": status})
|
||||
return
|
||||
@@ -188,10 +305,11 @@ type MiddlewareGroup struct {
|
||||
|
||||
// Registry 路由注册中心
|
||||
type Registry struct {
|
||||
engine *gin.Engine
|
||||
modules []Module
|
||||
versions map[string]*VersionedAPI
|
||||
middlewareGroups map[string]*MiddlewareGroup
|
||||
mu sync.Mutex
|
||||
engine *gin.Engine
|
||||
modules []Module
|
||||
versions map[string]*VersionedAPI
|
||||
middlewareGroups map[string]*MiddlewareGroup
|
||||
globalMiddlewares []gin.HandlerFunc
|
||||
|
||||
// metricsMiddleware 在 Apply 时作为首个全局中间件装入(H8c),
|
||||
@@ -204,32 +322,77 @@ type Registry struct {
|
||||
// 原实现用裸 bool 无同步,并发 Apply 会竞态并可能重复 engine.Use/重复注册致 gin panic;
|
||||
// 改用 sync.Once,二次/并发 Apply 均安全幂等。
|
||||
applyOnce sync.Once
|
||||
applied atomic.Bool
|
||||
}
|
||||
|
||||
// NewRegistry 创建路由注册中心
|
||||
func NewRegistry(engine *gin.Engine) *Registry {
|
||||
return &Registry{
|
||||
engine: engine,
|
||||
modules: make([]Module, 0),
|
||||
versions: make(map[string]*VersionedAPI),
|
||||
engine: engine,
|
||||
modules: make([]Module, 0),
|
||||
versions: make(map[string]*VersionedAPI),
|
||||
middlewareGroups: make(map[string]*MiddlewareGroup),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) lockForMutation(action string) (func(), bool) {
|
||||
if r == nil {
|
||||
warnf("router: skip %s because registry is nil", action)
|
||||
return nil, false
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.applied.Load() {
|
||||
r.mu.Unlock()
|
||||
warnf("router: %s called after Apply; registration will not take effect", action)
|
||||
return nil, false
|
||||
}
|
||||
return r.mu.Unlock, true
|
||||
}
|
||||
|
||||
func filterNilHandlers(action string, handlers []gin.HandlerFunc) []gin.HandlerFunc {
|
||||
out := make([]gin.HandlerFunc, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
if h == nil {
|
||||
warnf("router: nil middleware skipped in %s", action)
|
||||
continue
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Use 注册全局中间件
|
||||
func (r *Registry) Use(middlewares ...gin.HandlerFunc) *Registry {
|
||||
r.globalMiddlewares = append(r.globalMiddlewares, middlewares...)
|
||||
unlock, ok := r.lockForMutation("Use")
|
||||
if !ok {
|
||||
return r
|
||||
}
|
||||
defer unlock()
|
||||
r.globalMiddlewares = append(r.globalMiddlewares, filterNilHandlers("Use", middlewares)...)
|
||||
return r
|
||||
}
|
||||
|
||||
// RegisterModule 注册模块(无版本)
|
||||
func (r *Registry) RegisterModule(module Module) *Registry {
|
||||
unlock, ok := r.lockForMutation("RegisterModule")
|
||||
if !ok {
|
||||
return r
|
||||
}
|
||||
defer unlock()
|
||||
if module == nil {
|
||||
warnf("router: nil module skipped")
|
||||
return r
|
||||
}
|
||||
r.modules = append(r.modules, module)
|
||||
return r
|
||||
}
|
||||
|
||||
// RegisterModuleFunc 注册函数式模块
|
||||
func (r *Registry) RegisterModuleFunc(name string, fn func(r *gin.RouterGroup)) *Registry {
|
||||
if fn == nil {
|
||||
warnf("router: module func %q skipped because function is nil", name)
|
||||
return r
|
||||
}
|
||||
return r.RegisterModule(&namedModule{name: name, fn: fn})
|
||||
}
|
||||
|
||||
@@ -239,25 +402,57 @@ type namedModule struct {
|
||||
fn func(r *gin.RouterGroup)
|
||||
}
|
||||
|
||||
func (m *namedModule) Name() string { return m.name }
|
||||
func (m *namedModule) Name() string { return m.name }
|
||||
func (m *namedModule) Register(r *gin.RouterGroup) { m.fn(r) }
|
||||
|
||||
// RegisterVersion 注册版本化 API
|
||||
func (r *Registry) RegisterVersion(version *VersionedAPI) *Registry {
|
||||
unlock, ok := r.lockForMutation("RegisterVersion")
|
||||
if !ok {
|
||||
return r
|
||||
}
|
||||
defer unlock()
|
||||
if version == nil {
|
||||
warnf("router: nil version skipped")
|
||||
return r
|
||||
}
|
||||
version.Middlewares = filterNilHandlers("RegisterVersion "+version.Version, version.Middlewares)
|
||||
if _, exists := r.versions[version.Version]; exists {
|
||||
warnf("router: duplicate version %q overwritten", version.Version)
|
||||
}
|
||||
r.versions[version.Version] = version
|
||||
return r
|
||||
}
|
||||
|
||||
// RegisterMiddlewareGroup 注册中间件分组
|
||||
func (r *Registry) RegisterMiddlewareGroup(group *MiddlewareGroup) *Registry {
|
||||
unlock, ok := r.lockForMutation("RegisterMiddlewareGroup")
|
||||
if !ok {
|
||||
return r
|
||||
}
|
||||
defer unlock()
|
||||
if group == nil {
|
||||
warnf("router: nil middleware group skipped")
|
||||
return r
|
||||
}
|
||||
group.Middlewares = filterNilHandlers("RegisterMiddlewareGroup "+group.Name, group.Middlewares)
|
||||
if _, exists := r.middlewareGroups[group.Name]; exists {
|
||||
warnf("router: duplicate middleware group %q overwritten", group.Name)
|
||||
}
|
||||
r.middlewareGroups[group.Name] = group
|
||||
return r
|
||||
}
|
||||
|
||||
// GetMiddlewareGroup 获取中间件分组
|
||||
func (r *Registry) GetMiddlewareGroup(name string) []gin.HandlerFunc {
|
||||
if r == nil {
|
||||
warnf("router: GetMiddlewareGroup(%q) on nil registry", name)
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if group, ok := r.middlewareGroups[name]; ok {
|
||||
return group.Middlewares
|
||||
return append([]gin.HandlerFunc(nil), group.Middlewares...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -266,31 +461,43 @@ func (r *Registry) GetMiddlewareGroup(name string) []gin.HandlerFunc {
|
||||
// 装入 engine,使所有经注册中心注册的路由都被采集,不再依赖注册顺序。
|
||||
// 传入 nil 清除。须在 Apply 之前调用。
|
||||
func (r *Registry) SetMetricsMiddleware(mw gin.HandlerFunc) {
|
||||
unlock, ok := r.lockForMutation("SetMetricsMiddleware")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
r.metricsMiddleware = mw
|
||||
}
|
||||
|
||||
type versionApplySnapshot struct {
|
||||
isNil bool
|
||||
version string
|
||||
basePath string
|
||||
middlewares []gin.HandlerFunc
|
||||
modules []Module
|
||||
}
|
||||
|
||||
// Apply 应用所有路由注册。
|
||||
//
|
||||
// 幂等(H8b):二次调用直接返回,避免重复 engine.Use 与 Gin 重复路由 panic。
|
||||
// 装入顺序:metrics 中间件(若有)→ 用户全局中间件 → 模块/版本路由。
|
||||
// metrics 置于首位保证全量业务路由被采集,且不依赖 RegisterMetricsRoute 调用顺序。
|
||||
func (r *Registry) Apply() {
|
||||
if r == nil {
|
||||
warnf("router: Apply called on nil registry")
|
||||
return
|
||||
}
|
||||
if r.engine == nil {
|
||||
warnf("router: Apply skipped because engine is nil")
|
||||
return
|
||||
}
|
||||
r.applyOnce.Do(func() {
|
||||
// 指标采集中间件首个装入,统计所有经注册中心注册的业务路由
|
||||
if r.metricsMiddleware != nil {
|
||||
r.engine.Use(r.metricsMiddleware)
|
||||
}
|
||||
|
||||
// 应用全局中间件
|
||||
r.engine.Use(r.globalMiddlewares...)
|
||||
|
||||
// 注册无版本模块
|
||||
for _, module := range r.modules {
|
||||
module.Register(r.engine.Group(""))
|
||||
}
|
||||
|
||||
// 注册版本化 API。P1 #13:按 version 键排序遍历,使跨版本注册顺序确定,
|
||||
// 避免 map 随机序导致重叠路径"谁先胜出"(配合 registerGETOnce)每次运行不一致。
|
||||
r.mu.Lock()
|
||||
r.applied.Store(true)
|
||||
metricsMiddleware := r.metricsMiddleware
|
||||
globalMiddlewares := append([]gin.HandlerFunc(nil), r.globalMiddlewares...)
|
||||
modules := append([]Module(nil), r.modules...)
|
||||
versions := make([]versionApplySnapshot, 0, len(r.versions))
|
||||
versionKeys := make([]string, 0, len(r.versions))
|
||||
for k := range r.versions {
|
||||
versionKeys = append(versionKeys, k)
|
||||
@@ -298,11 +505,52 @@ func (r *Registry) Apply() {
|
||||
sort.Strings(versionKeys)
|
||||
for _, k := range versionKeys {
|
||||
v := r.versions[k]
|
||||
group := r.engine.Group(v.BasePath)
|
||||
if len(v.Middlewares) > 0 {
|
||||
group.Use(v.Middlewares...)
|
||||
if v == nil {
|
||||
versions = append(versions, versionApplySnapshot{isNil: true, version: k})
|
||||
continue
|
||||
}
|
||||
for _, module := range v.Modules {
|
||||
versions = append(versions, versionApplySnapshot{
|
||||
version: v.Version,
|
||||
basePath: v.BasePath,
|
||||
middlewares: append([]gin.HandlerFunc(nil), v.Middlewares...),
|
||||
modules: append([]Module(nil), v.Modules...),
|
||||
})
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
// 指标采集中间件首个装入,统计所有经注册中心注册的业务路由
|
||||
if metricsMiddleware != nil {
|
||||
r.engine.Use(metricsMiddleware)
|
||||
}
|
||||
|
||||
// 应用全局中间件
|
||||
r.engine.Use(globalMiddlewares...)
|
||||
|
||||
// 注册无版本模块
|
||||
for _, module := range modules {
|
||||
if module == nil {
|
||||
warnf("router: nil module skipped during Apply")
|
||||
continue
|
||||
}
|
||||
module.Register(r.engine.Group(""))
|
||||
}
|
||||
|
||||
// 注册版本化 API。P1 #13:按 version 键排序遍历,使跨版本注册顺序确定,
|
||||
// 避免 map 随机序导致重叠路径"谁先胜出"(配合 registerGETOnce)每次运行不一致。
|
||||
for _, v := range versions {
|
||||
if v.isNil {
|
||||
warnf("router: nil version %q skipped during Apply", v.version)
|
||||
continue
|
||||
}
|
||||
group := r.engine.Group(v.basePath)
|
||||
if len(v.middlewares) > 0 {
|
||||
group.Use(v.middlewares...)
|
||||
}
|
||||
for _, module := range v.modules {
|
||||
if module == nil {
|
||||
warnf("router: nil module skipped in version %q", v.version)
|
||||
continue
|
||||
}
|
||||
module.Register(group)
|
||||
}
|
||||
}
|
||||
@@ -369,19 +617,31 @@ func NewVersion(version, basePath string, middlewares ...gin.HandlerFunc) *Versi
|
||||
return &VersionedAPI{
|
||||
Version: version,
|
||||
BasePath: basePath,
|
||||
Middlewares: middlewares,
|
||||
Middlewares: filterNilHandlers("NewVersion "+version, middlewares),
|
||||
Modules: make([]Module, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddModule 为版本添加模块
|
||||
func (v *VersionedAPI) AddModule(module Module) *VersionedAPI {
|
||||
if v == nil {
|
||||
warnf("router: AddModule called on nil version")
|
||||
return v
|
||||
}
|
||||
if module == nil {
|
||||
warnf("router: nil module skipped in version %q", v.Version)
|
||||
return v
|
||||
}
|
||||
v.Modules = append(v.Modules, module)
|
||||
return v
|
||||
}
|
||||
|
||||
// AddModuleFunc 为版本添加函数式模块
|
||||
func (v *VersionedAPI) AddModuleFunc(name string, fn func(r *gin.RouterGroup)) *VersionedAPI {
|
||||
if fn == nil {
|
||||
warnf("router: module func %q skipped because function is nil", name)
|
||||
return v
|
||||
}
|
||||
return v.AddModule(&namedModule{name: name, fn: fn})
|
||||
}
|
||||
|
||||
@@ -389,7 +649,7 @@ func (v *VersionedAPI) AddModuleFunc(name string, fn func(r *gin.RouterGroup)) *
|
||||
func NewMiddlewareGroup(name string, middlewares ...gin.HandlerFunc) *MiddlewareGroup {
|
||||
return &MiddlewareGroup{
|
||||
Name: name,
|
||||
Middlewares: middlewares,
|
||||
Middlewares: filterNilHandlers("NewMiddlewareGroup "+name, middlewares),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +657,11 @@ func NewMiddlewareGroup(name string, middlewares ...gin.HandlerFunc) *Middleware
|
||||
|
||||
// Group 创建路由组(带中间件分组)
|
||||
func Group(engine *gin.Engine, path string, middlewares ...gin.HandlerFunc) *gin.RouterGroup {
|
||||
return engine.Group(path, middlewares...)
|
||||
if engine == nil {
|
||||
warnf("router: Group(%q) skipped because engine is nil", path)
|
||||
return nil
|
||||
}
|
||||
return engine.Group(path, filterNilHandlers("Group "+path, middlewares)...)
|
||||
}
|
||||
|
||||
// GroupWithMiddlewareGroup 使用中间件分组创建路由组。
|
||||
@@ -405,9 +669,17 @@ func Group(engine *gin.Engine, path string, middlewares ...gin.HandlerFunc) *gin
|
||||
// H-B 修复:改走 ensureRegistry()(与 Use/RegisterModule/Apply 等所有全局 helper 一致),
|
||||
// 把"未初始化"从 nil 解引用 panic 转成可定位的明确 panic(H8a 目标)。原实现用 GetRegistry()
|
||||
// 可返回 nil,nil.GetMiddlewareGroup 即 panic,错误信息晦涩。
|
||||
func (r *Registry) GroupWithMiddlewareGroup(engine *gin.Engine, path string, groupName string) *gin.RouterGroup {
|
||||
if engine == nil {
|
||||
warnf("router: GroupWithMiddlewareGroup(%q) skipped because engine is nil", path)
|
||||
return nil
|
||||
}
|
||||
return engine.Group(path, r.GetMiddlewareGroup(groupName)...)
|
||||
}
|
||||
|
||||
// GroupWithMiddlewareGroup 使用中间件分组创建路由组(全局方式,代理到默认 Registry)。
|
||||
func GroupWithMiddlewareGroup(engine *gin.Engine, path string, groupName string) *gin.RouterGroup {
|
||||
middlewares := ensureRegistry().GetMiddlewareGroup(groupName)
|
||||
return engine.Group(path, middlewares...)
|
||||
return ensureRegistry().GroupWithMiddlewareGroup(engine, path, groupName)
|
||||
}
|
||||
|
||||
// RESTfulRoute RESTful 路由快捷注册
|
||||
@@ -421,28 +693,61 @@ func NewRESTful(group *gin.RouterGroup, path string) *RESTfulRoute {
|
||||
return &RESTfulRoute{Group: group, Path: path}
|
||||
}
|
||||
|
||||
func (r *RESTfulRoute) prepareHandlers(method string, handlers []gin.HandlerFunc) []gin.HandlerFunc {
|
||||
if r == nil || r.Group == nil {
|
||||
warnf("router: RESTful %s skipped because route/group is nil", method)
|
||||
return nil
|
||||
}
|
||||
filtered := filterNilHandlers("RESTful "+method+" "+r.Path, handlers)
|
||||
if len(filtered) == 0 {
|
||||
warnf("router: RESTful %s %q skipped because handlers are empty", method, r.Path)
|
||||
return nil
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// GET 注册 GET 路由
|
||||
func (r *RESTfulRoute) GET(handlers ...gin.HandlerFunc) {
|
||||
handlers = r.prepareHandlers(http.MethodGet, handlers)
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
r.Group.GET(r.Path, handlers...)
|
||||
}
|
||||
|
||||
// POST 注册 POST 路由
|
||||
func (r *RESTfulRoute) POST(handlers ...gin.HandlerFunc) {
|
||||
handlers = r.prepareHandlers(http.MethodPost, handlers)
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
r.Group.POST(r.Path, handlers...)
|
||||
}
|
||||
|
||||
// PUT 注册 PUT 路由
|
||||
func (r *RESTfulRoute) PUT(handlers ...gin.HandlerFunc) {
|
||||
handlers = r.prepareHandlers(http.MethodPut, handlers)
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
r.Group.PUT(r.Path, handlers...)
|
||||
}
|
||||
|
||||
// DELETE 注册 DELETE 路由
|
||||
func (r *RESTfulRoute) DELETE(handlers ...gin.HandlerFunc) {
|
||||
handlers = r.prepareHandlers(http.MethodDelete, handlers)
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
r.Group.DELETE(r.Path, handlers...)
|
||||
}
|
||||
|
||||
// PATCH 注册 PATCH 路由
|
||||
func (r *RESTfulRoute) PATCH(handlers ...gin.HandlerFunc) {
|
||||
handlers = r.prepareHandlers(http.MethodPatch, handlers)
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
r.Group.PATCH(r.Path, handlers...)
|
||||
}
|
||||
|
||||
@@ -453,6 +758,10 @@ func (r *RESTfulRoute) PATCH(handlers ...gin.HandlerFunc) {
|
||||
// PUT /path/:id - 更新
|
||||
// DELETE /path/:id - 删除
|
||||
func (r *RESTfulRoute) CRUD(list, detail, create, update, delete gin.HandlerFunc) {
|
||||
if r == nil || r.Group == nil {
|
||||
warnf("router: RESTful CRUD skipped because route/group is nil")
|
||||
return
|
||||
}
|
||||
if list != nil {
|
||||
r.Group.GET(r.Path, list)
|
||||
}
|
||||
@@ -468,4 +777,4 @@ func (r *RESTfulRoute) CRUD(list, detail, create, update, delete gin.HandlerFunc
|
||||
if delete != nil {
|
||||
r.Group.DELETE(r.Path+"/:id", delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func captureRouterWarnings(t *testing.T) *[]string {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
warnings := make([]string, 0)
|
||||
prev, hadPrev := routerWarnf.Load().(func(string, ...any))
|
||||
routerWarnf.Store(func(format string, args ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
warnings = append(warnings, format)
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
if hadPrev {
|
||||
routerWarnf.Store(prev)
|
||||
return
|
||||
}
|
||||
routerWarnf.Store(func(string, ...any) {})
|
||||
})
|
||||
return &warnings
|
||||
}
|
||||
|
||||
func warningsContain(warnings *[]string, substr string) bool {
|
||||
for _, warning := range *warnings {
|
||||
if strings.Contains(warning, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestM7DuplicateGETWarnsAndSkips(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
warnings := captureRouterWarnings(t)
|
||||
engine := gin.New()
|
||||
|
||||
RegisterHealthRoute(engine)
|
||||
RegisterHealthRoute(engine)
|
||||
|
||||
if !warningsContain(warnings, "duplicate GET") {
|
||||
t.Fatalf("duplicate registration should warn, got %#v", *warnings)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("/health status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM7RouterGroupWildcardConflictPanics(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
group := engine.Group("")
|
||||
group.GET("/files/:id", func(c *gin.Context) {})
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec == nil {
|
||||
t.Fatal("wildcard conflict should panic, not be swallowed as duplicate")
|
||||
}
|
||||
}()
|
||||
registerGETOnce(group, "/files/*path", func(c *gin.Context) {})
|
||||
}
|
||||
|
||||
func TestM7RegistryGroupWithMiddlewareGroupUsesLocalRegistry(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
registry := NewRegistry(engine)
|
||||
registry.RegisterMiddlewareGroup(NewMiddlewareGroup("auth", func(c *gin.Context) {
|
||||
c.Set("auth", true)
|
||||
c.Next()
|
||||
}))
|
||||
|
||||
group := registry.GroupWithMiddlewareGroup(engine, "/api", "auth")
|
||||
if group == nil {
|
||||
t.Fatal("GroupWithMiddlewareGroup returned nil")
|
||||
}
|
||||
group.GET("/check", func(c *gin.Context) {
|
||||
v, _ := c.Get("auth")
|
||||
c.JSON(http.StatusOK, gin.H{"auth": v})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/check", nil))
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"auth":true`) {
|
||||
t.Fatalf("local middleware group not applied: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestM7NilGuardsNoPanic(t *testing.T) {
|
||||
warnings := captureRouterWarnings(t)
|
||||
var nilRegistry *Registry
|
||||
var nilEngine *gin.Engine
|
||||
var nilGroup *gin.RouterGroup
|
||||
var nilRoute *RESTfulRoute
|
||||
|
||||
requireNoPanic := func(name string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("%s panicked: %v", name, rec)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
requireNoPanic("RegisterHealthRoute nil engine", func() { RegisterHealthRoute(nilEngine) })
|
||||
requireNoPanic("DefaultModule nil group", func() { DefaultModule.Register(nilGroup) })
|
||||
requireNoPanic("nil registry Use", func() { nilRegistry.Use(nil) })
|
||||
requireNoPanic("nil registry RegisterModule", func() { nilRegistry.RegisterModule(nil) })
|
||||
requireNoPanic("nil registry RegisterVersion", func() { nilRegistry.RegisterVersion(nil) })
|
||||
requireNoPanic("nil registry RegisterMiddlewareGroup", func() { nilRegistry.RegisterMiddlewareGroup(nil) })
|
||||
requireNoPanic("nil registry GetMiddlewareGroup", func() { _ = nilRegistry.GetMiddlewareGroup("x") })
|
||||
requireNoPanic("nil registry Apply", func() { nilRegistry.Apply() })
|
||||
requireNoPanic("nil engine Group", func() { _ = Group(nil, "/x", nil) })
|
||||
requireNoPanic("nil RESTful GET", func() { nilRoute.GET(nil) })
|
||||
requireNoPanic("nil RESTful CRUD", func() { nilRoute.CRUD(nil, nil, nil, nil, nil) })
|
||||
|
||||
if len(*warnings) == 0 {
|
||||
t.Fatal("nil guards should emit warnings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestM7RegistryApplyConcurrentMutationNoRace(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
_ = captureRouterWarnings(t)
|
||||
engine := gin.New()
|
||||
registry := NewRegistry(engine)
|
||||
|
||||
const workers = 32
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers + 1)
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
idx := i
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
registry.Use(func(c *gin.Context) { c.Next() })
|
||||
registry.RegisterMiddlewareGroup(NewMiddlewareGroup(
|
||||
fmt.Sprintf("group-%d", idx),
|
||||
func(c *gin.Context) { c.Next() },
|
||||
))
|
||||
registry.RegisterModuleFunc(fmt.Sprintf("module-%d", idx), func(g *gin.RouterGroup) {
|
||||
g.GET(fmt.Sprintf("/m%d", idx), func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
})
|
||||
registry.RegisterVersion(NewVersion(
|
||||
fmt.Sprintf("v%d", idx),
|
||||
fmt.Sprintf("/v%d", idx),
|
||||
).AddModuleFunc("ping", func(g *gin.RouterGroup) {
|
||||
g.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
}))
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
registry.Apply()
|
||||
}()
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestM7RegisterAfterApplyWarnsAndDoesNotTakeEffect(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
warnings := captureRouterWarnings(t)
|
||||
engine := gin.New()
|
||||
registry := NewRegistry(engine)
|
||||
registry.Apply()
|
||||
|
||||
registry.RegisterModuleFunc("late", func(g *gin.RouterGroup) {
|
||||
g.GET("/late", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
})
|
||||
|
||||
if !warningsContain(warnings, "after Apply") {
|
||||
t.Fatalf("late registration should warn, got %#v", *warnings)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/late", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("late route status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -430,7 +430,7 @@ func TestGroupWithMiddlewareGroup(t *testing.T) {
|
||||
|
||||
registry.RegisterMiddlewareGroup(router.NewMiddlewareGroup("auth", func(c *gin.Context) { c.Next() }))
|
||||
|
||||
group := router.GroupWithMiddlewareGroup(engine, "/api", "auth")
|
||||
group := registry.GroupWithMiddlewareGroup(engine, "/api", "auth")
|
||||
if group == nil {
|
||||
t.Error("GroupWithMiddlewareGroup should not return nil")
|
||||
}
|
||||
@@ -456,4 +456,4 @@ type testModule struct {
|
||||
func (m *testModule) Name() string { return m.name }
|
||||
func (m *testModule) Register(r *gin.RouterGroup) {
|
||||
r.GET("/"+m.name, func(c *gin.Context) { c.JSON(200, gin.H{"module": m.name}) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 扫描收敛协议(review discipline)
|
||||
|
||||
> 本文件是 CLAUDE.md「开发纪律」的姊妹篇。开发纪律管"怎么写代码",本协议管"怎么审代码"。
|
||||
> 起源:多轮大模型全局复审非收敛--每换一个 reviewer 就重新生成一批 nits/功能建议/风格偏好,finding 数永不归零,压迫感持续累积却无关真实质量。本协议把复审收敛到"只产出严重问题"。
|
||||
|
||||
## 0. 一句话原则
|
||||
|
||||
**只报可达、真实、严重的缺陷。功能正常、质量良好、无 bug 的代码不报。低于 HIGH 一律不报。**
|
||||
|
||||
## 1. 栅栏:只报 CRITICAL 与 HIGH
|
||||
|
||||
仅当缺陷**同时**满足以下三条才计入交付:
|
||||
|
||||
- **可达**:存在真实调用路径触发(必须给出路径:哪条调用链、什么输入、什么并发时序)。纸上推演"理论上如果有人这么调"不算,必须有真实调用方或合理下游用法。
|
||||
- **是缺陷,非设计**:不是已文档化的设计取舍,不是功能缺口,不是已知 footgun。
|
||||
- **严重**:达到 CRITICAL 或 HIGH。
|
||||
|
||||
级别定义:
|
||||
|
||||
- **CRITICAL**:正常使用下可触发的 数据损坏 / 安全突破 / 死锁 / panic / 资源泄漏。
|
||||
- **HIGH**:真实(不必常见)路径下的 契约违反 / 并发竞态 / 生命周期泄漏 / 安全弱点,且无优雅降级。
|
||||
|
||||
低于 HIGH(MEDIUM/LOW)**默认不报**。若某条 MEDIUM 实际后果严重(如静默停摆),应在证伪后上调为 HIGH 再报,而不是以 MEDIUM 身份进清单。
|
||||
|
||||
## 2. 吹毛求疵禁区(明确不报)
|
||||
|
||||
以下一律**不作为 finding**。可在交付末尾"观察区(非 bug)"单列,但**不计入、不阻塞、不计数**:
|
||||
|
||||
1. **风格 / 格式 / 命名**:`fmt.Errorf` vs `errors.New`、变量命名、注释措辞、import 顺序。
|
||||
2. **"无害冗余" / 微优化**:无热路径实测证据的重复调用、可选的零拷贝、可省的临时变量。
|
||||
3. **功能缺口 / 未实现能力**:TLS 未加、交叉校验未加、某配置项未暴露--这些是 **roadmap**,不是 bug。归观察区。
|
||||
4. **已文档化的设计决策 / footgun**:代码注释或 CLAUDE.md 已声明"刻意如此"的,不重挂。
|
||||
5. **不可达路径的防御性补丁**:找不到真实 nil 调用方,就不报"缺 nil-check";找不到触发 panic 的输入,就不报"缺 recover"。防御性编码是好习惯,但不构成缺陷。
|
||||
6. **与红线有张力但场景"可接受"**:逐条对照开发纪律判定,若该场景被纪律明文豁免(如 watcher 的 for-range 关闭语义),不报。
|
||||
7. **维护性风险但当前无缺陷**:Clone 手动列举字段今天工作正确、某 switch 缺 default 今天不会命中--只有当"未来扩展会漏"已造成**当前**可证缺陷时才报,否则不报。
|
||||
|
||||
## 3. 证伪门(计入前强制)
|
||||
|
||||
每条 finding 在写入交付前,reviewer 必须主动执行证伪:
|
||||
|
||||
1. **写出触发路径**(具体到调用链 / 输入 / 并发时序)。
|
||||
2. **尝试证伪**:路径上有无 guard?调用方是否真实存在?是否已文档化?是否已是已知设计?是否其实有优雅降级?
|
||||
3. **裁定**:证伪成功 → 丢弃或降级;证伪失败 → 计入。
|
||||
|
||||
只有**证伪失败**的 finding 进入交付清单。证伪成功的进入"证伪台账"(见 §5),透明留痕但不制造压迫。
|
||||
|
||||
## 4. 范围规则(防重新生成噪声)
|
||||
|
||||
非收敛的头号成因是"对已加固模块反复全量重扫"--每个新 reviewer 都会重新生成一批 §2 禁区项。规则:
|
||||
|
||||
- **默认扫 diff**:自上次绿灯复审以来的变更,不扫全模块。
|
||||
- **已加固模块不重扫**,除非本次改动了它。模块是否"已加固"以最近一份"达标"总评为准。
|
||||
- **全模块扫描**只在"首次复审"或"大重构后"做,且必须在报告头标注"首次 / 重构后重审"。
|
||||
- **换 reviewer 不算重扫理由**:同一模块换模型复审前,先确认自上次复审有无改动;无改动则拒绝重扫。
|
||||
|
||||
## 5. 输出格式
|
||||
|
||||
交付物只含三段,严格物理分隔:
|
||||
|
||||
### 5.1 真 bug 清单(仅 CRITICAL/HIGH,证伪失败)
|
||||
每条:
|
||||
- 标题
|
||||
- `file:line`(源码唯一证据)
|
||||
- 触发路径(可达性证明)
|
||||
- 为何是缺陷(违反哪条契约 / 红线)
|
||||
- 修复方向(一两句,不展开实现)
|
||||
|
||||
### 5.2 观察区(非 bug,不计入,不阻塞)
|
||||
功能缺口 / roadmap 建议 / 可选增强。明确标注"非 bug"。用户可忽略。
|
||||
|
||||
### 5.3 证伪台账(透明留痕)
|
||||
列出本轮考虑过但丢弃的条目及丢弃理由(落入 §2 哪一条 / 证伪成功)。让用户看到"审过了但拒绝计入",避免"是不是漏审了"的疑虑。
|
||||
|
||||
**明确不输出**:风格 nits、"无害冗余"、防御性补丁建议、纯维护性担忧。
|
||||
|
||||
## 6. 成功度量(避免把不可达目标绑在自己身上)
|
||||
|
||||
- ✅ **证伪后剩余的真实 CRITICAL/HIGH 数** -- 可归零,这是唯一硬指标。
|
||||
- ✅ **是否出现新 bug 类别** -- 已收敛的类别(多视图状态 / 公开生命周期 / 用户扩展点 / 间接层吞错)再出现同类的实例,属"扫尾",不属"新问题"。
|
||||
- ❌ **原始 finding 条数** -- 永不归零,不作度量。任何用"finding 条数下降"衡量进步的做法都是错的。
|
||||
|
||||
## 7. 给 reviewer 的硬约束(可直接喂入 prompt)
|
||||
|
||||
> 你在审查 xlgo 框架代码。遵守 `scan_protocol.md`:
|
||||
> 1. 只报 CRITICAL/HIGH,且每条必须给出可达触发路径,否则不报。
|
||||
> 2. 风格、冗余、功能缺口、已文档化设计、不可达防御、可接受场景、纯维护性风险--一律不报。
|
||||
> 3. 每条 finding 先尝试证伪;证伪成功的丢弃。
|
||||
> 4. 交付三段:真 bug 清单 / 观察区(非 bug)/ 证伪台账。
|
||||
> 5. 不重扫已加固模块,只扫本次改动 diff(除非报告头标注"首次/重构后")。
|
||||
> 6. 若你只能找到 MEDIUM/LOW,直接在证伪台账说明并结束,不要为凑数而上调。
|
||||
+21
-2
@@ -14,12 +14,16 @@ package sse
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var ErrInvalidEventName = errors.New("sse: event name must not contain CR or LF")
|
||||
|
||||
// SSEWriter SSE 写入器
|
||||
type SSEWriter struct {
|
||||
writer gin.ResponseWriter
|
||||
@@ -54,10 +58,13 @@ func NewSSEWriter(c *gin.Context) (*SSEWriter, error) {
|
||||
// 写错误向上传播(C3b 修复):旧实现丢弃 fmt.Fprintf 错误且恒 return nil,
|
||||
// 导致客户端断连后 Stream 守卫永不触发、消费循环不退出、上游 LLM 流持续运行。
|
||||
func (w *SSEWriter) WriteEvent(event, data string) error {
|
||||
if strings.ContainsAny(event, "\r\n") {
|
||||
return ErrInvalidEventName
|
||||
}
|
||||
if _, err := fmt.Fprintf(w.writer, "event: %s\n", event); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w.writer, "data: %s\n\n", data); err != nil {
|
||||
if err := w.writeDataLines(data); err != nil {
|
||||
return err
|
||||
}
|
||||
w.flusher.Flush()
|
||||
@@ -67,13 +74,25 @@ func (w *SSEWriter) WriteEvent(event, data string) error {
|
||||
// WriteMessage 写入消息(无事件类型)
|
||||
// 格式: data: <data>\n\n
|
||||
func (w *SSEWriter) WriteMessage(data string) error {
|
||||
if _, err := fmt.Fprintf(w.writer, "data: %s\n\n", data); err != nil {
|
||||
if err := w.writeDataLines(data); err != nil {
|
||||
return err
|
||||
}
|
||||
w.flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *SSEWriter) writeDataLines(data string) error {
|
||||
data = strings.ReplaceAll(data, "\r\n", "\n")
|
||||
data = strings.ReplaceAll(data, "\r", "\n")
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
if _, err := fmt.Fprintf(w.writer, "data: %s\n", line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprint(w.writer, "\n")
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteJSON 写入 JSON 数据
|
||||
func (w *SSEWriter) WriteJSON(event string, data any) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
|
||||
+46
-1
@@ -1,6 +1,7 @@
|
||||
package sse_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -139,4 +140,48 @@ func TestSSEHeaders(t *testing.T) {
|
||||
if w.Header().Get("Connection") != "keep-alive" {
|
||||
t.Error("Connection should be keep-alive")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEWriterRejectsEventNewlineInjection(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
var writeErr error
|
||||
r.GET("/sse", func(c *gin.Context) {
|
||||
writer, _ := sse.NewSSEWriter(c)
|
||||
writeErr = writer.WriteEvent("message\nevent: hacked", "safe")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/sse", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if !errors.Is(writeErr, sse.ErrInvalidEventName) {
|
||||
t.Fatalf("WriteEvent newline err = %v, want ErrInvalidEventName", writeErr)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "event: hacked") {
|
||||
t.Fatalf("response contains injected event: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEWriterSplitsMultilineData(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/sse", func(c *gin.Context) {
|
||||
writer, _ := sse.NewSSEWriter(c)
|
||||
if err := writer.WriteMessage("first\nevent: injected\r\nsecond"); err != nil {
|
||||
t.Errorf("WriteMessage: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/sse", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "\nevent: injected") {
|
||||
t.Fatalf("data newline escaped into event field: %q", body)
|
||||
}
|
||||
for _, want := range []string{"data: first", "data: event: injected", "data: second"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("body = %q, want %q", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-31
@@ -30,7 +30,9 @@ type Storage interface {
|
||||
GetURL(path string) string
|
||||
Delete(path string) error
|
||||
Get(path string) ([]byte, error)
|
||||
Exists(path string) bool
|
||||
// Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil);
|
||||
// 未初始化、路径非法/穿越、后端错误返回 (false, err)。
|
||||
Exists(path string) (bool, error)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -41,9 +43,14 @@ var (
|
||||
ErrPathTraversal = errors.New("path traversal detected")
|
||||
// ErrInvalidPath 路径非法(空、含 NUL 等)。
|
||||
ErrInvalidPath = errors.New("invalid path")
|
||||
// ErrUploadTooLarge 上传实际字节数超过 MaxSizeBytes(P0)。客户端声明的 file.Size
|
||||
// 不可信,故除前置校验外,拷贝阶段按实际字节封顶,防止声明小体积却流式发送大 body 撑爆磁盘/OSS。
|
||||
// ErrInvalidFile 上传文件参数非法,例如 nil *multipart.FileHeader。
|
||||
ErrInvalidFile = errors.New("invalid file")
|
||||
// ErrUploadTooLarge 上传声明大小或实际字节数超过 MaxSizeBytes(P0)。客户端声明的 file.Size
|
||||
// 不可信,故除前置校验外,拷贝阶段也按实际字节封顶,防止声明小体积却流式发送大 body 撑爆磁盘/OSS。
|
||||
ErrUploadTooLarge = errors.New("upload exceeds max size")
|
||||
// ErrReadTooLarge Get 读取内容超过 maxReadBytes 上限(C4c)。与 ErrUploadTooLarge 区分:
|
||||
// 前者是读操作的超限,后者是上传操作的超限。
|
||||
ErrReadTooLarge = errors.New("read exceeds max size")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -71,7 +78,7 @@ func resolveMaxRead(n int64) int64 {
|
||||
// 在拷贝阶段按实际字节数二次把关(P0)。
|
||||
func validateUploadSize(p config.UploadPolicy, size int64) error {
|
||||
if p.MaxSizeBytes > 0 && size > p.MaxSizeBytes {
|
||||
return fmt.Errorf("文件大小 %d 超过上限 %d: %w", size, p.MaxSizeBytes, ErrInvalidPath)
|
||||
return fmt.Errorf("文件大小 %d 超过上限 %d: %w", size, p.MaxSizeBytes, ErrUploadTooLarge)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -186,6 +193,14 @@ func sanitizeObjectKey(key string) (string, error) {
|
||||
return path.Clean(key), nil
|
||||
}
|
||||
|
||||
func publicURL(baseURL, key string) string {
|
||||
cleanKey, err := sanitizeObjectKey(key)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(baseURL, "/") + "/" + cleanKey
|
||||
}
|
||||
|
||||
// LocalStorage 本地存储
|
||||
type LocalStorage struct {
|
||||
rootAbs string // 绝对根路径(已 Clean),前缀锚定用(C4a)
|
||||
@@ -199,7 +214,12 @@ type LocalStorage struct {
|
||||
// 安全约束:Path 指向的根目录应为框架独占目录,不与用户可控内容混用。
|
||||
// safeJoin 已防 `..` 路径穿越,但若根目录内已存在指向外部的符号链接,
|
||||
// Get 会跟随 symlink 读到根外内容——需保证攻击者无法在根目录内创建 symlink。
|
||||
// cfg 为 nil、Path 无法解析或根目录本身是 symlink 时,实例 fail-closed:
|
||||
// 后续文件操作返回 ErrStorageNotInitialized。
|
||||
func NewLocalStorage(cfg *config.LocalStorageConfig) *LocalStorage {
|
||||
if cfg == nil {
|
||||
return &LocalStorage{maxReadBytes: resolveMaxRead(0)}
|
||||
}
|
||||
// 用绝对路径作根锚定,避免相对路径 + `..` 组合绕过前缀校验(C4a)。
|
||||
var rootAbs string
|
||||
abs, err := filepath.Abs(cfg.Path)
|
||||
@@ -213,6 +233,12 @@ func NewLocalStorage(cfg *config.LocalStorageConfig) *LocalStorage {
|
||||
} else {
|
||||
rootAbs = filepath.Clean(abs)
|
||||
}
|
||||
if rootAbs != "" {
|
||||
if info, err := os.Lstat(rootAbs); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
logger.Error("storage: local storage root must not be a symlink", zap.String("path", rootAbs))
|
||||
rootAbs = ""
|
||||
}
|
||||
}
|
||||
return &LocalStorage{
|
||||
rootAbs: rootAbs,
|
||||
baseURL: cfg.BaseURL,
|
||||
@@ -244,7 +270,10 @@ func (s *LocalStorage) safeJoin(parts ...string) (string, error) {
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
|
||||
func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (ret string, err error) {
|
||||
if file == nil {
|
||||
return "", ErrInvalidFile
|
||||
}
|
||||
// 上传安全策略校验(大小 / 扩展名);file.Size 由 multipart 解析时填,无需打开文件(C4b)。
|
||||
if err := validateUploadSize(s.policy, file.Size); err != nil {
|
||||
return "", err
|
||||
@@ -294,13 +323,18 @@ func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
defer func() {
|
||||
if cerr := dstFile.Close(); cerr != nil {
|
||||
err = errors.Join(err, cerr)
|
||||
}
|
||||
if err != nil {
|
||||
ret = ""
|
||||
_ = os.Remove(dst)
|
||||
}
|
||||
}()
|
||||
|
||||
// 复制文件内容。按策略实测封顶(P0):超限即报错并清理已落盘的部分文件。
|
||||
if _, err := io.Copy(dstFile, enforceUploadSize(s.policy, srcReader)); err != nil {
|
||||
// 先关闭再删除(Windows 下删除打开中的文件会失败);defer 的 Close 随后成为无害 no-op。
|
||||
_ = dstFile.Close()
|
||||
_ = os.Remove(dst)
|
||||
return "", fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -314,7 +348,7 @@ func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string
|
||||
}
|
||||
|
||||
// UploadFromBytes 从字节数组上传文件
|
||||
func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) {
|
||||
func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (ret string, err error) {
|
||||
// 上传安全策略校验(C4b)
|
||||
if err := validateUploadSize(s.policy, int64(len(data))); err != nil {
|
||||
return "", err
|
||||
@@ -360,7 +394,15 @@ func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (st
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
defer func() {
|
||||
if cerr := dstFile.Close(); cerr != nil {
|
||||
err = errors.Join(err, cerr)
|
||||
}
|
||||
if err != nil {
|
||||
ret = ""
|
||||
_ = os.Remove(dst)
|
||||
}
|
||||
}()
|
||||
|
||||
// 写入文件内容
|
||||
if _, err := io.Copy(dstFile, srcReader); err != nil {
|
||||
@@ -378,7 +420,7 @@ func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (st
|
||||
|
||||
// GetURL 获取文件访问 URL
|
||||
func (s *LocalStorage) GetURL(path string) string {
|
||||
return fmt.Sprintf("%s/%s", s.baseURL, path)
|
||||
return publicURL(s.baseURL, path)
|
||||
}
|
||||
|
||||
// Delete 删除文件
|
||||
@@ -421,19 +463,25 @@ func (s *LocalStorage) Get(p string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("读取文件内容失败: %w", err)
|
||||
}
|
||||
if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes {
|
||||
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrInvalidPath)
|
||||
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
func (s *LocalStorage) Exists(p string) bool {
|
||||
// Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil);
|
||||
// 路径非法/穿越返回 (false, err);其他 Stat 错误返回 (false, err)。
|
||||
func (s *LocalStorage) Exists(p string) (bool, error) {
|
||||
fullPath, err := s.safeJoin(p)
|
||||
if err != nil {
|
||||
return false
|
||||
return false, err
|
||||
}
|
||||
_, err = os.Stat(fullPath)
|
||||
return err == nil
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// OSSStorage OSS 存储
|
||||
@@ -449,6 +497,9 @@ type OSSStorage struct {
|
||||
|
||||
// NewOSSStorage 创建 OSS 存储实例
|
||||
func NewOSSStorage(cfg *config.OSSStorageConfig) (*OSSStorage, error) {
|
||||
if cfg == nil {
|
||||
return nil, ErrStorageNotInitialized
|
||||
}
|
||||
client, err := oss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 OSS 客户端失败: %w", err)
|
||||
@@ -472,6 +523,9 @@ func NewOSSStorage(cfg *config.OSSStorageConfig) (*OSSStorage, error) {
|
||||
|
||||
// Upload 上传文件到 OSS
|
||||
func (s *OSSStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
|
||||
if file == nil {
|
||||
return "", ErrInvalidFile
|
||||
}
|
||||
// 上传安全策略校验(C4b)
|
||||
if err := validateUploadSize(s.policy, file.Size); err != nil {
|
||||
return "", err
|
||||
@@ -563,15 +617,23 @@ func (s *OSSStorage) UploadFromBytes(data []byte, filename, subdir string) (stri
|
||||
|
||||
// GetURL 获取文件访问 URL
|
||||
func (s *OSSStorage) GetURL(path string) string {
|
||||
if s.baseURL != "" {
|
||||
return fmt.Sprintf("%s/%s", s.baseURL, path)
|
||||
cleanKey, err := sanitizeObjectKey(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://%s.%s/%s", s.bucketName, s.endpoint, path)
|
||||
if s.baseURL != "" {
|
||||
return strings.TrimRight(s.baseURL, "/") + "/" + cleanKey
|
||||
}
|
||||
return fmt.Sprintf("https://%s.%s/%s", s.bucketName, s.endpoint, cleanKey)
|
||||
}
|
||||
|
||||
// GetSignedURL 获取带签名的临时访问 URL(用于私有文件)
|
||||
func (s *OSSStorage) GetSignedURL(path string, expire time.Duration) (string, error) {
|
||||
return s.bucket.SignURL(path, oss.HTTPGet, int64(expire.Seconds()))
|
||||
key, err := sanitizeObjectKey(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.bucket.SignURL(key, oss.HTTPGet, int64(expire.Seconds()))
|
||||
}
|
||||
|
||||
// Delete 删除 OSS 文件
|
||||
@@ -610,19 +672,27 @@ func (s *OSSStorage) Get(p string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("读取 OSS 文件内容失败: %w", err)
|
||||
}
|
||||
if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes {
|
||||
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrInvalidPath)
|
||||
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Exists 检查 OSS 文件是否存在
|
||||
func (s *OSSStorage) Exists(p string) bool {
|
||||
// Exists 检查 OSS 文件是否存在。存在返回 (true, nil);object 不存在返回 (false, nil);
|
||||
// key 非法/穿越返回 (false, err);鉴权/网络/服务端错误返回 (false, err)。
|
||||
func (s *OSSStorage) Exists(p string) (bool, error) {
|
||||
key, err := sanitizeObjectKey(p)
|
||||
if err != nil {
|
||||
return false
|
||||
return false, err
|
||||
}
|
||||
_, err = s.bucket.GetObjectMeta(key)
|
||||
return err == nil
|
||||
if _, err := s.bucket.GetObjectMeta(key); err != nil {
|
||||
// OSS object 不存在(404 / NoSuchKey)不是错误,返回 (false, nil)。
|
||||
var se *oss.ServiceError
|
||||
if errors.As(err, &se) && (se.StatusCode == http.StatusNotFound || se.Code == "NoSuchKey") {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// StorageManager 存储管理器(#10)。照 database.Manager 模式:
|
||||
@@ -655,8 +725,21 @@ func SetDefaultStorageManager(m *StorageManager) {
|
||||
}
|
||||
}
|
||||
|
||||
// SwapDefaultStorageManager 将指定 StorageManager 置为全局默认,并返回被替换的旧 Manager。
|
||||
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存(照
|
||||
// SwapDefaultRedisManager / database.SwapDefaultManager 模式)。nil 被忽略,返回当前默认。
|
||||
func SwapDefaultStorageManager(m *StorageManager) *StorageManager {
|
||||
if m == nil {
|
||||
return DefaultStorage.Load()
|
||||
}
|
||||
return DefaultStorage.Swap(m)
|
||||
}
|
||||
|
||||
// Init 初始化存储
|
||||
func (m *StorageManager) Init(cfg *config.StorageConfig) error {
|
||||
if cfg == nil {
|
||||
return ErrStorageNotInitialized
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -695,6 +778,24 @@ func (m *StorageManager) Set(s Storage) {
|
||||
m.current = s
|
||||
}
|
||||
|
||||
// Close 释放存储资源,闭合 StorageManager 的 Init/Close 生命周期(与 database/redis/logger
|
||||
// manager 对齐,供 App.closeResources 统一调用)。当前 LocalStorage 无可关资源;OSSStorage 的
|
||||
// *oss.Client 未暴露 Close(aliyun-oss-go-sdk),故当前为 no-op。保留此方法为未来驱动
|
||||
// (S3/MinIO/自研等带连接池的驱动)预留收口点:实现了 io.Closer 的 Storage 实现会被自动调用,
|
||||
// 框架骨架无需改动。幂等(未初始化或驱动非 io.Closer 时 no-op)。
|
||||
func (m *StorageManager) Close() error {
|
||||
m.mu.Lock()
|
||||
current := m.current
|
||||
m.mu.Unlock()
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
if c, ok := current.(io.Closer); ok {
|
||||
return c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 包级 facade(代理到 DefaultStorage,兼容存量) ---
|
||||
|
||||
// Init 初始化存储
|
||||
@@ -757,11 +858,12 @@ func Get(path string) ([]byte, error) {
|
||||
return s.Get(path)
|
||||
}
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
func Exists(path string) bool {
|
||||
// Exists 检查文件是否存在。未初始化返回 (false, ErrStorageNotInitialized);
|
||||
// 其余语义同 Storage.Exists。
|
||||
func Exists(path string) (bool, error) {
|
||||
s := GetStorage()
|
||||
if s == nil {
|
||||
return false
|
||||
return false, ErrStorageNotInitialized
|
||||
}
|
||||
return s.Exists(path)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"mime/multipart"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// closeableMock 实现 Storage + io.Closer,验证 StorageManager.Close 调用驱动的 Close。
|
||||
type closeableMock struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (closeableMock) Upload(*multipart.FileHeader, string) (string, error) { return "", nil }
|
||||
func (closeableMock) UploadFromBytes([]byte, string, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (closeableMock) GetURL(string) string { return "" }
|
||||
func (closeableMock) Delete(string) error { return nil }
|
||||
func (closeableMock) Get(string) ([]byte, error) { return nil, nil }
|
||||
func (closeableMock) Exists(string) (bool, error) { return false, nil }
|
||||
func (c *closeableMock) Close() error { c.closed = true; return nil }
|
||||
|
||||
// TestStorageManagerCloseNil:未初始化时 Close 为 no-op,不 panic。
|
||||
func TestStorageManagerCloseNil(t *testing.T) {
|
||||
m := NewStorageManager()
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatalf("Close on uninitialized manager should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStorageManagerCloseLocalStorageNoop:LocalStorage 未实现 io.Closer,Close 为 no-op。
|
||||
func TestStorageManagerCloseLocalStorageNoop(t *testing.T) {
|
||||
m := NewStorageManager()
|
||||
if err := m.Init(&config.StorageConfig{
|
||||
Driver: "local",
|
||||
Local: config.LocalStorageConfig{Path: t.TempDir()},
|
||||
}); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatalf("Close on LocalStorage-backed manager should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStorageManagerCloseInvokesCloser:驱动实现 io.Closer 时,StorageManager.Close 调用它。
|
||||
func TestStorageManagerCloseInvokesCloser(t *testing.T) {
|
||||
m := NewStorageManager()
|
||||
mock := &closeableMock{}
|
||||
m.Set(mock)
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if !mock.closed {
|
||||
t.Fatal("StorageManager.Close did not invoke io.Closer on driver (未来带连接池的驱动应经此收口)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStorageManagerCloseIdempotent:重复 Close 不 panic。
|
||||
func TestStorageManagerCloseIdempotent(t *testing.T) {
|
||||
m := NewStorageManager()
|
||||
mock := &closeableMock{}
|
||||
m.Set(mock)
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatalf("first Close: %v", err)
|
||||
}
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatalf("second Close should be idempotent, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -64,3 +65,28 @@ func TestResolveMaxRead(t *testing.T) {
|
||||
t.Errorf("resolveMaxRead(2048) = %d, want 2048", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOSSStorageNilConfig(t *testing.T) {
|
||||
if _, err := NewOSSStorage(nil); !errors.Is(err, ErrStorageNotInitialized) {
|
||||
t.Fatalf("NewOSSStorage(nil) err = %v, want ErrStorageNotInitialized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSSStorageGetURLSanitizesPath(t *testing.T) {
|
||||
s := &OSSStorage{
|
||||
baseURL: "https://cdn.example.com/",
|
||||
bucketName: "bucket",
|
||||
endpoint: "oss.example.com",
|
||||
}
|
||||
if got := s.GetURL("docs//a.txt"); got != "https://cdn.example.com/docs/a.txt" {
|
||||
t.Fatalf("GetURL clean path = %q", got)
|
||||
}
|
||||
if got := s.GetURL("../secret.txt"); got != "" {
|
||||
t.Fatalf("GetURL traversal = %q, want empty", got)
|
||||
}
|
||||
|
||||
s.baseURL = ""
|
||||
if got := s.GetURL("docs/a.txt"); got != "https://bucket.oss.example.com/docs/a.txt" {
|
||||
t.Fatalf("GetURL endpoint fallback = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +84,9 @@ func TestLocalStoragePathTraversal(t *testing.T) {
|
||||
t.Errorf("Get(%q) err = %v, want ErrPathTraversal", escapeRel, err)
|
||||
}
|
||||
|
||||
// Exists 必须返回 false(而非穿越探测到 canary)
|
||||
if s.Exists(escapeRel) {
|
||||
t.Errorf("Exists(%q) = true, want false (traversal must not probe outside root)", escapeRel)
|
||||
// Exists 必须返回 (false, ErrPathTraversal)(而非穿越探测到 canary)
|
||||
if ok, err := s.Exists(escapeRel); ok || !errors.Is(err, storage.ErrPathTraversal) {
|
||||
t.Errorf("Exists(%q) = (%v, %v), want (false, ErrPathTraversal)", escapeRel, ok, err)
|
||||
}
|
||||
|
||||
// 绝对路径也必须拒绝
|
||||
@@ -111,8 +111,8 @@ func TestLocalStorageNormalPathStillWorks(t *testing.T) {
|
||||
}
|
||||
|
||||
rel := filepath.ToSlash(filepath.Join("sub", "ok.txt"))
|
||||
if !s.Exists(rel) {
|
||||
t.Error("Exists(normal) = false, want true")
|
||||
if ok, err := s.Exists(rel); err != nil || !ok {
|
||||
t.Errorf("Exists(normal) = (%v, %v), want (true, nil)", ok, err)
|
||||
}
|
||||
data, err := s.Get(rel)
|
||||
if err != nil {
|
||||
@@ -170,8 +170,8 @@ func TestLocalStorageGetReadLimit(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Upload big file: %v", err)
|
||||
}
|
||||
if _, err := s.Get(rel); !errors.Is(err, storage.ErrInvalidPath) {
|
||||
t.Errorf("Get over-limit err = %v, want ErrInvalidPath", err)
|
||||
if _, err := s.Get(rel); !errors.Is(err, storage.ErrReadTooLarge) {
|
||||
t.Errorf("Get over-limit err = %v, want ErrReadTooLarge", err)
|
||||
}
|
||||
|
||||
// 小文件应正常读取。
|
||||
@@ -196,8 +196,8 @@ func TestLocalStorageGetReadLimit(t *testing.T) {
|
||||
func TestLocalStorageUploadSizeLimit(t *testing.T) {
|
||||
s := newLocalStorageWithPolicy(t, config.UploadPolicy{MaxSizeBytes: 5}, 0)
|
||||
fh := makeFileHeader(t, "file", "big.txt", "text/plain", []byte("0123456789")) // 10 字节
|
||||
if _, err := s.Upload(fh, "docs"); !errors.Is(err, storage.ErrInvalidPath) {
|
||||
t.Errorf("Upload over size err = %v, want ErrInvalidPath", err)
|
||||
if _, err := s.Upload(fh, "docs"); !errors.Is(err, storage.ErrUploadTooLarge) {
|
||||
t.Errorf("Upload over size err = %v, want ErrUploadTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,14 +239,14 @@ func TestLocalStorageUploadMIMESniff(t *testing.T) {
|
||||
// 回归 C4b:UploadFromBytes 同样受策略约束(大小 / 扩展名 / MIME)。
|
||||
func TestLocalStorageUploadFromBytesPolicy(t *testing.T) {
|
||||
s := newLocalStorageWithPolicy(t, config.UploadPolicy{
|
||||
MaxSizeBytes: 100,
|
||||
AllowedExts: []string{".txt"},
|
||||
AllowedMIMEs: []string{"text/plain"},
|
||||
MaxSizeBytes: 100,
|
||||
AllowedExts: []string{".txt"},
|
||||
AllowedMIMEs: []string{"text/plain"},
|
||||
}, 0)
|
||||
|
||||
// 超限
|
||||
if _, err := s.UploadFromBytes(make([]byte, 200), "ok.txt", "docs"); !errors.Is(err, storage.ErrInvalidPath) {
|
||||
t.Errorf("UploadFromBytes over size err = %v, want ErrInvalidPath", err)
|
||||
if _, err := s.UploadFromBytes(make([]byte, 200), "ok.txt", "docs"); !errors.Is(err, storage.ErrUploadTooLarge) {
|
||||
t.Errorf("UploadFromBytes over size err = %v, want ErrUploadTooLarge", err)
|
||||
}
|
||||
// 扩展名不符
|
||||
if _, err := s.UploadFromBytes([]byte("hi"), "evil.php", "docs"); !errors.Is(err, storage.ErrInvalidPath) {
|
||||
@@ -295,3 +295,50 @@ func TestLocalStorageZeroPolicyAllowsAll(t *testing.T) {
|
||||
t.Errorf("Upload with zero policy err = %v (must remain unrestricted for backward compat)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageNilConfigFailsClosed(t *testing.T) {
|
||||
s := storage.NewLocalStorage(nil)
|
||||
if _, err := s.Get("x.txt"); !errors.Is(err, storage.ErrStorageNotInitialized) {
|
||||
t.Fatalf("Get with nil config err = %v, want ErrStorageNotInitialized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageInitNilConfigNoPanic(t *testing.T) {
|
||||
if err := storage.NewStorageManager().Init(nil); !errors.Is(err, storage.ErrStorageNotInitialized) {
|
||||
t.Fatalf("Init(nil) err = %v, want ErrStorageNotInitialized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageUploadNilFile(t *testing.T) {
|
||||
s := newLocalStorageWithPolicy(t, config.UploadPolicy{}, 0)
|
||||
if _, err := s.Upload(nil, "docs"); !errors.Is(err, storage.ErrInvalidFile) {
|
||||
t.Fatalf("Upload(nil) err = %v, want ErrInvalidFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageRejectsSymlinkRoot(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
target := filepath.Join(parent, "target")
|
||||
if err := os.Mkdir(target, 0755); err != nil {
|
||||
t.Fatalf("mkdir target: %v", err)
|
||||
}
|
||||
link := filepath.Join(parent, "link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlink not available on this system: %v", err)
|
||||
}
|
||||
|
||||
s := storage.NewLocalStorage(&config.LocalStorageConfig{Path: link, BaseURL: "http://localhost/uploads"})
|
||||
if _, err := s.Get("x.txt"); !errors.Is(err, storage.ErrStorageNotInitialized) {
|
||||
t.Fatalf("Get with symlink root err = %v, want ErrStorageNotInitialized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageGetURLSanitizesPath(t *testing.T) {
|
||||
s := newLocalStorageWithPolicy(t, config.UploadPolicy{}, 0)
|
||||
if got := s.GetURL("docs//a.txt"); got != "http://localhost/uploads/docs/a.txt" {
|
||||
t.Fatalf("GetURL clean path = %q", got)
|
||||
}
|
||||
if got := s.GetURL("../secret.txt"); got != "" {
|
||||
t.Fatalf("GetURL traversal = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSwapDefaultStorageManagerPreservesReplacedManager 固化 Phase 2:
|
||||
// SwapDefaultStorageManager 必须返回被替换的旧 Manager 且不关闭它(供 App Init 失败回滚暂存)。
|
||||
// 照 database.SwapDefaultRedisManager 的内部测试模式。
|
||||
func TestSwapDefaultStorageManagerPreservesReplacedManager(t *testing.T) {
|
||||
orig := DefaultStorage.Load()
|
||||
|
||||
old := NewStorageManager()
|
||||
previous := SwapDefaultStorageManager(old)
|
||||
if previous != orig {
|
||||
t.Fatalf("SwapDefaultStorageManager 应返回被替换的旧默认(orig),got %v", previous)
|
||||
}
|
||||
if DefaultStorage.Load() != old {
|
||||
t.Fatal("SwapDefaultStorageManager 应安装新的默认 manager(old)")
|
||||
}
|
||||
|
||||
next := NewStorageManager()
|
||||
returned := SwapDefaultStorageManager(next)
|
||||
if returned != old {
|
||||
t.Fatalf("SwapDefaultStorageManager 应返回上一次安装的 manager(old),got %v", returned)
|
||||
}
|
||||
if DefaultStorage.Load() != next {
|
||||
t.Fatal("SwapDefaultStorageManager 应安装新的默认 manager(next)")
|
||||
}
|
||||
|
||||
// nil 被忽略,返回当前默认
|
||||
if got := SwapDefaultStorageManager(nil); got != next {
|
||||
t.Fatalf("SwapDefaultStorageManager(nil) 应返回当前默认,got %v", got)
|
||||
}
|
||||
|
||||
// 恢复 init() 时的默认,避免污染其它测试
|
||||
SwapDefaultStorageManager(orig)
|
||||
}
|
||||
@@ -40,8 +40,8 @@ func TestStorageNotInitialized(t *testing.T) {
|
||||
if url := storage.GetURL("missing"); url != "" {
|
||||
t.Fatalf("expected empty URL, got %q", url)
|
||||
}
|
||||
if storage.Exists("missing") {
|
||||
t.Fatal("expected Exists false without storage")
|
||||
if ok, err := storage.Exists("missing"); ok || !errors.Is(err, storage.ErrStorageNotInitialized) {
|
||||
t.Fatalf("expected (false, ErrStorageNotInitialized) without storage, got (%v, %v)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +107,9 @@ func TestLocalStorageDeleteGetExists(t *testing.T) {
|
||||
}
|
||||
|
||||
// 测试 Exists
|
||||
if !local.Exists("test.txt") {
|
||||
t.Error("Exists should return true for existing file")
|
||||
ok, err := local.Exists("test.txt")
|
||||
if err != nil || !ok {
|
||||
t.Errorf("Exists should return (true, nil) for existing file, got (%v, %v)", ok, err)
|
||||
}
|
||||
|
||||
// 测试 Get
|
||||
@@ -127,8 +128,9 @@ func TestLocalStorageDeleteGetExists(t *testing.T) {
|
||||
}
|
||||
|
||||
// 验证删除后不存在
|
||||
if local.Exists("test.txt") {
|
||||
t.Error("Exists should return false after delete")
|
||||
ok, err = local.Exists("test.txt")
|
||||
if err != nil || ok {
|
||||
t.Errorf("Exists should return (false, nil) after delete, got (%v, %v)", ok, err)
|
||||
}
|
||||
|
||||
// 删除不存在的文件应该失败
|
||||
|
||||
+114
-77
@@ -3,25 +3,27 @@ package test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetupRouter 创建测试路由。
|
||||
// SetupRouter 创建不带框架默认中间件的测试路由。
|
||||
//
|
||||
// 刻意返回裸 gin.New()(N3 文档化):不含框架中间件(RequestID/Recover/超时等),
|
||||
// 让测试方完全控制中间件链。需要框架中间件的集成测试应直接构造 xlgo.App 或手动 Use。
|
||||
// 需要 RequestID、Recover、超时等框架中间件的集成测试,应直接构造 xlgo.App
|
||||
// 或手动 Use 对应中间件。
|
||||
func SetupRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
return gin.New()
|
||||
}
|
||||
|
||||
// Request 测试请求辅助函数
|
||||
// Request 是测试请求构造器。
|
||||
type Request struct {
|
||||
router *gin.Engine
|
||||
method string
|
||||
@@ -30,7 +32,7 @@ type Request struct {
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
// NewRequest 创建测试请求
|
||||
// NewRequest 创建测试请求。
|
||||
func NewRequest(router *gin.Engine, method, path string) *Request {
|
||||
return &Request{
|
||||
router: router,
|
||||
@@ -40,90 +42,92 @@ func NewRequest(router *gin.Engine, method, path string) *Request {
|
||||
}
|
||||
}
|
||||
|
||||
// GET 创建 GET 请求
|
||||
// GET 创建 GET 请求。
|
||||
func GET(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodGet, path)
|
||||
}
|
||||
|
||||
// POST 创建 POST 请求
|
||||
// POST 创建 POST 请求。
|
||||
func POST(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodPost, path)
|
||||
}
|
||||
|
||||
// PUT 创建 PUT 请求
|
||||
// PUT 创建 PUT 请求。
|
||||
func PUT(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodPut, path)
|
||||
}
|
||||
|
||||
// DELETE 创建 DELETE 请求
|
||||
// DELETE 创建 DELETE 请求。
|
||||
func DELETE(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodDelete, path)
|
||||
}
|
||||
|
||||
// PATCH 创建 PATCH 请求
|
||||
// PATCH 创建 PATCH 请求。
|
||||
func PATCH(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodPatch, path)
|
||||
}
|
||||
|
||||
// WithBody 设置请求体
|
||||
// WithBody 设置请求体。
|
||||
func (r *Request) WithBody(body any) *Request {
|
||||
r.body = body
|
||||
return r
|
||||
}
|
||||
|
||||
// WithJSON 设置 JSON 请求体
|
||||
// WithJSON 设置 JSON 请求体并写入 Content-Type。
|
||||
func (r *Request) WithJSON(body any) *Request {
|
||||
r.body = body
|
||||
r.headers["Content-Type"] = "application/json"
|
||||
return r
|
||||
}
|
||||
|
||||
// WithHeader 设置请求头
|
||||
// WithHeader 设置请求头。
|
||||
func (r *Request) WithHeader(key, value string) *Request {
|
||||
r.headers[key] = value
|
||||
return r
|
||||
}
|
||||
|
||||
// WithToken 设置 Authorization 头
|
||||
// WithToken 设置 Authorization Bearer token。
|
||||
func (r *Request) WithToken(token string) *Request {
|
||||
r.headers["Authorization"] = "Bearer " + token
|
||||
return r
|
||||
}
|
||||
|
||||
// Execute 执行请求
|
||||
func (r *Request) Execute() *httptest.ResponseRecorder {
|
||||
var bodyReader *bytes.Reader
|
||||
// Execute 执行请求并返回带断言方法的响应包装。
|
||||
func (r *Request) Execute() *Response {
|
||||
return &Response{ResponseRecorder: r.ExecuteRecorder()}
|
||||
}
|
||||
|
||||
// ExecuteRecorder 执行请求并返回原始 httptest.ResponseRecorder。
|
||||
func (r *Request) ExecuteRecorder() *httptest.ResponseRecorder {
|
||||
var bodyReader *bytes.Reader
|
||||
if r.body != nil {
|
||||
bodyBytes, _ := json.Marshal(r.body)
|
||||
bodyReader = bytes.NewReader(bodyBytes)
|
||||
} else {
|
||||
bodyReader = bytes.NewReader([]byte{})
|
||||
bodyReader = bytes.NewReader(nil)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(r.method, r.path, bodyReader)
|
||||
|
||||
for key, value := range r.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
r.router.ServeHTTP(recorder, req)
|
||||
|
||||
return recorder
|
||||
}
|
||||
|
||||
// Response 响应解析辅助
|
||||
// Response 是测试响应辅助包装。
|
||||
type Response struct {
|
||||
*httptest.ResponseRecorder
|
||||
}
|
||||
|
||||
// ParseJSON 解析 JSON 响应
|
||||
// ParseJSON 解析 JSON 响应。
|
||||
func (r *Response) ParseJSON(v any) error {
|
||||
return json.Unmarshal(r.Body.Bytes(), v)
|
||||
}
|
||||
|
||||
// AssertStatus 断言状态码
|
||||
// AssertStatus 断言 HTTP 状态码。
|
||||
func (r *Response) AssertStatus(t *testing.T, expected int) {
|
||||
t.Helper()
|
||||
if r.Code != expected {
|
||||
@@ -131,43 +135,43 @@ func (r *Response) AssertStatus(t *testing.T, expected int) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssertOK 断言状态码为 200
|
||||
// AssertOK 断言状态码为 200。
|
||||
func (r *Response) AssertOK(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusOK)
|
||||
}
|
||||
|
||||
// AssertCreated 断言状态码为 201
|
||||
// AssertCreated 断言状态码为 201。
|
||||
func (r *Response) AssertCreated(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusCreated)
|
||||
}
|
||||
|
||||
// AssertBadRequest 断言状态码为 400
|
||||
// AssertBadRequest 断言状态码为 400。
|
||||
func (r *Response) AssertBadRequest(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// AssertUnauthorized 断言状态码为 401
|
||||
// AssertUnauthorized 断言状态码为 401。
|
||||
func (r *Response) AssertUnauthorized(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// AssertForbidden 断言状态码为 403
|
||||
// AssertForbidden 断言状态码为 403。
|
||||
func (r *Response) AssertForbidden(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusForbidden)
|
||||
}
|
||||
|
||||
// AssertNotFound 断言状态码为 404
|
||||
// AssertNotFound 断言状态码为 404。
|
||||
func (r *Response) AssertNotFound(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// AssertJSONContains 断言 JSON 包含指定字段
|
||||
// AssertJSONContains 断言 JSON 响应包含指定字段和值,支持 data.user.id 形式的嵌套键。
|
||||
func (r *Response) AssertJSONContains(t *testing.T, key string, expected any) {
|
||||
t.Helper()
|
||||
|
||||
@@ -177,10 +181,8 @@ func (r *Response) AssertJSONContains(t *testing.T, key string, expected any) {
|
||||
return
|
||||
}
|
||||
|
||||
// 支持嵌套键(如 "data.user.id")
|
||||
keys := splitKey(key)
|
||||
current := result
|
||||
|
||||
for i, k := range keys {
|
||||
if i == len(keys)-1 {
|
||||
if current[k] != expected {
|
||||
@@ -211,92 +213,107 @@ func splitKey(key string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// Mock 模拟工具
|
||||
|
||||
// MockDB 模拟数据库(用于简单测试)
|
||||
// MockDB 是并发安全的简单内存数据库 mock。
|
||||
type MockDB struct {
|
||||
mu sync.RWMutex
|
||||
data map[any]any
|
||||
}
|
||||
|
||||
// NewMockDB 创建模拟数据库
|
||||
// NewMockDB 创建模拟数据库。
|
||||
func NewMockDB() *MockDB {
|
||||
return &MockDB{
|
||||
data: make(map[any]any),
|
||||
}
|
||||
return &MockDB{data: make(map[any]any)}
|
||||
}
|
||||
|
||||
// Set 设置数据
|
||||
// Set 设置数据。
|
||||
func (m *MockDB) Set(key, value any) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data[key] = value
|
||||
}
|
||||
|
||||
// Get 获取数据
|
||||
// Get 获取数据。
|
||||
func (m *MockDB) Get(key any) (any, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
v, ok := m.data[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Delete 删除数据
|
||||
// Delete 删除数据。
|
||||
func (m *MockDB) Delete(key any) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.data, key)
|
||||
}
|
||||
|
||||
// Clear 清空数据
|
||||
// Clear 清空数据。
|
||||
func (m *MockDB) Clear() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data = make(map[any]any)
|
||||
}
|
||||
|
||||
// MockCache 模拟缓存
|
||||
// MockCache 是并发安全的简单内存缓存 mock。
|
||||
type MockCache struct {
|
||||
mu sync.RWMutex
|
||||
data map[string][]byte
|
||||
}
|
||||
|
||||
// NewMockCache 创建模拟缓存
|
||||
// NewMockCache 创建模拟缓存。
|
||||
func NewMockCache() *MockCache {
|
||||
return &MockCache{
|
||||
data: make(map[string][]byte),
|
||||
}
|
||||
return &MockCache{data: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
// Set 设置缓存
|
||||
// Set 设置缓存,内部会复制 value,避免调用方后续修改污染 mock。
|
||||
func (m *MockCache) Set(key string, value []byte) {
|
||||
m.data[key] = value
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data[key] = append([]byte(nil), value...)
|
||||
}
|
||||
|
||||
// Get 获取缓存
|
||||
// Get 获取缓存,返回值会复制一份,避免调用方修改内部状态。
|
||||
func (m *MockCache) Get(key string) ([]byte, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
v, ok := m.data[key]
|
||||
return v, ok
|
||||
return append([]byte(nil), v...), ok
|
||||
}
|
||||
|
||||
// Delete 删除缓存
|
||||
// Delete 删除缓存。
|
||||
func (m *MockCache) Delete(key string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.data, key)
|
||||
}
|
||||
|
||||
// Exists 检查缓存是否存在
|
||||
// Exists 检查缓存是否存在。
|
||||
func (m *MockCache) Exists(key string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
_, ok := m.data[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Clear 清空缓存
|
||||
// Clear 清空缓存。
|
||||
func (m *MockCache) Clear() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data = make(map[string][]byte)
|
||||
}
|
||||
|
||||
// MockStorage 模拟存储。
|
||||
//
|
||||
// 注意(N3):本 mock 与真实 storage 的签名已对齐——
|
||||
// - Upload(file *multipart.FileHeader, subdir string) 对应 storage.Upload
|
||||
// - UploadFromBytes(data []byte, filename, subdir string) 对应 storage.UploadFromBytes
|
||||
// 真实 storage 无导出接口,此 mock 仅供测试手写注入,不强制实现接口。
|
||||
// ErrMockStorageTooLarge 表示 mock storage 收到超过内存保护上限的内容。
|
||||
var ErrMockStorageTooLarge = errors.New("mock storage: 文件超过大小限制")
|
||||
|
||||
const mockStorageMaxBytes int64 = 32 << 20
|
||||
|
||||
// MockStorage 是并发安全的简单内存存储 mock。
|
||||
type MockStorage struct {
|
||||
mu sync.RWMutex
|
||||
files map[string][]byte
|
||||
urls map[string]string
|
||||
}
|
||||
|
||||
// NewMockStorage 创建模拟存储
|
||||
// NewMockStorage 创建模拟存储。
|
||||
func NewMockStorage() *MockStorage {
|
||||
return &MockStorage{
|
||||
files: make(map[string][]byte),
|
||||
@@ -304,35 +321,55 @@ func NewMockStorage() *MockStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 模拟上传(签名对齐 storage.Upload)。
|
||||
// Upload 模拟上传,签名对齐 storage.Upload。
|
||||
func (m *MockStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
|
||||
if file == nil {
|
||||
return "", errors.New("mock storage: 文件不能为空")
|
||||
}
|
||||
if file.Size > mockStorageMaxBytes {
|
||||
return "", ErrMockStorageTooLarge
|
||||
}
|
||||
|
||||
data, err := file.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer data.Close()
|
||||
b, err := io.ReadAll(data)
|
||||
b, err := io.ReadAll(io.LimitReader(data, mockStorageMaxBytes+1))
|
||||
if cerr := data.Close(); cerr != nil {
|
||||
err = errors.Join(err, cerr)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int64(len(b)) > mockStorageMaxBytes {
|
||||
return "", ErrMockStorageTooLarge
|
||||
}
|
||||
|
||||
path := "/mock/" + subdir + "/" + file.Filename
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.files[path] = b
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// UploadFromBytes 模拟从字节上传(签名对齐 storage.UploadFromBytes)。
|
||||
// UploadFromBytes 模拟字节上传,签名对齐 storage.UploadFromBytes。
|
||||
func (m *MockStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) {
|
||||
if int64(len(data)) > mockStorageMaxBytes {
|
||||
return "", ErrMockStorageTooLarge
|
||||
}
|
||||
path := "/mock/" + subdir + "/" + filename
|
||||
m.files[path] = data
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.files[path] = append([]byte(nil), data...)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// GetURL 获取 URL
|
||||
// GetURL 获取 mock URL。
|
||||
func (m *MockStorage) GetURL(path string) string {
|
||||
return "http://mock.test" + path
|
||||
}
|
||||
|
||||
// AssertEqual 通用相等断言
|
||||
// AssertEqual 断言相等。
|
||||
func AssertEqual(t *testing.T, expected, actual any) {
|
||||
t.Helper()
|
||||
if expected != actual {
|
||||
@@ -340,7 +377,7 @@ func AssertEqual(t *testing.T, expected, actual any) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNotNil 断言不为空
|
||||
// AssertNotNil 断言非空。
|
||||
func AssertNotNil(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
if value == nil {
|
||||
@@ -348,7 +385,7 @@ func AssertNotNil(t *testing.T, value any) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNil 断言为空
|
||||
// AssertNil 断言为空。
|
||||
func AssertNil(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
if value != nil {
|
||||
@@ -356,23 +393,23 @@ func AssertNil(t *testing.T, value any) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssertTrue 断言为真
|
||||
// AssertTrue 断言为 true。
|
||||
func AssertTrue(t *testing.T, value bool) {
|
||||
t.Helper()
|
||||
if !value {
|
||||
t.Error("期望为真,实际为假")
|
||||
t.Error("期望为 true,实际为 false")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertFalse 断言为假
|
||||
// AssertFalse 断言为 false。
|
||||
func AssertFalse(t *testing.T, value bool) {
|
||||
t.Helper()
|
||||
if value {
|
||||
t.Error("期望为假,实际为真")
|
||||
t.Error("期望为 false,实际为 true")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertError 断言有错误
|
||||
// AssertError 断言有错误。
|
||||
func AssertError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
@@ -380,7 +417,7 @@ func AssertError(t *testing.T, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNoError 断言无错误
|
||||
// AssertNoError 断言无错误。
|
||||
func AssertNoError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRequestExecuteReturnsResponse(t *testing.T) {
|
||||
router := SetupRouter()
|
||||
router.POST("/users", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"name": "张三"}})
|
||||
})
|
||||
|
||||
resp := POST(router, "/users").WithJSON(gin.H{"name": "张三"}).Execute()
|
||||
resp.AssertOK(t)
|
||||
resp.AssertJSONContains(t, "data.name", "张三")
|
||||
}
|
||||
|
||||
func TestRequestExecuteRecorderKeepsRawRecorder(t *testing.T) {
|
||||
router := SetupRouter()
|
||||
router.GET("/ping", func(c *gin.Context) {
|
||||
c.String(http.StatusCreated, "pong")
|
||||
})
|
||||
|
||||
recorder := GET(router, "/ping").ExecuteRecorder()
|
||||
if _, ok := any(recorder).(*httptest.ResponseRecorder); !ok {
|
||||
t.Fatalf("ExecuteRecorder 应返回原始 ResponseRecorder")
|
||||
}
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("状态码错误: 期望 %d, 实际 %d", http.StatusCreated, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockCacheCopiesBytes(t *testing.T) {
|
||||
cache := NewMockCache()
|
||||
input := []byte("abc")
|
||||
cache.Set("k", input)
|
||||
input[0] = 'x'
|
||||
|
||||
got, ok := cache.Get("k")
|
||||
if !ok {
|
||||
t.Fatalf("缓存不存在")
|
||||
}
|
||||
if string(got) != "abc" {
|
||||
t.Fatalf("缓存被外部输入污染: %q", got)
|
||||
}
|
||||
|
||||
got[0] = 'y'
|
||||
gotAgain, _ := cache.Get("k")
|
||||
if string(gotAgain) != "abc" {
|
||||
t.Fatalf("缓存被返回值污染: %q", gotAgain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMocksConcurrentAccess(t *testing.T) {
|
||||
db := NewMockDB()
|
||||
cache := NewMockCache()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 64; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
db.Set(i, i)
|
||||
_, _ = db.Get(i)
|
||||
cache.Set("k", []byte{byte(i)})
|
||||
_, _ = cache.Get("k")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestMockStorageRejectsNilAndLargeInput(t *testing.T) {
|
||||
storage := NewMockStorage()
|
||||
if _, err := storage.Upload(nil, "avatars"); err == nil {
|
||||
t.Fatalf("nil 文件应返回错误")
|
||||
}
|
||||
|
||||
large := bytes.Repeat([]byte{'x'}, int(mockStorageMaxBytes)+1)
|
||||
if _, err := storage.UploadFromBytes(large, "large.bin", "files"); !errors.Is(err, ErrMockStorageTooLarge) {
|
||||
t.Fatalf("超大文件错误不符合预期: %v", err)
|
||||
}
|
||||
}
|
||||
+67
-16
@@ -3,9 +3,11 @@ package trace
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/contrib/propagators/b3"
|
||||
@@ -66,6 +68,10 @@ var tracerProviderPtr atomic.Pointer[sdktrace.TracerProvider]
|
||||
// tracerPtr 全局 Tracer(atomic,C13a)。Init 之前为 Noop,调用安全。
|
||||
var tracerPtr atomic.Pointer[trace.Tracer]
|
||||
|
||||
const defaultOperationTimeout = 5 * time.Second
|
||||
|
||||
var operationTimeoutNanos atomic.Int64
|
||||
|
||||
func init() {
|
||||
// 初始化为 Noop,保证包级函数在任何时刻(未 Init / 已 Close)Load 均非 nil(C13a)。
|
||||
// P1 #19:用 noop.NewTracerProvider()(新 OTel API),替代已弃用的 trace.NewNoopTracerProvider()。
|
||||
@@ -74,6 +80,7 @@ func init() {
|
||||
tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample()))
|
||||
tracerProviderPtr.Store(tp)
|
||||
tracerPtr.Store(&noopTracer)
|
||||
operationTimeoutNanos.Store(int64(defaultOperationTimeout))
|
||||
}
|
||||
|
||||
// getTracer 返回全局 Tracer 的 atomic 快照(永不 nil)。
|
||||
@@ -88,6 +95,10 @@ func TracerProvider() *sdktrace.TracerProvider {
|
||||
|
||||
// Init 初始化链路追踪
|
||||
func Init(cfg Config) error {
|
||||
if math.IsNaN(cfg.SampleRatio) || cfg.SampleRatio < 0 || cfg.SampleRatio > 1 {
|
||||
return fmt.Errorf("trace SampleRatio must be between 0.0 and 1.0: %v", cfg.SampleRatio)
|
||||
}
|
||||
|
||||
if !cfg.Enabled {
|
||||
// 设置 Noop Tracer(P1 #19:noop 包替代弃用 API)
|
||||
noopProvider := noop.NewTracerProvider()
|
||||
@@ -98,7 +109,7 @@ func Init(cfg Config) error {
|
||||
// 避免禁用 trace 后旧 exporter 后台 goroutine/连接持续占用。
|
||||
fallback := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample()))
|
||||
if old := tracerProviderPtr.Swap(fallback); old != nil {
|
||||
_ = old.Shutdown(context.Background())
|
||||
_ = shutdownProviderWithTimeout(context.Background(), old)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -139,7 +150,7 @@ func Init(cfg Config) error {
|
||||
if err != nil {
|
||||
// M-65 修复:传播器非法时回滚已创建的 provider。此时尚未 otel.SetTracerProvider,
|
||||
// otel 全局仍指向旧 provider,无需恢复——仅 Shutdown 新 provider 避免泄漏。
|
||||
_ = newProvider.Shutdown(context.Background())
|
||||
_ = shutdownProviderWithTimeout(context.Background(), newProvider)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -152,12 +163,36 @@ func Init(cfg Config) error {
|
||||
tracer := newProvider.Tracer(cfg.ServiceName)
|
||||
tracerPtr.Store(&tracer)
|
||||
if oldProvider != nil {
|
||||
_ = oldProvider.Shutdown(context.Background())
|
||||
_ = shutdownProviderWithTimeout(context.Background(), oldProvider)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func operationTimeout() time.Duration {
|
||||
timeout := time.Duration(operationTimeoutNanos.Load())
|
||||
if timeout <= 0 {
|
||||
return defaultOperationTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func contextWithOperationTimeout(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
return context.WithTimeout(parent, operationTimeout())
|
||||
}
|
||||
|
||||
func shutdownProviderWithTimeout(parent context.Context, provider *sdktrace.TracerProvider) error {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := contextWithOperationTimeout(parent)
|
||||
defer cancel()
|
||||
return provider.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// createExporter 创建导出器
|
||||
func createExporter(cfg Config) (sdktrace.SpanExporter, error) {
|
||||
switch cfg.ExporterType {
|
||||
@@ -167,14 +202,18 @@ func createExporter(cfg Config) (sdktrace.SpanExporter, error) {
|
||||
opts = append(opts, otlptracehttp.WithInsecure()) // C13c
|
||||
}
|
||||
client := otlptracehttp.NewClient(opts...)
|
||||
return otlptrace.New(context.Background(), client)
|
||||
ctx, cancel := contextWithOperationTimeout(context.Background())
|
||||
defer cancel()
|
||||
return otlptrace.New(ctx, client)
|
||||
case "otlp-grpc":
|
||||
opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.Endpoint)}
|
||||
if cfg.Insecure {
|
||||
opts = append(opts, otlptracegrpc.WithInsecure()) // C13c
|
||||
}
|
||||
client := otlptracegrpc.NewClient(opts...)
|
||||
return otlptrace.New(context.Background(), client)
|
||||
ctx, cancel := contextWithOperationTimeout(context.Background())
|
||||
defer cancel()
|
||||
return otlptrace.New(ctx, client)
|
||||
case "stdout":
|
||||
return stdouttrace.New(
|
||||
stdouttrace.WithWriter(os.Stdout),
|
||||
@@ -219,7 +258,7 @@ func Close(ctx context.Context) error {
|
||||
noopTracer := noop.NewTracerProvider().Tracer("xlgo") // P1 #19:noop 包替代弃用 API
|
||||
tracerPtr.Store(&noopTracer)
|
||||
if old != nil {
|
||||
return old.Shutdown(ctx)
|
||||
return shutdownProviderWithTimeout(ctx, old)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -242,15 +281,20 @@ func Middleware(serviceName string) gin.HandlerFunc {
|
||||
spanName = c.Request.Method + " " + route
|
||||
}
|
||||
|
||||
attrs := []attribute.KeyValue{
|
||||
semconv.HTTPRequestMethodKey.String(c.Request.Method),
|
||||
semconv.URLPathKey.String(c.Request.URL.Path),
|
||||
semconv.HTTPRouteKey.String(route),
|
||||
attribute.String("http.user_agent", c.Request.UserAgent()),
|
||||
attribute.String("http.host", c.Request.Host),
|
||||
}
|
||||
if serviceName != "" {
|
||||
attrs = append(attrs, attribute.String("service.name", serviceName))
|
||||
}
|
||||
|
||||
ctx, span := getTracer().Start(ctx, spanName,
|
||||
trace.WithSpanKind(trace.SpanKindServer),
|
||||
trace.WithAttributes(
|
||||
semconv.HTTPRequestMethodKey.String(c.Request.Method),
|
||||
semconv.URLPathKey.String(c.Request.URL.Path),
|
||||
semconv.HTTPRouteKey.String(route),
|
||||
attribute.String("http.user_agent", c.Request.UserAgent()),
|
||||
attribute.String("http.host", c.Request.Host),
|
||||
),
|
||||
trace.WithAttributes(attrs...),
|
||||
)
|
||||
// P1 #19:defer 保证下游 handler panic(在上游 recovery 捕获前)时 span 也被结束,
|
||||
// 不泄漏未结束的 span。
|
||||
@@ -262,8 +306,9 @@ func Middleware(serviceName string) gin.HandlerFunc {
|
||||
c.Set("otel_ctx", ctx)
|
||||
|
||||
// 将 TraceID 添加到响应头
|
||||
traceID := span.SpanContext().TraceID().String()
|
||||
c.Header("X-Trace-ID", traceID)
|
||||
if span.SpanContext().HasTraceID() {
|
||||
c.Header("X-Trace-ID", span.SpanContext().TraceID().String())
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
c.Next()
|
||||
@@ -321,6 +366,9 @@ func StartSpanFromContext(ctx context.Context, name string, attrs ...attribute.K
|
||||
|
||||
// RecordError 记录错误
|
||||
func RecordError(c *gin.Context, err error) {
|
||||
if c == nil || err == nil {
|
||||
return
|
||||
}
|
||||
ctx := GetContext(c)
|
||||
span := trace.SpanFromContext(ctx)
|
||||
span.RecordError(err)
|
||||
@@ -329,6 +377,9 @@ func RecordError(c *gin.Context, err error) {
|
||||
|
||||
// RecordErrorToSpan 记录错误到指定 Span
|
||||
func RecordErrorToSpan(span trace.Span, err error) {
|
||||
if span == nil || err == nil {
|
||||
return
|
||||
}
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
}
|
||||
@@ -364,4 +415,4 @@ func SetAttribute(c *gin.Context, key string, value any) {
|
||||
default:
|
||||
span.SetAttributes(attribute.String(key, fmt.Sprintf("%v", v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+203
-5
@@ -2,10 +2,14 @@ package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/otel"
|
||||
@@ -39,6 +43,60 @@ func resetGlobal(t *testing.T) {
|
||||
propagation.TraceContext{}, propagation.Baggage{}))
|
||||
}
|
||||
|
||||
func setOperationTimeoutForTest(t *testing.T, timeout time.Duration) {
|
||||
t.Helper()
|
||||
old := operationTimeoutNanos.Load()
|
||||
operationTimeoutNanos.Store(int64(timeout))
|
||||
t.Cleanup(func() {
|
||||
operationTimeoutNanos.Store(old)
|
||||
})
|
||||
}
|
||||
|
||||
type blockingExporter struct{}
|
||||
|
||||
func (blockingExporter) ExportSpans(context.Context, []sdktrace.ReadOnlySpan) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (blockingExporter) Shutdown(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
type recordingExporter struct {
|
||||
mu sync.Mutex
|
||||
spans []sdktrace.ReadOnlySpan
|
||||
}
|
||||
|
||||
func (e *recordingExporter) ExportSpans(_ context.Context, spans []sdktrace.ReadOnlySpan) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.spans = append(e.spans, spans...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *recordingExporter) Shutdown(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *recordingExporter) Spans() []sdktrace.ReadOnlySpan {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
spans := make([]sdktrace.ReadOnlySpan, len(e.spans))
|
||||
copy(spans, e.spans)
|
||||
return spans
|
||||
}
|
||||
|
||||
func requireNoPanic(t *testing.T, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("unexpected panic: %v", r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
// TestC13aInitNoopInvariant 锁定 init() 的 Noop 兜底不变式:
|
||||
// 包加载后(任何 Init/Close/resetGlobal 之前)getTracer() 必须非 nil。
|
||||
//
|
||||
@@ -204,11 +262,11 @@ func TestC13dRequestContextContainsSpan(t *testing.T) {
|
||||
|
||||
// 用 stdout 导出器 + 全采样,使 span 真实生成(非 Noop)。
|
||||
if err := Init(Config{
|
||||
Enabled: true,
|
||||
ServiceName: "test-svc",
|
||||
ExporterType: "stdout",
|
||||
SampleRatio: 1.0,
|
||||
Propagator: "w3c",
|
||||
Enabled: true,
|
||||
ServiceName: "test-svc",
|
||||
ExporterType: "stdout",
|
||||
SampleRatio: 1.0,
|
||||
Propagator: "w3c",
|
||||
}); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
@@ -432,6 +490,146 @@ func TestH14CloseIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// M14 trace residue: validation, timeout, nil guards, headers, service name
|
||||
// ============================================================
|
||||
func TestM14SampleRatioRange(t *testing.T) {
|
||||
t.Cleanup(func() { resetGlobal(t) })
|
||||
|
||||
for _, ratio := range []float64{-0.01, 1.01, math.NaN()} {
|
||||
err := Init(Config{
|
||||
Enabled: true,
|
||||
ExporterType: "stdout",
|
||||
Propagator: "w3c",
|
||||
SampleRatio: ratio,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("Init with SampleRatio=%v should fail", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ratio := range []float64{0, 1} {
|
||||
if err := Init(Config{
|
||||
Enabled: true,
|
||||
ServiceName: "svc",
|
||||
ExporterType: "stdout",
|
||||
Propagator: "w3c",
|
||||
SampleRatio: ratio,
|
||||
}); err != nil {
|
||||
t.Fatalf("Init with boundary SampleRatio=%v failed: %v", ratio, err)
|
||||
}
|
||||
if err := Close(context.Background()); err != nil {
|
||||
t.Fatalf("Close after SampleRatio=%v: %v", ratio, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestM14CloseUsesOperationTimeout(t *testing.T) {
|
||||
resetGlobal(t)
|
||||
t.Cleanup(func() { resetGlobal(t) })
|
||||
setOperationTimeoutForTest(t, 20*time.Millisecond)
|
||||
|
||||
blockingProvider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(blockingExporter{}))
|
||||
tracerProviderPtr.Store(blockingProvider)
|
||||
|
||||
start := time.Now()
|
||||
err := Close(context.Background())
|
||||
elapsed := time.Since(start)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("Close error = %v, want context deadline exceeded", err)
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("Close took %s, expected operation timeout to bound shutdown", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM14InitDisabledShutdownUsesOperationTimeout(t *testing.T) {
|
||||
resetGlobal(t)
|
||||
t.Cleanup(func() { resetGlobal(t) })
|
||||
setOperationTimeoutForTest(t, 20*time.Millisecond)
|
||||
|
||||
blockingProvider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(blockingExporter{}))
|
||||
tracerProviderPtr.Store(blockingProvider)
|
||||
|
||||
start := time.Now()
|
||||
if err := Init(Config{Enabled: false, ServiceName: "svc"}); err != nil {
|
||||
t.Fatalf("Init disabled: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("Init disabled took %s, expected operation timeout to bound old provider shutdown", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM14RecordErrorNilInputsNoPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
requireNoPanic(t, func() { RecordError(nil, errors.New("boom")) })
|
||||
requireNoPanic(t, func() { RecordError(c, nil) })
|
||||
|
||||
var nilSpan oteltrace.Span
|
||||
requireNoPanic(t, func() { RecordErrorToSpan(nilSpan, errors.New("boom")) })
|
||||
requireNoPanic(t, func() {
|
||||
RecordErrorToSpan(oteltrace.SpanFromContext(context.Background()), nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestM14NoZeroTraceIDHeaderFromNoop(t *testing.T) {
|
||||
resetGlobal(t)
|
||||
t.Cleanup(func() { resetGlobal(t) })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Middleware("svc"))
|
||||
r.GET("/p", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/p", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
const zeroTraceID = "00000000000000000000000000000000"
|
||||
if got := w.Header().Get("X-Trace-ID"); got == zeroTraceID {
|
||||
t.Fatalf("X-Trace-ID should not expose all-zero TraceID, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM14MiddlewareServiceNameAttribute(t *testing.T) {
|
||||
resetGlobal(t)
|
||||
t.Cleanup(func() { resetGlobal(t) })
|
||||
|
||||
exporter := &recordingExporter{}
|
||||
provider := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSyncer(exporter),
|
||||
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||
)
|
||||
tracer := provider.Tracer("test")
|
||||
tracerProviderPtr.Store(provider)
|
||||
tracerPtr.Store(&tracer)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Middleware("middleware-svc"))
|
||||
r.GET("/p", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/p", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
for _, span := range exporter.Spans() {
|
||||
for _, attr := range span.Attributes() {
|
||||
if string(attr.Key) == "service.name" && attr.Value.AsString() == "middleware-svc" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatal("server span missing service.name attribute from Middleware(serviceName)")
|
||||
}
|
||||
|
||||
// TestM65PropagatorFailureLeavesOtelUsable 验证传播器失败时 otel 全局未被切到
|
||||
// 已 Shutdown 的 provider(M-65)。失败后仍可正常 start span 不 panic。
|
||||
func TestM65PropagatorFailureLeavesOtelUsable(t *testing.T) {
|
||||
|
||||
+14
-2
@@ -10,6 +10,12 @@ func ToInt(s string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// ToIntE converts a string to int and returns parse errors. Prefer it when 0
|
||||
// is a meaningful value and parse failure must be distinguishable.
|
||||
func ToIntE(s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
|
||||
// ToIntDefault 字符串转 int,失败返回默认值
|
||||
func ToIntDefault(s string, def int) int {
|
||||
n, err := strconv.Atoi(s)
|
||||
@@ -25,6 +31,12 @@ func ToInt64(s string) int64 {
|
||||
return n
|
||||
}
|
||||
|
||||
// ToInt64E converts a string to int64 and returns parse errors. Prefer it when
|
||||
// 0 is a meaningful value and parse failure must be distinguishable.
|
||||
func ToInt64E(s string) (int64, error) {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
|
||||
// ToInt64Default 字符串转 int64,失败返回默认值
|
||||
func ToInt64Default(s string, def int64) int64 {
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
@@ -74,7 +86,7 @@ func ToString64(n int64) string {
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
|
||||
// CalcPageCount 计算总页数
|
||||
// CalcPageCount 计算总页数(向上取整)。total<=0 或 pageSize<=0 时返回 0。
|
||||
func CalcPageCount(total, pageSize int64) int64 {
|
||||
if total <= 0 || pageSize <= 0 {
|
||||
return 0
|
||||
@@ -82,7 +94,7 @@ func CalcPageCount(total, pageSize int64) int64 {
|
||||
return (total + pageSize - 1) / pageSize
|
||||
}
|
||||
|
||||
// CalcOffset 计算分页偏移量
|
||||
// CalcOffset 计算分页偏移量 (page-1)*pageSize。page<=0 时按 1 处理,pageSize<=0 时按 20 处理。
|
||||
func CalcOffset(page, pageSize int) int {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
|
||||
+10
-6
@@ -2,8 +2,8 @@ package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/md5" // #nosec G501 -- exported checksum helper for non-password/non-security use; callers needing security should use SHA256/HMAC.
|
||||
"crypto/sha1" // #nosec G505 -- exported checksum helper for legacy/non-security use; callers needing security should use SHA256/HMAC.
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
@@ -20,6 +20,7 @@ func MD5(s string) string {
|
||||
|
||||
// MD5Bytes 计算字节数组的 MD5 哈希值
|
||||
func MD5Bytes(data []byte) string {
|
||||
// #nosec G401 -- MD5 helper is retained for legacy checksums, not password storage or security decisions.
|
||||
h := md5.New()
|
||||
h.Write(data)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
@@ -28,6 +29,7 @@ func MD5Bytes(data []byte) string {
|
||||
// SHA1 计算字符串的 SHA1 哈希值
|
||||
// 注意: 不应用于密码存储
|
||||
func SHA1(s string) string {
|
||||
// #nosec G401 -- SHA1 helper is retained for legacy checksums, not password storage or security decisions.
|
||||
h := sha1.New()
|
||||
h.Write([]byte(s))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
@@ -47,12 +49,14 @@ func SHA256Bytes(data []byte) string {
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// HashFile 计算文件的哈希值。
|
||||
// HashFile 流式计算文件的哈希值,返回 hex 编码字符串。
|
||||
//
|
||||
// M-F 修复:改为流式 io.Copy(h, f),不再经 ReadFile 把整文件读入内存——原实现大文件 OOM。
|
||||
// 流式哈希内存占用恒定、可处理任意大小文件,与 hash.Hash 的 io.Writer 接口契合。
|
||||
// 调用方负责选择哈希算法(如 sha256.New)。
|
||||
// newHash 须返回全新的 hash.Hash 实例(如 sha256.New),本函数不复用它。
|
||||
// Open 或读取失败时返回错误。M-F 修复:改为流式 io.Copy(h, f),不再经 ReadFile 把整文件
|
||||
// 读入内存——原实现大文件 OOM。流式哈希内存占用恒定、可处理任意大小文件,与 hash.Hash
|
||||
// 的 io.Writer 接口契合。调用方负责选择哈希算法(如 sha256.New)。
|
||||
func HashFile(path string, newHash func() hash.Hash) (string, error) {
|
||||
// #nosec G304 -- generic hashing helper intentionally opens caller-provided trusted local paths.
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
+2
-2
@@ -73,12 +73,12 @@ func StartOfWeek(t time.Time) time.Time {
|
||||
return StartOfDay(monday)
|
||||
}
|
||||
|
||||
// StartOfMonth 返回指定时间当月的开始时间
|
||||
// StartOfMonth 返回指定时间当月 1 日 00:00:00(保留原时区)。
|
||||
func StartOfMonth(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// EndOfMonth 返回指定时间当月的结束时间
|
||||
// EndOfMonth 返回指定时间当月最后一天 23:59:59.999999999(保留原时区)。
|
||||
func EndOfMonth(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month()+1, 0, 23, 59, 59, 999999999, t.Location())
|
||||
}
|
||||
|
||||
+17
-4
@@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -30,6 +31,7 @@ func DirExists(path string) bool {
|
||||
|
||||
// EnsureDir 确保目录存在,不存在则创建
|
||||
func EnsureDir(path string) error {
|
||||
// #nosec G301 -- generic filesystem helper intentionally uses conventional directory permissions; caller controls trusted path and policy.
|
||||
return os.MkdirAll(path, 0755)
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ func EnsureDir(path string) error {
|
||||
// 注意:本函数无大小上限(os.ReadFile 语义),读取不可信/超大文件有 OOM 风险——
|
||||
// 需限长读取请用 io.ReadAll(io.LimitReader(...));流式哈希大文件请用 HashFile(已流式)。
|
||||
func ReadFile(path string) ([]byte, error) {
|
||||
// #nosec G304 -- generic filesystem helper intentionally reads caller-provided trusted paths; package docs require callers to sanitize untrusted input.
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
@@ -51,6 +54,7 @@ func WriteFile(path string, data []byte) error {
|
||||
if err := EnsureDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
// #nosec G306 -- generic filesystem helper intentionally uses conventional file permissions; caller controls trusted path and policy.
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
@@ -60,22 +64,28 @@ func AppendFile(path string, data []byte) error {
|
||||
if err := EnsureDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
// #nosec G302,G304 -- generic filesystem helper intentionally appends caller-provided trusted paths with conventional file permissions.
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(data)
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
return errors.Join(err, cerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// CopyFile 复制文件
|
||||
// CopyFile 将 src 复制到 dst(覆盖),自动创建目标目录。src 不存在时返回其 Open 错误。
|
||||
func CopyFile(dst, src string) error {
|
||||
// #nosec G304 -- generic filesystem helper intentionally opens caller-provided trusted source paths.
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
defer func() {
|
||||
_ = srcFile.Close()
|
||||
}()
|
||||
|
||||
// 确保目标目录存在
|
||||
dir := filepath.Dir(dst)
|
||||
@@ -83,13 +93,16 @@ func CopyFile(dst, src string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// #nosec G304 -- generic filesystem helper intentionally creates caller-provided trusted destination paths.
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
if cerr := dstFile.Close(); cerr != nil {
|
||||
return errors.Join(err, cerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
+130
-126
@@ -19,54 +19,42 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPClient HTTP 客户端封装
|
||||
// HTTPClient 封装可运行期调整配置的 HTTP 客户端。
|
||||
type HTTPClient struct {
|
||||
// mu 保护 client/transport/cfg/timeout/skipTLS 的并发读写。SetSkipTLS/SetTimeout
|
||||
// 在写锁下重建 client/transport;do/DoWithResponse/Close 在读锁下快照后无锁调用
|
||||
// http.Client.Do(Do 自身并发安全),不持有锁发请求以避免序列化。
|
||||
mu sync.RWMutex
|
||||
client *http.Client
|
||||
transport *http.Transport
|
||||
cfg HTTPClientConfig // 保留配置以便 SetSkipTLS 重建 transport
|
||||
cfg HTTPClientConfig
|
||||
timeout time.Duration
|
||||
headers map[string]string
|
||||
cookies map[string]string
|
||||
skipTLS bool
|
||||
// maxRespBodySize 响应体读取上限(字节),0 表示用默认 32MB,-1 表示不限。
|
||||
// 防止恶意/异常服务端返回超大响应打爆内存(C5/N5)。
|
||||
// maxRespBodySize 限制 do 读取的响应体大小;0 使用默认值,-1 表示不限制。
|
||||
maxRespBodySize int64
|
||||
}
|
||||
|
||||
// UploadFile 上传文件信息
|
||||
// UploadFile 描述 multipart 上传中的文件字段。
|
||||
type UploadFile struct {
|
||||
FieldName string // 表单字段名
|
||||
FilePath string // 文件路径
|
||||
FieldName string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// HTTPClientConfig HTTP 客户端配置
|
||||
// HTTPClientConfig 是 HTTPClient 的配置。
|
||||
type HTTPClientConfig struct {
|
||||
Timeout time.Duration // 请求超时时间
|
||||
MaxIdleConns int // 最大空闲连接数
|
||||
IdleConnTimeout time.Duration // 空闲连接超时时间
|
||||
MaxConnsPerHost int // 每个主机最大连接数
|
||||
MaxIdleConnsPerHost int // 每个主机最大空闲连接数
|
||||
SkipTLSVerify bool // 是否跳过 TLS 验证(默认 false 校验 TLS;自签证书场景需显式设 true)
|
||||
// MaxResponseBodySize 响应体读取上限(字节)。0 = 默认 32MB,-1 = 不限制。
|
||||
// 防止异常服务端返回超大响应打爆内存(C5/N5)。
|
||||
MaxResponseBodySize int64
|
||||
// BlockPrivateNetworks 启用 SSRF 防护(P0,默认 false 保持兼容)。启用后,连接建立时
|
||||
// 校验解析出的目标 IP,拒绝回环/私有(RFC1918+ULA)/链路本地/元数据(169.254.169.254)/
|
||||
// 未指定/多播等内网地址。校验在 DialContext.Control 中进行,对重定向的每一跳同样生效。
|
||||
// 当 URL 可能来自用户输入(webhook、头像抓取等)时应开启。
|
||||
Timeout time.Duration
|
||||
MaxIdleConns int
|
||||
IdleConnTimeout time.Duration
|
||||
MaxConnsPerHost int
|
||||
MaxIdleConnsPerHost int
|
||||
SkipTLSVerify bool
|
||||
MaxResponseBodySize int64
|
||||
BlockPrivateNetworks bool
|
||||
}
|
||||
|
||||
// ErrSSRFBlocked 目标 IP 落在被拦截网段(SSRF 防护,P0)。
|
||||
var ErrSSRFBlocked = errors.New("ssrf guard: 目标 IP 属被拦截网段(回环/私有/链路本地/元数据)")
|
||||
// ErrSSRFBlocked 表示 SSRF 防护拦截了目标 IP。
|
||||
var ErrSSRFBlocked = errors.New("ssrf guard: 目标 IP 属于被拦截的网络范围")
|
||||
|
||||
// isBlockedIP 判断 IP 是否应被 SSRF 防护拦截。
|
||||
// 覆盖:回环、私有(RFC1918 + ULA fc00::/7)、链路本地(含 169.254.169.254 云元数据 / fe80::)、
|
||||
// 未指定(0.0.0.0/::)、多播。
|
||||
// isBlockedIP 判断 ip 是否属于应被 SSRF 防护拦截的网络范围。
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
@@ -76,8 +64,7 @@ func isBlockedIP(ip net.IP) bool {
|
||||
ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast()
|
||||
}
|
||||
|
||||
// ssrfControl 是 net.Dialer.Control 回调:在 DNS 解析后、真正 dial 前校验目标 IP,
|
||||
// 命中内网段即拒绝。放在 Control 层可覆盖 DNS 重绑定与重定向的每一跳(P0)。
|
||||
// ssrfControl 在拨号前拦截私有、回环、链路本地、未指定和多播 IP。
|
||||
func ssrfControl(network, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
@@ -93,32 +80,31 @@ func ssrfControl(network, address string, _ syscall.RawConn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultHTTPClientConfig 默认配置
|
||||
// DefaultHTTPClientConfig 是包级默认 HTTP 客户端配置。
|
||||
var DefaultHTTPClientConfig = HTTPClientConfig{
|
||||
Timeout: 30 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
MaxConnsPerHost: 10,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
SkipTLSVerify: false, // H2 修复:默认校验 TLS,防 MITM;自签证书需显式 SetSkipTLS(true)
|
||||
SkipTLSVerify: false,
|
||||
MaxResponseBodySize: 32 * 1024 * 1024,
|
||||
}
|
||||
|
||||
// NewHTTPClient 创建 HTTP 客户端
|
||||
// NewHTTPClient 使用默认配置创建 HTTP 客户端。
|
||||
func NewHTTPClient() *HTTPClient {
|
||||
cfg := DefaultHTTPClientConfig
|
||||
return NewHTTPClientWithConfig(cfg)
|
||||
}
|
||||
|
||||
// NewSSRFSafeHTTPClient 创建启用 SSRF 防护的 HTTP 客户端(P0):拒绝连接内网目标 IP。
|
||||
// 适用于目标 URL 可能来自用户输入的场景(webhook 回调、远程图片抓取等)。
|
||||
// NewSSRFSafeHTTPClient 创建启用 SSRF 防护的 HTTP 客户端。
|
||||
func NewSSRFSafeHTTPClient() *HTTPClient {
|
||||
cfg := DefaultHTTPClientConfig
|
||||
cfg.BlockPrivateNetworks = true
|
||||
return NewHTTPClientWithConfig(cfg)
|
||||
}
|
||||
|
||||
// NewHTTPClientWithConfig 使用自定义配置创建 HTTP 客户端
|
||||
// NewHTTPClientWithConfig 使用 cfg 创建 HTTP 客户端。
|
||||
func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
|
||||
transport, client := buildHTTPClientPair(cfg)
|
||||
return &HTTPClient{
|
||||
@@ -133,12 +119,10 @@ func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// buildHTTPClientPair 按 cfg 构造 (transport, client) 对。SetSkipTLS 重建时复用。
|
||||
// buildHTTPClientPair 根据 cfg 构造 transport/client 对。
|
||||
func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
|
||||
// Transport 在初始化时创建,连接池可复用
|
||||
transport := &http.Transport{
|
||||
// #nosec G402 -- InsecureSkipVerify 仅在调用方显式设 cfg.SkipTLSVerify=true 时启用,
|
||||
// 默认 false 校验 TLS(H2 修复)。自签证书场景需 opt-in。
|
||||
// #nosec G402 -- SkipTLSVerify 仅在调用方显式配置时启用,默认校验 TLS。
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.SkipTLSVerify,
|
||||
},
|
||||
@@ -148,7 +132,6 @@ func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
|
||||
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
|
||||
DisableCompression: false,
|
||||
}
|
||||
// SSRF 防护(P0):启用后为拨号器装 Control 回调,连接建立时拦截内网目标 IP。
|
||||
if cfg.BlockPrivateNetworks {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -164,36 +147,33 @@ func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
|
||||
return transport, client
|
||||
}
|
||||
|
||||
// currentClient 在读锁下快照当前 *http.Client(H-12:与 SetSkipTLS/SetTimeout 重建无竞态)。
|
||||
// currentClient 返回当前 client 快照。
|
||||
func (c *HTTPClient) currentClient() *http.Client {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.client
|
||||
}
|
||||
|
||||
// currentTransport 在读锁下快照当前 *http.Transport。
|
||||
// currentTransport 返回当前 transport 快照。
|
||||
func (c *HTTPClient) currentTransport() *http.Transport {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.transport
|
||||
}
|
||||
|
||||
// SetTimeout 设置超时时间。
|
||||
// H-12 修复:在写锁下重建 *http.Client(复用 transport 保留连接池),避免与并发 Do 对
|
||||
// client.Timeout 字段的数据竞争。
|
||||
// SetTimeout 更新请求超时时间,并保留当前 transport。
|
||||
func (c *HTTPClient) SetTimeout(timeout time.Duration) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.timeout = timeout
|
||||
c.cfg.Timeout = timeout
|
||||
_, client := buildHTTPClientPair(c.cfg)
|
||||
// 复用旧 transport 保留连接池:新 client 用旧 transport + 新 timeout。
|
||||
client.Transport = c.transport
|
||||
c.client = client
|
||||
return c
|
||||
}
|
||||
|
||||
// SetHeader 设置请求头(P0:写锁保护,与并发 do/DoWithResponse 读 map 无竞态)。
|
||||
// SetHeader 设置后续请求使用的请求头。
|
||||
func (c *HTTPClient) SetHeader(key, value string) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.headers[key] = value
|
||||
@@ -201,7 +181,7 @@ func (c *HTTPClient) SetHeader(key, value string) *HTTPClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// SetHeaders 批量设置请求头(P0:写锁保护)。
|
||||
// SetHeaders 批量设置后续请求使用的请求头。
|
||||
func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
for k, v := range headers {
|
||||
@@ -211,7 +191,7 @@ func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// SetCookie 设置 Cookie(P0:写锁保护)。
|
||||
// SetCookie 设置后续请求使用的 Cookie。
|
||||
func (c *HTTPClient) SetCookie(key, value string) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.cookies[key] = value
|
||||
@@ -219,8 +199,7 @@ func (c *HTTPClient) SetCookie(key, value string) *HTTPClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// snapshotHeadersCookies 在读锁下拷贝 headers/cookies,供 do/DoWithResponse 在锁外应用到请求,
|
||||
// 消除与 SetHeader/SetHeaders/SetCookie 的 map 读写竞态(P0)。
|
||||
// snapshotHeadersCookies 在锁内复制可变的 headers/cookies。
|
||||
func (c *HTTPClient) snapshotHeadersCookies() (headers, cookies map[string]string) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -235,14 +214,7 @@ func (c *HTTPClient) snapshotHeadersCookies() (headers, cookies map[string]strin
|
||||
return headers, cookies
|
||||
}
|
||||
|
||||
// SetSkipTLS 设置是否跳过 TLS 验证。
|
||||
// 跳过 TLS 校验会暴露于 MITM 攻击,仅在受控环境(如自签证书的内网服务)且明确风险时启用,
|
||||
// 生产环境应保持 false。
|
||||
//
|
||||
// H-12 修复:原实现直接覆盖 c.transport.TLSClientConfig 指针,与并发 Do 读取该字段
|
||||
// 存在数据竞争(-race 必采),且注释自承"需重建 Transport"却未重建。改为在写锁下用
|
||||
// 新配置重建 transport+client 并原子替换,旧 transport 释放空闲连接(保留旧 TLS 配置的
|
||||
// idle 连接不再被复用)。支持运行期并发调用与并发请求无竞态。
|
||||
// SetSkipTLS 切换后续请求是否校验 TLS 证书。
|
||||
func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.skipTLS = skip
|
||||
@@ -252,16 +224,14 @@ func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient {
|
||||
c.transport = transport
|
||||
c.client = client
|
||||
c.mu.Unlock()
|
||||
// 锁外释放旧 transport 的空闲连接(CloseIdleConnections 仅关 idle 连接,不影响在途请求)。
|
||||
// 在锁外释放旧 transport 的空闲连接。
|
||||
if oldTransport != nil {
|
||||
oldTransport.CloseIdleConnections()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SetBlockPrivateNetworks 运行期开关 SSRF 防护(P0)。开启后连接内网 IP(回环/私有/链路本地/
|
||||
// 元数据等)会被拒绝并返回 ErrSSRFBlocked。写锁下重建 transport+client 并原子替换,
|
||||
// 与并发请求无竞态;旧 transport 的空闲连接在锁外释放。
|
||||
// SetBlockPrivateNetworks 切换后续请求的 SSRF 防护。
|
||||
func (c *HTTPClient) SetBlockPrivateNetworks(block bool) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.cfg.BlockPrivateNetworks = block
|
||||
@@ -276,7 +246,7 @@ func (c *HTTPClient) SetBlockPrivateNetworks(block bool) *HTTPClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// Get 发送 GET 请求
|
||||
// Get 发送 GET 请求。
|
||||
func (c *HTTPClient) Get(urlStr string, params map[string]string) ([]byte, error) {
|
||||
if len(params) > 0 {
|
||||
u, err := url.Parse(urlStr)
|
||||
@@ -298,7 +268,7 @@ func (c *HTTPClient) Get(urlStr string, params map[string]string) ([]byte, error
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Post 发送 POST 请求(form 表单格式)
|
||||
// Post 发送表单编码的 POST 请求。
|
||||
func (c *HTTPClient) Post(urlStr string, params map[string]string) ([]byte, error) {
|
||||
data := url.Values{}
|
||||
for k, v := range params {
|
||||
@@ -313,7 +283,7 @@ func (c *HTTPClient) Post(urlStr string, params map[string]string) ([]byte, erro
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// PostJSON 发送 POST 请求(JSON 格式)
|
||||
// PostJSON 发送 JSON POST 请求。
|
||||
func (c *HTTPClient) PostJSON(urlStr string, data any) ([]byte, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
@@ -328,7 +298,7 @@ func (c *HTTPClient) PostJSON(urlStr string, data any) ([]byte, error) {
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Put 发送 PUT 请求(JSON 格式)
|
||||
// Put 发送 JSON PUT 请求。
|
||||
func (c *HTTPClient) Put(urlStr string, data any) ([]byte, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
@@ -343,7 +313,7 @@ func (c *HTTPClient) Put(urlStr string, data any) ([]byte, error) {
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Delete 发送 DELETE 请求
|
||||
// Delete 发送 DELETE 请求。
|
||||
func (c *HTTPClient) Delete(urlStr string) ([]byte, error) {
|
||||
req, err := http.NewRequest("DELETE", urlStr, nil)
|
||||
if err != nil {
|
||||
@@ -352,49 +322,85 @@ func (c *HTTPClient) Delete(urlStr string) ([]byte, error) {
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
// Upload 以流式 multipart 上传文件,避免把完整请求体缓存在内存中。
|
||||
func (c *HTTPClient) Upload(urlStr string, files []UploadFile, params map[string]string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
pr, pw := io.Pipe()
|
||||
writer := multipart.NewWriter(pw)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
for _, f := range files {
|
||||
file, err := os.Open(f.FilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go func() {
|
||||
errCh <- writeMultipartUpload(pw, writer, files, params)
|
||||
}()
|
||||
|
||||
part, err := writer.CreateFormFile(f.FieldName, filepath.Base(f.FilePath))
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err = io.Copy(part, file); err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
// 显式关闭,避免循环内 defer 累积 FD(N5/C5)。
|
||||
file.Close()
|
||||
}
|
||||
|
||||
for k, v := range params {
|
||||
if err := writer.WriteField(k, v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, &buf)
|
||||
req, err := http.NewRequest("POST", urlStr, pr)
|
||||
if err != nil {
|
||||
_ = pr.CloseWithError(err)
|
||||
_ = pw.CloseWithError(err)
|
||||
<-errCh
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return c.do(req)
|
||||
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
_ = pr.CloseWithError(err)
|
||||
<-errCh
|
||||
return nil, err
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// UploadFromBytes 从字节数据上传文件
|
||||
func writeMultipartUpload(pw *io.PipeWriter, writer *multipart.Writer, files []UploadFile, params map[string]string) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
if closeErr := writer.Close(); closeErr != nil {
|
||||
_ = pw.CloseWithError(closeErr)
|
||||
err = closeErr
|
||||
return
|
||||
}
|
||||
err = pw.Close()
|
||||
}()
|
||||
|
||||
for _, f := range files {
|
||||
if err = writeMultipartFile(writer, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range params {
|
||||
if err = writer.WriteField(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeMultipartFile(writer *multipart.Writer, f UploadFile) (err error) {
|
||||
file, err := os.Open(f.FilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := file.Close(); cerr != nil {
|
||||
err = errors.Join(err, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
part, err := writer.CreateFormFile(f.FieldName, filepath.Base(f.FilePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(part, file)
|
||||
return err
|
||||
}
|
||||
|
||||
// UploadFromBytes 将内存中的字节切片作为 multipart 文件上传。
|
||||
func (c *HTTPClient) UploadFromBytes(urlStr string, fieldName string, filename string, data []byte, params map[string]string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
@@ -425,7 +431,7 @@ func (c *HTTPClient) UploadFromBytes(urlStr string, fieldName string, filename s
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Request 发送自定义请求
|
||||
// Request 使用调用方指定的方法和 JSON 请求体发送请求。
|
||||
func (c *HTTPClient) Request(method, urlStr string, body []byte) ([]byte, error) {
|
||||
req, err := http.NewRequest(method, urlStr, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -437,38 +443,37 @@ func (c *HTTPClient) Request(method, urlStr string, body []byte) ([]byte, error)
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// do 执行请求(使用共享的 client 和 transport)
|
||||
// do 执行 req,并按配置封顶读取响应体。
|
||||
func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
|
||||
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
|
||||
headers, cookies := c.snapshotHeadersCookies()
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
for k, v := range cookies {
|
||||
// #nosec G124 -- 这里构造的是出站 Cookie 请求头;Secure/HttpOnly/SameSite 适用于响应 Set-Cookie。
|
||||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||||
}
|
||||
|
||||
// 发送请求(H-12:快照 client,与 SetSkipTLS/SetTimeout 重建无竞态)
|
||||
// #nosec G704 -- 默认客户端为兼容性允许调用方传入 URL;不可信 URL 请使用 NewSSRFSafeHTTPClient/BlockPrivateNetworks。
|
||||
resp, err := c.currentClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查状态码
|
||||
// 将 4xx/5xx 响应视为请求错误。
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("http error: %d %s", resp.StatusCode, resp.Status)
|
||||
return nil, fmt.Errorf("HTTP 请求失败: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// 响应体读取封顶,防异常服务端返回超大响应打爆内存(C5/N5)。
|
||||
// maxRespBodySize: 0=默认 32MB,-1=不限。
|
||||
// maxRespBodySize: 0 使用默认 32MiB 上限;-1 表示不限制。
|
||||
limit := c.maxRespBodySize
|
||||
if limit == 0 {
|
||||
limit = 32 * 1024 * 1024
|
||||
}
|
||||
var reader io.Reader = resp.Body
|
||||
if limit > 0 {
|
||||
// 多读 1 字节用于判断是否超限。
|
||||
// 多读 1 字节用于判断响应体是否超限。
|
||||
reader = io.LimitReader(resp.Body, limit+1)
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
@@ -476,44 +481,43 @@ func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
if limit > 0 && int64(len(data)) > limit {
|
||||
return nil, fmt.Errorf("response body exceeds limit %d bytes", limit)
|
||||
return nil, fmt.Errorf("响应体超过限制 %d 字节", limit)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// DoWithResponse 执行请求并返回完整响应。
|
||||
//
|
||||
// 注意:调用方负责关闭返回的 resp.Body;且此方法不套用 maxRespBodySize 读封顶(由调用方掌控)。
|
||||
// DoWithResponse 执行 req 并返回原始响应;调用方必须关闭 resp.Body。
|
||||
func (c *HTTPClient) DoWithResponse(req *http.Request) (*http.Response, error) {
|
||||
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
|
||||
headers, cookies := c.snapshotHeadersCookies()
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
for k, v := range cookies {
|
||||
// #nosec G124 -- 这里构造的是出站 Cookie 请求头;Secure/HttpOnly/SameSite 适用于响应 Set-Cookie。
|
||||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||||
}
|
||||
|
||||
// #nosec G704 -- 默认客户端为兼容性允许调用方传入 URL;不可信 URL 请使用 NewSSRFSafeHTTPClient/BlockPrivateNetworks。
|
||||
return c.currentClient().Do(req)
|
||||
}
|
||||
|
||||
// Close 关闭客户端(释放连接池资源)
|
||||
// Close 释放客户端 transport 持有的空闲连接。
|
||||
func (c *HTTPClient) Close() {
|
||||
if t := c.currentTransport(); t != nil {
|
||||
t.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// JSONMarshal 内部 JSON 序列化函数
|
||||
// JSONMarshal 将 v 序列化为 JSON。
|
||||
func JSONMarshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// 全局默认 HTTP 客户端
|
||||
// defaultClient 是包级共享 HTTP 客户端。
|
||||
var defaultClient *HTTPClient
|
||||
var defaultClientOnce sync.Once
|
||||
|
||||
// DefaultHTTPClient 获取全局默认 HTTP 客户端
|
||||
// DefaultHTTPClient 返回包级共享 HTTP 客户端。
|
||||
func DefaultHTTPClient() *HTTPClient {
|
||||
defaultClientOnce.Do(func() {
|
||||
defaultClient = NewHTTPClient()
|
||||
@@ -521,17 +525,17 @@ func DefaultHTTPClient() *HTTPClient {
|
||||
return defaultClient
|
||||
}
|
||||
|
||||
// HTTPGet 使用默认客户端发送 GET 请求
|
||||
// HTTPGet 使用默认客户端发送 GET 请求。
|
||||
func HTTPGet(url string, params map[string]string) ([]byte, error) {
|
||||
return DefaultHTTPClient().Get(url, params)
|
||||
}
|
||||
|
||||
// HTTPPost 使用默认客户端发送 POST 请求
|
||||
// HTTPPost 使用默认客户端发送表单 POST 请求。
|
||||
func HTTPPost(url string, params map[string]string) ([]byte, error) {
|
||||
return DefaultHTTPClient().Post(url, params)
|
||||
}
|
||||
|
||||
// HTTPPostJSON 使用默认客户端发送 JSON POST 请求
|
||||
// HTTPPostJSON 使用默认客户端发送 JSON POST 请求。
|
||||
func HTTPPostJSON(url string, data any) ([]byte, error) {
|
||||
return DefaultHTTPClient().PostJSON(url, data)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user