From e00aa620f9f938a5a15b2df00c411974aa4f4703 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Tue, 14 Jul 2026 10:24:10 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .gitignore | 16 + CHANGELOG.md | 1141 +++++++++++ GUIDE.md | 1894 ++++++++++++++++++ LICENSE | 21 + Makefile | 34 + README.md | 1117 +++++++++++ README.wehub.md | 7 + app.go | 1364 +++++++++++++ app_cache_test.go | 131 ++ app_config_test.go | 60 + app_cron_test.go | 135 ++ app_go_test.go | 43 + app_hdb1_test.go | 116 ++ app_m1_test.go | 463 +++++ app_ratelimit_bound_test.go | 101 + app_ratelimit_test.go | 107 + app_storage_test.go | 71 + app_test.go | 361 ++++ app_trace_test.go | 120 ++ cache/cache.go | 279 +++ cache/cache_m10_internal_test.go | 199 ++ cache/cache_phase4_internal_test.go | 69 + cache/keybuilder.go | 297 +++ cache/keybuilder_m13_internal_test.go | 31 + cache/keybuilder_test.go | 225 +++ cache/lock.go | 428 ++++ cache/lock_concurrency_test.go | 413 ++++ cache/lock_test.go | 151 ++ cmd/xlgo/commands.go | 336 ++++ cmd/xlgo/commands_test.go | 56 + cmd/xlgo/main.go | 71 + cmd/xlgo/templates.go | 571 ++++++ cmd/xlgo/types.go | 10 + cmd/xlgo/utils.go | 57 + compress/compress.go | 403 ++++ compress/compress_security_test.go | 347 ++++ compress/compress_test.go | 244 +++ config/config.go | 1161 +++++++++++ config/config_c10_test.go | 499 +++++ config/config_fix_test.go | 329 +++ config/config_test.go | 441 ++++ config/validate.go | 109 + config/validate_test.go | 81 + console/console.go | 292 +++ console/console_test.go | 152 ++ console/console_unix.go | 15 + console/console_windows.go | 69 + cron/cron.go | 840 ++++++++ cron/cron_c12_internal_test.go | 190 ++ cron/cron_c12_test.go | 321 +++ cron/cron_m13_internal_test.go | 168 ++ cron/cron_test.go | 268 +++ database/dialect.go | 184 ++ database/manager.go | 914 +++++++++ database/manager_c11_internal_test.go | 404 ++++ database/manager_hdb1_internal_test.go | 199 ++ database/manager_test.go | 277 +++ database/redis.go | 194 ++ database/redis_hdb1_internal_test.go | 84 + database/redis_internal_test.go | 56 + database/redis_manager_internal_test.go | 45 + database/redis_test.go | 66 + database/retry_internal_test.go | 38 + database/tls.go | 72 + database/tls_internal_test.go | 146 ++ docs/plans/Version_Update_Plan_v1.0.2.md | 1047 ++++++++++ docs/plans/Version_Update_Plan_v1.1.0.md | 128 ++ docs/plans/Version_v1.0.2_report.md | 519 +++++ docs/plans/v2.0-review.md | 485 +++++ examples/README.md | 50 + examples/full/config.yaml | 44 + examples/full/main.go | 175 ++ examples/full/main_test.go | 160 ++ examples/minimal/config.yaml | 8 + examples/minimal/main.go | 42 + glm_note_report.md | 235 +++ go.mod | 119 ++ go.sum | 330 +++ handler/handler.go | 199 ++ handler/handler_test.go | 414 ++++ jwt/jwt.go | 526 +++++ jwt/jwt_c9c_internal_test.go | 142 ++ jwt/jwt_test.go | 578 ++++++ logger/field.go | 30 + logger/logger.go | 537 +++++ logger/logger_h7_internal_test.go | 482 +++++ logger/logger_test.go | 181 ++ logs/app.log | 3 + middleware/auth.go | 258 +++ middleware/auth_test.go | 198 ++ middleware/cors.go | 217 ++ middleware/cors_internal_test.go | 94 + middleware/csrf.go | 465 +++++ middleware/csrf_internal_test.go | 234 +++ middleware/logger.go | 308 +++ middleware/logger_internal_test.go | 275 +++ middleware/metrics.go | 64 + middleware/middleware_test.go | 1548 ++++++++++++++ middleware/ratelimit.go | 654 ++++++ middleware/ratelimit_phase5_internal_test.go | 96 + middleware/recover.go | 88 + middleware/requestid.go | 49 + middleware/timeout.go | 37 + middleware/timeout_test.go | 72 + model/base.go | 28 + repository/page_internal_test.go | 72 + repository/repository.go | 718 +++++++ repository/repository_h6_internal_test.go | 491 +++++ repository/repository_m5_internal_test.go | 261 +++ repository/repository_test.go | 26 + response/error.go | 233 +++ response/error_test.go | 67 + response/mode.go | 110 + response/mode_test.go | 106 + response/response.go | 170 ++ response/response_test.go | 399 ++++ router/health_test.go | 157 ++ router/livez_readyz_test.go | 55 + router/metrics.go | 26 + router/router.go | 780 ++++++++ router/router_h8_internal_test.go | 271 +++ router/router_m7_internal_test.go | 196 ++ router/router_test.go | 459 +++++ scan_protocol.md | 90 + sse/sse.go | 241 +++ sse/sse_concurrency_test.go | 77 + sse/sse_stream_internal_test.go | 34 + sse/sse_test.go | 187 ++ storage/storage.go | 869 ++++++++ storage/storage_close_internal_test.go | 71 + storage/storage_path_internal_test.go | 92 + storage/storage_security_test.go | 344 ++++ storage/storage_swap_internal_test.go | 36 + storage/storage_test.go | 172 ++ storage/unique_internal_test.go | 34 + test/test.go | 426 ++++ test/test_m16_test.go | 89 + trace/trace.go | 418 ++++ trace/trace_test.go | 653 ++++++ utils/convert.go | 106 + utils/crypto.go | 126 ++ utils/datetime.go | 101 + utils/file.go | 125 ++ utils/http.go | 541 +++++ utils/http_test.go | 295 +++ utils/random.go | 141 ++ utils/strings.go | 150 ++ utils/url.go | 83 + utils/utils_test.go | 862 ++++++++ utils/uuid.go | 28 + utils/validator.go | 120 ++ validation/hash.go | 82 + validation/password.go | 89 + validation/validation_test.go | 461 +++++ validation/validator.go | 457 +++++ ws/ws.go | 607 ++++++ ws/ws_concurrency_test.go | 414 ++++ ws/ws_m11_internal_test.go | 199 ++ ws/ws_origin_internal_test.go | 77 + ws/ws_send_internal_test.go | 86 + ws/ws_test.go | 135 ++ 161 files changed, 43937 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 GUIDE.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 app.go create mode 100644 app_cache_test.go create mode 100644 app_config_test.go create mode 100644 app_cron_test.go create mode 100644 app_go_test.go create mode 100644 app_hdb1_test.go create mode 100644 app_m1_test.go create mode 100644 app_ratelimit_bound_test.go create mode 100644 app_ratelimit_test.go create mode 100644 app_storage_test.go create mode 100644 app_test.go create mode 100644 app_trace_test.go create mode 100644 cache/cache.go create mode 100644 cache/cache_m10_internal_test.go create mode 100644 cache/cache_phase4_internal_test.go create mode 100644 cache/keybuilder.go create mode 100644 cache/keybuilder_m13_internal_test.go create mode 100644 cache/keybuilder_test.go create mode 100644 cache/lock.go create mode 100644 cache/lock_concurrency_test.go create mode 100644 cache/lock_test.go create mode 100644 cmd/xlgo/commands.go create mode 100644 cmd/xlgo/commands_test.go create mode 100644 cmd/xlgo/main.go create mode 100644 cmd/xlgo/templates.go create mode 100644 cmd/xlgo/types.go create mode 100644 cmd/xlgo/utils.go create mode 100644 compress/compress.go create mode 100644 compress/compress_security_test.go create mode 100644 compress/compress_test.go create mode 100644 config/config.go create mode 100644 config/config_c10_test.go create mode 100644 config/config_fix_test.go create mode 100644 config/config_test.go create mode 100644 config/validate.go create mode 100644 config/validate_test.go create mode 100644 console/console.go create mode 100644 console/console_test.go create mode 100644 console/console_unix.go create mode 100644 console/console_windows.go create mode 100644 cron/cron.go create mode 100644 cron/cron_c12_internal_test.go create mode 100644 cron/cron_c12_test.go create mode 100644 cron/cron_m13_internal_test.go create mode 100644 cron/cron_test.go create mode 100644 database/dialect.go create mode 100644 database/manager.go create mode 100644 database/manager_c11_internal_test.go create mode 100644 database/manager_hdb1_internal_test.go create mode 100644 database/manager_test.go create mode 100644 database/redis.go create mode 100644 database/redis_hdb1_internal_test.go create mode 100644 database/redis_internal_test.go create mode 100644 database/redis_manager_internal_test.go create mode 100644 database/redis_test.go create mode 100644 database/retry_internal_test.go create mode 100644 database/tls.go create mode 100644 database/tls_internal_test.go create mode 100644 docs/plans/Version_Update_Plan_v1.0.2.md create mode 100644 docs/plans/Version_Update_Plan_v1.1.0.md create mode 100644 docs/plans/Version_v1.0.2_report.md create mode 100644 docs/plans/v2.0-review.md create mode 100644 examples/README.md create mode 100644 examples/full/config.yaml create mode 100644 examples/full/main.go create mode 100644 examples/full/main_test.go create mode 100644 examples/minimal/config.yaml create mode 100644 examples/minimal/main.go create mode 100644 glm_note_report.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handler/handler.go create mode 100644 handler/handler_test.go create mode 100644 jwt/jwt.go create mode 100644 jwt/jwt_c9c_internal_test.go create mode 100644 jwt/jwt_test.go create mode 100644 logger/field.go create mode 100644 logger/logger.go create mode 100644 logger/logger_h7_internal_test.go create mode 100644 logger/logger_test.go create mode 100644 logs/app.log create mode 100644 middleware/auth.go create mode 100644 middleware/auth_test.go create mode 100644 middleware/cors.go create mode 100644 middleware/cors_internal_test.go create mode 100644 middleware/csrf.go create mode 100644 middleware/csrf_internal_test.go create mode 100644 middleware/logger.go create mode 100644 middleware/logger_internal_test.go create mode 100644 middleware/metrics.go create mode 100644 middleware/middleware_test.go create mode 100644 middleware/ratelimit.go create mode 100644 middleware/ratelimit_phase5_internal_test.go create mode 100644 middleware/recover.go create mode 100644 middleware/requestid.go create mode 100644 middleware/timeout.go create mode 100644 middleware/timeout_test.go create mode 100644 model/base.go create mode 100644 repository/page_internal_test.go create mode 100644 repository/repository.go create mode 100644 repository/repository_h6_internal_test.go create mode 100644 repository/repository_m5_internal_test.go create mode 100644 repository/repository_test.go create mode 100644 response/error.go create mode 100644 response/error_test.go create mode 100644 response/mode.go create mode 100644 response/mode_test.go create mode 100644 response/response.go create mode 100644 response/response_test.go create mode 100644 router/health_test.go create mode 100644 router/livez_readyz_test.go create mode 100644 router/metrics.go create mode 100644 router/router.go create mode 100644 router/router_h8_internal_test.go create mode 100644 router/router_m7_internal_test.go create mode 100644 router/router_test.go create mode 100644 scan_protocol.md create mode 100644 sse/sse.go create mode 100644 sse/sse_concurrency_test.go create mode 100644 sse/sse_stream_internal_test.go create mode 100644 sse/sse_test.go create mode 100644 storage/storage.go create mode 100644 storage/storage_close_internal_test.go create mode 100644 storage/storage_path_internal_test.go create mode 100644 storage/storage_security_test.go create mode 100644 storage/storage_swap_internal_test.go create mode 100644 storage/storage_test.go create mode 100644 storage/unique_internal_test.go create mode 100644 test/test.go create mode 100644 test/test_m16_test.go create mode 100644 trace/trace.go create mode 100644 trace/trace_test.go create mode 100644 utils/convert.go create mode 100644 utils/crypto.go create mode 100644 utils/datetime.go create mode 100644 utils/file.go create mode 100644 utils/http.go create mode 100644 utils/http_test.go create mode 100644 utils/random.go create mode 100644 utils/strings.go create mode 100644 utils/url.go create mode 100644 utils/utils_test.go create mode 100644 utils/uuid.go create mode 100644 utils/validator.go create mode 100644 validation/hash.go create mode 100644 validation/password.go create mode 100644 validation/validation_test.go create mode 100644 validation/validator.go create mode 100644 ws/ws.go create mode 100644 ws/ws_concurrency_test.go create mode 100644 ws/ws_m11_internal_test.go create mode 100644 ws/ws_origin_internal_test.go create mode 100644 ws/ws_send_internal_test.go create mode 100644 ws/ws_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df4ecab --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Claude Code 协作指引(本地,不入库) +CLAUDE.md + +AGENTS.md + +# 临时发版辅助文件 +gitHub_release_*.md + +# 构建产物 +*.exe +bin/ +md/ +.gocache/ +gpt_check_report_framework.md +gpt_check_report_review.md +gpt_check_report_module.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5a30b83 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1141 @@ +# Changelog + +xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 规范, +版本号遵循 [语义化版本 SemVer](https://semver.org/lang/zh-CN/)。 + +> **如何阅读**:每个版本下分类列出变更类型—— +> - **Breaking**:⚠️ 破坏性变更,升级前必须阅读迁移说明 +> - **Added**:新增功能 +> - **Changed**:变更已有功能(非破坏性) +> - **Deprecated**:标记为废弃,未来版本会移除 +> - **Removed**:移除的功能 +> - **Fixed**:Bug 修复 +> - **Security**:安全相关修复 + +--- + +## [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 + +> 框架整体评估后的结构性修复:4 轮对抗性评审(deepseek / GLM / Claude / 终审)收口的全部 CRITICAL/HIGH/MEDIUM 问题 + 主线A 包级可变全局并发治理统一。 +> 项目处于初级阶段,无下游用户,放心引入破坏性变更。`go vet` + `go build` + `go test -race ./...` 全绿。 + +### Breaking ⚠️ + +- **`database.RedisClient` 不再可外部访问**:包级变量改为 unexported。所有消费者必须通过 `database.GetRedis()` 获取客户端。测试注入使用 `database.SetTestRedisClient(c)` 替代直接赋值。 +- **`xlgo.WithConfig(cfg)` 不再调用 `config.Set(cfg)`**:配置不再写入全局状态。依赖 `config.Get()` 获取注入配置的下游代码需改用 `WithConfigPath`。 +- **`App.Init()` 内部改为 `sync.Once`**:多次调用返回首次执行的结果(含错误),不再是之前的"第二次直接返回 nil"。 +- **`database.DefaultRedis` 类型变更**(database/redis.go):由 `*RedisManager` 改为 `atomic.Pointer[RedisManager]`,消除裸指针无锁置换的数据竞争(C-1)。下游若直接调用 `DefaultRedis.Init(...)` 等方法需改用 `InitRedis(...)` facade,或 `DefaultRedis.Load().Init(...)`。 +- **`storage.DefaultStorage` 类型变更**(storage/storage.go):由 `*StorageManager` 改为 `atomic.Pointer[StorageManager]`,与 `config.defaultManager`/`database.DefaultRedis` 并发保护对齐。下游直接调用方法需改用 facade 或 `.Load()`。 +- **`validation.Validator` 类型变更**(validation/validator.go):由 `*validator.Validate` 改为 `atomic.Pointer[validator.Validate]`,消除无锁读写竞态(H-13)。下游直接读 `validation.Validator.Struct(...)` 需改用 `ValidateStruct(...)`,或 `validation.Validator.Load().Struct(...)`。 +- **`repository.FindWhereOrdered` / `FindPageWhereOrdered` 签名变更**(repository/repository.go):`args []any` 改为 `args ...any`,与同类方法统一;因 Go 变长参数须为末尾参数,`order` 前置于 `query`。新签名:`FindWhereOrdered(ctx, order, query string, args ...any)`、`FindPageWhereOrdered(ctx, page, pageSize int, order, query string, args ...any)`(H-15)。 +- **`response.Response` 的 `Data`/`RequestID` 去掉 `omitempty`**(response/response.go):`data` 与 `request_id` 字段在所有响应中恒存在(失败时 `data:null`、未装 RequestID 中间件时 `request_id:""`),下游严格按 schema 解析不再缺字段(M-38/M-39)。 +- **`cache.IsLocked`/`GetLockTTL`/`ForceUnlock` Redis 不可用时改返 `ErrRedisNotReady`**(cache/lock.go,M-E):原返 `(false/0, nil)` 与"锁确实未占用/已过期"不可区分,调用方可能误以为可获取锁而进入临界区。现统一为:锁操作(正确性相关)Redis 不可用返 `ErrRedisNotReady`,调用方 `errors.Is(err, ErrRedisNotReady)` 区分;cache 数据操作(Get/Set/Incr,性能层)保持 best-effort 静默。 +- **`config.Load()` 返回深拷贝**(config/config.go,M-G):新增 `(*Config).Clone()` 深拷贝所有切片字段(CORS/Upload/Storage 白名单),`Load()` 与 reload 回调改返 Clone。原"防御性拷贝"为浅拷贝,切片字段与内部 `m.cfg` 共享底层数组,调用方 append/sort/改元素会污染全局。`Get()` 仍返回内部只读指针(热路径零分配),需可变副本用 `Clone()`。 +- **`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)`(并发安全,替换后关闭旧 manager)、`database.SwapDefaultManager(m)`(并发安全,返回旧 manager 且不关闭)与 `database.GetDefaultManager()`(atomic 读取)。下游若直接调用 `DefaultManager.Init/Master` 等方法需改用 `InitDB`/`GetDB` 等 facade,或 `DefaultManager.Load().Init(...)`,或经 `GetDefaultManager()` 取实例;原 `database.DefaultManager = myDB` 直接赋值改为 `database.SetDefaultManager(myDB)`。 + +### Fixed 🐛 + +#### 第一轮:CRITICAL + HIGH 基金会战 + +- **U1 UUIDShort 生成错误**(utils/uuid.go):`uuid.New().String()[:32]` 产生的 32 字符保留了 4 个破折号(UUID 格式为 36 字符含 4 个 `-`,`[:32]` 截断末尾 4 个 hex 字符但保留破折号)。改为 `strings.ReplaceAll(uuid.New().String(), "-", "")`,现在正确生成 32 位纯十六进制字符串。 +- **M1 filterSensitiveFields 假过滤**(middleware/logger.go):旧实现用 `strings.ReplaceAll` 在 key 后面追加 `"[FILTERED]"` 而不删除原始值,导致 `{"password":"mypass"}` → `{"password":"[FILTERED]""mypass"}` 密码仍然可见。改为编译期正则 `sensitiveFieldsRE` 匹配 `"key":"value"` 整体并替换 value 部分为 `[FILTERED]`。 +- **A1 App.Init 并发竞态**(app.go):`a.initialized` 是普通 bool,并发 `Init()` 调用会同时通过检查导致双重初始化。改为 `sync.Once`,`doInit()` 提取为内部方法,错误通过 `a.initErr` 保存。 +- **D3/CK1 Redis 客户端访问竞态与不一致**(database/redis.go, cache/lock.go):`database.RedisClient` 全局变量在 `Init`/`Close` 中有锁写入但可被外部无锁读取(数据竞态);`cache/lock.go` 直接引用 `RedisClient` 而非 `GetRedis()`(绕过 RedisManager 抽象)。修复方案:`RedisClient` → unexported `redisClient`;`GetRedis()` 加入回退逻辑(优先 `DefaultRedis.Client()`,回退内部 `redisClient`);lock.go 全部改为 `rdb := database.GetRedis()` 单次获取 + nil 检查。 +- **M2 Metrics in-flight gauge 泄漏**(middleware/metrics.go):`httpRequestsInFlight.Inc()` 后无 `defer Dec()`,handler panic 时 gauge 永久膨胀。改为 `Inc()` 后立即 `defer Dec()`,不依赖 Recover 中间件顺序。 +- **R1 FailWithError 丢弃 Detail**(response/error.go):`FailWithError` 直接 `writeResp(c, err.Code, err.Message, nil)` 丢弃了 `err.Detail`。改为 `resp := err.ToResponse(); writeResp(c, resp.Code, resp.Msg, resp.Data)`。 + +#### 第二轮:并发纪律红线 + 生命周期/泄漏修复 + +> 来源:glm_check_report_framework.md 结构性评估。聚焦"包级可变全局统一治理"与"重建路径资源释放"两条主线。 + +- **C-1/H-4 `database.DefaultRedis` 并发竞态 + `redisClient` 双源**(database/redis.go):`DefaultRedis` 改 `atomic.Pointer[RedisManager]`(init Store 兜底),所有 facade 经 `Load()`;废弃 `redisClient` 包级变量,`GetRedis` 单源化;`SetTestRedisClient` 改为在当前 manager 上持锁 `setClientForTest` 替换 client,消除双源真相与无锁写竞态。 +- **H-13 `validation.Validator` 无锁读写**(validation/validator.go):改 `atomic.Pointer[validator.Validate]`;`InitValidator` 先注册全部自定义规则再 Store,确保 Load 得到的 validator 永远完整。 +- **H-11 + 主线A `storage` 死代码全局 + `DefaultStorage` 裸指针**(storage/storage.go):删除从未被读取的死代码 `var storage Storage`;`DefaultStorage` 改 `atomic.Pointer[StorageManager]`。 +- **H-10 `response.Error.WithDetail` 并发不安全**(response/error.go):原实现 `e.Detail = detail` mutate 共享的预定义 `Err*`(并发写竞争 + 全局污染)。改为返回新 `*Error` 拷贝。 +- **H-6 `middleware.RedisRateLimiter.failClosed` 数据竞争**(middleware/ratelimit.go):改 `atomic.Bool`,`SetFailClosed`/`Allow` 全用 atomic,支持运行期并发切换策略。 +- **H-7 `middleware.GetCSRFToken` 裸断言 + 非恒定时间比较**(middleware/csrf.go):`token.(string)` 改 comma-ok;CSRF token 比较改 `subtle.ConstantTimeCompare`(两处),防时序侧信道。 +- **H-14/M-64/M-65 `trace.Close` sync.Once 泄漏 + Init 回滚不全**(trace/trace.go):`Close` 去 `sync.Once` 改 Swap+Shutdown,支持 Close→Init→Close 多轮生命周期(原第二次 Close no-op 致 exporter 泄漏);`Init` `!Enabled` 分支 Swap+Shutdown 旧 provider;传播器失败时不切 otel 全局(移到成功路径末尾),避免指向已 Shutdown provider。 +- **H-8/H-9 `ws.Hub.Stop` double-close panic + WaitGroup Add/Wait 竞态**(ws/ws.go):`Stop` 用 `sync.Once` 包 `close(stop)`(删除"stopped 标志"虚假注释);用 `runDone chan` + `runStarted atomic.Bool` 替代 WaitGroup,消除 `wg.Add(0→1)` 与 `wg.Wait` 的 happens-before 竞态,Stop 先于 Run 调用也不泄漏。 +- **H-1 `app.OnReady` 失败资源泄漏**(app.go):失败时走 `Shutdown()` 释放 HTTP 端口/后台 goroutine/资源,不再直接 return 致监听 goroutine 永久阻塞。 +- **H-2 `app.Go` + `Init` 失败 goroutine 泄漏**(app.go):`Init` 失败时 cancel rootCtx + 等 wg(10s 超时兜底),通知已通过 `App.Go` 启动的后台 goroutine 退出。 +- **H-12 `utils.HTTPClient.SetSkipTLS` 数据竞争**(utils/http.go):加 `sync.RWMutex`;`SetSkipTLS`/`SetTimeout` 写锁下重建 client/transport 并释放旧 transport 空闲连接;`do`/`DoWithResponse`/`Close` 读锁快照后无锁调 `Do`。修复注释自承"需重建 Transport"却未重建的不一致。 + +#### 第三轮:P0 安全阻断 + P1 并发/资源收口(Claude 终审) + +> 来源:claude_check_report_framework.md / claude_check_report_module.md。在 deepseek/GLM 修复后的版本上补审,闭合两者共同漏掉的 JWT 算法混淆等安全默认值问题。 + +- **#1 JWT 算法混淆(alg confusion)**(jwt/jwt.go):`ParseWithClaims` 统一传 `jwt.WithValidMethods(["HS256","HS384","HS512"])`,keyfunc 内断言 `*jwt.SigningMethodHMAC`,拒绝 alg=none 及非 HMAC 算法(防公钥当 HMAC 密钥伪造 token)。回归 `TestParseTokenRejectsAlgNone`。 +- **#2 JWT 空密钥 fail-closed**(jwt/jwt.go):新增 `secretKey()`,`GenerateToken*`/`ParseToken*` 空 secret 返 `ErrEmptySecret`。回归 `TestEmptySecretFailsClosed`。 +- **#3 JWT 不支持算法拒绝**(jwt/jwt.go):`signingMethod` 改返 `(SigningMethod, error)`,RS256 等返 `ErrUnsupportedAlgorithm`,不再静默回退 HS256。回归 `TestUnsupportedAlgorithmFailsClosed`。 +- **#4 上传大小实测封顶**(storage/storage.go):新增 `enforceUploadSize`/`enforceMaxReader`,本地与 OSS 上传拷贝阶段按实际字节封顶,超限返 `ErrUploadTooLarge` 并清理部分落盘文件。修复原信任客户端 `file.Size` 的绕过。 +- **#5 HTTP header/cookie map 竞态**(utils/http.go):`SetHeader/SetHeaders/SetCookie` 加写锁,`do/DoWithResponse` 经 `snapshotHeadersCookies()` 读锁快照。 +- **#6 HTTP SSRF 防护**(utils/http.go):新增 `BlockPrivateNetworks` + `NewSSRFSafeHTTPClient()` + `SetBlockPrivateNetworks()`,经 `net.Dialer.Control` 在连接建立时拦截回环/私有/链路本地/元数据 IP,覆盖重定向每一跳。 +- **#7–#21 P1 并发/资源/泄露收口**:console 写锁(`writeMu`);config 包级 `Load/LoadWithWatch` `pkgLoadMu` 串行化 + debounce timer Stop;CSRF `ShouldBindBodyWith`(body 可重放);cron `StopWithTimeout`/`WithCron()` 接入 App 生命周期;database `m.cfg` 经 `getCfg/setCfg` 锁内读写;`isTransientDBError` 移除过宽 `"database"` 子串;router `applyOnce sync.Once` + 版本 map 排序;logger `filterSensitiveQuery` 脱敏;response `SetExposeDetail`(生产隐藏 Detail);validation 密码 MaxLength=72 + phone 全数字 + `RegisterValidation` `must` 检查;trace noop 包 + `defer span.End()` + `[unmatched]` 低基数 span 名;storage `NewLocalStorage` Abs fail-closed;cmd/xlgo 项目名/模块路径校验 + `os.Exit(1)` + 失败回滚。 + +#### 第四轮:终审剩余项收口(last_report.md) + +> 来源:last_report.md 终审。在第三轮基础上逐项回读核实后修复,均经 `go test -race` 验证。 + +- **H-A 从库连接池上限截断**(database/manager.go):`MaxOpenConns/2` 在配置为 1 时得 0(`database/sql` 视 0 为无限制),与"从库减少"意图相反、资源失控。新增 `replicaMaxOpenConns()`:`>0` 取 `max(1, /2)`,`<=0` 与主库一致无限。 +- **H-B `router.GroupWithMiddlewareGroup` nil panic**(router/router.go):改走 `ensureRegistry()`(与其他全局 helper 一致),把未初始化 nil 解引用 panic 转成可定位错误。 +- **M-A JWT 黑名单无超时 + 吞错**(jwt/jwt.go):`Add`/`IsBlacklisted` 改用 `context.WithTimeout(1s)`(`blacklistCtx`),把鉴权路径阻塞上限收敛到 1s;`IsBlacklisted` 用 `.Result()` 替代 `.Val()` 显式处理错误(fail-open 保留但记录告警)。 +- **M-B `Recover` 响应已写出时无效写 500**(middleware/recover.go):加 `c.Writer.Written()` 守卫,已写出时仅 Abort + 记录,避免流式/部分写场景下 `response.Custom` 沦为 no-op 致客户端收到截断响应。 +- **M-C `RedisRateLimiter` 多次 `GetRedis()` nil-deref 窗口**(middleware/ratelimit.go):`Allow`/`GetCount`/`Reset` 改为取一次 `rdb` 复用,消除两次调用间 `CloseRedis` 致第二次 nil-deref 的窗口。 +- **M-D `handler.GetPage` 深分页 DoS**(handler/handler.go):加 `page` 上限 `MaxPage=10000`(见 Breaking)。 +- **M-E Redis 不可用失败语义统一**(cache/lock.go,见 Breaking):锁操作返 `ErrRedisNotReady`;`IsLocked` 用 `.Result()` 返错(合并 L-G)。 +- **M-F `HashFile`/`ReadFile` OOM 与 TOCTOU**(utils/crypto.go, utils/file.go):`HashFile` 改流式 `io.Copy(h, f)`(恒定内存,可哈希任意大文件);`ReadFile` 去 `FileExists` 前置检查(见 Breaking)。 +- **M-G `config.Get()` 返回内部指针切片别名**(config/config.go,见 Breaking):新增 `Clone()`,`Load()` 与 reload 回调返深拷贝;`Get()` 保持热路径零分配返只读指针。 +- **M-H `cron.checkAndRun` 持锁 spawn 阻塞管理 API**(cron/cron.go):锁内只收集到期任务(CAS + 推进 NextRun + `wg.Add`),锁外 spawn。`wg.Add` 必须在锁内以保证 `Stop` 的 `wg.Wait` 等到本批(否则 Stop 可能在任务间提前返回、后启动 goroutine `wg.Done` 下溢 panic)。 +- **L-A compress 解压残留文件**(compress/compress.go):`GzipDecompressFileWithOptions`/`unzipFile` 失败时(超限/拷贝错误)`os.Remove` 部分落盘文件,避免被拒炸弹遗留残片。 +- **L-B utils 正则重编译**(utils/validator.go):`IsPhone`/`IsEmail`/`IsIPv4`/`IsIDCard` 正则提为包级 `MustCompile`,避免每次调用重编译。 +- **L-C `EqualsIgnoreCase` 非 ASCII 误判**(utils/strings.go,见 Breaking):改 `strings.EqualFold`。 +- **L-D `redisLimiters` 死代码**(middleware/ratelimit.go):删除从未被读写的包级 `redisLimiters` map 与其 `init()`。 +- **L-E `logger.Logger` 导出变量**(logger/logger.go):标 `Deprecated:`,引导用包级 `Info/Debug/...` 函数(atomic 读取,并发安全)。 +- **L-H `ws.SetCheckOrigin` 无锁写**(ws/ws.go):文档明确"仅启动前调用",运行期切换 Origin 用自有 upgrader 实例。 +- **L-J `cron.RunTask` 用 `s.ctx`**(cron/cron.go):文档说明 Stop 后 handler 收到 canceled ctx 的预期行为。 + +> **未修(评估后决定)**:L-K `model` 时间戳 `omitempty`——`encoding/json` 对 `time.Time`(结构体)的 `omitempty` 是 no-op,报告建议的修复无效;真正的修复需改 `*time.Time`(破坏性 + nil 安全隐患),收益与成本不匹配,暂不做。L-I `response.writeResp` nil-c 守卫——nil `*gin.Context` 是程序员错误,panic 是恰当反馈,加静默守卫反而掩盖 bug。L-F/L-M/L-N/L-O 为文档/命名类,已就地注释说明或属设计取舍。 + +### Changed 🔄 + +- **`database.GetRedis()` 加入回退逻辑**:优先返回 `DefaultRedis.Client()`,若未初始化则回退到内部 `redisClient`(测试注入路径)。确保 `SetTestRedisClient` + `GetRedis()` 闭环。 +- **`database.SetTestRedisClient(c)` 新增**:供测试注入 mock Redis 客户端,返回旧值便于清理恢复。 + +--- + +## [v1.1.1] - 2026-06-28 + +> v1.1.1 后的安全/正确性补丁,依据 `version_1.1.1_report.md` 权威缺陷清单逐项修复(13 CRITICAL + 8 HIGH)。 + +### Fixed 🐛 + +#### P3 清理第三批(MEDIUM/MINOR 收尾) + +> 续前两批。本批为校验收紧、全局状态并发、Windows 控制台、trace 遗留、logger 级别、测试质量。`go test -race` + `go vet` + `gosec` 通过。 + +- **M5 身份证无校验位 + 用户名首字节**(validation/validator.go):18 位身份证补 GB 11643-1999 校验位验证(`validateIDCardChecksum`),原仅查长度+格式可被任意构造通过;`username` 验证首字符改按 `[]rune` 取,避免非 ASCII 首字节误判(原 `rune(username[0])` 取字节)。 +- **M13 cache globalKeyBuilder 无锁 + 无 sync.Once**(cache/keybuilder.go):`globalKeyBuilder` 加 `sync.RWMutex` 读写保护,`GetKeyBuilder` 用 `sync.Once` 保证自动初始化只执行一次,消除 check-then-init 竞态。`SetPrefix` 补实例非并发安全注释。 +- **M12 NewRedisCache 构造时快照 client**(cache/cache.go):`redisCache` 不再构造时 `database.GetRedis()` 快照(Init Redis 之前构造则永久 nil no-op),改每次操作实时取 client,使"先构造后 Init Redis"顺序也能正确工作。 +- **M18 trace 显式设 codes.Ok + Close 无 double-close 守卫**(trace/trace.go):成功路径不再 `SetStatus(codes.Ok, "")`(OTel 规范默认 UNSET,显式 Ok 会掩盖子 Span 错误状态);`Close` 用 `sync.Once` 保证只 Shutdown 一次,重复调用安全。 +- **M19 logger 无法显式设级别**(logger/logger.go):三个 core(app/api/db/console)改共享 `zap.AtomicLevel`,新增 `LogManager.SetLevel`/`GetLevel` + 包级 `SetLevel`/`GetLevel`,支持运行期热切换日志级别。 +- **M17 console_windows 死代码 + 着色句柄分裂**(console/console_windows.go):移除从未被调用的 `EnableVirtualTerminal`;`printColor` 原对 `syscall.Stdout` 设置颜色、文本却写 `c.output`(非 stdout 时二者分裂),改为按 `c.output` 实际类型取句柄(`*os.File` 用 Fd,否则退化为纯文本)。 +- **N2 repository_test 空壳**(repository/repository_test.go):原全为注释空壳、CRUD 零覆盖,改为编译期断言 `var _ BaseRepository[T] = (*BaseRepo[T])(nil)` 锁定接口契约(实现与接口漂移即编译失败)。 +- **N3 test MockStorage 签名不符 + SetupRouter 文档**(test/test.go):`MockStorage.Upload` 签名对齐真实 `storage.Upload(file *multipart.FileHeader, subdir)`,新增 `UploadFromBytes` 对齐 `storage.UploadFromBytes`;`SetupRouter` 补文档说明刻意返回裸 `gin.New()`(不含框架中间件,由测试方控制)。 +- **Added**:`logger.LogManager.SetLevel`/`GetLevel`、`logger.SetLevel`/`GetLevel`、`validation.validateIDCardChecksum`(未导出)。无 breaking(新 API;身份证校验位收紧可能拒绝先前"格式正确但校验位错"的输入——这正是修复目的)。 + +#### P3 清理第二批(MEDIUM/MINOR 文档/正确性/校验) + +> 续上一批。本批以正确性修复 + 文档/命名澄清为主,风险分级处理,`go test -race` + `go vet` + `gosec` 通过。 + +- **M4 datetime StartOfWeek DST 落错日**(utils/datetime.go):原用 `t.Add(-N*24h)` 回退到周一,DST 切换日 24h ≠ 1 个日历日会落错日。改为按日历日 `time.Date(..., Day-(weekday-1), ...)` 计算,保留原时区。`ParseDateInt` 补注释说明非法输入会被 time.Date 静默规范化、调用方须校验。 +- **M11 HealthCheck 同步 ping 无超时 + WriteQuery 命名误导**(database/manager.go):包级 `HealthCheck()` 的 `sqlDB.Ping()` 改 `pingWithTimeout`(3s 超时,尊重调用方 ctx deadline),避免探针被慢/挂起的 DB 长期阻塞。`WriteQuery` 补注释说明其命名沿用历史、实际为读取语义(强制主库 read-your-writes)。 +- **M9 DSN 密码不转义 + 时区硬编**(config/config.go):MySQL DSN 密码改 `url.QueryEscape`,Postgres DSN 密码改单引号包裹+内嵌单引号翻倍,避免含 `@`/`:`/空格/引号 的密码破坏 DSN。新增 `DatabaseConfig.Timezone` 字段(MySQL loc / Postgres TimeZone 可配,空则保持原默认 `Local`/`Asia/Shanghai` 向后兼容)。 +- **M7 ToResponse 丢 Detail**(response/error.go):`Error.ToResponse()` 把 `Detail` 放入 `data.detail`(非空时),不再丢失细节信息。 +- **M14 timeout 软超时文档化**(middleware/timeout.go):注释明确软超时语义——仅注入带 deadline 的 ctx,不主动中断 handler;纯 CPU/不查 ctx 的 handler 不生效,硬中断需配合 `http.Server.WriteTimeout` 或 handler 内 `select ctx.Done`。 +- **C3 收尾:生产者取消信号契约**(sse/sse.go):包文档化断连契约——框架消费循环已监听 `c.Request.Context()` 断连即退,但生产者(LLM 流)必须自行监听同一 ctx 在取消时停止,否则上游持续运行浪费算力。`StreamText` 注释已有,补包级 doc。 +- **M20 生成器非法标识符 + fileExists 权限误判**(cmd/xlgo):`make handler my-thing` 原 `cases.Title` 得 `My-ThingHandler`(非法标识符);新增 `sanitizeIdent` 把非字母数字转下划线再 Title,得 `MyThingHandler`。`fileExists` 注释澄清权限错误的判定语义。 +- **N1 BaseModelWithTime 命名误导**(model/base.go):补注释说明与 BaseModel 唯一区别是 `type:datetime`(部分 MySQL 丢毫秒),名字 "WithTime" 易误导,保留仅为兼容。 +- **N4 Nl2br 死分支 + IsEmpty 文档不符**(utils/):`Nl2br` 的 `case '\n'` 内 `r == '\r'` 半恒假(r 恒为 '\n')已清理;`IsEmpty` 文档原称支持 slice/map 但实现仅支持 string/[]byte/nil,文档修正为实际行为。 +- **N6 sse KeepAlive 触发 onmessage**(sse/sse.go):心跳由 `data: \n\n`(触发客户端 onmessage)改 SSE 注释行 `: ping\n\n`(不产生消息事件,更符合心跳语义)。 +- **Added**:`config.DatabaseConfig.Timezone`。无 breaking(Timezone 零值保持原默认时区);DSN 密码转义对合法密码无影响(无特殊字符的密码转义后不变)。Postgres DSN 格式变更(password 加单引号)对下游 GORM 透明。 + +#### P3 安全/正确性轻量清理(一批 MEDIUM/MINOR) + +> 风险分级处理:安全与逻辑正确性项实际修复 + 针对性用例;纯文档/感知项仅加注释。全部经 `go test -race` + `go vet` + `gosec` 验证。 + +- **M15 requestid 头注入**(middleware/requestid.go):原无条件信任客户端 `X-Request-ID`,可注入 CRLF 伪造响应头/日志。新增 `sanitizeRequestID`:仅接受可见 ASCII(0x20-0x7e)、无换行、长度 ≤128,非法则忽略并重新生成。合法 ASCII ID 仍沿用客户端值(向后兼容)。 +- **C7/N7 ws CheckOrigin 默认 true(CSWSH)**(ws/ws.go):默认 `CheckOrigin` 由恒 `true` 改为同源校验(空 Origin 放行非浏览器客户端、否则要求 Origin host 与请求 Host 一致),防 Cross-Site WebSocket Hijacking。新增 `AllowOrigins(origins...)` 辅助多可信域名场景。**Breaking ⚠️**:原默认放行所有跨域 WS 连接,现拒绝跨域;依赖跨域 WS 的下游需用 `ws.SetCheckOrigin` 或 `ws.AllowOrigins(...)` 显式放行。 +- **C5/N5 HTTPClient Upload FD 累积 + 响应体无上限**(utils/http.go):`Upload` 循环内 `defer file.Close` 改为显式关闭,避免大批量上传累积 FD;`do` 的 `io.ReadAll(resp.Body)` 改 `io.LimitReader` 封顶(默认 32MB,可经 `HTTPClientConfig.MaxResponseBodySize` 配置,-1 不限),防异常服务端返回超大响应打爆内存。 +- **M16/B18 压缩写侧 defer Close 吞错**(compress/compress.go):`GzipCompressFile`/`Zip` 的 `defer gz.Close()`/`defer zipWriter.Close()`/`defer archive.Close()` 改为显式关闭并向上传播错误——flush 失败(归档损坏)不再被吞成成功返回。 +- **M6 Download 中文文件名乱码**(response/response.go):`Content-Disposition` 由直接拼接 `filename=` 改为 RFC 5987:同时给 ASCII 回退 `filename="..."` 与 UTF-8 百分号编码 `filename*=UTF-8''...`,中文等非 ASCII 文件名不再乱码。 +- **M8 CodeDataAlreadyExists 状态不一致**(response/mode.go):`CodeDataAlreadyExists` 在 ModeREST 下原落 200,与同语义的 `CodeDataConflict`(409) 不一致。映射到 409 Conflict。 +- **M10 driver 拼写错误静默回退 MySQL**(database/dialect.go):未注册驱动回退 MySQL 时新增 `logger.Warnf` 告警(含已注册驱动列表),避免拼错 driver 名(如 `postgrs`)静默回退导致连接错误难排查。 +- **M2 AddQueries 实为 Set**(utils/url.go):`AddQueries` 原用 `query.Set`(覆盖同名),与 `AddQuery`(追加)语义不一致。改为 `query.Add`,同 key 多值共存。 +- **M3 file.go 路径穿越感知**(utils/file.go):文件工具函数加包级文档警告——直接操作调用方路径不做穿越校验,不可信输入须调用方自行净化(框架 storage 包已做防护)。 +- **Added**:`ws.AllowOrigins`、`utils.HTTPClientConfig.MaxResponseBodySize`。**Breaking**:`ws` 默认 CheckOrigin 收紧为同源(见 C7)。无配置/migration 变更(MaxResponseBodySize 零值默认 32MB)。 + +#### H6:`repository/repository.go` BaseRepo 不接 GetDBFromContext + 读写分离失效 + 事务无法 join + 分页不一致(repository/repository.go, database/manager.go) + +`BaseRepo` 构造时捕获 `r.db`,所有方法 `r.db.WithContext(ctx)` 从不调 `database.GetDBFromContext` → 读写分离形同虚设(读全走主库)、外层 ctx 事务无法 join、`WithTransaction` 内方法走 `r.db` 拿不到事务。叠加 `Update` 用 `Save` 全列覆写(H6a)、`FindPage` 的 count+list 为两条独立语句高并发下 total/items 不一致(H6d)、`QueryBuilder` 终结方法未克隆且 `Count` 受残留 Limit/Offset 截断(H6e)。修复: + +- **H6c 连接路由**:新增 `readConn(ctx)`/`writeConn(ctx)`,优先级为「外层 ctx 事务(`database.TxFromContext`)> 本 repo 事务(`r.tx`)> 路由 db > `r.db` 回退」。读走 `database.GetDBFromContext`(默认从库,支持 `UseMaster`/`UseReplica`),写走 `database.GetWriteDB()`(主库,不路由到只读从库)。`DefaultManager` 未初始化(如单测注入 sqlite)时回退 `r.db`,兼容下游 `NewBaseRepo[T](database.GetDB())`。 +- **H6c 事务 join**:`BaseRepo` 新增未导出 `tx` 字段;`WithTransaction` 创建 `txRepo` 时注入 `tx`,其方法自动 join 事务。新增 `database.WithTx(ctx, tx)`/`TxFromContext(ctx)` 支持跨层/跨 repo join(外层 `database.TransactionWithContext` 拿到的 tx 经 `WithTx` 注入 ctx 后传给 repo 方法即可参与同一事务)。`WithTransaction` 签名**不变**。 +- **H6a 局部更新**:新增 `UpdateFields(ctx, model, conds...)` 基于 `gorm.Updates`(struct 仅更新非零字段、map 可显式置零),避免 `Save` 全列覆写丢失更新/零值不可辨。`Update`(Save)保留并文档化其全列覆写语义。 +- **H6b 软删除契约**:`Delete` 文档化行为契约——`T` 内嵌 `gorm.DeletedAt`/`gorm.Model` 时软删除,否则硬删除(泛型类型约束无法编译期强制)。 +- **H6d 分页一致性**:`FindPage`/`FindPageOrdered`/`FindPageWhere`/`FindPageWhereOrdered` 的 count+list 包进单事务(同一快照),消除高并发下 total/items 不一致。 +- **H6e QueryBuilder 克隆**:终结方法(`Find`/`First`/`Count`/`Page`)基于 `Session(&gorm.Session{})` 克隆,不污染 `qb.db`;`Count`/`Page` 的 count 额外 `Limit(-1).Offset(-1)` 剥离残留分页条件。文档标注 QueryBuilder 单次使用、非并发安全。 +- **Added**:`database.WithTx`/`database.TxFromContext`、`repository.BaseRepo.UpdateFields`。无既有 API 签名/配置/migration 变更(非 breaking)。行为变更:读操作默认路由到从库(原全走主库)、写操作显式走主库、分页查询包单事务(每页一次 BEGIN/COMMIT,见下)。 + +#### C10:`config/config.go` 全局 Manager 无锁置换 + 热重载绕过 Validate + StopWatcher 空函数(config/config.go) + +- **C10a 全局 Manager 无锁置换**:包级 `defaultManager` 原为裸 `*Manager` 指针,`Load`/`LoadWithWatch`/`SetDefaultManager` 直接赋值,与 `Get`/`GetViper`/`GetString` 等请求 goroutine 的无锁读存在数据竞争。改为 `atomic.Pointer[Manager]`,所有包级便捷函数经 `Load()`/`Store()` 原子读写。 +- **C10b 热重载绕过 Validate**:`OnConfigChange` 与 `Reload` 原均不调 `Validate()`(仅 `Load` 调用),非法配置(坏端口、负超时、短密钥)直接发布。`Reload` 与文件监听路径统一走 `reload()`:读取/解析/校验任一步失败均保留旧配置并返回错误,仅新配置通过 `Validate` 后才替换 `m.cfg` 并触发回调。 +- **C10c Load 返回可变指针**:`Load` 原返回 `&cfg` 与 `m.cfg` 同一指针,调用方可变并竞争。改为返回防御性浅拷贝,调用方修改返回值不污染全局读取路径。 +- **C10d StopWatcher 空函数**:原 `StopWatcher()` 为空,viper 内部 watcher goroutine + fd 永不释放。改为自管 `fsnotify.Watcher`(监听配置文件所在目录以兼容编辑器改写/k8s ConfigMap 原子替换,按文件名过滤 + 200ms 去抖),`StopWatcher` 关闭 watcher 并等待监听 goroutine 退出(`watchDone`),幂等。废弃 viper `WatchConfig`/`OnConfigChange`。 +- 无 API 签名/配置结构变更;行为变更(热重载非法配置保留旧配置而非发布、StopWatcher 真正释放监听资源)。 + +#### C11:`database/manager.go` 池泄漏 + Master/Replicas 无锁读 + 健康状态陈旧(database/manager.go) + +- **C11b InitDB 重试泄漏**:`InitDB` 原直接 `m.master = gorm.Open(...)`,`gorm.Open` 成功但 `Ping` 失败时旧池不关、下轮覆盖 `m.master`,每次重试泄漏一池。改为先打开到局部变量,仅 `Ping` 通过后才在锁内安装为 `m.master` 并关闭旧主库池;`Ping`/`DB()` 失败时关闭刚打开的池。 +- **C11c InitDBWithReplicas 泄漏**:原 `m.replicas = nil` 前不关旧从库池,且从库 `DB()`/`Ping` 失败时 `continue` 不关刚打开的池。改为重建前在锁内取出旧从库、重置健康状态后逐个 `closeDB`;从库构建失败时关闭刚打开的池;新从库先构建到局部切片再原子安装。 +- **C11a 健康状态陈旧**:`initReplicaHealth` 的 `replicaHealthSet` 早返回使重新 `InitDBWithReplicas` 后健康切片与新 replicas 长度错位。新增 `resetReplicaHealth`,`InitDBWithReplicas`/`Close` 重建/关闭前调用,使下次 `initReplicaHealth` 按新 replicas 长度重建。 +- **C11d Master/Replicas 无锁读**:`Master()`/`Replicas()` 原裸读 `m.master`/`m.replicas`,与 `Close`/`InitDB` 写竞争。改为全程持 `m.mu` 锁;`Replicas()` 返回拷贝;`Replica()` 的空从库判断移入锁内;`FromContext`/`HealthCheck`/`Transaction`/`TransactionWithContext`/`WriteQuery`/包级 `HealthCheck` 改经 `Master()`/`Replicas()` 读取;`probeOnce` 快照 `replicaHealthy` 切片头避免与重置竞争。 +- **C11f 包级 Close 仅关主库**:包级 `Close()` 原仅关 master 且无锁,命名误导致从库泄漏。改为委托 `CloseAll()`(关主+从并重置健康状态)。 +- **C11e(非缺陷)**:`RoundRobinPicker` `int(n-1)%len` 取模后仍在 `[0,len)` 内,无 panic/正确性问题;`RandomPicker` 全局 `math/rand` 仅锁竞争。属微优化,非功能 bug,未改。 +- 无 API 签名/配置结构变更;行为变更(包级 `Close` 现关闭从库、`InitDB` 重试/重建不再泄漏旧池、`Master`/`Replicas` 加锁读取)。gosec G115/G404 为 `RoundRobinPicker`/`RandomPicker` 既有项(C11e 范围外)。 + +#### C9c:`jwt/jwt.go` 包级 `DefaultJWT`/`tokenBlacklist` 无锁置换(jwt/jwt.go) + +`SetDefaultJWTManager` 原裸写包级 `DefaultJWT`/`tokenBlacklist`,与请求 goroutine(`ParseToken`/`RefreshToken`/`InvalidateToken`/`InvalidateTokenByID`/`IsTokenRevoked` 读 `tokenBlacklist`)存在数据竞争(C9c,C9a/b 已在 C9b 修复 fail-closed,此项是遗留并发隐患)。修复: +- 新增内部 `defaultManager atomic.Pointer[Manager]` 作真实存储,`init()` Store;包级函数经 `currentManager()`/`currentBlacklist()`(atomic 读取)访问,消除裸指针读写竞争。 +- `SetDefaultJWTManager` 改用 `defaultManager.Store(m)` 原子置换;移除裸写的包级 `tokenBlacklist` 变量。 +- `DefaultJWT` 保留为导出 `*Manager` 兼容别名(类型不变,非 breaking),由 `SetDefaultJWTManager` 同步维护;注释标注直接读 `DefaultJWT` 非并发安全,并发访问应用包级函数或 `SetDefaultJWTManager`。 +- 无 API 签名变更;`DefaultJWT` 类型不变(非 breaking)。行为变更:包级黑名单读写改经 atomic,`SetDefaultJWTManager` 可安全在请求期调用。 + +#### H3:`middleware/logger.go` 请求/响应 body 无上限读 → OOM(middleware/logger.go) + +`LoggerWithConfig` 在 `LogRequestBody:true` 时用 `io.ReadAll(c.Request.Body)` 无封顶读入内存,`MaxBodyLength` 仅在读完后截断**日志副本**,全 body 已驻留并二次 buffer——多 GB POST 可 OOM;响应侧 `bodyLogWriter.body` 同样无上限累积。默认 `LogRequestBody:false` 使默认安全,但 `LoggerForAPI`/`LoggerForDebug` 显式开启即暴露。修复: +- 请求体新增 `readBodyBounded(c, maxLen)`:`io.LimitReader(body, maxLen+1)` 仅向内存读入最多 `maxLen+1` 字节(+1 检测截断),通过 `io.MultiReader(已读前缀, 原始 body 剩余)` 复原 `c.Request.Body`——**下游处理器仍得完整请求体**;日志副本截断到 `maxLen`。 +- 响应体 `bodyLogWriter` 增 `maxLen` 字段,捕获缓冲区封顶;`Write`/`WriteString` 仍把完整响应写入下游 `ResponseWriter`,仅捕获缓冲区封顶。 +- `LoggerWithConfig` 入口归一化 `MaxBodyLength`:`<=0` 时回退默认值(1024),确保请求/响应两侧捕获均有上限,消除手配 `MaxBodyLength:0` 时响应侧无上限的 OOM 残留路径。 +- 无 API 签名/配置结构变更;行为变更:`MaxBodyLength` 现同时门控响应体捕获(此前响应侧无视该值无上限累积,属 bug 修正);`MaxBodyLength<=0` 不再意味"无上限",统一回退默认上限。 + +#### H7:`logger/logger.go` 全局指针写有锁读无锁 + `Field.Duration` 签名与实现矛盾(logger/logger.go, logger/field.go) + +`Init`/`Close` 持 `m.mu`(实例锁)写包级 `Logger`/`sugar`/`apiLog`/`dbLog`,但 `Info`/`Error`/`APILog()`/`DBLog()`/`Sync` 等请求期函数无锁裸读——锁与被保护对象作用域错配(实例锁保护包级全局变量),热重载 re-Init/Close 与请求日志存在数据竞争(H7a)。另 `Field.Duration` 签名为 `func(key string, value interface{})`,`case zap.Field` 分支 `return v` 丢弃 `key`,签名与实现矛盾(H7b)。修复: +- **H7a**:新增内部 `loggerPtr`/`sugarPtr`/`apiLogPtr`/`dbLogPtr atomic.Pointer[...]` 作真实存储,`init()` Store 为 Nop;`Info`/`Debug`/`Warn`/`Error`/`Fatal`/`Debugf`-`Fatalf`/`APILog`/`DBLog`/`Sync` 读路径统一经 `currentLogger()`/`currentSugar()`/`currentAPILog()`/`currentDBLog()`(atomic Load,nil 防御回退 Nop),消除请求期裸读竞争。`Init`/`Close` 在 `m.mu` 下 Store atomic。 +- **H7a 兼容别名**:`Logger` 保留为导出 `*zap.Logger` 兼容别名(类型不变,非 breaking),由 `Init`/`Close` 在 `m.mu` 下同步维护;注释标注直接读 `Logger` 变量在 re-Init/Close 期间非并发安全,并发访问应用包级函数。 +- **H7b**:`Field.Duration` 签名改为 `func(key string, value time.Duration) zap.Field`,直接委托 `zap.Duration(key, value)`,类型安全、key 不再可能被丢弃。 +- 顺带收紧 `os.MkdirAll` 日志目录权限 `0o755`→`0o750`(与 storage 目录权限一致,gosec G301)。 +- 无 API 签名/配置结构变更(`Logger` 类型不变);行为变更:包级日志读路径改经 atomic,re-Init/Close 可安全与请求日志并发。`Field.Duration` 签名变更为**类型收紧**(`interface{}`→`time.Duration`),旧调用方传 `time.Duration` 不受影响,传 `zap.Field` 等非 Duration 类型将编译失败(属修复目的)。 + +#### C12:`cron/cron.go` 数据竞争 + 重叠执行 + 漂移 + Weekly 跳周 + cron 解析缺陷(cron/cron.go) + +`cron/cron.go` 存在 5 子项缺陷(C12a–C12e)。修复: +- **C12a 数据竞争**:`runTask` 原无锁写 `LastRun`/`RunCount`,`GetTask`/`ListTasks` 返回 live 指针并发读 → data race。改为 `LastRun`/`RunCount`/`NextRun` 写入一律在 `s.mu` 写锁内;`GetTask`/`ListTasks` 返回拷贝快照(`cp := *task`)。 +- **C12b 无重叠守卫**:`checkAndRun` 每秒 tick,长任务跨 tick 被反复 spawn 同一任务并发执行。新增 per-task `running *atomic.Bool` 守卫,`checkAndRun` 与 `RunTask` 均经 `CompareAndSwap(false,true)` 占用,正在执行则跳过/返错。 +- **C12c Interval 漂移**:`NextRun` 原在 handler 完成后以 `time.Now()` 锚定,每周期累积 handler 时长。改为 `checkAndRun` spawn 前 `task.NextRun = task.Schedule.Next(task.NextRun)`(以上次 `NextRun` 锚定),`runTask`/`RunTask` 不再更新 `NextRun`。 +- **C12d Weekly 跳周**:原 `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻,当天目标未到点被跳一周。重写为 `((day-now)+7)%7` 加天数后 `!next.After(now)` 才 +7,当天未到点返回本周、已过返回下周。 +- **C12e cron 解析缺陷**:`parseInt` 忽略非数字逐位累积,`1-5,8` 因先判 `-` 被当范围(`parseInt("5,8")=58`)、`garbage`→0 误触发、`*/garbage`→step=0 匹配全部、周日 `7` 不匹配。重写 `matchField`:列表分支独立于范围分支(先按逗号拆,每项判 `*/n`/`a-b/n`/`a-b`/单值),全用 `strconv.Atoi` 返错;weekday `7→0`,范围 `lo>hi` 环绕;歧义范围 `0-7`/`7-0` 拒绝。新增 `ParseCronStrict(expr) (*FullCronSchedule, error)` 严格校验;当前 Unreleased 已进一步将 `ParseCron` 非法输入改为 fail-fast panic,旧版回退默认全 `*` 的兼容语义迁移到 `ParseCronOrDefault`。 +- 无 API 签名变更(`ParseCron` 仍返 `*FullCronSchedule`,`AddTask`/`RunTask`/`GetTask`/`ListTasks` 签名不变);新增 `ParseCronStrict`(非 breaking)。`Task` 新增未导出 `running` 字段(外部不可构造)。行为变更:`GetTask`/`ListTasks` 返回拷贝(修改返回值不影响内部状态);`RunTask` 占用守卫期间再次调用返"任务正在执行中"错误;长任务不再重叠;调度不漂移;Weekly 当天未到点不再跳周;cron 解析拒绝非法表达式。 + +#### C13:`trace/trace.go` opt-in 即崩 + 未实现导出器/传播器 + Middleware 不更新 c.Request(trace/trace.go) + +`trace/trace.go` 存在 5 子项缺陷(C13a–C13e)。修复: +- **C13a nil tracer panic**:包级 `tracer`/`tracerProvider` 原为裸指针,未 `Init` 即 nil,`Middleware`/`StartSpan`/`StartSpanFromContext`/`GetTracer` 裸用 → 首个请求 panic。改为 `atomic.Pointer` + `init()` Store Noop 兜底,`getTracer()` 永不 nil;`Init` 原子替换,`Close` Shutdown 后 Store 回 Noop(防 Close 后再用 panic)。`GetContext` 裸断言改 comma-ok。 +- **C13b 未知导出器 + stdout 缺失**:`createExporter` `default` 原返 `nil, nil` 喂 `WithBatcher(nil)`,文档承诺的 `stdout` 未实现。新增 `case "stdout"`(官方 `stdouttrace` 包);`default` 返 `fmt.Errorf`(不再喂 nil)。 +- **C13c OTLP 默认 HTTPS 无 WithInsecure**:`Config` 增 `Insecure bool`(零值 false=TLS,opt-in 明文,安全默认);`Insecure` 时 otlp-http/otlp-grpc 追加 `WithInsecure()`,对 `localhost:4318` 等明文 collector 不再握手失败。 +- **C13d Middleware 不更新 c.Request**:原仅 `c.Set("otel_ctx", ctx)`,下游 `c.Request.Context()` 拿不到 span。补 `c.Request = c.Request.WithContext(ctx)`(保留 `c.Set` 兼容)。 +- **C13e b3/jaeger 未实现**:`createPropagator` 原仅 `w3c` + default 静默回落 W3C。新增 `case "b3"`(contrib b3 propagator,单头+多头);`case "jaeger"` 映射 W3C TraceContext(现代 Jaeger agent 透传 W3C,不引入不稳定的 jaegerremix 模块);`default` 返错(不再静默回落);`Init` 在非法 propagator 时返错并回滚已创建 provider。 +- 顺带修复 `resource.Merge` SchemaURL 冲突(`resource.Default()` 与 `semconv v1.24.0` schema 不一致致 `Init` 报错)——改用空 schema URL 合并属性。 +- 新增依赖:`go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0`、`go.opentelemetry.io/contrib/propagators/b3 v1.43.0`(均与 OTel core v1.43.0 同版本族)。 +- 无 API 签名变更(`Init`/`Middleware`/`StartSpan`/`GetTracer`/`Close` 签名不变);`Config` 新增 `Insecure` 字段(零值兼容,非 breaking)。行为变更:未 Init 不 panic;未知导出器/传播器返错;`Insecure` opt-in 明文;Middleware 更新 c.Request;b3 实现、jaeger 映射 W3C。`Propagator` 空字符串现按 `w3c` 处理(兼容)。 + +#### H5:`handler` 业务码与 HTTP 状态混乱 + 丢失 RequestID(handler/handler.go) + +`handler.BadRequest`/`handler.InternalError` 原直接 `c.JSON(http.StatusBadRequest/StatusInternalServerError, response.Response{...})`:硬编 HTTP 400/500 **绕过响应模式系统**(ModeBusiness 下所有失败响应本应 HTTP 200,错误经 body code 表达),且不写 `RequestID`(对比 `response.writeResp` 在 mode.go:73 写入)——与 `response` 体系不一致、丢失链路追踪。这正是"handler 绕过 response 模式系统"反模式。修复: +- `BadRequest` 委托 `response.FailWithCode(c, CodeFail, msg)`,`InternalError` 委托 `response.ServerError(c, msg)`——复用 `writeResp` 路径,遵循当前 `Mode` 并写入 `RequestID`。 +- 无 API 签名变更;`net/http` 导入随之移除。行为变更见下方升级说明。 + +#### H4b:`middleware/ratelimit.go` CustomRateLimit goroutine 泄漏(middleware/ratelimit.go) + +`CustomRateLimit` 每次调用 `NewRateLimiter`(启动一个 cleanup goroutine)但创建的 limiter 无任何句柄,`StopRateLimiters` 仅停止 `loginLimiter`/`apiLimiter`/`uploadLimiter` 不感知自定义限流器 → cleanup goroutine 永久泄漏。修复: +- 新增包级 `customLimiters []*RateLimiter` 登记表(受 `limitersMu` 保护);`CustomRateLimit` 创建后登记入表。 +- `StopRateLimiters` / `InitRateLimiters` 经 `drainCustomLimiters()` 取出并停止已登记的自定义限流器,释放 cleanup goroutine。 +- 无 API 签名变更;无行为变更(仅修复 goroutine 泄漏,限流语义不变)。`StopRateLimiters` 现可正确停止所有自定义限流器(与 H4a 报告建议一致)。 + +#### H4c:`middleware/ratelimit.go` RedisRateLimiter fail-open + 裸断言(middleware/ratelimit.go) + +`RedisRateLimiter.Allow` 原有两个缺陷(H4c): +- **H4c-1 fail-open**:Redis 错误时 `return true, err`(放行),中间件层 `err != nil → c.Next()` 同样放行——**含登录防爆破场景静默失效**(Redis 抖动窗口限流失效,攻击者可借机爆破)。无 fail-closed 选项。 +- **H4c-2 裸断言**:`result.(int64)` 无 comma-ok,Redis 返回非 int64 时 panic(当前 Lua 脚本恒返整数不会触发,属脆弱性)。 + +修复: +- **H4c-1**:`RedisRateLimiter` 新增 `failClosed` 字段(零值 false=兼容默认 fail-open)。`Allow` 在 Redis 未启用/错误/断言失败时按策略决定:fail-closed 返 `(false, err)` 拒绝,fail-open 返 `(true, err)` 放行(兼容旧行为)。中间件层抽取 `redisLimitDecision`——不再无条件 fail-open,按 `allowed` 值决定:fail-closed 故障拒绝返 **503**(`CodeServiceUnavailable`,区别于真实超限的 429),fail-open 故障放行。 +- **H4c-2**:`result.(int64)` 改 comma-ok,断言失败返 `ErrRedisRateLimiterUnexpectedResult`(按 failClosed 策略拒绝/放行)而非 panic。 +- 新增构造函数 `NewRedisRateLimiterFailClosed`、切换方法 `SetFailClosed`、中间件 `RedisRateLimitFailClosed`/`CustomRedisRateLimitFailClosed`、导出错误 `ErrRedisRateLimiterUnavailable`/`ErrRedisRateLimiterUnexpectedResult`。 +- **`LoginRedisRateLimit` 改 fail-closed**(行为变更,见升级说明):登录防爆破场景 Redis 故障时拒绝,防限流静默失效。 +- 无 API 签名变更(既有函数签名不变);`RedisRateLimiter` 新增未导出 `failClosed` 字段。 + + + +`Allow` 每次放行都 `v.lastSeen = time.Now()`,重置分支 `time.Since(lastSeen) > window` 对持续客户端永不成立 → count 单调累加,稳态客户端(低于 rate)被误限流,须静默满 window 才解锁(算例:rate=10/min、9 req/min 客户端也会被误限)。修复: +- `visitor.lastSeen` 改名 `windowStart`,语义改为"当前固定窗口起点",仅在新窗口开始时设置,放行时不变更。 +- `Allow` 窗口过期时重置 count + 新 windowStart;放行 count++ 不更新 windowStart;超限拒绝。 +- 新增 `nowFunc` 字段 + `SetNowFunc` 导出方法(默认 time.Now,测试可注入可控时钟,避免真实 Sleep flaky)。 +- 注:固定窗口允许窗口边界突发(2×rate),如需平滑用 Redis 版滑动窗口。H4b(CustomRateLimit goroutine 泄漏)/H4c(Redis fail-open + 裸断言)属独立缺陷,后续跟进。 + +#### C9b:`jwt/jwt.go` 刷新令牌撤销失败仍签发(jwt/jwt.go) + +`RefreshToken` 原丢弃 `tokenBlacklist.Add` 错误仍 `return GenerateToken(...)`,Redis 抖动时旧 token 未拉黑、新旧 token 双有效,形成会话固定窗口。叠加 C9a:`Add`/`IsBlacklisted` 在 `client==nil` 时静默 `return nil`/`false`,黑名单失效无信号。修复: +- **C9b**:`RefreshToken` 对 `Add` 错误 `return "", fmt.Errorf(...)`,fail-closed 不签发新 token。 +- **C9a**:`Add` 无 Redis 时返 `ErrBlacklistUnavailable`(新增导出错误),让 `RefreshToken`/`InvalidateToken`/`InvalidateTokenByID` 感知黑名单不可用并 fail-closed;`IsBlacklisted` 无 Redis 仍返 false(验证侧 fail-open 是无 Redis 部署固有局限,文档约束安全场景必须启用 Redis)。 + +#### H1:`utils/random.go` 不安全 RNG 且文档反向推荐(utils/random.go) + +`randPool` 用 `math/rand` + `time.Now().UnixNano()` 播种,`RandString`/`RandDigit`/`RandInt`/`RandInt64` 取自该池,非密码学安全、可预测(`-race` 下并发同纳秒取池实例甚至生成相同序列)。GUIDE.md 原主动推荐 `RandString(16)` 用于 token、`RandDigit(6)` 用于 OTP 验证码,使可预测性可被实际利用。修复: +- 新增 `RandStringSecure(n) (string, error)` / `RandDigitSecure(n) (string, error)`,基于 `crypto/rand` + `big.Int` 索引(拒绝采样无偏),不可预测;`n>1<<20` 返 `ErrRandInvalidLength` 保护熵池。 +- `RandString`/`RandDigit` 加安全警示注释(禁止用于 token/OTP/重置码/会话 ID);保留用于非安全场景(测试数据、随机展示等)。 +- GUIDE.md token/OTP 示例改用 Secure 版本并标注错误处理;高分函数表区分"随机(安全/非安全)";移除"sync.Pool 性能"误导宣传,改为并列说明。 +- gosec G404(randPool 的 math/rand)加 `#nosec` 留痕(非安全函数,安全场景用 Secure 版本)。 + +#### H2:`utils/http.go` 默认关闭 TLS 校验(utils/http.go) + +`DefaultHTTPClientConfig.SkipTLSVerify` 原为 `true`,`NewHTTPClient()` → `HTTPGet`/`HTTPPost`/`HTTPPostJSON` 经 `DefaultHTTPClient()` 全部默认 `InsecureSkipVerify: true`,可被中间人攻击(MITM)。改为默认 `false`(校验 TLS);自签证书场景需显式 `SetSkipTLS(true)` 或配置 `SkipTLSVerify: true`。`SetSkipTLS` 注释补充安全警示。gosec G402 加 `#nosec` 留痕(默认 false,opt-in 跳过)。 + +#### H8:路由/注册中心全局单例无锁 + Apply 不幂等 + metrics 依赖调用顺序 + 三个 `/health` 行为不一(router/router.go, router/metrics.go, handler/handler.go, app.go) + +- **H8a 全局注册中心无锁 + 无 nil 守卫**:包级 `globalRegistry` 原为裸 `*Registry`,`Init` 写、`Use`/`RegisterModule`/`RegisterVersion`/`Apply` 等全局 helper 读存在数据竞争;且 `Init` 之前调用任意全局 helper 触发晦涩的 nil 解引用 panic。改为 `atomic.Pointer[Registry]`,读写均经 `Load()`/`Store()`;新增 `ensureRegistry()`,未初始化时以明确信息 panic(`router: 全局注册中心未初始化,请先调用 router.Init(engine)`),把 nil 解引用转成可定位的初始化顺序错误。 +- **H8b Apply 不幂等**:`Registry.Apply` 原无幂等位,二次调用重复 `engine.Use` 装入全局中间件并触发 Gin 重复路由 panic。新增 `applied` 标记,二次及以后 `Apply` 直接返回,中间件与路由仅装入一次。 +- **H8c metrics 依赖调用顺序**:`RegisterMetricsRoute` 原用 `r.Use(middleware.Metrics())`,Gin `engine.Use` 仅对其后注册的路由生效,先于其注册的路由不被采集(依赖调用顺序)。改为:`RegisterMetricsRoute` 仅注册 `/metrics` 暴露端点;采集中间件经新增 `Registry.SetMetricsMiddleware` 在 `Apply` 内作首个全局中间件装入,覆盖所有经注册中心注册的业务路由,不依赖注册顺序。`/metrics` 自身与 `/health` 等基础路由直接挂 engine、不经采集中间件,不被自采集(保留原意图)。 +- **H8d 三个 `/health` 行为/响应体不一**:`RegisterHealthRoute`(可 503)、`defaultModule`(恒 200 `{"status":"ok"}`)、`handler.HealthCheck`(恒 200 经 `response.Success` 包成 `{code,msg,data}` 信封)三处 schema/行为各异。抽取统一 `healthHandler(checks)`,`RegisterHealthRoute`/`RegisterReadinessRoute`/`defaultModule` 均委托之;`handler.HealthCheck` 响应体收敛为 `{"status":"ok"}`(不再走 response 业务信封),便于 K8s 探针直读。 +- **H8d 收尾:defaultModule 与 Register* 并存重复路由 panic(footgun)**:`defaultModule`(经 `WithModules` 注册 `/health`+`/swagger/*any`)与 `RegisterHealthRoute`/`RegisterSwaggerRoutes`(经 `WithDefaultRoutes` 注册同名路由)并存时触发 Gin `handlers are already registered` panic。新增 `registerGETOnce(r, path, h)` 幂等注册辅助,`RegisterHealthRoute`/`RegisterLivenessRoute`/`RegisterReadinessRoute`/`RegisterSwaggerRoutes`/`RegisterMetricsRoute`/`defaultModule` 全部经之:(GET, path) 已存在则静默跳过,首次注册胜出。`*gin.Engine` 经 `Routes()` 精确预检(不吞 panic,真正不同的路由冲突仍按 gin 原语义 panic);`*gin.RouterGroup`(gin 未暴露 engine,无法预检)用 recover 兜底,仅吞 gin 重复路由 panic(`already registered` / `conflicts with existing wildcard`),最坏情况退化为原行为。 +- **Breaking ⚠️**:`handler.HealthCheck` 响应体由 `{code,msg,data:{status:"ok"}}` 改为 `{"status":"ok"}`,与 `router.RegisterHealthRoute` 同 schema。直接断言旧信封字段的下游需改断言 `status` 字段。需依赖探活(mysql/redis 失败 503)时改用 `router.RegisterHealthRoute(checks...)`。 +- **Changed**:框架基础路由注册(health/livez/readyz/swagger/metrics/defaultModule)改为幂等,重复注册静默跳过(首次胜出)——消除并存组合的 panic footgun,非破坏性(原本重复注册即 panic,现安全跳过)。 +- **Added**:`router.Registry.SetMetricsMiddleware`、`router.registerGETOnce`/`ensureRegistry`/`healthHandler`(未导出)。无配置/migration 变更。 + +#### C3:`sse/sse.go` 断连泄漏 goroutine + 算力(AI 主场景,sse/sse.go) + +- **C3b 写/Flush 错误被吞**:`WriteEvent`/`WriteMessage` 原丢弃 `fmt.Fprintf` 错误且恒 `return nil`,导致 `StreamText` 等的 `if err := WriteJSON(...); err != nil` 守卫只对 marshal 失败生效、对客户端断连永不触发 → 消费循环不退出 + 上游 LLM 流持续运行直到进程结束。改为传播 `fmt.Fprintf` 写错误。 +- **C3a 循环无 ctx.Done**:`Stream`/`StreamText`/`StreamChunks`/`StreamWithID` 的 `for range ch` 改 `for { select { case <-ctx.Done(): return ctx.Err(); case v,ok:=<-ch: ... } }`,客户端断连即退出。`SSEWriter` 加 `ctx` 字段(NewSSEWriter 存 `c.Request.Context()`),并对 nil ctx 回退 `context.Background()` 防御。 +- **C3c 手设 chunked 头**:删除 `Transfer-Encoding: chunked`(HTTP/1.1 冗余、HTTP/2 非法),交由 server 自动分帧。 +- 生产者契约文档化:`StreamText` 注释说明生产者应监听 `c.Request.Context()`,取消时停止上游 LLM 流(框架无法单方面停止生产者)。 + +#### C7:`middleware/cors.go` 通配符后缀绕过 + 开发态任意 Origin 回显(middleware/cors.go) + +- **C7a 通配后缀绕过**:`*.example.com` 原用 `strings.HasSuffix(origin, domain)` 未锚定 host 边界,`https://notexample.com`、`https://evil-example.com` 被接受。改用 `net/url` 解析 origin 的 host,要求 host 以 `.domain` 结尾(真实子域边界)且不等于 apex 自身。抽取 `matchOrigin` 函数(精确匹配 + 通配子域,大小写不敏感、支持端口与 FQDN 尾点)。 +- **C7b 开发态任意 Origin 回显**:开发态原无条件回显任意 Origin,若同时 `AllowCredentials=true` 构成凭据型反射。改仅对 localhost/127.0.0.1/::1 回显(`isLocalhostOrigin`),杜绝任意站点携凭证访问。 +- **C7 收尾(信息泄露收敛)**:未匹配 origin 时不再发送 `Access-Control-Allow-Methods`/`Allow-Headers`/`Expose-Headers`/`Max-Age`,避免向未授权 origin 暴露 API 允许的方法/头清单。这些头现仅在 origin 匹配时随 `Allow-Origin` 一并发送。 + +#### C1:`cache/lock.go` 分布式锁 panic/泄漏/裸断言(cache/lock.go) + +- **C1a `WithLockAutoExtend` send-on-closed panic + 锁泄漏**:续期改"父关停 + 子 ack"双 channel(`close(stop)` + `<-finished`),消除旧 `done <- struct{}{}` 向已关闭 channel send 的 panic。`Unlock` 用 `context.WithTimeout(context.Background(), 5s)` 派生超时,避免原 ctx 已取消致解锁失败再泄漏。`fn()` panic 路径加 `defer` 兜底,保证 panic 时也停止续期 goroutine 并释放锁(独立复审发现 CRITICAL)。 +- **C1a 一致性(HIGH)**:`WithLock` 的 `defer Unlock` 同改 Background 超时 ctx + defer 兜底,与 `WithLockAutoExtend` 一致。 +- **C1b 裸类型断言**:新增 `toInt64(v)` 辅助函数(comma-ok),`NewLock`/`Unlock`/`ExtendLock` 三处 `result.(int64)` 改用之,断言失败返 `ErrLockUnexpectedResult` 而非 panic。新增导出错误 `ErrLockUnexpectedResult`。 +- **C1c `TryLock` 忽略 ctx**:`time.Sleep` 改 `select { ctx.Done()/time.After }`,响应取消。 +- **C1d 无 fencing token**:`LockToken` 文档化设计局限(需 Redis INCR + 下游校验,框架无法单方面保证),不引入破坏性数据结构变更。 + +#### C2:`ws` Hub 死锁 + send-on-closed panic + 半开连接泄漏(ws/ws.go) + +- **C2a 广播死锁**:`Hub.Run` 的 broadcast 分支原在 `conn.Send` 失败时 `h.unregister <- conn`(向自身消费的 channel 发送,无接收者)→ 永久阻塞、整个 Hub 卡死。改持写锁单次遍历,失败连接行内 `delete + conn.Close()`,去掉 channel 回环。`Send` 改非阻塞投递(缓冲满返回 `ErrSendBufferFull`),避免持写锁期间因慢消费者/已死连接阻塞最长 `pongWait` 导致 Hub stall(C2a-residual)。 +- **C2b send-on-closed panic**:`Close()` 不再 `close(c.send)`,仅 `close(c.closeChan)` + `c.conn.Close()`;`Send` 前置 `IsClosed()` 快速失败 + select 兜底。消除 `Close` 与并发 `Send` 的 send-on-closed panic。 +- **C2c 半开连接泄漏**:`Handle` 读循环前置 `SetReadDeadline(pongWait)` + `SetPongHandler`(重置读 deadline);`writePump` 每次写前 `SetWriteDeadline(writeWait)`、ping 周期 `pingPeriod = pongWait*9/10`;写失败主动 `Close` 触发读循环退出,加速半开连接回收。新增常量 `pongWait=60s`/`pingPeriod=54s`/`writeWait=10s` 与导出错误 `ErrSendBufferFull`。 + +#### C5:`compress` Zip-Slip + 解压炸弹(compress/compress.go) + +- **C5a Zip-Slip**:`unzipFile` 改用 `filepath.Join` + 前缀锚定(`absDst+sep`),拒绝条目名含 `..` 逃逸、绝对路径、以分隔符开头;拒绝符号链接条目(`ModeSymlink`)防经软链二次穿越。修复前 `file.Name` 可含 `../`,`os.Create` 覆盖任意文件。 +- **C5b 解压炸弹**:`GzipDecompress` 由 `io.ReadAll` 改 `io.LimitReader`;`GzipDecompressFile`/`Unzip` 由 `io.Copy` 改 `io.CopyN` 单条目封顶 + Unzip 累计封顶。新增 `DecompressOptions{MaxBytes, MaxTotalBytes}`(0=默认,-1=不限)与 `*WithOptions` 变体;默认单流/单条目 100MB、Unzip 累计 1GB。 + +#### C4:`storage` 路径穿越 + 无上传校验 + Get OOM(storage/storage.go) + +- **C4a 路径穿越**:Local 的 `Delete/Get/Exists/Upload/UploadFromBytes` 的相对路径全经新增 `safeJoin` 前缀锚定(`rootAbs+sep`),拒绝绝对路径/NUL/`..` 逃逸,杜绝任意文件删/读/探测与任意目录写。OSS 的 `Delete/Get/Exists/Upload/UploadFromBytes` 全经新增 `sanitizeObjectKey` 拒绝含 `..`/绝对路径/空/NUL 的 key。 +- **C4b 上传校验**:新增可配置 `UploadPolicy{MaxSizeBytes, AllowedExts, AllowedMIMEs}`(嵌入 `local`/`oss` 配置)。`AllowedMIMEs` 非空时用 `http.DetectContentType` 嗅探前 512B(取主类型比较)并拼回头部。零值不限(兼容下游)。 +- **C4c Get 读封顶**:Local `Get` 由 `os.ReadFile` 改为 `io.LimitReader` 封顶,OSS `Get` 同理;默认上限 100MB(`max_read_bytes`:0=默认,-1=不限,正数=该值),防全量读入内存 OOM。 +- **HIGH(跨平台)**:OSS object key 拼接由 `filepath.Join`(Windows 产 `\`)改 `path.Join` + `sanitizeObjectKey` 归一化 `\`→`/`,保证 Windows/Linux 部署 key 一致。 +- 附:`MkdirAll` 权限 0755→0750(gosec G301)。 + +### 升级说明 🛠️ + +- **H6 行为变更(非破坏性,正向修复)**: + - `BaseRepo` 读操作(`FindByID`/`FindAll`/`FindPage`/`FindWhere`/`Count`/`Exists`/...)默认路由到**从库**(原全部走构造时捕获的主库),支持 `database.UseMaster(ctx)`/`UseReplica(ctx)` 显式路由。未配置从库时仍走主库(`Replica()` 无从库回退主库)。 + - `BaseRepo` 写操作(`Create`/`Update`/`Delete`/`*Batch`/`Restore`/...)显式走**主库**,即便 ctx 标记 `UseReplica` 也不写到从库。 + - `FindPage*` 的 count+list 现包进单事务(每页一次 BEGIN/COMMIT)以保证 total/items 快照一致。高频分页接口会有极小额外往返开销;若不可接受可自行用 `QueryBuilder.Page`(轻量、不包事务)。 + - 跨层/跨 repo 事务 join:外层 `database.TransactionWithContext`(或任意 `*gorm.DB` 事务)中,用 `database.WithTx(ctx, tx)` 注入 ctx 后传给 `BaseRepo` 方法即可参与同一事务。 + - 新增 `BaseRepo.UpdateFields`(局部更新,推荐替代 `Update` 的全列覆写);`Update`(`Save`)行为不变但文档化其全列覆写语义。 + - `QueryBuilder` 标注为单次使用、非并发安全;终结方法现克隆不污染构建器,`Count`/`Page` 的 count 剥离残留 Limit/Offset。 + - 无 API 签名/配置/migration 变更;下游 `NewBaseRepo[T](database.GetDB())` 用法完全兼容。 +- **C8 行为变更(非破坏性)**:panic 响应的 HTTP 状态由 200(ModeBusiness)改为 500,body 不变(`code:500` + msg + `request_id`)。ModeREST 行为不变。下游若按"panic 返 200"做适配(极罕见)需注意。已知局限:若 handler 在 panic 前已 flush 部分响应,HTTP 状态无法再改写(HTTP 固有局限,非本次引入)。 +- **C6 行为变更**:API 模式 CSRF token 改为单次消费(每次成功 POST 后需重新 `GenerateAPIToken`)+ 30min TTL;`DoubleSubmitCookie` 的 cookie `HttpOnly` 由 true 改 false。原 API 模式整体不可用,故无真实回归。 +- **C4 行为变更(非破坏性)**: + - 含 `..`/绝对路径的 `Delete/Get/Exists` 路径现被拒绝(`ErrPathTraversal`),合法相对路径不受影响。 + - `Get` 默认读取上限 100MB,超限返回错误;需读大文件请配置 `storage.local.max_read_bytes: -1`(不限)或具体值。OSS 同理(`storage.oss.max_read_bytes`)。 + - 上传目录权限 0755→0750(仅 owner/group 可访问)。 + - 新增可选配置 `storage.local.upload` / `storage.oss.upload`(`max_size_bytes`/`allowed_exts`/`allowed_mime_types`),零值不限制以兼容现有下游;生产环境建议显式配置。 + - 安全约束:本地存储根目录应为框架独占,不与用户可控内容混用(防符号链接二次穿越)。 +- **C5 行为变更(非破坏性)**: + - `Unzip` 现默认拒绝含 `..`/绝对路径的条目(`ErrPathTraversal`)与符号链接条目(`ErrSymlinkEntry`),合法归档不受影响。 + - `GzipDecompress`/`GzipDecompressFile`/`Unzip` 默认解压上限:单流/单条目 100MB、Unzip 累计 1GB,超限返回 `ErrDecompressLimit`。需解压更大文件用 `*WithOptions` 变体设 `MaxBytes: -1`(不限)或具体值。 + - 原函数签名保留;新增 `GzipDecompressWithOptions`/`GzipDecompressFileWithOptions`/`UnzipWithOptions`。 +- **C2 行为变更(非破坏性)**: + - `Connection.Send` 改为非阻塞投递:发送缓冲满时返回 `ErrSendBufferFull`(新导出错误)而非阻塞等待。原阻塞语义的下游需改为重试或关闭连接。 + - `Hub` 广播对发送失败(含缓冲满/已关闭)的连接行内移除并关闭——慢消费者会被踢除(ws 广播 best-effort 语义)。 + - WebSocket 连接现启用读写超时与 ping/pong 心跳:半开连接在 `pongWait`(60s)内退出,不再永久阻塞 goroutine。 + - 公共 API 签名不变;`Connection.send` 不再被 close(内部行为)。 +- **C1 行为变更(非破坏性)**: + - `WithLockAutoExtend`/`WithLock` 的解锁改用独立 `context.Background()` 超时(5s),原 ctx 已取消也能解锁(语义:取消业务 ≠ 继续独占锁)。 + - `WithLockAutoExtend` fn panic 时仍释放锁(defer 兜底)。 + - `TryLock` 重试等待响应 ctx 取消。 + - 新增导出错误 `ErrLockUnexpectedResult`(Lua 返回非 int64)。 +- **C7 行为变更(非破坏性,收紧)**: + - `*.example.com` 通配不再匹配 `notexample.com`/`evil-example.com` 等后缀相同但非真实子域的域名;apex `example.com` 不由通配覆盖,需显式配置。 + - 开发态 CORS 兜底仅对 localhost/127.0.0.1/::1 回显 Origin,不再回显任意 Origin。原本依赖开发态回显任意域名的下游需改用显式白名单。 + - 未匹配 origin 的响应不再携带 `Access-Control-Allow-Methods`/`Allow-Headers`/`Expose-Headers`/`Max-Age`(信息泄露收敛);匹配时正常发送。 +- **C3 行为变更(非破坏性)**: + - `StreamText`/`StreamChunks`/`StreamWithID`/`Stream` 在客户端断连(`c.Request.Context()` 取消)时返回 `context.Canceled` 而非永久阻塞。 + - `WriteEvent`/`WriteMessage` 现可能返回写错误(旧实现恒 nil);下游若忽略返回值不受影响。 + - 响应不再手设 `Transfer-Encoding: chunked`。 + - 公共 API 签名不变;`SSEWriter` 新增私有 `ctx` 字段(外部字面量构造需走 `NewSSEWriter`)。 +- **H2 行为变更(可能影响下游)**:`utils` HTTP 客户端默认**校验 TLS**(`DefaultHTTPClientConfig.SkipTLSVerify` 由 `true` 改 `false`)。`HTTPGet`/`HTTPPost`/`HTTPPostJSON`/`NewHTTPClient()` 不再默认跳过证书校验。下游访问**自签证书**的内网/开发服务会因证书校验失败报错,需显式 `client.SetSkipTLS(true)` 或 `NewHTTPClientWithConfig(HTTPClientConfig{SkipTLSVerify: true})`。生产环境应保持默认校验。 +- **H1 行为变更(Breaking,删除函数)**: + - **删除** `utils.RandString` / `RandDigit`(math/rand 版本)。字符串随机的用途几乎都是安全场景(token/OTP/验证码/会话 ID),保留 math/rand 版本会诱导误用(H1 的根因正是 GUIDE 推荐 RandString 做 token)。下游迁移: + - token/OTP/验证码/会话 ID → `RandStringSecure` / `RandDigitSecure`(crypto/rand)。 + - 非安全场景需高性能随机串 → 直接用标准库 `math/rand`。 + - **保留** `RandInt` / `RandInt64`(范围随机,有明确非安全场景:负载均衡/游戏/A-B 分桶),加非安全警示注释。 + - 新增 `RandStringSecure` / `RandDigitSecure`(基于 `crypto/rand`,返 `(string, error)`)与 `ErrRandInvalidLength`。 + - 新增 `RandIntSecure` / `RandInt64Secure`(基于 `crypto/rand` + `big.Int` 拒绝采样无偏,返 `(T, error)`),用于安全 nonce 范围、防猜抽奖、密钥分桶等。 + - GUIDE 示例改用 Secure 版本。 +- **C9b 行为变更(可能影响下游)**: + - `jwt.RefreshToken` 在旧 token 撤销失败(Redis 不可用/抖动)时**不再签发新 token**(fail-closed),返回错误。原行为是丢弃错误仍签发,致新旧 token 双有效。 + - `jwt.InvalidateToken`/`InvalidateTokenByID`/`TokenBlacklist.Add` 在无 Redis 时返回 `ErrBlacklistUnavailable`(新增导出错误),不再静默成功。 + - `IsBlacklisted` 无 Redis 仍返 false(验证侧 fail-open,无 Redis 部署固有局限——安全敏感场景必须启用 Redis)。 + - 签名不变;新增 `ErrBlacklistUnavailable`。 +- **H4a 行为变更(非破坏性)**:内存限流器 `RateLimiter` 改固定窗口语义,稳态客户端(低于 rate 的持续请求)不再被误限流。`Allow`/`NewRateLimiter`/`Stop` 签名不变;新增 `SetNowFunc`(测试用可控时钟)。 +- **H5 行为变更(非破坏性)**:`handler.BadRequest`/`handler.InternalError` 不再硬编 HTTP 400/500,改为遵循当前响应模式(委托 `response` 体系):默认 `ModeBusiness` 下两者均返回 HTTP 200(错误经 body `code` 表达,与 `response.Fail*` 一致);`ModeREST` 下 `InternalError` 返回 500(`CodeServerError` 映射),`BadRequest` 返回 200(`CodeFail` 属业务失败不映射 HTTP 错误,与 `response.Fail` 一致)。两者现写入 `RequestID`。下游若依赖 `handler.BadRequest` 恒返 400 需改用 `response.Custom(c, 400, code, msg, nil)` 或业务自定义 4xxxx 错误码。 +- **H4c 行为变更(可能影响下游)**: + - **`LoginRedisRateLimit` 改 fail-closed**:Redis 故障/未启用时由放行改为拒绝(HTTP 503,`CodeServiceUnavailable`)。登录防爆破场景下 Redis 故障不再静默放行(原 fail-open 致限流失效)。下游登录接口须确保 Redis 可用,否则登录会在 Redis 故障时不可用(安全语义:宁拒勿放)。 + - 其余 Redis 限流中间件(`RedisRateLimit`/`APIRedisRateLimit`/`UploadRedisRateLimit`/`CustomRedisRateLimit`/`RedisRateLimitWithIdentifier`)保持 fail-open(兼容默认)。 + - 新增 fail-closed 变体:`RedisRateLimitFailClosed`/`CustomRedisRateLimitFailClosed`/`NewRedisRateLimiterFailClosed`/`SetFailClosed`,供安全敏感场景选用。 + - 新增导出错误 `ErrRedisRateLimiterUnavailable`/`ErrRedisRateLimiterUnexpectedResult`。 + - 无既有 API 签名变更。 +- 无 API 签名变更、无 migration。新增测试依赖 `github.com/alicebob/miniredis/v2`。 + +--- + +## [1.1.1] - 2026-06-23 + +> 本版本为 v1.1.0 的补丁发布:补 ServerConfig.Host 字段、统一面向用户文案为中文、修正 README 过时/错误描述。 + +### Added ✨ + +#### ServerConfig.Host(绑定地址) + +`server` 新增 `host` 字段,控制监听地址: + +- `host: ""`(默认)→ `:8080`,监听所有接口(0.0.0.0),向后兼容 +- `host: "127.0.0.1"` → `127.0.0.1:8080`,仅本机(前面有 nginx 时常用) +- `host: "10.0.0.5"` → 绑定内网网卡 + +避免生产环境无意暴露在 0.0.0.0。启动日志相应区分"所有接口"/指定地址。 + +### Changed 🔄 + +#### 面向用户文案统一中文 + +v1.1.0 前部分面向用户/调用的文案为英文,与其余中文文案不一致。本次统一为中文: + +- `middleware/recover.go`:`"Panic recovered"` → `"panic 已恢复"`;`"Panic: %v"` → `"服务器内部错误: %v"`(消除同文件内中英矛盾) +- `middleware/logger.go`:5 处日志消息(慢请求/请求错误/客户端请求错误/API 请求/请求)改中文 +- `middleware/metrics.go`:3 个 Prometheus `Help` 文本改中文 +- `app.go` / `database/manager.go` / `logger/logger.go`:英文 error 改中文 + +**保留英文**(非文案,属协议/约定/技术必需):JSON 字段名(`code`/`msg`/`data`)、health 探针状态枚举(`ok`/`error`/`disabled`)、Prometheus metric `Name`(命名规则限制)、`database/manager.go` 中匹配 MySQL 驱动错误串的英文(`"Access denied"` 等,改了会失效)、Redis/CSRF Token/JWT/OSS 等技术专有名词。 + +### Fixed 🐛 + +#### README 错误描述修正 + +v1.1.0 后 README 存在过时/错误描述,照抄会导致新用户启动失败,本次修正: + +- **删除目录结构里已移除的 `wire/` 段**(wire 包 v1.1.0 已删) +- **快速开始配置示例**:`jwt.secret` 补足 ≥32 字节(否则被 v1.1.0 `Validate` 拦截启动失败);`expire: 86400`(int 秒)改为 `expire: "24h"`(`time.Duration`),补 `refresh_expire`/`issuer`/`algorithm` +- `server` 段补 `host`/`read_timeout`/`write_timeout`/`idle_timeout`/`shutdown_timeout`/`response_mode` 字段 +- v1.0.2 更新日志标注 `WithWire` 已于 v1.1.0 移除 +- 目录结构补 v1.1.0 新文件:`middleware/metrics.go`、`middleware/timeout.go`、`router/metrics.go`、`config/validate.go`、`response/mode.go` +- 框架特性段重写为三组(架构可注入 / 生产就绪 / 基础功能),补全 v1.1.0 能力 +- 响应格式段补 `Mode` 开关(`ModeBusiness`/`ModeREST`)与 `Custom` API + +#### README 首段重写 + +原首段描述("轻量级 Web 开发框架,提供完整后端基础设施")过于普通化,适用于多数 Gin 脚手架,无辨识度。重写为: + +- tagline 点明核心差异:组件全部 Manager 化,简单调用与注入实例兼得 +- "为什么是 xlgo"段:对比一般 Gin 脚手架的包级单例痛点 + 对照代码 +- 5 条差异化卖点(可注入 / 生产就绪内置 / 零 Fatal / 默认轻量 / 可插拔方言) +- 30 秒上手极简可跑示例 + +### 升级说明 🛠️ + +从 v1.1.0 升级无破坏性变更,`go get github.com/EthanCodeCraft/xlgo-core@v1.1.1` 即可。`host` 字段默认空,行为与 v1.1.0 一致。 + +--- + +## [1.1.0] - 2026-06-23 + +> 本版本定位为 **HA & Manager 化 release**:高可用与生产就绪改进 + 组件 Manager 化。对应体检报告 #10-#24。 +> 含少量破坏性变更,升级前请阅读下方「升级说明」。 + +### Breaking ⚠️ + +详见下方「升级说明」。 + +- 删除 `wire` 包及其 `WithWire` Option(其事 App Option 已覆盖)。`WithoutWire` 保留为空 stub 以兼容调用。 +- 删除 `AppConfig.TokenExpire` 字段(与 `JWTConfig.Expire` 重复),过期统一由 `jwt.expire` 配置。 +- `JWTConfig.Expire` 类型由 `int`(秒)改为 `time.Duration`(如 `"24h"`)。 +- 删除 `StartServerWithPort` 与 `GracefulShutdown` 双轨函数(与 `App.StartServer`/`App.Shutdown` 重复)。 + +### Added ✨ + +#### 组件 Manager 化(#10) + +storage / cache / redis / jwt / logger 五个组件照 `database.Manager` 模式新增 `XxxManager` + `DefaultXxx` + `SetDefaultXxxManager`,包级 facade 保留兼容存量。支持多实例与测试注入 mock: + +```go +// 注入自定义实现 / 多实例 +database.SetDefaultRedisManager(myRedisMgr) +cache.SetDefaultCacheManager(mockCacheMgr) +jwtMgr := jwt.NewJWTManagerWithRedis(refreshRedisClient) // 独立黑名单 +``` + +#### Lifecycle Hooks(#12) + +```go +xlgo.New( + xlgo.WithHook(xlgo.Hook{ + Name: "register-service", + OnStart: func(a *xlgo.App) error { return registerToDiscovery() }, + OnStop: func(a *xlgo.App) error { return deregisterFromDiscovery() }, + }), +) +``` + +各阶段:`OnInit`(Init 内组件就绪后)/ `OnStart`(监听前)/ `OnReady`(端口就绪后)/ `OnStop`(Shutdown 开头)。 + +#### App.Go + in-flight goroutine(#22) + +`App.Go(func(ctx context.Context))` 启动受 App 生命周期管理的后台 goroutine,Shutdown 时 cancel ctx 并 `wg.Wait`(带 `shutdown_timeout` 超时),避免业务异步任务被进程退出强制砍掉。 + +#### Server 参数配置化(#13) + +`server` 新增 `read_timeout`/`write_timeout`/`idle_timeout`/`shutdown_timeout`/`max_header_bytes`/`tls`/`unix_socket`/`response_mode`,缺省回退原硬编码值。支持 TLS 与 unix socket 监听。 + +#### JWTConfig time.Duration(#14) + +`jwt.expire`/`refresh_expire` 用 `time.Duration`(`"24h"`/`"168h"`),新增 `issuer`/`algorithm`(HS256/HS384/HS512)。删除冗余的 `AppConfig.TokenExpire`。 + +#### Config Validate(#16) + +`Config.Validate()` 在 `Manager.Load` 解析后自动调用,校验端口范围、JWT 密钥长度(≥32 字节)、启用数据库时关键字段、TLS 证书、Duration 非负等。把配置错误从"运行时第一次请求"提前到"进程启动"。 + +#### response REST 模式(#15) + +`response.SetMode(ModeBusiness|ModeREST)`,默认 `ModeBusiness`(全 200 + 业务码,兼容存量)。`ModeREST` 下失败响应按错误码映射 HTTP status(401/404/429/500...),body 仍带业务码,便于 APM/Prometheus/网关按 status 区分异常。可在 `server.response_mode` 配置。新增 `response.Custom(c, httpStatus, code, msg, data)`。 + +#### livez / readyz(#17) + +```go +xlgo.New(xlgo.WithLivenessRoute(), xlgo.WithReadinessRoute()) +// GET /livez 永不依赖外部,始终 200(K8s livenessProbe) +// GET /readyz 复用 healthChecks,失败 503(K8s readinessProbe) +``` + +`/health` 保留兼容。`WithFullStack`/`NewFullStack` 默认启用。 + +#### Prometheus metrics(#18) + +```go +xlgo.New(xlgo.WithMetricsRoute()) // 默认 /metrics +``` + +`middleware.Metrics()` 采集 `http_requests_total` / `http_request_duration_seconds` / `http_requests_in_flight`。新增 `prometheus/client_golang` 依赖。 + +#### 请求级 Timeout 中间件(#19) + +`middleware.Timeout(d)` 为每个请求的 context 设 deadline,下游 GORM/Redis 走 `c.Request.Context()` 级联取消。可通过 `WithRequestTimeout(d)` 装入全局。 + +#### 依赖健康自愈(#21) + +`database.Manager` 后台探活(`App.Go` 启动,每 `health_check_interval` ping 一次):主库连续失败达阈值标记不健康,`/readyz`/`/health` 返回 503;从库失败临时剔除读流量,恢复自动重新纳入。新增 `database.ConnMaxIdleTime`/`health_check_interval`/`health_check_failure_threshold` 配置。 + +#### RequestID 默认装入(#24) + +`App.Init` 无条件装入 `middleware.RequestID()`(在 Recovery 之前),让每个响应/panic 日志都带 `request_id`。移除 `gin.Recovery()` 双重保险,统一用 `middleware.Recover()`(#23 已带 request_id)。 + +### 升级说明 🛠️ + +1. **wire 包删除**:移除 `import "github.com/EthanCodeCraft/xlgo-core/wire"` 与 `wire.InitServices()`/`WithWire()` 调用。原由 wire 触发的 `cache.Init()` 现由 `WithRedis` 自动触发。 +2. **AppConfig.TokenExpire 删除**:改用 `jwt.expire` 配置 token 过期。grep `token_expire` 清理旧配置。 +3. **JWTConfig.Expire 类型变更**:YAML 由 `expire: 86400`(秒)改为 `expire: "24h"`(Duration 字符串)。代码中 `time.Duration(cfg.JWT.Expire) * time.Second` 改为直接 `cfg.JWT.Expire`。 +4. **StartServerWithPort / GracefulShutdown 删除**:改用 `App.Run()` / `App.Shutdown()`。 +5. **JWT 密钥长度**:`Config.Validate` 要求启用 JWT 时 secret ≥32 字节,原短密钥会在启动期被拦截,请生成足够长的随机密钥。 +6. **配置文件**:建议补 `server.read_timeout` 等字段(缺省自动回退,不强制),`jwt.expire` 必须改为 Duration 字符串。 + +--- + +## [1.0.4] - 2026-06-22 + +> 本版本定位为 **DX & Docs release**:开发体验与文档改进,无破坏性 API 变更。对应体检报告 #25/#27/#28/#29/#30。 + +### Added ✨ + +#### CLI 多模板(#28) + +`xlgo new` 新增 `--template` 参数,支持三种脚手架模板: + +```bash +xlgo new myapp --template minimal # 轻量 HTTP,无 MySQL/Redis 依赖 +xlgo new myapp --template api # 标准业务 API,含分层目录(默认) +xlgo new myapp --template fullstack # 全组件,NewFullStack 一键启用 +``` + +- `minimal`:仅 logger + health + 示例路由,目录结构最小化,第一次接触 xlgo 从这里开始 +- `api`:含 handler/model/repository/service 分层 + MySQL/Redis/JWT 配置(默认模板) +- `fullstack`:`NewFullStack` 全组件 + Swagger + Storage + +#### examples/ 目录(#29) + +新增两个可运行示例,帮助快速上手: + +- `examples/minimal/` — 50 行可跑,不依赖外部服务 +- `examples/full/` — MySQL + Redis + JWT + user CRUD 完整示例(登录发 token、认证路由、创建/查询用户) +- `examples/README.md` — 运行说明与接口文档 + +#### docs/ 文档结构(#30) + +- 新增 `docs/` 目录,`docs/plans/` 归档历史规划与体检报告 +- 新增 `docs/README.md` 文档索引 +- `Version_Update_Plan_v1.0.2.md` → `docs/plans/` +- `Version_v1.0.2_report.md` → `docs/plans/` +- 早期 `report.md` → `docs/plans/v2.0-review.md` +- `CHANGELOG.md` / `GUIDE.md` 按惯例保留在仓库根目录 + +### Changed 🔄 + +#### 模块路径文档改进(#25) + +经评估**保留** `xlgo-core` 模块路径——`-core` 后缀反映这是 xlgo 多产品系列(xlgo-core / xlgo-orm / xlgo-ai ...)的核心产品,不去掉。模块路径(`github.com/EthanCodeCraft/xlgo-core`)与包名(`xlgo`)不同是 Go 惯例(cf. `github.com/gin-gonic/gin` → 包名 `gin`)。 + +改进文档说明,消除新用户 `go mod tidy` 撞墙的困惑: + +- README 快速开始新增「模块路径与包名」小节,给出完整 import 示例: + ```go + import xlgo "github.com/EthanCodeCraft/xlgo-core" + ``` +- CLAUDE.md `Import Path Note` 措辞明确化,说明 module path / package name / `-core` 语义 + +#### Without* Option 定位文档化(#27) + +经调研 `Without*` 系列 Option 有真实用例(测试覆盖「先开再关」语义 + `NewFullStack` 后排除单项),**不删除、不标 Deprecated**,改为文档化其定位: + +- `app.go` `WithoutLogger` 注释说明:`Without*` 主要用于 `NewFullStack` / `RunFullStack` 启用全部组件后排除个别项 +- README 快速开始补充用法说明:`xlgo.NewFullStack(xlgo.WithoutSwaggerRoutes())` + +### 依赖与构建 + +- `.gitignore` 整理:忽略 `CLAUDE.md`、构建产物(`*.exe` / `bin/`)、临时发版文件(`gitHub_release_*.md`) + +### 升级说明 + +v1.0.4 **无破坏性变更**,从 v1.0.3 升级只需: + +```bash +go get github.com/EthanCodeCraft/xlgo-core@v1.0.4 +go mod tidy +``` + +--- + +## [1.0.3] - 2026-06-22 + +> 本版本定位为 **bug fix release**:收口 v1.0.2 引入的破坏性清理,并修复 4 个轻量 bug + 依赖复查。 + +### Removed 🗑️ + +#### ⚠️ Breaking — 清理 v1.0.2 兼容别名(database 包) + +xlgo 仍是早期框架,本次彻底移除 v1.0.2 临时保留的兼容别名,避免长期累积技术债。 + +**移除内容**: + +- `database.InitMySQL(cfg)` 包级函数 +- `database.InitMySQLWithReplicas(cfg, replicas)` 包级函数 +- `(*Manager).InitMySQL(cfg)` 实例方法 +- `(*Manager).InitMySQLWithReplicas(cfg, replicas)` 实例方法 +- `database.driverName(driver)` 内部辅助(已被 `driverDescription` 替代) + +**迁移指南**: + +```go +// ❌ 旧 +database.InitMySQL(cfg) +database.InitMySQLWithReplicas(cfg, replicas) + +// ✅ 新(驱动由 cfg.Database.Driver 决定,可以是 mysql / postgres / 自定义注册的方言) +database.InitDB(ctx, cfg) +database.InitDBWithReplicas(ctx, cfg, replicas) +``` + +**为什么现在动手**: + +- xlgo 还在小范围使用,破坏式调整成本最低 +- "默认开启可插拔方言"已经是 v1.0.2 的正式 API,再叫 `InitMySQL` 名实不符 +- 早期保留别名 → 长期变成永久负担的反面教材太多,与其在 v1.0.4 / v1.1 删,不如现在删 + +#### 删除死代码 `database.DBResolver` + +`database.DBResolver` 类型与其 `BeforeQuery` 方法**从未被注册**到 GORM callback chain(既没有 `db.Callback().Query().Before(...)` 的调用,也没有任何 plugin 包装),属于纯死代码。文档暗示的"自动读写分离"实际上从未生效——读写分离一直依赖业务侧显式调用 `database.UseMaster(ctx)` / `database.UseReplica(ctx)`。 + +**移除内容**: + +- `database.DBResolver` 类型 +- `(*DBResolver).BeforeQuery` 方法 + +**对用户影响**: + +- 几乎无影响。该类型从未在框架内部被使用,也未被文档推荐为 public API +- 若你的代码 `database.DBResolver{}` 出现编译错误,说明你曾尝试将其注册到 GORM callback;这种用法并不能让"读路由从库"自动生效,请改用: + + ```go + // 强制主库(事务、写后立刻读) + ctx := database.UseMaster(c.Request.Context()) + user, err := repo.FindByID(ctx, id) + + // 显式读从库(报表、统计) + ctx := database.UseReplica(c.Request.Context()) + list, err := repo.FindAll(ctx) + ``` + +未来若需要"基于 callback 的自动路由",建议直接接入官方 [`gorm.io/plugin/dbresolver`](https://github.com/go-gorm/dbresolver),它有完整的权重 / policy / 健康摘除支持,比自造轮子更稳。 + +### Changed + +#### 文件重命名:`database/mysql.go → database/manager.go` + +文件内容自 v1.0.2 引入可插拔方言注册表后,已经与 MySQL 解耦——本版本同时清理了 `InitMySQL` / `InitMySQLWithReplicas` / `driverName` 兼容别名(详见下方 Removed 段),文件中已经全部是通用代码(`Manager`、`ReplicaPicker`、`Init/Close/HealthCheck`、`UseMaster/UseReplica` 等)。继续叫 `mysql.go` 误导新用户认为框架仅支持 MySQL。 + +**对用户影响**: + +- **导入路径无变化**:`github.com/EthanCodeCraft/xlgo-core/database` 不变,所有公开 API 都还在 +- 只有直接 `git grep mysql.go` 或在 issue / PR review 里提到该文件的内部协作会感知 + +测试文件同步重命名为 `database/manager_test.go`。 + +### Added ✨ + +#### console 包:显式 level 控制 + +为 `console` 包补齐显式级别屏蔽能力,让用户在 main 中**显式**控制何时收紧调试输出,避免上线前到处屏蔽 `console.Debug` / `console.Info` 调用。 + +**API 增量**: + +- `console.LevelSilent` — 完全静默 +- `console.WithLevel(l Level)` — 构造时设置级别 +- `(*Console).SetLevel(l)` / `(*Console).Level()` — 实例方法 +- `console.SetLevel(l)` / `console.GetLevel()` — 包级 API(操作 Default 实例) +- `(Level).String()` — 可读名称 + +**典型用法**: + +```go +func main() { + cfg, _ := config.Load("./config.yaml") + + // 显式收紧:生产期只保留 Warn / Error + if cfg.IsProduction() { + console.SetLevel(console.LevelWarn) + } + // 或完全静默:console.SetLevel(console.LevelSilent) + + app := xlgo.New(...) + app.Run() +} +``` + +**设计立场**: + +- console 包**不会**根据 `app.env` 自动切级别——选择权完全在调用方,避免"dev 看到的 / prod 看到的"行为不一致 +- console 仍然是**纯彩色 stdout 工具**,不写文件、不感知环境、跟 `fmt.Println` 同级 +- 业务可观测信息(用户登录、订单事件、审计日志等"上线必须保留的")请使用 `logger` 包;console 只用于开发期肉眼调试 +- 完整对比表见 [GUIDE.md §3.3](./GUIDE.md#33-彩色控制台输出) + +并发安全:level 通过 `atomic.Int32` 存取,运行期热切换无锁。 + +### Changed + +#### console.WithCaller 签名收敛 + +`WithCaller(show bool, skip int)` 改为 `WithCaller(show bool, skip ...int)`——`skip` 99% 用户用不到,强制传是 API 噪音。无 breaking:旧调用 `WithCaller(true, 2)` 仍然合法。 + +### Fixed 🐛 + +#### Logger Tee 重复写入修复(logger 包) + +修复 `logger.Init` 把 `apiCore` 与 `dbCore` 都 Tee 进通用 `Logger`,导致**每条 `logger.Info(...)` 同时落到 `api.log` + `database.log` + console 三份**的 bug。`APILog()` / `DBLog()` 的"分流"在旧实现中形同虚设,且日志体积凭空翻倍。 + +**修复内容**: + +1. **三个 logger 各自独立**: + - `Logger`(通用)→ `logs/app.log` + console + - `APILog()` → `logs/api.log` + console + - `DBLog()` → `logs/database.log` + console + - 互不 Tee,互不串扰 +2. **新增 `logger.Close()`**:关闭文件句柄并把全局 logger 重置为 Nop。`App.Shutdown` 已自动调用。 +3. **Init 健壮性**:`Init(nil)` 不再 panic 改为返回 error;构造失败时不会留下半初始化状态(旧实现 mkdir 之后任意一步失败都会半切换全局变量)。 +4. **`Sync()` 全覆盖**:旧实现只 sync `Logger`,apiLog / dbLog 缓冲不会落盘;新实现 sync 全部三个 logger,并识别忽略 stdout/stderr 平台相关的预期错误。 +5. **生产默认级别从 `Warn` 调整为 `Info`**:原默认在生产丢失大量业务信息,多数项目反而需要在配置里覆盖回 Info;新默认更符合直觉。Debug 级别仍仅在开发模式生效。 + +**新增文件输出**: + +启动后日志目录会出现一个新文件 `logs/app.log`(之前所有通用日志都被串写进 `api.log` / `database.log`)。如果你的运维脚本配置了**只**采集 `api.log` / `database.log`,请补上 `app.log`。 + +**新增测试覆盖**(`logger/logger_test.go`): +- `TestLoggerNoCrossWriting` — 三个 logger 互不串扰(这是核心修复的回归测试) +- `TestLoggerInitNilConfig` — `Init(nil)` 返回 error +- `TestLoggerSyncBeforeInit` — 未初始化时 `Sync()` 安全返回 nil + +#### JWT JTI 生成忽略 `rand.Read` 错误(jwt 包) + +`generateJTI()` 调用 `crypto/rand.Read` 却丢弃返回的 error,且函数签名只返回 `string`,无法把失败传播给调用方。一旦 `rand.Read` 失败(极罕见,但理论上可能),会基于全零字节生成 JTI,所有 token 的 JTI 完全相同,黑名单机制失效。 + +**修复**:`generateJTI()` 改为 `(string, error)`,`GenerateToken` / `GenerateTokenWithCustomExpiry` 传播该错误。 + +#### `QueryBuilder.Page` 统计行数被残留 Limit 截断(repository 包) + +`Page()` 用 `qb.db.Session(&gorm.Session{})` 复制查询做 Count,但未清除残留的 `Limit`/`Offset`。若调用方先 `.Limit(n).Offset(m)` 再 `.Page(...)`,Count 会被包成 `SELECT count(*) FROM (... LIMIT n)` 子查询,返回的 `total` 被截断为 ≤ n,分页总数错误。 + +**修复**:countDB 增加 `.Limit(-1).Offset(-1)`(GORM 官方惯用法,表示移除该条件)。新增 DryRun 模式回归测试 `repository/page_internal_test.go`,校验 Count SQL 不含 `LIMIT`、Find SQL 仍含分页 `LIMIT`。 + +#### OSS / 本地存储文件名冲突(storage 包) + +4 处上传路径(`LocalStorage.Upload` / `LocalStorage.UploadFromBytes` / `OSSStorage.Upload` / `OSSStorage.UploadFromBytes`)仅用 `time.Now().UnixNano()` 作为文件名。同一纳秒内的并发上传会生成相同 objectKey,后者覆盖前者。 + +**修复**:新增 `uniqueFilename(now, ext)` 辅助函数,格式 `-<8字节crypto/rand hex>.`,4 处统一改用。新增 `storage/unique_internal_test.go` 验证格式与 100 次近似唯一性。 + +#### 数据库重试策略对不可恢复错误无效(database 包) + +`Manager.InitDB` 的重试循环对所有失败都退避重试 5 次。但认证失败(`Access denied`)、未知数据库(`Unknown database`)、非法 DSN(`invalid DSN`)、未注册驱动(`unknown driver` / `unsupported driver`)、不支持的认证插件(`authentication plugin`)属于配置类错误,重试无意义,反而把启动失败延迟 1+2+4+8+16=31 秒。 + +**修复**:新增 `isTransientDBError`,上述错误判为不可恢复,首次出现即直接返回。连接拒绝、I/O 超时等网络类错误仍正常重试。新增 `database/retry_internal_test.go` 用例表覆盖 8 种错误。 + +### Security 🔒 + +#### CORS 中间件修复(middleware/cors.go) + +修复多个 CORS 安全与规范遵守问题。**这是行为变更**——升级后不正确的 CORS 配置会更严格,符合 W3C CORS 规范。 + +**修复内容**: + +1. **`Access-Control-Allow-Credentials` 永远是 `true`** — 旧实现 `if/else` 两个分支都设了 `"true"`,相当于即使配置 `AllowCredentials=false` 也会发送凭证头。修复后**只在显式启用且 Origin 不是 `*` 时**才发送该头。 +2. **`*` + `credentials: true` 的规范违规** — 旧实现配置 `AllowedOrigins=["*"]` 且 `AllowCredentials=true` 时会同时发送 `Allow-Origin: *` 与 `Allow-Credentials: true`,**浏览器会直接拒绝响应**。修复后此场景下回显具体 Origin(spec 允许的兼容做法)。 +3. **缺失 `Vary: Origin`** — 当回显具体 Origin 时,下游 CDN / 网关必须按 Origin 区分缓存,否则可能把 A 用户的 CORS 响应缓存给 B 用户。修复后自动加 `Vary: Origin`。 +4. **开发环境兜底改为回显具体 Origin** — 旧实现开发环境直接发 `*`,与 credentials 不兼容;新实现回显具体 Origin,开发环境也能正常调试带 Cookie 的请求。 + +**升级影响**: + +- 如果你**没有**显式设置 `cors.allow_credentials`:响应将不再带 `Access-Control-Allow-Credentials: true`,前端如果依赖了 Cookie/Authorization,需要在配置里显式打开: + + ```yaml + cors: + allowed_origins: ["https://your-frontend.example"] + allow_credentials: true # 显式启用 + ``` + +- 如果你配置了 `allowed_origins: ["*"]` 且 `allow_credentials: true`:行为更安全(不再发 `*`),无需改动。 +- 已经显式列出 origin 列表的配置:完全无影响。 + +**新增测试覆盖**(`middleware/middleware_test.go`): +- `TestCORSAllowCredentialsDefault` — 默认不发凭证头 +- `TestCORSAllowCredentialsExplicitOrigin` — 显式 origin + credentials 正常工作 +- `TestCORSWildcardWithCredentials` — `*` + credentials 时回显具体 origin +- `TestCORSWildcardWithoutCredentials` — `*` 单独使用保持通配符语义 +- `TestCORSOriginNotAllowed` — 非白名单 origin 不回显(防反射型 CORS 漏洞) + +### Breaking ⚠️ + +#### 错误码体系重构(response 包) + +修复 `CodeSuccess` 与 `CodeInvalidParams` 撞码的生产级 bug(两者都等于 `1`,导致业务错误响应被前端误判为成功)。 + +**数值变更**: + +| 常量 | 旧值 | 新值 | +|---|---|---| +| `response.CodeSuccess` | `1` | **`0`** | +| `response.CodeFail` | `0` | **`1`** | + +**移除**: + +- `response.CodeInvalidParams`(与 `CodeSuccess` 撞码) +- `response.ErrInvalidParams` + +**迁移指南**: + +1. **前端代码**:`if (resp.code === 1) { /* 成功 */ }` → `if (resp.code === 0) { /* 成功 */ }` +2. **后端代码**: + + ```go + // ❌ 编译失败 + response.FailWithError(c, response.ErrInvalidParams) + + // ✅ 推荐:业务侧自行定义参数错误码(不再由框架内置) + var ErrInvalidParams = response.NewError(40001, "参数错误") + response.FailWithError(c, ErrInvalidParams) + + // ✅ 或直接使用通用失败响应 + 自定义消息 + response.Fail(c, "用户名格式错误") + ``` + +3. **手写常量比较**:`if resp.Code == 0 { /* fail */ }` → `if resp.Code == 1 { /* fail */ }` + +**为什么**: + +- 业内主流约定 `0 = success`(gRPC、HTTP-style 业务码、阿里云 / 腾讯云 OpenAPI 等),改回常规更利于对接 +- 参数错误码各业务系统差异极大(有的用 `400`、有的用 `40001`、有的用 `1001`),框架不应内置 +- 撞码不修是真实生产风险,必须破坏式修正 + +**新增编译期防撞码保护**:`response/error.go` 末尾新增 `_errorCodeUniquenessGuard` map,任何后续 `Code*` 常量重复都会在 `go build` 阶段直接报 `duplicate key in map literal`,杜绝再次撞码。新增 `Code*` 时**必须**登记到该 map。 + +### Dependencies 📦 + +#### `go mod tidy` 补全 postgres 方言间接依赖 + +v1.0.2 引入可插拔方言注册表后,`gorm.io/driver/postgres` 成为直接依赖,但其传递依赖(`jackc/pgpassfile` / `jackc/pgservicefile` / `jackc/pgx/v5` / `jackc/puddle/v2` / `golang.org/x/sync`)此前未在 `go.mod` 显式登记。`go mod tidy` 已补全,避免在干净环境构建时拉到不可预期的版本。 + +#### 安全相关补丁升级(仅补丁/小版本,无 API 变更) + +| 依赖 | 旧 | 新 | +|---|---|---| +| `golang.org/x/crypto` | v0.49.0 | v0.53.0 | +| `github.com/golang-jwt/jwt/v5` | v5.2.1 | v5.3.1 | +| `github.com/gorilla/websocket` | v1.5.1 | v1.5.3 | + +连同其传递依赖(`golang.org/x/net`、`x/sys`、`x/text`、`x/sync`、`x/tools`)一并升级。全量 `go test ./...` 与 `go vet ./...` 通过。 + +#### 暂缓升级(留待下一个小版本) + +以下直接依赖存在可用更新,但跨越多个小版本或含破坏性变更,**不在本次 bugfix release 范围内**,留待 v1.0.4 / v1.1 专门评估: + +- `github.com/gin-gonic/gin` v1.9.1 → v1.12.0 +- `github.com/go-playground/validator/v10` v10.19.0 → v10.30.3 +- `gorm.io/gorm` v1.25.10 → v1.31.1(及其 driver v1.5 → v1.6) +- `github.com/aliyun/aliyun-oss-go-sdk` v2.2.9 → v3.0.2(**major 版本,破坏性**,需迁移) +- `github.com/spf13/viper` v1.18.2、`go.opentelemetry.io/otel` v1.43.0、`go.uber.org/zap` v1.27.0、`github.com/fsnotify/fsnotify` v1.7.0 等 + +--- + +## [1.0.2] - 2026-06-20 + +> 详见 [README 更新日志](./README.md#更新日志) 中的 v1.0.2 章节,本节列出关键摘要。 + +### Added + +- **数据库**:可插拔方言注册表(`database.RegisterDialect`),内置 `mysql` / `postgres`,支持任意 GORM 驱动 +- **数据库**:实例化 `database.Manager`,`ReplicaPicker` 接口(`RoundRobinPicker` / `RandomPicker`) +- **配置**:实例化 `config.Manager`,`SetDefaultManager` 让 App 私有 manager 推为全局默认 +- **App**:`WithFullStack` / `NewFullStack` / `RunFullStack` batteries-included 入口 +- **App**:`Migrator` 类型与 `WithMigrator` / `WithModels`,迁移由用户显式注册 +- **App**:组件 Option 全套(`WithLogger / WithMySQL / WithRedis / WithStorage / WithWire / WithHealthRoutes / WithSwaggerRoutes / WithDefaultRoutes / WithAutoMigrate` 及 `Without*` 对应项) +- **权限**:通用 `AuthUser`、`GetAuthUser`、`RequireUserTypes` / `RequireRoles` / `RequireAuth` +- **健康检查**:`/health` 支持注册 `HealthCheck`,失败返回 HTTP 503 + +### Changed (Breaking) + +- **App**:`xlgo.New()` 默认不再初始化 MySQL / Redis / Storage,也不注册 `/health` 与 `/swagger/*`;需显式 `With*` 启用 +- **权限**:`super_admin / admin / staff` 调整为默认常量而非固定业务模型 +- **错误处理**:框架初始化失败一律 `return error`,不再 `Fatalf` 退出进程 + +### Fixed + +- 修复 `WithConfigPath` 此前的空实现问题 +- 修复读写分离场景下从库连接可能未关闭的问题(改为 `database.CloseAll()` + `errors.Join`) +- 修复此前 README 中错误的 v2.0.0 / v2.1.0 更新日志表述 + +--- + +## [1.0.1] - 2026-04-30 + +### Added + +- 工具函数库、彩色控制台输出、压缩解压、RequestID、Recover 中间件 +- 缓存键名前缀、分布式锁、计数器、Redis 分布式限流 +- 增强 JWT 黑名单、Repository、CORS、日志中间件和优雅关闭能力 +- 路由架构:模块化、版本化 API、中间件分组和 RESTful CRUD +- 配置热更新、数据库读写分离、CSRF、SSE、WebSocket、定时任务、CLI、测试工具、统一错误码 + +--- + +## [1.0.0] - 2024-04 + +### Added + +- 初始版本发布 +- 基础框架功能 +- 完整示例代码 + +[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.1.1...v1.2.0 +[1.1.1]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.1.1 +[1.1.0]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.1.0 +[1.0.4]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.4 +[1.0.3]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.3 +[1.0.2]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.0.1...v1.0.3 +[1.0.1]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.1 +[1.0.0]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.0 diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..4574b86 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,1894 @@ +# xlgo 框架使用指南 + +## 目录 + +1. [快速开始](#1-快速开始) +2. [配置管理](#2-配置管理) +3. [日志系统](#3-日志系统) +4. [数据库操作](#4-数据库操作) +5. [缓存与 Redis](#5-缓存与redis) +6. [请求处理](#6-请求处理) +7. [响应与错误处理](#7-响应与错误处理) +8. [中间件](#8-中间件) +9. [认证与授权](#9-认证与授权) +10. [文件存储](#10-文件存储) +11. [工具函数库](#11-工具函数库) +12. [实时通信](#12-实时通信) +13. [定时任务](#13-定时任务) +14. [链路追踪](#14-链路追踪) +15. [压缩解压](#15-压缩解压) +16. [测试工具](#16-测试工具) +17. [CLI 脚手架](#17-cli脚手架) +18. [最佳实践](#18-最佳实践) + +--- + +## 1. 快速开始 + +> **环境要求**:xlgo 需要 **Go 1.25+**。本项目作为新框架不背负旧版本兼容包袱,可直接使用 Go 1.25 的新特性。 + +### 1.1 安装框架 + +```bash +# 创建新项目 +xlgo new myproject + +# 或手动安装 +go get github.com/EthanCodeCraft/xlgo-core +``` + +### 1.2 项目结构 + +``` +myproject/ +├── config.yaml # 配置文件 +├── main.go # 入口文件 +├── handler/ # 请求处理器 +├── model/ # 数据模型 +├── repository/ # 数据仓库 +├── service/ # 业务逻辑 +├── middleware/ # 自定义中间件 +├── public/ # 静态文件 +├── logs/ # 日志目录 +└── go.mod +``` + +### 1.3 最简示例 + +```go +package main + +import ( + xlgo "github.com/EthanCodeCraft/xlgo-core" + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" +) + +func main() { + // v1.0.2 起 xlgo.New 默认是轻量应用,不会自动初始化 MySQL/Redis/Storage, + // 也不会自动注册 /health 或 /swagger/* 路由。 + // 通过 Option 显式启用所需组件即可。 + app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithLogger(), + xlgo.WithDefaultRoutes(), // 同时启用 /health 与 /swagger/* + xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()), + ) + + // 通过 Engine 直接挂路由,或使用 xlgo.WithModules(...) 注册模块 + app.GetRouter().GET("/hello", func(c *gin.Context) { + response.Success(c, gin.H{"message": "Hello World"}) + }) + + if err := app.Run(); err != nil { + panic(err) + } +} +``` + +> 想要 batteries-included 体验,可改用 `xlgo.NewFullStack(...)` 或 +> `xlgo.RunFullStack(...)`,它会一次性启用 Logger / MySQL / Redis / Storage / +> Wire / Health / Swagger / AutoMigrate 等组件。 + +**优雅关闭特性:** +- 监听系统信号(SIGINT、SIGTERM) +- 等待请求处理完成(最多 30 秒) +- 按顺序关闭各组件(限流器、数据库、Redis、日志) + +--- + +## 2. 配置管理 + +### 2.1 配置文件结构 + +```yaml +# config.yaml +app: + name: "我的应用" + site_name: "my_app" # 站点别名(重要:多项目共用Redis时区分) + version: "1.0.0" + env: "dev" # dev/test/prod + debug: true + base_url: "http://localhost:8080" + +server: + port: 8080 + mode: development # development/production + +database: + host: localhost + port: 3306 + user: root + password: your_password + name: mydb + max_idle_conns: 10 + max_open_conns: 100 + +redis: + host: localhost + port: 6379 + password: "" + db: 0 + +jwt: + secret: your_jwt_secret_key + expire: "24h" # time.Duration,如 "24h"/"30m" + +log: + dir: ./logs + max_size: 100 # MB + max_backups: 30 + max_age: 30 # 天 + compress: true +``` + +### 2.2 使用配置 + +```go +import "github.com/EthanCodeCraft/xlgo-core/config" + +// 加载配置 +cfg, err := config.Load("./config.yaml") + +// 获取配置值 +port := cfg.Server.Port +dbHost := cfg.Database.Host + +// 获取站点别名(用于缓存键前缀) +siteName := cfg.GetSiteName() + +// 判断环境 +if cfg.IsProduction() { + gin.SetMode(gin.ReleaseMode) +} + +// 热更新配置 +config.LoadWithWatch("./config.yaml", func(newCfg *config.Config) { + // 配置变更时的回调 +}) + +// 动态获取配置值 +debug := config.GetBool("app.debug") +``` + +--- + +## 3. 日志系统 + +### 3.1 初始化日志 + +```go +import "github.com/EthanCodeCraft/xlgo-core/logger" + +// 初始化 +logger.Init(cfg) + +// 程序退出前同步 +// v1.0.2 起 Sync 返回 error,可按需处理 +_ = logger.Sync() +``` + +### 3.2 日志级别 + +```go +// 基础日志 +logger.Debug("调试信息") +logger.Info("普通信息") +logger.Warn("警告信息") +logger.Error("错误信息") + +// 格式化日志 +logger.Infof("用户 %s 登录成功", username) +logger.Warnf("请求失败: %v", err) + +// 致命错误(会终止程序)—— 仅在业务进程入口(main 等)使用 +// v1.0.2 起,框架内部已禁止使用 Fatal/Fatalf,初始化错误改为 error 返回 +logger.Fatalf("数据库连接失败: %v", err) + +// 结构化日志 +logger.Info("用户登录", + zap.String("username", "admin"), + zap.Int("user_id", 123), +) +``` + +### 3.3 彩色控制台输出 + +`console` 包定位是**开发期彩色 stdout 工具**——跟 `fmt.Println` 同级,不写文件、不感知运行环境、不做任何隐式行为。 + +```go +import "github.com/EthanCodeCraft/xlgo-core/console" + +console.Debug("调试信息") // 青色 +console.Info("普通信息") // 白色 +console.Success("成功信息") // 绿色 +console.Warn("警告信息") // 黄色 +console.Error("错误信息") // 红色 + +// 自定义配置 +c := console.New( + console.WithColor(true), + console.WithTime(true), + console.WithCaller(true), // skip 可选,默认 2 + console.WithLevel(console.LevelInfo), +) +c.Debug("此条不会输出,已被 LevelInfo 过滤") +c.Info("自定义输出") +``` + +#### console vs logger:怎么选? + +| 维度 | `console` | `logger` | +|---|---|---| +| 定位 | 开发期肉眼调试 | 业务可观测性记录 | +| 输出目标 | stdout(彩色) | 文件 + stdout | +| 持久化 | ❌ | ✅ 滚动归档 | +| 结构化 | 文本 | JSON 字段 | +| 性能 | 一般 | zap 高性能 | +| 默认级别 | `LevelDebug`(全开) | dev=Debug / prod=Info | +| 适用场景 | 临时打印、开发联调 | 用户登录、订单事件、审计日志 | + +**简单判断**: + +- 这条信息上线后想留 → 用 `logger` +- 这条信息上线就该消失 → 用 `console`,并在 main 中显式切到高级别 + +#### 上线前显式收紧 console 输出 + +```go +func main() { + cfg, _ := config.Load("./config.yaml") + + // 显式选择:生产期 console 只看 Warn / Error + if cfg.IsProduction() { + console.SetLevel(console.LevelWarn) + } + + // 或者完全静默 console(业务全靠 logger) + // console.SetLevel(console.LevelSilent) + + app := xlgo.New(...) + app.Run() +} +``` + +> **注意**:xlgo 不会自动根据 `app.env` 切换 console 级别——选择权完全在调用方。 +> 我们认为隐式切换会带来"开发期看到的 / 生产期看到的"行为不一致,调试体验更糟。 + +--- + +## 4. 数据库操作 + +xlgo 基于 GORM,驱动由配置 `database.driver` 决定。v1.0.2 起 GORM 方言通过 **可插拔注册表** 管理:内置 `mysql`(默认)与 `postgres`(含 `postgresql`、`pg` 别名),应用可通过 `database.RegisterDialect` 接入任意 GORM 驱动;`database.dsn` 字段始终可用于手写连接串。 + +### 4.1 初始化数据库 + +```go +import "github.com/EthanCodeCraft/xlgo-core/database" + +// 初始化数据库(驱动由配置决定,等价于 database.DefaultManager.Load().InitDB(cfg)) +database.InitDB(cfg) + +// 关闭全部连接(含从库) +defer database.CloseAll() + +// 获取数据库实例 +db := database.GetDB() // 主库 +read := database.GetReadDB() // 从库(无从库时回退主库) +``` + +### 4.1.1 主从读写分离 + +```go +// 从库 DSN 列表需与主库驱动匹配 +database.InitDBWithReplicas(cfg, []string{ + "root:pass@tcp(slave1:3306)/db", + "root:pass@tcp(slave2:3306)/db", +}) + +// 选择策略:默认 Random,可换成 RoundRobin +database.SetReplicaPicker(&database.RoundRobinPicker{}) + +// 强制使用主库(事务、需要实时数据的场景) +ctx := database.UseMaster(context.Background()) +database.GetDBFromContext(ctx).Find(&users) + +// 强制使用从库(报表查询) +ctx = database.UseReplica(context.Background()) +database.GetDBFromContext(ctx).Find(&reports) +``` + +### 4.1.2 实例化 Manager + +需要多套数据库连接(如平台库 + 租户库)时,`database.NewManager(cfg)` 创建独立管理器,互不影响: + +```go +mgr := database.NewManager(cfg) +if err := mgr.Open(context.Background()); err != nil { + return err +} +defer mgr.Close() + +mgr.SetPicker(&database.RoundRobinPicker{}) +db := mgr.FromContext(ctx) +``` + +`database.SetDefaultManager(m)` 会把新 manager 提升为全局默认,并关闭被替换的旧 manager,避免连接池泄漏。需要失败回滚或延迟释放旧资源时,请使用 `database.SwapDefaultManager(m)` 并自行关闭其返回的旧 manager。 + +### 4.1.3 注册自定义 GORM 驱动 + +`database.RegisterDialect` 一次注册即让 `database.driver: ` 生效,DSN 构建器同步登记到 `config` 包: + +```go +import ( + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/database" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func init() { + database.RegisterDialect(database.DialectSpec{ + Name: "sqlite", + Aliases: []string{"sqlite3"}, + Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) }, + DSN: func(c *config.DatabaseConfig) string { return c.Name }, // Name 当作文件路径 + }) +} +``` + +之后只需把配置改成 `database.driver: sqlite` 即可。SQL Server / ClickHouse / TiDB 等同理。 + +诊断接口: + +- `database.RegisteredDialects()` - 列出已注册驱动(含别名) +- `database.LookupDialect(name)` - 检查某驱动是否可用 +- `config.RegisteredDrivers()` - 列出已登记的 DSN 构建器 + +未注册的驱动会回退到 MySQL 以保持向后兼容。 + +### 4.2 定义模型 + +```go +import "github.com/EthanCodeCraft/xlgo-core/model" + +type User struct { + model.BaseModel // 包含 ID, CreatedAt, UpdatedAt + Username string `gorm:"size:50;unique" json:"username"` + Email string `gorm:"size:100" json:"email"` + Password string `gorm:"size:255" json:"-"` + Status int `gorm:"default:1" json:"status"` +} + +func (User) TableName() string { + return "users" +} +``` + +### 4.3 使用 Repository(扩展版) + +```go +import "github.com/EthanCodeCraft/xlgo-core/repository" + +// 创建仓库 +userRepo := repository.NewBaseRepo[model.User](database.GetDB()) + +// 连接路由(v1.1.1+ 修复 H6c): +// - 读操作(FindByID/FindAll/FindPage/FindWhere/Count/Exists/...)默认路由到从库, +// 支持 database.UseMaster(ctx)/UseReplica(ctx) 显式路由;未配置从库时回退主库。 +// - 写操作(Create/Update/Delete/*Batch/Restore/...)始终走主库,不路由到只读从库。 +// - DefaultManager 未初始化(如单测注入 sqlite)时回退到 NewBaseRepo 传入的 db。 + +// 基础 CRUD +user, err := userRepo.FindByID(ctx, 1) +users, err := userRepo.FindAll(ctx) +users, err := userRepo.FindByIDs(ctx, []uint{1, 2, 3}) +err := userRepo.Create(ctx, &user) +err := userRepo.Update(ctx, &user) // Save 全列覆写(含零值),见下方 UpdateFields +err := userRepo.Delete(ctx, 1) // T 含 gorm.Model 时软删除,否则硬删除 + +// 局部更新(推荐,H6a):避免 Save 全列覆写丢失更新/零值不可辨 +err := userRepo.UpdateFields(ctx, &model.User{Name: "new"}, "id = ?", id) // struct 仅更新非零字段 +err := userRepo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map 可显式置零 + +// 统计数量 +count, err := userRepo.Count(ctx) +count, err := userRepo.CountWhere(ctx, "status = ?", 1) + +// 分页查询(内置) +result, err := userRepo.FindPage(ctx, 1, 20) +// result.Items - 数据列表 +// result.Total - 总数 +// result.Page - 当前页 +// result.PageSize - 每页数量 + +// 条件分页查询 +result, err := userRepo.FindPageWhere(ctx, 1, 20, "status = ?", 1) + +// 分页并排序 +result, err := userRepo.FindPageOrdered(ctx, 1, 20, "created_at DESC") + +// 条件查询 +user, err := userRepo.FindOne(ctx, "email = ?", "test@example.com") +users, err := userRepo.FindWhere(ctx, "status = ?", 1) +users, err := userRepo.FindWhereOrdered(ctx, "created_at DESC", "status = ?", 1) + +// 排序查询 +users, err := userRepo.FindOrdered(ctx, "created_at DESC", 10) + +// 批量操作 +err := userRepo.CreateBatch(ctx, users) +err := userRepo.UpdateBatch(ctx, []uint{1, 2}, "status", 2) +err := userRepo.DeleteBatch(ctx, []uint{1, 2, 3}) + +// 存在性检查 +exists, err := userRepo.Exists(ctx, 1) +exists, err := userRepo.ExistsWhere(ctx, "email = ?", "test@example.com") + +// 软删除恢复 +err := userRepo.Restore(ctx, 1) +err := userRepo.RestoreBatch(ctx, []uint{1, 2}) + +// 查询已删除的记录 +deletedUsers, err := userRepo.FindDeleted(ctx) + +// 链式查询(灵活构建;QueryBuilder 为单次使用、非并发安全) +users, err := userRepo.NewQueryBuilder(). + Where("status = ?", 1). + Where("role = ?", "admin"). + Order("created_at DESC"). + Limit(10). + Find(ctx) + +// 链式分页查询 +result, err := userRepo.NewQueryBuilder(). + Where("status = ?", 1). + Order("created_at DESC"). + Page(ctx, 1, 20) + +// 事务支持 +err := userRepo.WithTransaction(ctx, func(txRepo *repository.BaseRepo[model.User]) error { + // 创建用户 + if err := txRepo.Create(ctx, &user); err != nil { + return err + } + // 更新关联数据 + return txRepo.Update(ctx, &profile) +}) + +// 跨层/跨 repo 事务 join(H6c):把外层事务注入 ctx 后传给任意 repo 方法 +err := database.TransactionWithContext(ctx, func(tx *gorm.DB) error { + ctx2 := database.WithTx(ctx, tx) + if err := userRepo.Create(ctx2, &user); err != nil { + return err + } + return otherRepo.Update(ctx2, &other) // 同一事务 +}) +``` + +### 4.4 Model 基础模型 + +```go +import "github.com/EthanCodeCraft/xlgo-core/model" + +// BaseModel 包含 ID、CreatedAt、UpdatedAt、DeletedAt +type User struct { + model.BaseModel // 自动包含 ID、时间戳、软删除 + Username string `gorm:"size:50;unique" json:"username"` + Email string `gorm:"size:100" json:"email"` + Status int `gorm:"default:1" json:"status"` +} + +// BaseModel 字段说明 +// ID uint - 主键 +// CreatedAt time.Time - 创建时间 +// UpdatedAt time.Time - 更新时间 +// DeletedAt gorm.DeletedAt - 软删除时间(不返回给前端) +``` + +--- + +## 5. 缓存与 Redis + +### 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" + +// 初始化(通常在应用启动时) +cache.Init() + +// 获取缓存实例 +c := cache.GetCache() +``` + +### 5.2 缓存操作 + +```go +// 设置缓存 +c.Set(ctx, "user:1", userData, 30*time.Minute) + +// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err) +var user User +if hit, err := c.Get(ctx, "user:1", &user); err != nil { + return err +} else if hit { + // 缓存命中 +} + +// 删除缓存 +c.Delete(ctx, "user:1") + +// 批量删除(按模式) +c.DeleteByPattern(ctx, "user:*") + +// 检查是否存在(未命中 false,nil;后端错误返回 err) +exists, err := c.Exists(ctx, "user:1") +if err != nil { + return err +} +``` + +### 5.3 键名前缀管理(多站点共用 Redis) + +```go +import "github.com/EthanCodeCraft/xlgo-core/cache" + +// 自动从配置读取 site_name 作为前缀 +// config.yaml: app.site_name: "my_app" + +cache.K("user:1") // → "cache:my_app:user:1" +cache.KTemp("token") // → "temp:my_app:token" +cache.KPerm("config") // → "perm:my_app:config" +cache.KLock("order:123") // → "lock:my_app:order:123" +cache.KCounter("visit") // → "counter:my_app:visit" +cache.KSession("sid") // → "session:my_app:sid" + +// 使用带前缀的缓存 +c.Set(ctx, cache.K("user:1"), userData, ttl) +hit, err := c.Get(ctx, cache.K("user:1"), &user) +if err != nil { + return err +} +``` + +### 5.4 分布式锁(安全增强版) + +**使用 Lua 脚本 + UUID Token,只有锁的持有者才能释放锁:** + +```go +// 安全加锁(返回 LockToken) +token, err := cache.NewLock(ctx, cache.KLock("pay:123"), 30*time.Second) +if token != nil { + defer cache.Unlock(ctx, token) // 只有持有者能释放 + // 执行业务逻辑 +} + +// 带重试的锁 +token, err := cache.TryLock(ctx, key, ttl, 100*time.Millisecond, 10) +if token != nil { + defer cache.Unlock(ctx, token) +} + +// 自动管理锁(简单场景) +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(ctx context.Context) error { + // 执行时间超过 TTL 时自动续期 + return nil +}) + +// 续期锁(手动续期) +cache.ExtendLock(ctx, token, 30*time.Second) + +// 检查锁是否被占用(Redis 不可用时返回 ErrRedisNotReady,可经 +// errors.Is(err, cache.ErrRedisNotReady) 与"锁未占用"区分) +locked, err := cache.IsLocked(ctx, key) + +// 强制释放锁(管理场景;Redis 不可用时返回 ErrRedisNotReady) +if err := cache.ForceUnlock(ctx, key); err != nil { + // 处理 Redis 不可用 +} +``` + +**安全特性:** +- Lua 脚本保证原子性操作 +- UUID Token 标识锁的持有者 +- 只有持有者才能释放锁(防止误释放) +- 支持续期(长任务不会因锁过期而中断) + +### 5.5 计数器 + +```go +// 自增 +n, err := cache.Incr(ctx, cache.KCounter("page_view")) + +// 指定增量 +n, err := cache.IncrBy(ctx, key, 10) + +// 自减 +n, err := cache.Decr(ctx, key) + +// 设置过期时间 +cache.SetExpire(ctx, key, 1*time.Hour) + +// 获取剩余时间 +ttl, err := cache.GetTTL(ctx, key) +``` + +--- + +## 6. 请求处理 + +### 6.1 参数获取 + +```go +import "github.com/EthanCodeCraft/xlgo-core/handler" + +// 类型安全的参数获取(推荐) + +// Query参数 +page := handler.QueryInt(c, "page", 1) // Query参数 → int +id := handler.QueryInt64(c, "id", 0) // Query参数 → int64 +price := handler.QueryFloat64(c, "price", 0.0) // Query参数 → float64 +enabled := handler.QueryBool(c, "enabled", false) // Query参数 → bool + +// 路径参数(RESTful API) +id := handler.PathInt(c, "id", 0) // 路径参数 → int +id := handler.PathInt64(c, "id", 0) // 路径参数 → int64 +id := handler.PathUint64(c, "id", 0) // 路径参数 → uint64 + +// 表单参数(POST提交) +count := handler.FormInt(c, "count", 0) // 表单参数 → int +id := handler.FormInt64(c, "id", 0) // 表单参数 → int64 +id := handler.FormUint64(c, "id", 0) // 表单参数 → uint64 +price := handler.FormFloat64(c, "price", 0.0) // 表单参数 → float64 +enabled := handler.FormBool(c, "enabled", false) // 表单参数 → bool +name := handler.FormString(c, "name", "") // 表单参数 → string + +// 分页参数 +page, pageSize := handler.GetPage(c) // 默认 page=1, pageSize=20 + +// 路径ID +id, ok := handler.GetIDFromPath(c, "id") + +// 绑定请求 +var req LoginRequest +handler.BindJSON(c, &req) +``` + +### 6.2 请求验证 + +```go +import "github.com/EthanCodeCraft/xlgo-core/validation" + +type RegisterRequest struct { + Username string `json:"username" label:"用户名" validate:"required,min=3,max=20" msg_required:"用户名不能为空"` + Password string `json:"password" label:"密码" validate:"required,password" msg_required:"密码不能为空"` + Email string `json:"email" label:"邮箱" validate:"required,email" msg_required:"邮箱不能为空"` + Phone string `json:"phone" label:"手机号" validate:"omitempty,phone"` +} + +// 绑定并验证 +errors, ok := validation.ShouldBindAndValidate(c, &req) +if !ok { + response.Fail(c, errors.Error()) + return +} +``` + +### 6.3 验证规则 + +| 规则 | 说明 | +| ---------- | ------------------------------------- | +| `required` | 必填 | +| `min=3` | 最小长度 | +| `max=20` | 最大长度 | +| `email` | 邮箱格式 | +| `phone` | 手机号格式 | +| `phone_strict` | 手机号严格校验(验证运营商号段) | +| `password` | 密码强度(8-72 字节,需含大写+小写+数字) | +| `username` | 字母开头,3-20 位字母/数字/下划线 | +| `url` | URL 格式 | +| `ip` | IP 地址格式 | + +--- + +## 7. 响应与错误处理 + +### 7.1 统一响应格式 + +```json +{ + "code": 1, + "msg": "操作成功", + "data": {}, + "request_id": "abc123" +} +``` + +### 7.2 成功响应 + +```go +import "github.com/EthanCodeCraft/xlgo-core/response" + +// 基础成功响应 +response.Success(c, gin.H{"user": user}) + +// 自定义消息 +response.SuccessWithMsg(c, "注册成功", gin.H{"user_id": 123}) + +// 分页响应 +response.Page(c, users, total, page, pageSize) +``` + +### 7.3 错误响应 + +```go +// 基础失败 +response.Fail(c, "参数错误") + +// 自定义错误码 +response.FailWithCode(c, response.CodeUserNotFound, "用户不存在") + +// 使用预定义错误 +response.FailWithError(c, response.ErrUserNotFound) + +// 带详细信息 +response.FailWithDetail(c, response.ErrPasswordWrong, "连续错误3次将锁定") +``` + +### 7.4 预定义错误码 + +> 注:参数错误等业务化错误码请由业务项目自行定义,框架不再内置 `ErrInvalidParams`。 +> 推荐使用 `response.NewError(40001, "参数错误")` 在业务侧统一管理。 + +| 错误 | 码 | 说明 | +| ------------------ | ------ | ---------- | +| `ErrUnauthorized` | 401 | 未授权 | +| `ErrForbidden` | 403 | 无权限访问 | +| `ErrNotFound` | 404 | 资源不存在 | +| `ErrRateLimit` | 429 | 请求过于频繁 | +| `ErrServerError` | 500 | 服务器错误 | +| `ErrUserNotFound` | 10001 | 用户不存在 | +| `ErrPasswordWrong` | 10004 | 密码错误 | + +### 7.5 特殊响应 + +```go +// 文件下载 +response.Download(c, "report.xlsx", fileData) +// 大文件/对象存储流式下载优先使用 DownloadReader + +// HTML响应 +response.HTML(c, "...") +// HTML 会原样输出;只传入可信或已清洗的 markup + +// 页面跳转 +response.Redirect(c, 302, "https://example.com") +``` + +--- + +## 8. 中间件 + +### 8.1 内置中间件 + +```go +import "github.com/EthanCodeCraft/xlgo-core/middleware" + +// 日志中间件(默认配置) +r.Use(middleware.Logger()) + +// API 专用日志(记录请求体) +r.Use(middleware.LoggerForAPI()) + +// 调试日志(最详细,记录请求体和响应体) +r.Use(middleware.LoggerForDebug()) + +// 最简日志(只记录基本信息) +r.Use(middleware.LoggerMinimal()) + +// 自定义日志配置 +r.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ + LogRequestBody: true, // 记录请求体 + LogResponseBody: false, // 不记录响应体 + MaxBodyLength: 2048, // 最大记录长度 + SkipPaths: []string{"/health"}, // 跳过路径 + SlowRequestThreshold: 500 * time.Millisecond, // 慢请求阈值 +})) + +// CORS跨域 +r.Use(middleware.CORS()) + +// 请求ID(追踪) +r.Use(middleware.RequestID()) + +// Panic恢复 +r.Use(middleware.Recover()) + +// 认证中间件 +r.Use(middleware.AuthRequired()) + +// 自定义用户类型权限 +r.Use(middleware.RequireUserTypes("tenant_admin", "platform_admin")) + +// 自定义角色权限 +r.Use(middleware.RequireRoles("owner", "manager")) + +// 默认快捷权限(super_admin/admin/staff 只是默认常量) +r.Use(middleware.AdminRequired()) + +// CSRF防护 +r.Use(middleware.CSRF()) +``` + +### 8.2 CORS 配置(支持配置文件) + +```yaml +# config.yaml 添加 CORS 配置 +cors: + allowed_origins: + - "https://example.com" + - "https://app.example.com" + - "*.example.com" # 支持通配符 + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "DELETE" + allowed_headers: + - "Content-Type" + - "Authorization" + allow_credentials: true + max_age: 86400 # 预检请求缓存时间(秒) +``` + +```go +// 使用配置文件的 CORS +r.Use(middleware.CORS()) + +// 指定域名列表 +r.Use(middleware.CORSWithOrigins([]string{ + "https://example.com", + "https://app.example.com", +})) + +// 允许所有来源(仅开发环境) +r.Use(middleware.CORSWithWildcard()) + +// API 专用 CORS(不允许凭证) +r.Use(middleware.CORSForAPI()) +``` + +**生产环境 CORS 配置建议:** +- 必须配置具体的 `allowed_origins` +- 不使用 `*` 通配符 +- 根据需要配置 `allow_credentials` + +### 8.3 CSRF 防护 + +```go +// Cookie模式(Web应用) +r.Use(middleware.CSRF()) + +// 双重提交Cookie模式(无状态) +r.Use(middleware.DoubleSubmitCookie()) + +// API模式 +r.Use(middleware.CSRFForAPI()) + +// 获取Token +token := middleware.GetCSRFToken(c) +``` + +### 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(), func(c *gin.Context) { + response.Success(c, gin.H{"token": "..."}) +}) // 每分钟10次 +r.POST("/upload", middleware.UploadRateLimit(), func(c *gin.Context) { + response.Success(c, gin.H{"file": "..."}) +}) // 每分钟20次 +r.Use(middleware.APIRateLimit()) // 每分钟100次 + +// 自定义内存限流 +r.Use(middleware.CustomRateLimit(50, time.Minute)) // 每分钟50次 + +// Redis 分布式限流(多实例共享) +r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次(fail-open:Redis 故障时放行) +r.Use(middleware.LoginRedisRateLimit()) // 登录限流(fail-closed:Redis 故障时拒绝,防爆破) +r.Use(middleware.APIRedisRateLimit()) // API限流(fail-open) +r.Use(middleware.UploadRedisRateLimit()) // 上传限流(fail-closed:资源敏感,Redis 故障时拒绝) + +// 自定义 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,同样支持 WithFailClosed) +r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Context) string { + return fmt.Sprintf("user:%d", middleware.GetUserID(c)) +})) + +// 停止限流器(应用关闭时) +defer middleware.StopRateLimiters() +``` + +> **fail-open vs fail-closed(H4c)**:Redis 限流器在 Redis 故障时有两种策略—— +> - **fail-open**(默认):Redis 故障时放行,避免影响业务,但限流静默失效。 +> - **fail-closed**(`WithFailClosed(true)`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、上传、敏感操作)必须用 fail-closed**。 +> `RedisRateLimiter` 可经 `NewRedisRateLimiter(..., WithFailClosed(true))` 构造或 `SetFailClosed(true)` 切换策略。 + +**内存限流 vs Redis 限流:** +- 内存限流:单实例使用,简单高效 +- Redis 限流:多实例共享,滑动窗口算法,分布式场景必需 + +### 8.5 路由架构 + +框架提供灵活的路由系统,支持模块化、版本化 API 和中间件分组。 + +#### 8.4.1 模块化路由 + +```go +import "github.com/EthanCodeCraft/xlgo-core/router" + +// 方式一:实现 Module 接口 +type UserModule struct{} + +func (m *UserModule) Name() string { return "user" } +func (m *UserModule) Register(r *gin.RouterGroup) { + r.GET("/users", listUsers) + r.POST("/users", createUser) + r.GET("/users/:id", getUser) +} + +// 方式二:函数式注册 +router.ModuleFunc(func(r *gin.RouterGroup) { + r.GET("/users", listUsers) +}) + +// 使用 +app := xlgo.New( + xlgo.WithModules(&UserModule{}), +) +``` + +#### 8.4.2 版本化 API + +```go +// 单版本 +v1 := router.NewVersion("v1", "/api/v1") +v1.AddModuleFunc("user", func(r *gin.RouterGroup) { + r.GET("/users", listUsersV1) +}) + +// 多版本共存 +v1 := router.NewVersion("v1", "/api/v1") +v1.AddModuleFunc("user", func(r *gin.RouterGroup) { + r.GET("/users", listUsersV1) // 旧版本 +}) + +v2 := router.NewVersion("v2", "/api/v2") +v2.AddModuleFunc("user", func(r *gin.RouterGroup) { + r.GET("/users", listUsersV2) // 新版本 + r.GET("/users/:id/profile", getProfile) // 新增功能 +}) + +app := xlgo.New(xlgo.WithVersions(v1, v2)) + +// 结果: +// GET /api/v1/users -> listUsersV1 +// GET /api/v2/users -> listUsersV2 +``` + +#### 8.4.3 版本级中间件 + +```go +v1 := router.NewVersion("v1", "/api/v1", + middleware.CustomRateLimit(100, time.Minute), // 版本级限流 + middleware.AuthRequired(), // 版本级认证 +) +v1.AddModuleFunc("user", userRoutes) + +// 该版本所有路由自动应用限流和认证 +``` + +#### 8.4.4 中间件分组 + +```go +// 创建分组 +authGroup := router.NewMiddlewareGroup("auth", + middleware.AuthRequired(), + middleware.RequireUserTypes("tenant_admin", "platform_admin"), +) + +publicGroup := router.NewMiddlewareGroup("public", + middleware.CustomRateLimit(1000, time.Minute), +) + +// 注册分组 +registry := router.NewRegistry(engine) +registry.RegisterMiddlewareGroup(authGroup) +registry.RegisterMiddlewareGroup(publicGroup) + +// 使用分组 +userGroup := router.GroupWithMiddlewareGroup(engine, "/users", "auth") +userGroup.GET("/:id", getUser) +``` + +#### 8.4.5 RESTful CRUD + +```go +registry.RegisterModuleFunc("product", func(r *gin.RouterGroup) { + rest := router.NewRESTful(r, "/products") + + // 一行注册标准 CRUD + rest.CRUD( + listProducts, // GET /products + getProduct, // GET /products/:id + createProduct, // POST /products + updateProduct, // PUT /products/:id + deleteProduct, // DELETE /products/:id + ) +}) + +// 部分 CRUD(只注册需要的) +rest := router.NewRESTful(r, "/articles") +rest.CRUD(listArticles, getArticle, createArticle, nil, nil) +``` + +#### 8.4.6 全局注册方式 + +```go +engine := gin.New() +router.Init(engine) + +// 全局中间件 +router.Use(middleware.CORS(), middleware.Logger()) + +// 注册模块 +router.RegisterModule(&UserModule{}) +router.RegisterModuleFunc("health", func(r *gin.RouterGroup) { + r.GET("/health", healthCheck) +}) + +// 注册版本 +router.RegisterVersion(router.NewVersion("v1", "/api/v1")) + +// 应用路由 +router.Apply() + +// 启动服务(如需优雅关闭与生命周期管理,改用 app := xlgo.New(...); app.Run()) +engine.Run(":8080") +``` + +#### 8.4.7 默认路由 + +v1.0.2 起,`xlgo.New()` 默认是 **轻量应用**,不会自动注册任何默认路由。 +按需通过下列 Option 显式启用: + +```go +xlgo.New( + xlgo.WithHealthRoutes(), // 注册 /health + xlgo.WithSwaggerRoutes(), // 注册 /swagger/*any + // 或一步到位: + xlgo.WithDefaultRoutes(), // 同时注册 /health 与 /swagger/*any +) +``` + +也可以使用 `xlgo.NewFullStack(...)` / `xlgo.RunFullStack(...)` 启用全部默认组件。 + +> 生产环境建议关闭 Swagger,仅保留 `WithHealthRoutes()`,避免文档接口意外暴露。 + +--- + +## 9. 认证与授权 + +### 9.1 JWT 使用(黑名单优化版) + +**使用 JTI(JWT ID)替代完整 Token 存储黑名单,大幅节省 Redis 内存:** + +```go +import "github.com/EthanCodeCraft/xlgo-core/jwt" + +// 生成Token(自动包含唯一 JTI) +token, err := jwt.GenerateToken(userID, username, "admin", "admin") + +// 解析Token(默认 fail-closed:黑名单后端不可检查时拒绝 Token,需 Redis) +claims, err := jwt.ParseToken(tokenString) + +// 使Token失效(使用 JTI,内存占用约 30 字节) +jwt.InvalidateToken(tokenString) + +// 获取 Token 的 JTI +jti, err := jwt.GetJTI(tokenString) + +// 直接通过 JTI 撤销 Token +jwt.InvalidateTokenByID(jti, expiryTime) + +// 自定义过期时间 +token, err := jwt.GenerateTokenWithCustomExpiry(userID, username, "admin", "admin", 3600) + +// 刷新Token(旧Token自动加入黑名单) +newToken, err := jwt.RefreshToken(oldToken) + +// 获取已过期Token的信息(不验证过期) +claims, err := jwt.GetClaimsFromToken(tokenString) +``` + +**黑名单优化说明:** +- 每个Token自动生成唯一JTI(约24字节) +- 黑名单键名:`jwt_bl:{jti}`(而非完整Token) +- 内存节省:从数百字节降到约30字节每条记录 + +### 9.2 获取用户信息 + +```go +// 一次性取出全部认证信息(v1.0.2 推荐) +if user, ok := middleware.GetAuthUser(c); ok { + _ = user.UserID + _ = user.Username + _ = user.Role + _ = user.UserType +} + +// 单字段便捷函数(与旧版兼容) +userID := middleware.GetUserID(c) +username := middleware.GetUsername(c) +userType := middleware.GetUserType(c) // super_admin/admin/staff 等,由业务定义 +``` + +### 9.3 密码加密 + +```go +import "github.com/EthanCodeCraft/xlgo-core/validation" + +// 加密密码 +hash, err := validation.HashPassword("password123") + +// 验证密码 +if validation.CheckPassword(hash, "password123") { + // 密码正确 +} + +// 验证并自动升级成本 +match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, password, 12) +``` + +--- + +## 10. 文件存储 + +### 10.1 本地存储 + +```yaml +storage: + driver: local + local: + path: ./public + base_url: http://localhost:8080/public +``` + +### 10.2 阿里云 OSS + +```yaml +storage: + driver: oss + oss: + endpoint: oss-cn-hangzhou.aliyuncs.com + bucket: my-bucket + access_key_id: your_key + access_key_secret: your_secret + base_url: https://my-bucket.oss-cn-hangzhou.aliyuncs.com +``` + +### 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" + +// 初始化 +if err := storage.Init(&cfg.Storage); err != nil { + return err +} + +// 上传文件(从请求中获取) +file, _ := c.FormFile("file") +path, err := storage.Upload(file, "images") + +// 获取访问 URL +url := storage.GetURL(path) + +// 上传字节数据 +path, err := storage.UploadFromBytes(data, "avatar.jpg", "images") + +// 删除文件 +err := storage.Delete(path) + +// 获取文件内容 +data, err := storage.Get(path) + +// 检查文件是否存在(不存在 false,nil;后端错误返回 err) +ok, err := storage.Exists(path) +if err != nil { + return err +} +if ok { + // 文件存在 +} +``` + +--- + +## 11. 工具函数库 + +### 11.1 随机数生成 + +**安全选型**:字符串随机(token/OTP/验证码/会话 ID/nonce)用 `RandStringSecure`/`RandDigitSecure` +(基于 `crypto/rand`,不可预测)。`RandString`/`RandDigit`(math/rand 版本)已移除—— +字符串随机的用途几乎都是安全场景,保留 math/rand 版本会诱导误用。 + +```go +import "github.com/EthanCodeCraft/xlgo-core/utils" + +// 安全场景:token、会话 ID、API key(crypto/rand,不可预测) +token, err := utils.RandStringSecure(32) +if err != nil { + // 处理 crypto/rand 失败(极罕见,通常仅系统熵池耗尽) +} + +// 安全场景:6 位 OTP 验证码、密码重置码(crypto/rand) +code, err := utils.RandDigitSecure(6) + +// 安全场景:安全 nonce 范围、防猜抽奖、密钥分桶(crypto/rand 无偏) +idx, err := utils.RandIntSecure(0, 1000) + +// 范围随机数(非密码学安全,仅用于负载均衡/游戏/A-B 分桶等非安全场景) +n := utils.RandInt(1, 100) +``` + +> 非安全场景需要高性能随机串时,直接用标准库 `math/rand` 即可,框架不再提供 +> 易误用的 `RandString`/`RandDigit`。 + +### 11.2 字符串处理 + +```go +// 检查空白 +if utils.IsBlank(str) { } + +// 批量检查 +if utils.IsAnyBlank(name, email, phone) { } + +// 默认值 +name := utils.DefaultIfBlank(name, "未知") + +// Unicode长度(中文) +len := utils.StrLen("你好世界") // 4 + +// 截取子串(支持中文) +sub := utils.Substr("你好世界", 0, 2) // "你好" + +// 不区分大小写比较 +if utils.EqualsIgnoreCase("Admin", "admin") { } +``` + +### 11.3 时间日期 + +```go +// 时间戳 +now := utils.NowUnix() // 秒 +now := utils.NowTimestamp() // 毫秒 + +// 时间戳转时间 +t := utils.FromUnix(unix) +t := utils.FromTimestamp(ms) + +// 格式化 +s := utils.FormatDateTime(t) // "2006-01-02 15:04:05" +s := utils.FormatDate(t) // "2006-01-02" + +// 获取当天开始/结束 +start := utils.StartOfDay(t) +end := utils.EndOfDay(t) + +// 获取当月开始/结束 +start := utils.StartOfMonth(t) +end := utils.EndOfMonth(t) +``` + +### 11.4 类型转换 + +```go +// 字符串转数字 +n := utils.ToInt("123") +n := utils.ToIntDefault("abc", 0) // 失败返回默认值 + +n := utils.ToInt64("123") +f := utils.ToFloat64("3.14") + +// 分页计算 +totalPages := utils.CalcPageCount(100, 10) // 10页 +offset := utils.CalcOffset(2, 20) // 20 +``` + +### 11.5 文件操作 + +```go +// 检查存在 +utils.FileExists(path) +utils.DirExists(path) + +// 确保目录存在 +utils.EnsureDir(path) + +// 读写文件 +data, err := utils.ReadFile(path) +err := utils.WriteFile(path, data) +err := utils.AppendFile(path, []byte("\nnew line")) + +// 复制文件 +err := utils.CopyFile(dst, src) + +// 获取文件大小 +size, err := utils.FileSize(path) +``` + +### 11.6 格式验证 + +```go +// 手机号 +if utils.IsPhone("13812345678") { } + +// 邮箱 +if utils.IsEmail("test@example.com") { } + +// IPv4 +if utils.IsIPv4("192.168.1.1") { } + +// 身份证 +if utils.IsIDCard(id) { } + +// 纯数字 +if utils.IsNumeric(str) { } + +// 字母数字 +if utils.IsAlphanumeric(str) { } +``` + +### 11.7 HTTP 客户端(连接池优化版) + +```go +import "github.com/EthanCodeCraft/xlgo-core/utils" + +// 创建客户端(Transport 在初始化时创建,连接池可复用) +client := utils.NewHTTPClient() + +// 自定义配置 +client = utils.NewHTTPClientWithConfig(utils.HTTPClientConfig{ + Timeout: 30 * time.Second, + MaxIdleConns: 100, // 最大空闲连接数 + MaxIdleConnsPerHost: 10, // 每主机最大空闲连接 + IdleConnTimeout: 90 * time.Second, // 空闲连接超时 +}) + +// 链式配置 +client.SetTimeout(30 * time.Second) +client.SetHeader("Authorization", "Bearer xxx") +client.SetSkipTLS(false) // 生产环境建议设为 false + +// GET请求 +data, err := client.Get(url, map[string]string{"page": "1"}) + +// POST表单 +data, err := client.Post(url, map[string]string{"name": "test"}) + +// POST JSON +data, err := client.PostJSON(url, map[string]any{"name": "test"}) + +// PUT请求 +data, err := client.Put(url, map[string]any{"id": 1}) + +// DELETE请求 +data, err := client.Delete(url) + +// 上传文件 +files := []utils.UploadFile{ + {FieldName: "file", FilePath: "/path/to/file"}, +} +data, err := client.Upload(url, files, params) + +// 从字节数据上传 +data, err := client.UploadFromBytes(url, "file", "image.jpg", fileData, params) + +// 自定义请求 +data, err := client.Request("PATCH", url, bodyData) + +// 关闭客户端(释放连接池资源) +client.Close() +``` + +**HTTPClient 特性:** +- Transport 初始化时创建,连接池可复用 +- 支持连接池参数配置 +- 全局默认客户端:`utils.DefaultHTTPClient()` +- 快捷函数:`utils.HTTPGet/HTTPPost/HTTPPostJSON` + +### 11.8 UUID + +```go +// UUID v4 +uuid := utils.UUID() // "550e8400-e29b-41d4-a716-446655440000" + +// 短UUID(无横线) +uuid := utils.UUIDShort() // "550e8400e29b41d4a716446655440000" + +// 验证UUID +if utils.UUIDValid(uuid) { } +``` + +### 11.9 函数评估表 + +| 分类 | 高分函数(⭐⭐⭐⭐⭐) | 说明 | +|------|---------------------|------| +| **随机(安全)** | `RandStringSecure/RandDigitSecure/RandIntSecure/RandInt64Secure` | crypto/rand,token/OTP/nonce 用 | +| **随机(范围)** | `RandInt/RandInt64` | math/rand,负载均衡/游戏等非安全场景 | +| **字符串** | `IsBlank/DefaultIfBlank/StrLen` | 空值处理、Unicode支持 | +| **时间** | `FormatDateTime/StartOfDay/EndOfMonth` | 标准格式、边界计算 | +| **转换** | `ToIntDefault/CalcPageCount/CalcOffset` | 安全转换、分页计算 | +| **文件** | `FileExists/DirExists/EnsureDir/CopyFile` | 路径检查、目录创建 | +| **验证** | `IsIPv4/IsNumeric/IsAlphanumeric` | 格式验证 | +| **URL** | `ParseURL/URLBuilder` | 链式构建URL | +| **加密** | `SHA256/Base64Encode/Base64URLEncode` | 安全哈希、编码 | +| **HTTP** | `NewHTTPClient/Get/PostJSON/Upload` | 连接池、链式调用 | +| **UUID** | `UUID/UUIDShort` | UUID生成 | + +**设计改进点:** + +| 改进 | 说明 | +|------|------| +| 性能优化 | 非安全随机用 `RandInt/RandInt64`(math/rand,sync.Pool 复用源);安全场景用 `RandStringSecure/RandDigitSecure/RandIntSecure`(crypto/rand + big.Int 拒绝采样无偏) | +| 类型安全 | 移除使用反射的函数,保持类型安全 | +| 式调用 | `HTTPClient` 和 `URLBuilder` 支持链式调用 | +| 零依赖 | 仅依赖 `google/uuid`,其余使用标准库 | + +--- + +## 12. 实时通信 + +### 12.1 SSE 流式响应 + +```go +import "github.com/EthanCodeCraft/xlgo-core/sse" + +// AI对话场景 +func ChatHandler(c *gin.Context) { + ch := make(chan string) + go func() { + defer close(ch) + for _, chunk := range aiResponse { + ch <- chunk + } + }() + sse.StreamChunks(c, ch) +} + +// 手动写入 +writer, _ := sse.NewSSEWriter(c) +writer.WriteEvent("message", "Hello") +writer.WriteMessage("World") +writer.WriteDone() +``` + +### 12.2 WebSocket + +```go +import "github.com/EthanCodeCraft/xlgo-core/ws" + +// 简单使用 +r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) { + conn.SendText("收到: " + string(message)) +})) + +// 广播模式 +hub := ws.NewHub() +go hub.Run() + +// 注册连接 +hub.Register(conn) + +// 广播消息 +hub.Broadcast([]byte("广播消息")) + +// 获取连接数 +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 +import "github.com/EthanCodeCraft/xlgo-core/cron" + +// 间隔执行(每5分钟) +cron.AddTask("cleanup", cron.Every(5*time.Minute), func(ctx context.Context) error { + return cleanupOldData() +}) + +// 每天固定时间(凌晨2点) +cron.AddTask("report", cron.Daily(2, 0), generateReport) + +// 每周执行(周一上午10点) +cron.AddTask("weekly", cron.Weekly(time.Monday, 10, 0), weeklyTask) + +// 简化 Cron(分钟+小时) +cron.AddTask("noon", cron.Cron("0", "12"), doSomething) // 每天12:00 + +// 完整 Cron 表达式(5字段) +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 启动与停止 + +```go +// 启动调度器 +cron.Start() + +// 停止调度器 +cron.Stop() + +// 获取调度器实例进行更多操作 +scheduler := cron.GetScheduler() +scheduler.RemoveTask("cleanup") // 移除任务 +tasks := scheduler.ListTasks() // 查看任务列表 +scheduler.RunTask("report") // 立即执行任务 +scheduler.DisableTask("cleanup") // 禁用任务 +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 +import "github.com/EthanCodeCraft/xlgo-core/trace" + +trace.Init(trace.Config{ + Enabled: true, + ServiceName: "my-service", + Endpoint: "localhost:4318", + ExporterType: "otlp-http", + // Insecure: true, // 明文 collector(localhost:4318 等本地 collector 默认无 TLS,需显式开启) + SampleRatio: 1.0, + // Propagator: "w3c", // 可选 "w3c"(默认) / "b3" / "jaeger"(映射 W3C) +}) +defer trace.Close(ctx) +``` + +> **导出器类型**:`otlp-http` / `otlp-grpc` / `stdout`(写标准输出,便于调试)。未知类型 `Init` 返错。 +> **Insecure**:默认 `false`(TLS);对无 TLS 的本地 collector(如 `localhost:4318`)需显式置 `true`,否则握手失败。 +> **传播器**:`w3c`(默认,W3C TraceContext + Baggage)/ `b3`(同时支持单头与多头,兼容旧 B3 客户端)/ `jaeger`(映射为 W3C TraceContext——现代 Jaeger agent 透传 W3C;纯 Jaeger thrift 头协议请用 `b3`)。未知类型 `Init` 返错。 +> **未 Init 也安全**:未调用 `Init` 或 `Init(Enabled:false)` 时为 Noop tracer,`Middleware`/`StartSpan` 等不 panic。 + +### 14.2 使用中间件 + +```go +// 自动追踪所有请求 +r.Use(trace.Middleware("my-service")) + +// 响应头自动添加 X-Trace-ID +``` + +### 14.3 业务追踪 + +```go +// 创建子Span +ctx, span := trace.StartSpan(c, "db_query") +defer span.End() + +// 记录错误 +trace.RecordError(c, err) + +// 添加属性 +trace.SetAttribute(c, "user_id", 123) + +// 获取TraceID +traceID := trace.GetTraceID(c) +``` + +--- + +## 15. 压缩解压 + +### 15.1 Gzip + +```go +import "github.com/EthanCodeCraft/xlgo-core/compress" + +// 压缩数据 +compressed, err := compress.GzipCompress(data) + +// 解压数据 +data, err := compress.GzipDecompress(compressed) + +// 压缩文件 +err := compress.GzipCompressFile("src.txt", "dst.gz") + +// 解压文件 +err := compress.GzipDecompressFile("src.gz", "dst.txt") +``` + +### 15.2 Zip + +```go +// 打包文件/目录 +err := compress.Zip("archive.zip", []string{"file1.txt", "dir/"}) + +// 解压到目录 +err := compress.Unzip("archive.zip", "./output") +``` + +--- + +## 16. 测试工具 + +### 16.1 API 测试 + +```go +import "github.com/EthanCodeCraft/xlgo-core/test" + +func TestUserAPI(t *testing.T) { + router := test.SetupRouter() + + // POST请求 + resp := test.POST(router, "/api/users"). + WithJSON(map[string]any{"name": "test"}). + Execute() + resp.AssertOK(t) + resp.AssertJSONContains(t, "code", float64(1)) + + // GET请求 + resp = test.GET(router, "/api/users/1").Execute() + resp.AssertOK(t) + resp.AssertJSONContains(t, "data.id", float64(1)) +} +``` + +--- + +## 17. CLI 脚手架 + +### 17.1 创建项目 + +```bash +# 创建新项目 +xlgo new myproject + +# 指定模块名 +xlgo new myproject --module github.com/company/myproject +``` + +### 17.2 生成代码 + +```bash +# 生成Handler +xlgo make handler user + +# 生成Model +xlgo make model user + +# 生成Repository +xlgo make repository user + +# 生成Service +xlgo make service user +``` + +--- + +## 18. 最佳实践 + +### 18.1 项目分层 + +``` +handler/ → 接收请求、参数验证、调用Service +service/ → 业务逻辑、事务管理 +repository/ → 数据访问、CRUD操作 +model/ → 数据模型定义 +``` + +### 18.2 错误处理 + +```go +func GetUser(c *gin.Context) { + id := handler.PathInt64(c, "id", 0) + if id == 0 { + // 参数错误码由业务侧定义;这里直接返回通用失败 + 自定义消息 + response.Fail(c, "参数错误") + return + } + + user, err := userService.GetByID(id) + if err != nil { + trace.RecordError(c, err) + response.FailWithError(c, response.ErrUserNotFound) + return + } + + response.Success(c, user) +} +``` + +### 18.3 多站点配置 + +```yaml +# A站点 +app: + site_name: "site_a" + +# B站点 +app: + site_name: "site_b" +``` + +缓存自动隔离: + +- site_a: `cache:site_a:user:1` +- site_b: `cache:site_b:user:1` + +### 18.4 环境区分 + +```go +if cfg.IsProduction() { + gin.SetMode(gin.ReleaseMode) + // 生产环境收紧 console 输出(仅保留 Warn / Error) + // 业务事件请使用 logger 包记录 + console.SetLevel(console.LevelWarn) +} +``` + +--- + +## 附录:完整示例 + +### 用户登录接口 + +```go +package handler + +import ( + "github.com/EthanCodeCraft/xlgo-core/cache" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/EthanCodeCraft/xlgo-core/validation" + "github.com/gin-gonic/gin" +) + +type LoginRequest struct { + Username string `json:"username" label:"用户名" validate:"required" msg_required:"用户名不能为空"` + Password string `json:"password" label:"密码" validate:"required,password" msg_required:"密码不能为空"` +} + +func Login(c *gin.Context) { + var req LoginRequest + if !validation.ShouldBindAndValidate(c, &req) { + return + } + + // 查询用户 + user, err := userService.GetByUsername(req.Username) + if err != nil { + response.FailWithError(c, response.ErrUserNotFound) + return + } + + // 验证密码 + if !validation.CheckPassword(user.Password, req.Password) { + response.FailWithError(c, response.ErrPasswordWrong) + return + } + + // 生成Token + token, err := jwt.GenerateToken(user.ID, user.Username, "admin", "admin") + if err != nil { + response.FailWithError(c, response.ErrServerError) + return + } + + // 缓存用户信息 + cache.GetCache().Set(c.Request.Context(), + cache.KSession(token), + user, + cfg.JWT.Expire) + + response.Success(c, gin.H{ + "token": token, + "user_id": user.ID, + "username": user.Username, + }) +} +``` + +--- + +_文档版本: v1.2.0_ +_最后更新: 2026-07-04_ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f4beed7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ethan xlp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fe35d76 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: build run test clean tidy docker + +# 构建二进制文件 +build: + go build -o bin/server ./example + +# 开发模式运行 +run: + go run ./example + +# 运行测试 +test: + go test ./... + +# 清理 +clean: + rm -rf bin/ + rm -rf logs/ + +# 安装依赖 +tidy: + go mod tidy + +# 生成 Swagger 文档 +swagger: + swag init -g example/main.go -o example/swagger + +# Docker 构建 +docker: + docker build -t xlgo-app:latest . + +# Docker 运行 +docker-run: + docker run -d -p 8080:8080 xlgo-app:latest diff --git a/README.md b/README.md new file mode 100644 index 0000000..5754e74 --- /dev/null +++ b/README.md @@ -0,0 +1,1117 @@ +# xlgo Web Framework + +

+ Go Version + Gin Version + License +

+ +> 基于 Go + Gin 的后端开发框架。**组件全部 Manager 化**——既能像脚本一样用包级函数简单调用,又能像正经框架一样注入实例、跑多套、塞 mock 做单测。 + +## 为什么是 xlgo + +多数 Gin 脚手架把数据库、Redis、缓存、JWT、日志做成**包级全局单例**:写小项目顺手,但一旦要做单元测试、同进程跑多套实例、或替换实现,就动弹不得。xlgo 把这些组件全部抽象成 `XxxManager`,同时保留包级便捷函数做 facade——**简单用法零成本,复杂场景不封顶**: + +```go +// 简单用法:包级函数,跟单例脚手架一样直接 +database.InitDB(cfg) +db := database.GetDB() + +// 同一份代码,也能注入实例 / 跑多套 / 塞 mock +myDB := database.NewManager(cfg) +myDB.Open(ctx) // 独立实例,不受全局影响 +database.SetDefaultManager(myDB) // 提升为全局默认,并关闭旧默认 manager +cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入自定义 CacheManager +``` + +### 区别于一般 Gin 脚手架的几点 + +- **组件可注入** — database / redis / cache / jwt / storage / logger 均为 `Manager` + 全局 facade 双轨,支持多实例与 mock 注入,而非写死的包级单例 +- **生产就绪内置** — `/livez` `/readyz`、Prometheus `/metrics`、主库探活自愈、请求级超时、优雅关闭(等待 in-flight goroutine)开箱即用,不用自己搭 +- **框架内零 Fatal** — 错误一律返回由调用方处理,框架代码不 `panic` 杀进程 +- **默认轻量、按需启用** — `New()` 什么都不开,`NewFullStack()` 一键全开,中间任意组合;`Without*` 精细排除 +- **可插拔数据库方言** — GORM 方言注册表,内置 MySQL / PostgreSQL,可扩展 SQLite / ClickHouse 等 + +### 30 秒上手 + +```go +package main + +import ( + xlgo "github.com/EthanCodeCraft/xlgo-core" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" +) + +func main() { + app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithLogger(), + xlgo.WithHealthRoutes(), + ) + app.GetRouter().GET("/", func(c *gin.Context) { + response.Success(c, gin.H{"hello": "xlgo"}) + }) + _ = app.Run() +} +``` + +## 框架特性 + +**架构与可注入性** +- **组件 Manager 化** - database / redis / cache / jwt / storage / logger 均为 `XxxManager` + 全局 facade 双轨,支持多实例与测试注入 mock +- **可插拔数据库方言** - GORM 方言注册表,内置 MySQL / PostgreSQL,可注册 SQLite / ClickHouse 等任意驱动 +- **读写分离** - 主库 + 多副本,`ReplicaPicker` 策略可插拔(轮询 / 随机 / 自定义),context 路由强制主库 +- **Lifecycle Hooks** - `OnInit / OnStart / OnReady / OnStop` 生命周期钩子,可插拔启动/关闭逻辑 + +**生产就绪** +- **健康探针** - `/livez`(存活)+ `/readyz`(就绪)+ `/health`(兼容),K8s probe 友好 +- **Prometheus 指标** - `/metrics` 端点 + 请求计数 / 耗时 / 在飞数采集中间件 +- **依赖健康自愈** - 主库后台探活,连续失败标记不健康联动 503;从库失败临时剔除、恢复自动纳入 +- **请求级超时** - `middleware.Timeout` 级联取消下游 GORM/Redis +- **优雅关闭** - `App.Go` 管理后台 goroutine,Shutdown 时 cancel + 等待退出(带超时) +- **配置校验** - 启动期 `Validate` 拦截非法配置(端口/密钥/必填字段),错误前置 + +**基础功能** +- **配置管理** - YAML + 环境变量覆盖 + 热更新,`time.Duration` 字符串解析(`"24h"`) +- **缓存** - Redis 缓存,键前缀、TTL、SCAN 优化、分布式锁 +- **认证** - JWT 认证,Token 黑名单、刷新机制,HMAC 算法可选 +- **日志** - 分级日志(通用 / API / DB 三分流),lumberjack 轮转 +- **中间件** - CORS、限流(内存 + Redis 版)、请求日志、认证、CSRF、RequestID、Recover +- **文件存储** - 本地 + 阿里云 OSS +- **实时通信** - SSE 流式响应 + WebSocket +- **定时任务** - 内置调度器(interval / daily / weekly / cron) +- **验证器** - 请求参数验证,自定义错误消息 +- **响应模式** - `ModeBusiness`(业务码,默认)/ `ModeREST`(按错误码映射 HTTP status)一键切换 +- **CLI 工具** - 脚手架 + 代码生成(handler/repository/model/service) + +## 快速开始 + +> **环境要求**:xlgo 基于 Go 1.25+ 构建,请确保本地已安装 Go 1.25 或更高版本。 + +### 1. 安装 + +```bash +# 安装脚手架工具 +go install github.com/EthanCodeCraft/xlgo-core/cmd/xlgo@latest + +# 创建新项目 +xlgo new myproject + +# 进入目录 +cd myproject + +# 安装依赖 +go mod tidy +``` + +### 2. 创建配置文件 config.yaml + +```yaml +server: + host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机 + port: 8080 + mode: development + read_timeout: 15s + write_timeout: 30s + idle_timeout: 60s + shutdown_timeout: 30s + response_mode: business # business(默认,全200+业务码) 或 rest(按错误码映射HTTP status) + +database: + driver: mysql # mysql(默认)或 postgres + host: localhost + port: 3306 + user: root + password: your_password + name: your_database + max_idle_conns: 10 + max_open_conns: 100 + # dsn: "自定义连接字符串,设置后优先于上面的字段" + +redis: + host: localhost + port: 6379 + password: "" + db: 0 + +jwt: + secret: your_jwt_secret_key_at_least_32_bytes # ≥32 字节,否则启动期被 Validate 拦截 + expire: "24h" # time.Duration,支持 "24h"/"30m" 等 + refresh_expire: "168h" # 刷新 token 过期时间 + issuer: xlgo + algorithm: HS256 # HS256(默认)/HS384/HS512 + +storage: + driver: local + local: + path: ./public + base_url: http://localhost:8080/public + +log: + dir: ./logs + max_size: 100 + max_backups: 30 + max_age: 30 + compress: true +``` + +### 3. 运行项目 + +```bash +go run main.go +``` + +访问 http://localhost:8080/health 检查服务状态。 + +> v1.0.2 起,`xlgo.New()` 默认是轻量应用,不会自动初始化 MySQL、Redis、Storage 或 Swagger。需要完整基础设施时请显式使用 `WithMySQL()`、`WithRedis()`、`WithStorage()`、`WithSwaggerRoutes()`,或直接使用 `xlgo.NewFullStack()`。 +> +> `Without*` 系列 Option(如 `WithoutSwaggerRoutes`)主要用于 `NewFullStack` / `RunFullStack` 启用全部组件后排除个别项,例如 `xlgo.NewFullStack(xlgo.WithoutSwaggerRoutes())` 全组件但关闭 Swagger。`xlgo.New()` 本身已全关,无需再 `Without`。 + +### 4. 模块路径与包名 + +xlgo 的 **模块路径** 是 `github.com/EthanCodeCraft/xlgo-core`,**包名** 是 `xlgo`(二者不同是 Go 惯例,如 `github.com/gin-gonic/gin` → 包名 `gin`)。import 时用别名 `xlgo` 即可: + +```go +package main + +import ( + xlgo "github.com/EthanCodeCraft/xlgo-core" + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" +) + +func main() { + app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithLogger(), + xlgo.WithHealthRoutes(), + ) + // 注册一个示例路由 + app.GetRouter().GET("/", func(c *gin.Context) { + response.Success(c, gin.H{"message": "Hello xlgo!"}) + }) + // 启用 JWT 认证示例路由(需配合 WithMySQL/WithRedis 生成 token) + _ = middleware.RequireUserTypes("admin") + + _ = app.Run() +} +``` + +> 子包(`config` / `database` / `logger` / `middleware` ...)的包名与目录名一致,无需别名。 + +--- + +## 核心功能 + +### 配置管理 + +```go +// 加载配置 +cfg, err := config.Load("./config.yaml") + +// 获取配置 +cfg := config.Get() +serverPort := cfg.Server.Port + +// 配置热更新 +cfg, err := config.LoadWithWatch("./config.yaml", func(newCfg *config.Config) { + log.Println("配置已更新") +}) + +// 注册配置变更回调 +config.RegisterCallback(func(cfg *config.Config) { + // 处理配置变更 +}) + +// 手动重新加载 +config.Reload() +``` + +### 数据库操作 + +xlgo 基于 GORM,主库与从库的驱动由配置 `database.driver` 决定,内置支持 `mysql`(默认)与 `postgres`,也可通过 `database.dsn` 使用任意自定义连接字符串。v1.0.2 起 GORM 方言通过 **可插拔注册表** 管理,应用可自行接入 SQLite、SQL Server、ClickHouse 等任意 GORM 驱动而无需修改框架。 + +```go +// 初始化数据库(驱动由配置决定) +database.InitDB(cfg) +defer database.Close() + +// 主从读写分离(从库 DSN 需与主库驱动匹配) +database.InitDBWithReplicas(cfg, []string{ + "root:pass@tcp(slave1:3306)/db", + "root:pass@tcp(slave2:3306)/db", +}) + +// 读操作自动路由到从库 +var users []model.User +database.GetReadDB().Find(&users) + +// 强制使用主库 +ctx := database.UseMaster(context.Background()) +database.GetDBFromContext(ctx).Find(&users) + +// 事务 +database.Transaction(func(tx *gorm.DB) error { + return tx.Create(&user).Error +}) + +// 健康检查 +status := database.HealthCheck() +``` + +v1.0.2 起数据库状态由实例化的 `database.Manager` 管理,全局函数(`GetDB`、`GetReadDB`、`CloseAll` 等)作为默认 `DefaultManager` 的 facade 保留。可通过 `database.NewManager(cfg)` 创建独立管理器,并通过 `ReplicaPicker` 自定义从库选择策略: + +```go +// 独立管理器(不影响全局 DefaultManager) +mgr := database.NewManager(cfg) +if err := mgr.Open(context.Background()); err != nil { + return err +} +defer mgr.Close() + +// 从库选择策略:轮询(默认随机) +database.SetReplicaPicker(&database.RoundRobinPicker{}) +``` + +`database.SetDefaultManager(m)` 表示把新 manager 接管为全局默认,并关闭被替换的旧 manager,避免连接池泄漏。需要失败回滚或延迟释放旧资源的初始化流程,请使用 `database.SwapDefaultManager(m)` 并自行决定何时关闭返回的旧 manager。 + +#### 注册自定义 GORM 方言 + +通过 `database.RegisterDialect` 一次注册即可让 `database.driver: ` 生效,DSN 构建器会同步登记到 `config` 包,因此 `cfg.Database.DSN()` 也会识别新驱动: + +```go +import ( + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/database" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func init() { + database.RegisterDialect(database.DialectSpec{ + Name: "sqlite", + Aliases: []string{"sqlite3"}, + Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) }, + DSN: func(c *config.DatabaseConfig) string { return c.Name }, // Name 当作文件路径 + }) +} +``` + +诊断接口:`database.RegisteredDialects()` 返回当前已注册的驱动名(含别名),`database.LookupDialect(name)` 用于检查某个驱动是否已就绪。未注册的驱动会回退到 MySQL 以保持向后兼容。 + +### Repository 泛型 CRUD + +```go +// 创建仓库 +userRepo := repository.NewBaseRepo[model.User](database.GetDB()) + +// 基础 CRUD +user, err := userRepo.FindByID(ctx, 1) +err := userRepo.Create(ctx, &user) +err := userRepo.Update(ctx, &user) +err := userRepo.Delete(ctx, 1) + +// 分页查询 +result, err := userRepo.FindPage(ctx, 1, 20) +// result.Items, result.Total, result.Page, result.PageSize + +// 条件查询 +users, err := userRepo.FindWhere(ctx, "status = ?", 1) + +// 链式查询 +users, err := userRepo.NewQueryBuilder(). + Where("status = ?", 1). + Order("created_at DESC"). + Limit(10). + Find(ctx) + +// 批量操作 +err := userRepo.CreateBatch(ctx, users) +err := userRepo.DeleteBatch(ctx, []uint{1, 2, 3}) + +// 事务支持 +err := userRepo.WithTransaction(ctx, func(txRepo *repository.BaseRepo[model.User]) error { + return txRepo.Create(ctx, &user) +}) +``` + +### Redis 缓存 + +> **v1.4.0**:App 经 `WithRedis()` 启用时自动用 App 的 Redis 客户端初始化缓存(多 App 隔离,不串越)。下方 `cache.Init()` 用于 standalone。 + +```go +// 初始化缓存 +cache.Init() + +// 使用缓存 +ctx := context.Background() +cacheService := cache.GetCache() + +// 设置缓存 +cacheService.Set(ctx, "user:1", user, 10*time.Minute) + +// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err) +var user 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") + +// 按模式删除(使用 SCAN 优化) +cacheService.DeleteByPattern(ctx, "user:*") +``` + +### JWT 认证 + +```go +// 生成 Token(自动包含 JTI) +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) + +// 获取 Token 的 JTI +jti, err := jwt.GetJTI(tokenString) +``` + +**JWT 黑名单优化**:使用 JTI(JWT ID)替代完整 Token 存储,大幅节省 Redis 内存。 + +### 分布式锁 + +```go +// 安全加锁(返回 LockToken) +token, err := cache.NewLock(ctx, cache.KLock("order:123"), 30*time.Second) +if token != nil { + defer cache.Unlock(ctx, token) // 只有持有者能释放 + // 执行业务逻辑 +} + +// 续期锁(长任务场景) +cache.ExtendLock(ctx, token, 30*time.Second) + +// 自动续期执行 +err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func(ctx context.Context) error { + // 长任务自动续期 + return nil +}) +``` + +**分布式锁安全特性**:使用 Lua 脚本 + UUID Token,只有锁的持有者才能释放。 + +### Redis 分布式限流 + +> **v1.4.0**:多 App 走各自 Redis 用 `middleware.NewRedisRateLimiter(..., middleware.WithRedisClient(app.RedisClient()))`。 + +```go +// 内存限流(单实例) +r.Use(middleware.CustomRateLimit(100, time.Minute)) + +// Redis 分布式限流(多实例共享) +r.Use(middleware.RedisRateLimit("api_limit", 100)) +r.Use(middleware.LoginRedisRateLimit()) // 登录限流 +r.Use(middleware.APIRedisRateLimit()) // API 限流 +``` + +### 请求验证 + +```go +type LoginRequest struct { + Username string `json:"username" label:"用户名" validate:"required,username" msg_required:"请输入用户名"` + Password string `json:"password" label:"密码" validate:"required,password" error:"密码格式不正确"` + Phone string `json:"phone" label:"手机号" validate:"omitempty,phone" msg_phone:"请输入正确的手机号"` +} + +// 绑定并验证 +errors, ok := validation.ShouldBindAndValidate(c, &req) +if !ok { + response.Fail(c, errors.FirstMessage()) + return +} +``` + +**验证器支持的自定义标签**: + +- `label:"中文名"` - 字段显示名称 +- `error:"通用错误消息"` - 所有验证失败时显示 +- `msg_required:"必填项"` - 特定规则的错误消息 +- `msg_min:"最少5个字符"` - 针对 min 规则的错误消息 + +### 统一错误码 + +```go +// 使用预定义错误 +response.FailWithError(c, response.ErrUserNotFound) + +// 带详细信息 +response.FailWithDetail(c, response.ErrPasswordWrong, "连续错误3次将锁定账户") + +// 自定义错误 +err := response.NewError(10001, "自定义错误") +``` + +**预定义错误码**: + +- 用户模块:`ErrUserNotFound`, `ErrPasswordWrong`, `ErrTokenExpired` 等 +- 文件模块:`ErrFileTooLarge`, `ErrFileTypeInvalid` 等 +- 数据模块:`ErrDataNotFound`, `ErrDataConflict` 等 + +### 文件上传 + +```go +// 上传文件 +file, err := c.FormFile("file") +path, err := storage.Upload(file, "images") +url := storage.GetURL(path) + +// 上传字节数组 +path, err := storage.UploadFromBytes(data, "filename.jpg", "images") + +// 删除文件 +err := storage.Delete(path) + +// 获取文件内容 +data, err := storage.Get(path) + +// 检查文件是否存在(不存在 false,nil;后端错误返回 err) +ok, err := storage.Exists(path) +if err != nil { + return err +} +``` + +### SSE 流式响应 + +```go +// AI 对话场景 +func ChatHandler(c *gin.Context) { + ch := make(chan string) + go func() { + defer close(ch) + for _, chunk := range aiResponse { + ch <- chunk + } + }() + sse.StreamChunks(c, ch) +} + +// 带消息 ID 的流式响应 +sse.StreamWithID(c, "msg_123", ch) +``` + +### WebSocket + +```go +// 简单使用 +r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) { + conn.SendText("收到: " + string(message)) +})) + +// 广播模式 +hub := ws.NewHub() +go hub.Run() +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 { + return cleanupOldData() +}) + +// 每天凌晨 2 点执行 +cron.AddTask("daily_report", cron.Daily(2, 0), generateReport) + +// 每周一上午 9 点执行 +cron.AddTask("weekly", cron.Weekly(time.Monday, 9, 0), weeklyTask) + +// 完整 Cron 表达式 +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() +``` + +### CSRF 防护 + +```go +// 基本使用 +r.Use(middleware.CSRF()) + +// 获取 Token(返回给前端) +token := middleware.GetCSRFToken(c) + +// 跳过指定路径 +r.Use(middleware.CSRFWithSkip([]string{"/api/webhook"})) + +// API 模式(双重提交 Cookie) +r.Use(middleware.DoubleSubmitCookie()) +``` + +### 密码加密 + +```go +// 加密密码 +hash, err := validation.HashPassword("password123") + +// 验证密码 +if validation.CheckPassword(hash, "password123") { + // 密码正确 +} + +// 带成本升级的验证 +match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, password, 12) +``` + +--- + +## 中间件 + +### 认证中间件 + +```go +// JWT 认证(必须登录) +r.Use(middleware.AuthRequired()) + +// 自定义用户类型权限 +r.Use(middleware.RequireUserTypes("tenant_admin", "platform_admin")) + +// 自定义角色权限 +r.Use(middleware.RequireRoles("owner", "manager")) + +// 自定义复杂权限判断 +r.Use(middleware.RequireAuth(func(user middleware.AuthUser, c *gin.Context) bool { + return user.UserType == "merchant" && user.Role == "owner" +})) + +// 默认快捷方法(super_admin/admin/staff 只是框架默认常量) +r.Use(middleware.AdminRequired()) +r.Use(middleware.SuperAdminRequired()) +r.Use(middleware.StaffRequired()) +r.Use(middleware.AnyUserRequired()) + +// 获取用户信息 +user, ok := middleware.GetAuthUser(c) +userID := middleware.GetUserID(c) +username := middleware.GetUsername(c) +role := middleware.GetRole(c) +userType := middleware.GetUserType(c) +``` + +### 限流中间件 + +> **v1.4.0**:App `Shutdown` 自动 `registry.Stop()`(不再调全局 `StopRateLimiters`,避免误停其他 App)。多 App per-App 计数隔离用 `app.RateLimitRegistry().LoginRateLimit()` 等 App-bound 中间件。 + +```go +// 登录限流(每分钟 10 次) +r.POST("/login", middleware.LoginRateLimit(), func(c *gin.Context) { + response.Success(c, gin.H{"token": "..."}) +}) + +// 上传限流(每分钟 20 次) +r.POST("/upload", middleware.UploadRateLimit(), func(c *gin.Context) { + response.Success(c, gin.H{"file": "..."}) +}) + +// 自定义限流 +r.Use(middleware.CustomRateLimit(50, time.Minute)) + +// 停止限流器(应用关闭时) +defer middleware.StopRateLimiters() +``` + +--- + +## 响应格式 + +框架使用统一的响应格式: + +```json +{ + "code": 1, + "msg": "操作成功", + "data": {}, + "request_id": "abc123" +} +``` + +```go +// 成功响应 +response.Success(c, data) +response.SuccessWithMsg(c, "自定义消息", data) + +// 失败响应 +response.Fail(c, "错误消息") + +// 分页响应 +response.Page(c, list, total, page, pageSize) + +// 错误响应 +response.Unauthorized(c, "请先登录") +response.NotFound(c, "资源不存在") +response.ServerError(c, "服务器错误") +response.RateLimit(c) + +// 显式指定 HTTP status(不受 Mode 影响) +response.Custom(c, http.StatusBadRequest, response.CodeFail, "参数错误", nil) +``` + +**响应模式**(v1.1.0):默认 `ModeBusiness`,所有响应 HTTP 200 + 业务码(兼容存量)。切换 `ModeREST` 后,失败响应按错误码映射对应 HTTP status(401/404/429/500...),body 仍带业务码,便于 APM / Prometheus / 网关按 status 区分异常: + +```go +response.SetMode(response.ModeREST) // 代码切换 +// 或在 config.yaml 配置:server.response_mode: rest +``` + +--- + +## CLI 工具 + +```bash +# 创建新项目 +xlgo new myproject + +# 创建新项目并指定模块路径 +xlgo new myproject --module github.com/myorg/myproject + +# 生成代码 +xlgo make handler user # 创建 handler/user.go +xlgo make repository user # 创建 repository/user_repository.go +xlgo make model user # 创建 model/user.go +xlgo make service user # 创建 service/user_service.go + +# 显示版本 +xlgo version +``` + +--- + +## 测试 + +框架提供完整的测试工具包: + +```go +func TestUserAPI(t *testing.T) { + router := test.SetupRouter() + + // 创建请求 + resp := test.POST(router, "/api/v1/users"). + WithJSON(map[string]any{"name": "test"}). + WithToken("xxx"). + Execute() + + // 断言 + resp.AssertOK(t) + + // 解析响应 + var result map[string]any + resp.ParseJSON(&result) +} +``` + +--- + +## 目录结构 + +``` +xlgo/ +├── app.go # 应用入口 +├── cache/ +│ ├── cache.go # 缓存服务 +│ ├── keybuilder.go # 键名前缀管理 +│ └── lock.go # 分布式锁 +├── cmd/ +│ └── xlgo/ # CLI 脚手架 +├── compress/ +│ └── compress.go # Gzip/Zip 压缩解压 +├── config/ +│ ├── config.go # 配置管理(支持热更新、time.Duration 解析) +│ └── validate.go # 配置校验(启动期拦截非法配置) +├── console/ +│ └── console.go # 彩色控制台输出 +├── cron/ +│ └── cron.go # 定时任务调度 +├── database/ +│ ├── manager.go # 数据库管理器(主从、Picker、探活自愈、Init/Close/HealthCheck) +│ ├── dialect.go # GORM 方言注册表(mysql / postgres,可扩展) +│ └── redis.go # Redis 连接管理器(RedisManager) +├── handler/ +│ └── handler.go # 基础处理器(类型安全参数获取) +├── jwt/ +│ └── jwt.go # JWT 工具 +├── logger/ +│ ├── logger.go # 日志实现 +│ └── field.go # 日志字段工具 +├── middleware/ +│ ├── auth.go # JWT 认证中间件 +│ ├── cors.go # CORS 跨域中间件 +│ ├── csrf.go # CSRF 防护中间件 +│ ├── logger.go # 请求日志中间件 +│ ├── metrics.go # Prometheus 指标中间件 +│ ├── ratelimit.go # 限流中间件 +│ ├── requestid.go # 请求ID中间件 +│ ├── recover.go # Panic恢复中间件 +│ └── timeout.go # 请求级超时中间件 +├── model/ +│ └── base.go # 基础模型 +├── repository/ +│ └── repository.go # 基础仓库(泛型CRUD) +├── response/ +│ ├── response.go # 统一响应格式 +│ ├── mode.go # 响应模式开关(business / REST) +│ └── error.go # 统一错误码 +├── router/ +│ ├── router.go # 路由注册中心(模块化/版本化、livez/readyz/health) +│ └── metrics.go # Prometheus /metrics 端点 +├── sse/ +│ └── sse.go # SSE 流式响应 +├── storage/ +│ └── storage.go # 文件存储(本地+OSS) +├── test/ +│ └── test.go # 测试工具包 +├── trace/ +│ └── trace.go # 链路追踪 +├── utils/ +│ ├── random.go # 随机数生成 +│ ├── strings.go # 字符串处理 +│ ├── datetime.go # 时间日期 +│ ├── convert.go # 类型转换 +│ ├── file.go # 文件操作 +│ ├── url.go # URL处理 +│ ├── validator.go # 格式验证 +│ ├── crypto.go # 加密编码 +│ ├── http.go # HTTP客户端 +│ └── uuid.go # UUID生成 +├── validation/ +│ ├── validator.go # 请求验证器 +│ ├── password.go # 密码强度验证 +│ └── hash.go # 密码加密 +└── ws/ + └── ws.go # WebSocket 支持 +``` + +--- + +## 部署指南 + +### Docker 部署 + +```dockerfile +FROM golang:1.25-alpine AS builder + +WORKDIR /app +COPY . . +RUN CGO_ENABLED=0 go build -o server . + +FROM alpine:latest +RUN apk --no-cache add ca-certificates tzdata +WORKDIR /app +COPY --from=builder /app/server . +COPY --from=builder /app/config.yaml . +RUN mkdir -p /app/public /app/logs +EXPOSE 8080 +CMD ["./server", "-config", "./config.yaml"] +``` + +```bash +docker build -t xlgo-app:latest . +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)。 + +主要破坏性变更(升级前必读): + +- **包级可变全局一律 `atomic.Pointer`**(主线A):`database.DefaultRedis`/`database.DefaultManager`/`storage.DefaultStorage`/`validation.Validator` 由裸指针改为 `atomic.Pointer[T]`。直接读字段调方法需改用 facade(`InitRedis`/`GetDB`/`ValidateStruct` 等)或 `.Load()`;`database.DefaultManager = x` 改 `database.SetDefaultManager(x)`。 +- **`jwt.DefaultJWT` 包级变量删除** → `GetDefaultJWT()` / `SetDefaultJWTManager()`。 +- **`repository.FindWhereOrdered`/`FindPageWhereOrdered` 签名**:`args []any` → `args ...any`,`order` 前置于 `query`。 +- **`response.Response` 的 `Data`/`RequestID` 去 `omitempty`**:两字段在所有响应中恒存在。 +- **`cache.IsLocked`/`GetLockTTL`/`ForceUnlock`** Redis 不可用改返 `ErrRedisNotReady`(锁操作正确性相关)。 +- **`config.Load()` 返回深拷贝**:新增 `(*Config).Clone()`;`Get()` 仍返只读指针(热路径零分配)。 +- **`handler.GetPage` 加 `page` 上限 10000**;`utils.EqualsIgnoreCase` 改 `strings.EqualFold`;`utils.ReadFile` 去 TOCTOU 前置检查(错误改 `errors.Is(err, os.ErrNotExist)`)。 + +升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.2.0`(⚠️ 含破坏性变更,详见 CHANGELOG 迁移说明) + +### v1.1.1 (2026-06-23) + +> v1.1.0 的补丁发布:补 `ServerConfig.Host` 绑定地址、面向用户文案统一中文、修正 README 过时/错误描述(含会致启动失败的配置示例)。 + +- **ServerConfig.Host** - `server.host` 控制监听地址,空=所有接口(兼容),`127.0.0.1`=仅本机,内网 IP=绑定指定网卡 +- **文案统一中文** - recover/logger/metrics 等面向用户文案统一中文(保留协议字段名、Prometheus metric Name、MySQL 错误串匹配等英文) +- **README 修正** - 删 wire 段、修配置示例(secret≥32 字节 + expire 改 Duration)、补 v1.1.0 新文件、重写首段介绍 + +升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.1.1`(无破坏性变更) + +### v1.1.0 (2026-06-23) + +> 本版本定位为 **HA & Manager 化 release**:高可用与生产就绪改进 + 组件 Manager 化。含少量破坏性变更,升级前请阅读 [CHANGELOG 升级说明](./CHANGELOG.md#升级说明)。 + +- **组件 Manager 化** - storage/cache/redis/jwt/logger 五组件新增 `XxxManager` + `SetDefaultXxxManager`,包级 facade 保留兼容,支持多实例与测试注入 +- **Lifecycle Hooks** - `WithHook(Hook{OnInit/OnStart/OnReady/OnStop})` 生命周期钩子 +- **App.Go** - 受管理的后台 goroutine,Shutdown 时 cancel + 等待退出 +- **Server 配置化** - `server` 新增 timeout/TLS/unix_socket/response_mode 等字段 +- **JWTConfig time.Duration** - `jwt.expire` 用 `"24h"`,新增 issuer/algorithm +- **Config Validate** - 启动期校验配置(端口/密钥/必填字段),错误前置 +- **response REST 模式** - `SetMode(ModeREST)` 按错误码映射 HTTP status +- **livez/readyz** - K8s 友好的存活性/就绪性探针 +- **Prometheus metrics** - `/metrics` + `middleware.Metrics()` 采集请求指标 +- **请求级 Timeout** - `middleware.Timeout(d)` 级联取消下游 +- **依赖健康自愈** - 主库探活 + replica 健康剔除,`/readyz` 联动 503 +- **RequestID 默认装入** - 每个响应/panic 日志都带 request_id + +升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.1.0`(⚠️ 含破坏性变更,见升级说明) + +### v1.0.4 (2026-06-22) + +> 本版本定位为 **DX & Docs release**:开发体验与文档改进,无破坏性 API 变更。 + +- **CLI 多模板** - `xlgo new --template minimal/api/fullstack`,支持轻量 HTTP / 标准业务 / 全组件三种脚手架 +- **examples/ 目录** - 新增 `examples/minimal`(无依赖可跑)与 `examples/full`(mysql+redis+jwt+user CRUD)可运行示例 +- **模块路径文档** - 明确说明 module path `xlgo-core` 与 package name `xlgo` 的关系,补完整 import 示例(保留 `-core`,xlgo 多产品系列) +- **Without* 定位** - 文档化 `Without*` Option 主要用于 `NewFullStack` 后排除单项 +- **文档结构** - 规划文档归档到 `docs/plans/`,新增 `docs/README.md` 索引 + +升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.0.4`(无破坏性变更) + +### v1.0.3 (2026-06-22) + +> 本版本定位为 **bug fix release**:收口 v1.0.2 引入的破坏性清理,并修复 4 个轻量 bug + 依赖复查。完整说明见 [CHANGELOG.md#unreleased](./CHANGELOG.md#unreleased)。 + +#### 🐛 Fixed — JWT JTI 生成忽略 `rand.Read` 错误 + +`generateJTI()` 丢弃 `crypto/rand.Read` 的 error,失败时会基于全零字节生成 JTI,导致所有 token 的 JTI 相同、黑名单机制失效。改为 `(string, error)` 并在 `GenerateToken` / `GenerateTokenWithCustomExpiry` 传播错误。 + +#### 🐛 Fixed — `QueryBuilder.Page` 统计行数被残留 Limit 截断 + +`Page()` 复制查询做 Count 时未清除残留 `Limit`/`Offset`,调用方先 `.Limit(n)` 再 `.Page(...)` 会让 Count 被包成子查询,返回 `total` 被截断为 ≤ n。countDB 改为 `.Limit(-1).Offset(-1)` 清除残留条件。 + +#### 🐛 Fixed — OSS / 本地存储文件名冲突 + +4 处上传路径仅用 `time.Now().UnixNano()` 命名,同纳秒并发上传会撞名覆盖。新增 `uniqueFilename(now, ext)`(`-<8字节crypto/rand hex>.`),4 处统一改用。 + +#### 🐛 Fixed — 数据库重试策略对不可恢复错误无效 + +`InitDB` 对认证失败(`Access denied`)、未知数据库(`Unknown database`)、非法 DSN、未注册驱动等配置类错误也退避重试 5 次,白白延迟 31 秒。新增 `isTransientDBError`,这类错误首次出现即返回;网络类错误仍正常重试。 + +#### 📦 Dependencies — `go mod tidy` + 安全补丁升级 + +- `go mod tidy` 补全 postgres 方言传递依赖(`jackc/pgx` 家族、`golang.org/x/sync`),`gorm.io/driver/postgres` 提升为直接依赖 +- 安全补丁升级(无 API 变更):`golang.org/x/crypto` v0.49→v0.53、`golang-jwt/jwt/v5` v5.2.1→v5.3.1、`gorilla/websocket` v1.5.1→v1.5.3 +- 暂缓升级(留待 v1.0.4 / v1.1):`gin` / `validator` / `gorm` / `aliyun-oss-go-sdk` v2→v3(major 破坏性)等跨版本升级 + +#### ⚠️ Breaking — 清理 v1.0.2 兼容别名(database 包) + +```go +// ❌ 移除 +database.InitMySQL(cfg) +database.InitMySQLWithReplicas(cfg, replicas) +(*Manager).InitMySQL / InitMySQLWithReplicas + +// ✅ 改用(v1.0.2 起就是正式 API,驱动由 cfg.Database.Driver 决定) +database.InitDB(cfg) +database.InitDBWithReplicas(cfg, replicas) +``` + +xlgo 仍是早期框架,趁此一次彻底清理 v1.0.2 临时保留的别名,避免长期累积技术债。 + +#### 🗑️ Removed — 删除死代码 `database.DBResolver` + +`database.DBResolver.BeforeQuery` 从未被注册到 GORM callback chain,属于纯死代码。文档曾暗示的"自动读写分离"实际从未生效——读写分离一直依赖业务侧显式调用 `database.UseMaster(ctx)` / `database.UseReplica(ctx)`。 + +需要 callback 级自动路由的用户请直接接入官方 [`gorm.io/plugin/dbresolver`](https://github.com/go-gorm/dbresolver)。 + +#### 🔄 Changed — 文件重命名 `database/mysql.go → database/manager.go` + +文件内容已与 MySQL 解耦(v1.0.2 引入可插拔方言注册表后),继续叫 `mysql.go` 误导。**导入路径无变化**,公开 API 全部保留。 + +#### ✨ Added — console 包显式 level 控制 + +`console` 包新增显式级别屏蔽能力: + +```go +console.SetLevel(console.LevelWarn) // 只看 Warn / Error +console.SetLevel(console.LevelSilent) // 完全静默 +``` + +**定位明确**:console 是**开发期彩色 stdout 工具**(跟 `fmt.Println` 同级),**不写文件、不感知环境**。业务可观测信息请使用 `logger`。框架不会自动根据 `app.env` 切换级别——选择权完全在调用方,避免"dev / prod 行为不一致"的隐式陷阱。 + +完整对比表见 [GUIDE.md §3.3](./GUIDE.md#33-彩色控制台输出)。 + +#### 🐛 Fixed — Logger 重复写入修复 + +修复通用 `Logger` 把 `apiCore` 与 `dbCore` 都 Tee 进来导致**每条日志写三份**的 bug。 + +- `Logger`(通用)→ `logs/app.log` + console +- `APILog()` → `logs/api.log` + console +- `DBLog()` → `logs/database.log` + console +- 互不串扰,磁盘体积砍掉 2/3 + +**新增**:`logger.Close()` 显式关闭文件句柄,`App.Shutdown` 已自动调用;`Init(nil)` 改为返回 error 而非 panic;生产默认级别从 `Warn` 调整为 `Info`。 + +**升级注意**:日志目录会新增 `logs/app.log` 文件(之前通用日志被串写进了 `api.log`/`database.log`),运维采集脚本如有需要请补上。 + +#### 🔒 Security — CORS 中间件修复 + +修复 CORS 中间件多个安全与规范遵守问题: + +- **`Access-Control-Allow-Credentials` 永远是 `true`** — 旧实现 `if/else` 两个分支都设了 `"true"`,导致即使配置 `AllowCredentials=false` 也会发送凭证头 +- **`*` + `credentials: true` 的规范违规** — 同时发送会被浏览器拒绝;修复后此场景回显具体 Origin +- **缺失 `Vary: Origin`** — 防止 CDN / 网关把 A 用户的 CORS 响应缓存给 B 用户 +- 非白名单 Origin 不再被回显,防反射型 CORS 漏洞 + +**升级影响**:如果你之前依赖"默认允许凭证"的隐式行为,需要在配置里显式启用: + +```yaml +cors: + allowed_origins: ["https://your-frontend.example"] + allow_credentials: true +``` + +#### ⚠️ Breaking — 错误码体系重构 + +修复 `CodeSuccess` 与 `CodeInvalidParams` 撞码的生产级 bug(两者都等于 `1`,导致业务错误响应被前端误判为成功)。 + +**数值变更**: + +| 常量 | 旧值 | 新值 | +|---|---|---| +| `response.CodeSuccess` | `1` | **`0`** | +| `response.CodeFail` | `0` | **`1`** | + +**移除**: + +- `response.CodeInvalidParams`(与 `CodeSuccess` 撞码,且参数错误码应由业务自定义) +- `response.ErrInvalidParams` + +**迁移指南**: + +```go +// ❌ 旧代码(编译失败) +response.FailWithError(c, response.ErrInvalidParams) + +// ✅ 推荐:业务侧自定义错误码 +var ErrInvalidParams = response.NewError(40001, "参数错误") +response.FailWithError(c, ErrInvalidParams) + +// ✅ 或直接使用通用失败响应 +response.Fail(c, "用户名格式错误") +``` + +**前端**:`if (resp.code === 1)` → `if (resp.code === 0)`。 + +新增 `_errorCodeUniquenessGuard` 编译期防撞码 map,任何后续 `Code*` 常量重复都会在 `go build` 阶段直接报错。 + +详细迁移说明见 [CHANGELOG.md](./CHANGELOG.md)。 + +### v1.0.2 (2026-06-20) + +#### 数据库 +- **可插拔方言注册表** - 新增 `database.RegisterDialect` / `LookupDialect` / `RegisteredDialects`,应用可一次注册即让 `database.driver: ` 生效,DSN 构建器同步登记到 `config` 包;内置 `mysql` 与 `postgres`(含 `postgresql`、`pg` 别名),未注册驱动回退 MySQL +- **多数据库驱动** - `database.driver` 支持 `mysql`(默认)与 `postgres`,新增 `database.InitDB` / `InitDBWithReplicas`(v1.0.3 起原 `InitMySQL*` 别名已移除) +- **数据库管理器** - 引入实例化 `database.Manager` 持有主从连接,提供 `Master/Replica/FromContext/HealthCheck` 等方法 +- **从库选择策略** - 新增 `ReplicaPicker` 接口与 `RoundRobinPicker` / `RandomPicker` 实现,可通过 `SetReplicaPicker` 自定义 +- **私有上下文键** - `db_mode` 字符串键替换为私有 `dbModeContextKey{}` 类型,避免上下文键名冲突 +- **DSN 构建器注册表** - `config` 包新增 `RegisterDSNBuilder` / `LookupDSNBuilder` / `RegisteredDrivers`,`DatabaseConfig.DSN()` 改为查注册表,自定义 `CustomDSN` 优先 + +#### App 启动流程 +- **轻量默认** - `xlgo.New()` 默认不再初始化 MySQL / Redis / Storage,也不注册 `/health` 与 `/swagger/*`;通过 Option 显式启用 +- **组件 Option** - 新增 `WithLogger / WithMySQL / WithRedis / WithStorage / WithHealthRoutes / WithSwaggerRoutes / WithDefaultRoutes / WithAutoMigrate` 及对应 `WithoutXxx` 关闭项(注:`WithWire` 曾在此版本新增,已于 v1.1.0 随 wire 包删除而移除) +- **迁移控制** - 新增 `Migrator` 类型与 `WithMigrator / WithModels`,注册时自动开启 `WithAutoMigrate`,`WithoutAutoMigrate` 可显式关闭;不再强制调用空的 `database.AutoMigrate()` +- **batteries-included** - 新增 `WithFullStack` / `NewFullStack` / `RunFullStack`,一键启用全部默认组件 +- **错误传播** - 框架初始化失败一律 `return error`,不再在框架内部直接 `Fatalf` 退出进程 + +#### 权限中间件 +- **去业务化** - `super_admin/admin/staff` 调整为默认常量而非固定业务模型 +- **通用能力** - 新增 `AuthUser` 结构体、`GetAuthUser`、`RequireUserTypes / RequireRoles / RequireAuth`,旧的 `AdminRequired/SuperAdminRequired/StaffRequired/AnyUserRequired` 改为基于通用能力实现 + +#### 配置管理 +- **实例化 Manager** - 新增 `config.Manager`,提供 `Load / LoadWithWatch / Reload / RegisterCallback / Get / GetViper / Set` 等方法,支持多实例与重复加载 +- **App 持有 Manager** - `WithConfigPath` 创建 App 私有 manager 并通过新增的 `config.SetDefaultManager` 推为全局默认,使 `config.Get / GetString` 等便捷函数仍然可用 +- **修复** - 修复 `WithConfigPath` 此前的空实现问题 + +#### 健康检查与默认路由 +- **拆分注册** - `router` 包新增 `RegisterHealthRoute` / `RegisterSwaggerRoutes`,原 `RegisterDefaultRoutes` 改为组合调用 +- **检查项与状态** - `/health` 支持注册 `HealthCheck`,失败返回 HTTP 503 与 `{"status":"error", "checks":{...}}` +- **App 集成** - 启用 MySQL / Redis 时自动追加对应健康检查项,可通过 `WithHealthCheck` 注册自定义检查 + +#### 资源关闭 +- **Shutdown 修复** - 改为 `database.CloseAll()` 关闭主从连接,并使用 `errors.Join` 聚合限流器、日志、Redis 等组件的关闭错误 +- **Storage 可选化** - 未初始化 Storage 时返回 `ErrStorageNotInitialized`,不再 nil panic + +#### 文档与 CLI +- **CLI 修复** - `xlgo version` 更新为 v1.0.2,脚手架模板改为依赖 `github.com/EthanCodeCraft/xlgo-core` 并在注释中提示 Swagger / MySQL / Redis / FullStack 的开启方式 +- **GUIDE 同步** - 1.3 最简示例、3.2 日志注释、8.4.7 默认路由、9.2 用户信息获取均更新为 v1.0.2 行为 +- **历史日志修正** - 修复此前 README 中错误的 v2.0.0 / v2.1.0 更新日志表述 + +### v1.0.1 (2026-04-30) + +- 新增工具函数库、彩色控制台输出、压缩解压、RequestID、Recover 中间件 +- 新增缓存键名前缀、分布式锁、计数器、Redis 分布式限流 +- 增强 JWT 黑名单、Repository、CORS、日志中间件和优雅关闭能力 +- 新增路由架构,支持模块化、版本化 API、中间件分组和 RESTful CRUD +- 完善配置热更新、数据库读写分离、CSRF、SSE、WebSocket、定时任务、CLI、测试工具和统一错误码 + +### v1.0.0 (2024-04) + +- 初始版本发布 +- 基础框架功能 +- 完整示例代码 + +--- + +## 许可证 + +MIT License - 详见 [LICENSE](LICENSE) 文件 diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..323459c --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`frostbyte_neo/xlgo-core` +- 原始仓库:https://gitea-dev.autobee.pro/frostbyte_neo/xlgo-core +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/app.go b/app.go new file mode 100644 index 0000000..07ddac7 --- /dev/null +++ b/app.go @@ -0,0 +1,1364 @@ +package xlgo + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/EthanCodeCraft/xlgo-core/cache" + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/cron" + "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/EthanCodeCraft/xlgo-core/logger" + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/EthanCodeCraft/xlgo-core/router" + "github.com/EthanCodeCraft/xlgo-core/storage" + "github.com/EthanCodeCraft/xlgo-core/trace" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// Version 框架版本号。发版时只改这一处,避免版本字面量散落各处。 +// CLI(xlgo version)、脚手架生成的 go.mod 等均引用此常量。 +const Version = "1.4.0" + +// HealthCheckFunc 健康检查函数 +type HealthCheckFunc func(context.Context) error + +// Migrator 数据库迁移函数 +type Migrator func(*gorm.DB) error + +// Hook 生命周期钩子。各回调在 App 生命周期的对应阶段被调用: +// - OnInit: Init() 内组件初始化完成后、路由注册前 +// - OnStart: StartServer() 监听端口前 +// - OnReady: 端口就绪后(已开始接受连接) +// - OnStop: Shutdown() 开头,关 HTTP 之前,受 server.shutdown_timeout 约束 +// +// OnInit/OnStart/OnReady/OnStop 返回 error 会中断流程并向上返回。 +// A2 修复:OnReady 改为返回 error,失败时触发 shutdown。 +type Hook struct { + Name string + OnInit func(*App) error + OnStart func(*App) error + OnReady func(*App) error + OnStop func(*App) error +} + +type staticRoute struct { + relativePath string + root string +} + +// cronTask 收集 WithCronTask 注册的任务,doInit 创建 App 调度器后批量注册(照 +// WithMigrator/WithHealthCheck 的"Option 收集、doInit 应用"模式)。 +type cronTask struct { + name string + schedule cron.Schedule + handler cron.TaskHandler +} + +// appState 是 App 生命周期状态机(M1)。状态受 lifecycleMu 保护,单调推进: +// +// stateCreated → stateInitializing → stateInitialized → stateStopping → stateStopped +// ↓ (doInit 失败) +// stateStopping → stateStopped(failAfterInit 回滚) +// +// Go() 仅在 state < stateStopping 时放行 wg.Add;Init() 在 state >= stateStopping 时拒绝。 +type appState int32 + +const ( + stateCreated appState = iota + stateInitializing + stateInitialized + stateStopping + stateStopped +) + +// ErrAppClosed 表示 App 已 Shutdown 或 Init 失败已回滚,拒绝再 Init(M1)。 +var ErrAppClosed = errors.New("xlgo: app already shut down") + +// App 应用实例 +type App struct { + config *config.Config + configPath string + configManager *config.Manager + router *gin.Engine + registry *router.Registry + server *http.Server + + enableLogger bool + enableMySQL bool + enableRedis bool + enableStorage bool + enableHealth bool + enableSwagger bool + enableAutoMigrate bool + enableLiveness bool + enableReadiness bool + enableMetrics bool + enableCron bool + enableTrace bool + metricsPath string + + staticRoutes []staticRoute + migrators []Migrator + healthChecks []router.HealthCheck + hooks []Hook + + // 生命周期状态机(M1:取代裸 initOnce,解决 Init/Shutdown 并发改资源、 + // Init-after-Shutdown、App.Go 与 wg.Wait 的 WaitGroup 契约三类缺陷)。 + // state 受 lifecycleMu 保护;initMu 串行化 doInit/doShutdown 长段,避免并发改资源。 + // Go() 持 lifecycleMu.RLock 协调 wg.Add 与 Shutdown 的 wg.Wait(Add happens-before Wait)。 + state appState + lifecycleMu sync.RWMutex + initMu sync.Mutex + initErr error + + // shutdownOnce 确保 doShutdown 单次执行;shutdownErr 缓存结果供并发调用者共享。 + shutdownOnce sync.Once + shutdownErr error + + initializedLogger bool + initializedMySQL bool + initializedRedis bool + initializedCron bool + initializedTrace bool + initializedStorage bool + initializedCache bool + + loggerManager *logger.LogManager + loggerSnapshot *logger.DefaultSnapshot + dbManager *database.Manager + previousDB *database.Manager + redisManager *database.RedisManager + previousRedis *database.RedisManager + storageManager *storage.StorageManager + previousStorage *storage.StorageManager + scheduler *cron.Scheduler + previousScheduler *cron.Scheduler + cronTasks []cronTask + cacheManager *cache.CacheManager + previousCache *cache.CacheManager + rateLimitRegistry *middleware.RateLimitRegistry + previousRateLimitRegistry *middleware.RateLimitRegistry + + // 请求级超时(#19),<=0 表示不启用 + requestTimeout time.Duration + + // in-flight goroutine 管理(#22) + rootCtx context.Context // 根 ctx,App.Go 启动的 goroutine 共享 + cancel context.CancelFunc // Shutdown 时 cancel,通知后台任务退出 + wg sync.WaitGroup // 跟踪 App.Go 启动的 goroutine +} + +// Option 应用选项 +type Option func(*App) + +// WithConfigPath 设置配置文件路径。 +// +// M-config-5 全局可见性提示:WithConfigPath 在 Init 时会把 App 持有的 configManager 提升为 +// 全局默认(config.SetDefaultManager),故 config.Get / config.GetString 等包级便捷函数可读到 +// 该配置。这与 WithConfig 形成不对称:WithConfig 仅保留在 App 实例内,不污染全局,包级 +// config.Get() 取不到注入配置。下游若混用 a.config 与 config.GetString,两种注入方式行为不同-- +// 需要 config.Get/GetString 的下游请用 WithConfigPath(或自行 config.SetDefaultManager)。 +func WithConfigPath(path string) Option { + return func(a *App) { + a.configPath = path + a.configManager = config.NewManager(path) + } +} + +// WithConfig 设置配置对象。 +// 配置会在传入时深拷贝成 App 私有快照,并在 Init 时 Validate;调用方后续修改 cfg +// 不会污染 App 内部配置。不再调用 config.Set(cfg),配置仅保留在 App 实例内,不污染全局状态。 +// +// M-config-5:因此 config.Get / config.GetString 等包级函数取不到此注入配置(默认空 manager)。 +// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath(会提升为全局默认)。 +func WithConfig(cfg *config.Config) Option { + return func(a *App) { + a.config = cfg.Clone() + } +} + +// WithLogger 启用日志 +func WithLogger() Option { + return func(a *App) { a.enableLogger = true } +} + +// WithMySQL 启用 MySQL +func WithMySQL() Option { + return func(a *App) { a.enableMySQL = true } +} + +// WithRedis 启用 Redis +func WithRedis() Option { + return func(a *App) { a.enableRedis = true } +} + +// WithStorage 启用文件存储 +func WithStorage() Option { + return func(a *App) { a.enableStorage = true } +} + +// WithHealthRoutes 启用健康检查路由 +func WithHealthRoutes() Option { + return func(a *App) { a.enableHealth = true } +} + +// WithSwaggerRoutes 启用 Swagger 路由 +func WithSwaggerRoutes() Option { + return func(a *App) { a.enableSwagger = true } +} + +// WithDefaultRoutes 启用默认路由(健康检查、Swagger) +func WithDefaultRoutes() Option { + return func(a *App) { + a.enableHealth = true + a.enableSwagger = true + } +} + +// WithLivenessRoute 启用存活性探针路由 GET /livez(#17)。 +// 永不依赖外部,始终 200,供 K8s livenessProbe。 +func WithLivenessRoute() Option { + return func(a *App) { a.enableLiveness = true } +} + +// WithReadinessRoute 启用就绪性探针路由 GET /readyz(#17)。 +// 复用 healthChecks 检查依赖,失败返回 503,供 K8s readinessProbe。 +func WithReadinessRoute() Option { + return func(a *App) { a.enableReadiness = true } +} + +// WithMetricsRoute 启用 Prometheus 指标端点与采集中间件(#18)。 +// path 默认 /metrics,传入可自定义。 +func WithMetricsRoute(path ...string) Option { + return func(a *App) { + a.enableMetrics = true + if len(path) > 0 && path[0] != "" { + a.metricsPath = path[0] + } + } +} + +// WithoutLogger 关闭日志。 +// +// Without* 系列的定位:xlgo.New() 默认是轻量应用(组件全关),故 Without* +// 主要用于 NewFullStack / RunFullStack 启用全部组件后,排除个别不需要的项。 +// 例如:xlgo.NewFullStack(xlgo.WithoutSwaggerRoutes()) 全组件但关 Swagger。 +func WithoutLogger() Option { + return func(a *App) { a.enableLogger = false } +} + +// WithoutMySQL 关闭 MySQL +func WithoutMySQL() Option { + return func(a *App) { a.enableMySQL = false } +} + +// WithoutRedis 关闭 Redis +func WithoutRedis() Option { + return func(a *App) { a.enableRedis = false } +} + +// WithoutStorage 关闭文件存储 +func WithoutStorage() Option { + return func(a *App) { a.enableStorage = false } +} + +// WithAutoMigrate 启用数据库迁移(需配合 WithMigrator/WithModels 注册迁移逻辑) +func WithAutoMigrate() Option { + return func(a *App) { a.enableAutoMigrate = true } +} + +// WithoutAutoMigrate 关闭数据库迁移(即使已通过 WithMigrator/WithModels 注册) +func WithoutAutoMigrate() Option { + return func(a *App) { a.enableAutoMigrate = false } +} + +// WithoutHealthRoutes 关闭健康检查路由 +func WithoutHealthRoutes() Option { + return func(a *App) { a.enableHealth = false } +} + +// WithoutSwaggerRoutes 关闭 Swagger 路由 +func WithoutSwaggerRoutes() Option { + return func(a *App) { a.enableSwagger = false } +} + +// WithoutDefaultRoutes 关闭默认路由(健康检查、Swagger) +func WithoutDefaultRoutes() Option { + return func(a *App) { + a.enableHealth = false + a.enableSwagger = false + } +} + +// WithStatic 注册静态文件服务 +func WithStatic(relativePath, root string) Option { + return func(a *App) { + a.staticRoutes = append(a.staticRoutes, staticRoute{relativePath: relativePath, root: root}) + } +} + +// WithPublicStatic 注册默认 public 静态文件服务 +func WithPublicStatic() Option { + return WithStatic("/public", "./public") +} + +// WithHealthCheck 注册健康检查项 +func WithHealthCheck(name string, check HealthCheckFunc) Option { + return func(a *App) { + a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: name, Check: check}) + } +} + +// WithMigrator 注册数据库迁移函数(自动启用 AutoMigrate) +func WithMigrator(m Migrator) Option { + return func(a *App) { + if m != nil { + a.migrators = append(a.migrators, m) + a.enableAutoMigrate = true + } + } +} + +// WithHook 注册生命周期钩子(#12)。可多次调用注册多个,按注册顺序触发。 +// 详见 Hook 类型注释。 +func WithHook(h Hook) Option { + return func(a *App) { + a.hooks = append(a.hooks, h) + } +} + +// WithRequestTimeout 设置请求级超时(#19)。下游 GORM/Redis 走 +// c.Request.Context() 即可级联取消。d <= 0 不启用。 +func WithRequestTimeout(d time.Duration) Option { + return func(a *App) { a.requestTimeout = d } +} + +// WithCron 将 cron 调度器纳入 App 生命周期(Phase 3 实例化)。 +// 启用后:Init 时创建 App 专属 Scheduler、注册 WithCronTask 收集的任务、提升为全局默认、 +// Start 启动调度循环;Shutdown 在剩余预算内 StopWithTimeout 等待在跑任务退出。 +// +// 任务注册:优先用 WithCronTask(...)(或 Init 后 app.Scheduler().AddTask(...))注册到 App 实例。 +// 包级 cron.AddTask(...) 仍可用--它代理到当前全局默认(App swap 后即 App 的调度器)--但 +// 在 App.Init 之前调用会注册到 init 默认调度器、被 App swap 丢弃,故 pre-Init 注册请用 WithCronTask。 +func WithCron() Option { + return func(a *App) { a.enableCron = true } +} + +// WithCronTask 注册一个定时任务到 App 的调度器(Phase 3)。蕴含启用 cron(照 WithMigrator +// 自动启用 AutoMigrate 的模式),故无需同时 WithCron。任务在 doInit 创建调度器后注册。 +// +// name/schedule/handler 的校验由 cron.Scheduler.AddTask 负责(nil schedule/handler panic, +// 非法 cron 字段 panic)。动态输入请先用 cron.ParseCronStrict 处理 error。 +func WithCronTask(name string, schedule cron.Schedule, handler cron.TaskHandler) Option { + return func(a *App) { + a.cronTasks = append(a.cronTasks, cronTask{name: name, schedule: schedule, handler: handler}) + a.enableCron = true + } +} + +// WithTrace 将链路追踪(OpenTelemetry)纳入 App 生命周期(Phase 1)。 +// +// 启用后:Init 时按 config.Trace 调 trace.Init(安装 TracerProvider/Propagator)并把 +// trace.Middleware 装入全局中间件链;Shutdown 时调 trace.Close 刷出 exporter 并释放 +// 后台 goroutine/连接(H-14 后 Close 幂等,但 App 此前从未调它--本 Option 补上生命周期闭环)。 +// +// 实际是否导出 span 由 config.Trace.Enabled 控制:Enabled=false(默认)时 trace.Init +// 安装 Noop tracer,Middleware 不 panic、不导出。故"WithTrace + trace.enabled=true + endpoint" +// 才真正出链路;仅 WithTrace 而未配 enabled 为 Noop(安全)。 +// +// 不做 per-App 实例隔离:OTel 的 TracerProvider/Propagator 是进程级全局单例 +// (otel.SetTracerProvider 全局生效),多 App 进程共享同一 OTel 状态,与 OTel 设计一致。 +func WithTrace() Option { + return func(a *App) { a.enableTrace = true } +} + +// WithModels 注册 GORM 自动迁移模型(自动启用 AutoMigrate) +func WithModels(models ...any) Option { + return WithMigrator(func(db *gorm.DB) error { + return db.AutoMigrate(models...) + }) +} + +// WithModules 注册模块 +func WithModules(modules ...router.Module) Option { + return func(a *App) { + for _, m := range modules { + a.registry.RegisterModule(m) + } + } +} + +// WithVersions 注册版本化 API +func WithVersions(versions ...*router.VersionedAPI) Option { + return func(a *App) { + for _, v := range versions { + a.registry.RegisterVersion(v) + } + } +} + +// WithMiddlewares 注册全局中间件 +func WithMiddlewares(middlewares ...gin.HandlerFunc) Option { + return func(a *App) { + a.registry.Use(middlewares...) + } +} + +// WithFullStack 启用全功能默认组件 +func WithFullStack() Option { + return func(a *App) { + a.enableLogger = true + a.enableMySQL = true + a.enableRedis = true + a.enableStorage = true + a.enableHealth = true + a.enableSwagger = true + a.enableAutoMigrate = true + // 生产就绪路由(#17/#18) + a.enableLiveness = true + a.enableReadiness = true + a.enableMetrics = true + a.staticRoutes = append(a.staticRoutes, staticRoute{relativePath: "/public", root: "./public"}) + } +} + +// New 创建新应用 +func New(opts ...Option) *App { + app := &App{} + app.router = gin.New() + app.registry = router.NewRegistry(app.router) + // rootCtx 生命周期与 App 一致,不依赖 Init,使 App.Go 在 Init 前也可用(#22) + app.rootCtx, app.cancel = context.WithCancel(context.Background()) + // Phase 5:限流器 Registry 在 New 时预创建,使 app.RateLimitRegistry() 在 Init 前即可用于 + // 路由装配(app-bound 中间件捕获此实例,请求时走 App 自己的 Registry 而非全局默认, + // 实现多 App per-App 计数隔离)。doInit 时 swap 为全局默认供包级 facade 代理。 + app.rateLimitRegistry = middleware.NewRateLimitRegistry() + + for _, opt := range opts { + opt(app) + } + + return app +} + +// NewFullStack 创建启用默认全功能组件的应用 +func NewFullStack(opts ...Option) *App { + all := append([]Option{WithFullStack()}, opts...) + return New(all...) +} + +// RunFullStack 创建并启动启用默认全功能组件的应用 +func RunFullStack(opts ...Option) error { + return NewFullStack(opts...).Run() +} + +// Init 初始化应用,不启动 HTTP 监听(M1:状态机 + initMu 保证单次执行与并发安全)。 +// 多次调用:已成功则返回缓存结果;已 Shutdown/Init 失败则返回 ErrAppClosed。 +// +// initMu 串行化 doInit 与 doShutdown(避免并发改资源);lifecycleMu 仅短暂持有以翻状态, +// 不阻塞 Go()(doInit 内 a.Go 只抢 lifecycleMu.RLock)。 +// +// 注意:生命周期 hook(OnInit/OnStart/OnReady/OnStop)不允许重入调用 Init/Shutdown/Run—— +// initMu 非重入,hook 内调用会自锁死锁(M1 文档约束)。 +func (a *App) Init() error { + a.initMu.Lock() + defer a.initMu.Unlock() + + a.lifecycleMu.Lock() + switch a.state { + case stateCreated: + a.state = stateInitializing + a.lifecycleMu.Unlock() + case stateInitialized: + err := a.initErr + a.lifecycleMu.Unlock() + return err + case stateStopping, stateStopped: + a.lifecycleMu.Unlock() + return ErrAppClosed + default: // stateInitializing — initMu 串行下不可达,防御性返回 + err := a.initErr + a.lifecycleMu.Unlock() + return err + } + + a.initErr = a.doInit() + if a.initErr != nil { + a.failAfterInit() + return a.initErr + } + + a.lifecycleMu.Lock() + a.state = stateInitialized + a.lifecycleMu.Unlock() + return nil +} + +// failAfterInit 在 doInit 失败时执行资源回滚(M1)。 +// 顺序:标记 stateStopping(阻新 Go)→ cancel rootCtx → wg.Wait(让 a.Go 启动的探活等 +// goroutine 先退出,10s 超时)→ stopCron(5s) → rollbackReplacedResources。 +// 先停 goroutine 再回滚资源,避免“回滚 DB 时探活 goroutine 仍在用”的竞态。 +// +// 回滚走 rollbackReplacedResources(恢复 Init 前默认 manager 再关新建 manager), +// 而非 closeResources(直接关闭,仅供 doShutdown 复用)。Init 失败需恢复默认 manager +// 而非直接关,故两者不复用同一路径。cron 停止预算固定 5s,由本路径单独停止。 +// 回滚错误与原 initErr 合并上抛,不吞。 +func (a *App) failAfterInit() { + a.lifecycleMu.Lock() + a.state = stateStopping + a.lifecycleMu.Unlock() + + // H-2:cancel rootCtx 让 a.Go 启动的后台 goroutine 退出,不泄漏。 + if a.cancel != nil { + a.cancel() + } + waitDone := make(chan struct{}) + go func() { a.wg.Wait(); close(waitDone) }() + select { + case <-waitDone: + case <-time.After(10 * time.Second): + logger.Warnf("Init 失败后等待后台 goroutine 退出超时(10s),可能存在未响应 ctx.Done 的 goroutine") + } + + // M1:回滚 doInit 已初始化的资源。cron 显式 5s 超时不卡死回滚; + // logger/db/redis 恢复 Init 前默认 manager,再关闭本 App 新建的 manager。 + if err := a.stopCron(5 * time.Second); err != nil { + a.initErr = errors.Join(a.initErr, err) + } + // trace 不是 swap 资源(OTel 全局),不走 rollbackReplacedResources;Init 失败时单独 Close。 + if a.initializedTrace { + closeCtx, closeCancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := trace.Close(closeCtx); err != nil { + a.initErr = errors.Join(a.initErr, fmt.Errorf("trace 关闭失败: %w", err)) + } + closeCancel() + a.initializedTrace = false + } + if rbErr := a.rollbackReplacedResources(); rbErr != nil { + a.initErr = errors.Join(a.initErr, fmt.Errorf("回滚已初始化资源失败: %w", rbErr)) + } + + a.lifecycleMu.Lock() + a.state = stateStopped + a.lifecycleMu.Unlock() +} + +// closeResources 关闭 db→redis→logger(M1)。各均为幂等 no-op(未 init 时安全), +// 仅供 doShutdown 复用。cron 因停止预算随调用方不同(Init 失败 5s / Shutdown 剩余 +// 预算),由各调用方单独停止,不在此处。返回聚合错误。 +// 注意:Init 失败的回滚不经本函数,而走 failAfterInit 的 rollbackReplacedResources +// (需恢复 Init 前默认 manager,而非直接关闭)。 +func (a *App) closeResources() error { + var errs []error + if a.initializedMySQL { + if a.dbManager != nil { + if err := a.dbManager.Close(); err != nil { + errs = append(errs, err) + } + } else if err := database.CloseAll(); err != nil { + errs = append(errs, err) + } + a.initializedMySQL = false + a.dbManager = nil + } + if a.initializedRedis { + if a.redisManager != nil { + if err := a.redisManager.Close(); err != nil { + errs = append(errs, err) + } + } else if err := database.CloseRedis(); err != nil { + errs = append(errs, err) + } + a.initializedRedis = false + a.redisManager = nil + } + if a.initializedStorage { + // StorageManager.Close 当前为 no-op(oss.Client 无 Close),但闭合 Init/Close 生命周期, + // 为未来驱动预留收口点。与 db/redis/logger manager 对齐。 + if a.storageManager != nil { + if err := a.storageManager.Close(); err != nil { + errs = append(errs, err) + } + } + a.initializedStorage = false + a.storageManager = nil + } + if a.initializedLogger { + if a.loggerManager != nil { + if err := a.loggerManager.Close(); err != nil { + errs = append(errs, err) + } + } else if err := logger.Close(); err != nil { + errs = append(errs, err) + } + a.initializedLogger = false + a.loggerManager = nil + } + // M-config-3:停止 App 持有的 configManager 热重载 watcher(若已启用),避免 App 关闭后 + // 遗留监听 goroutine。默认流程(resolveConfig 用 Load 不启 watcher)为 no-op;用户对 + // configManager 调用 LoadWithWatch/StartWatcher 后由这里统一收口。StopWatcher 幂等且会等待 + // 进行中的 reload 回调结束,符合 C7 生命周期契约。configManager 可能为 nil(WithConfig 注入)。 + if a.configManager != nil { + a.configManager.StopWatcher() + } + return errors.Join(errs...) +} + +func (a *App) rollbackReplacedResources() error { + var errs []error + if a.initializedMySQL { + if a.previousDB != nil { + database.SwapDefaultManager(a.previousDB) + } + if a.dbManager != nil { + if err := a.dbManager.Close(); err != nil { + errs = append(errs, err) + } + } + a.dbManager = nil + a.previousDB = nil + a.initializedMySQL = false + } + if a.initializedRedis { + if a.previousRedis != nil { + database.SwapDefaultRedisManager(a.previousRedis) + } + if a.redisManager != nil { + if err := a.redisManager.Close(); err != nil { + errs = append(errs, err) + } + } + a.redisManager = nil + a.previousRedis = nil + a.initializedRedis = false + } + if a.initializedStorage { + // 把全局默认 swap 回 Init 前的旧实例,并 Close 本 App 新建的 manager(与 closeResources + // 的 Shutdown 路径对称)。当前 Close 为 no-op,但未来驱动有真实副作用时不漏关。 + if a.previousStorage != nil { + storage.SwapDefaultStorageManager(a.previousStorage) + } + if a.storageManager != nil { + if err := a.storageManager.Close(); err != nil { + errs = append(errs, err) + } + } + a.storageManager = nil + a.previousStorage = nil + a.initializedStorage = false + } + // cron:stopCron 已在 failAfterInit 停止调度 goroutine(并把 initializedCron 置 false), + // 此处仅把全局默认 swap 回 Init 前的旧调度器。用 a.scheduler 作守卫(stopCron 不 nil 它)。 + if a.scheduler != nil { + if a.previousScheduler != nil { + cron.SwapDefaultScheduler(a.previousScheduler) + } + a.previousScheduler = nil + a.scheduler = nil + } + if a.initializedCache { + // CacheManager 无 Close(client 由 redisManager 持有):仅 swap 回全局默认、清理引用。 + if a.previousCache != nil { + cache.SwapDefaultCacheManager(a.previousCache) + } + a.cacheManager = nil + a.previousCache = nil + a.initializedCache = false + } + if a.rateLimitRegistry != nil { + // 停止可能已创建的 limiter(Init 期间通常无请求、无 limiter;Stop 幂等)。 + a.rateLimitRegistry.Stop() + if a.previousRateLimitRegistry != nil { + middleware.SwapDefaultRateLimitRegistry(a.previousRateLimitRegistry) + } + a.previousRateLimitRegistry = nil + a.rateLimitRegistry = nil + } + if a.initializedLogger { + if err := logger.RestoreDefaultSnapshot(a.loggerSnapshot); err != nil { + errs = append(errs, err) + } + a.loggerManager = nil + a.loggerSnapshot = nil + a.initializedLogger = false + } + return errors.Join(errs...) +} + +func (a *App) commitReplacedResources() { + // Phase 4 多 App 修复:不关闭 previousDB/previousRedis。previous 是 Init 前的全局默认, + // 多 App 下可能属于另一个 App 的活跃 manager--关闭会误杀它(B 的 commit 关掉 A 的 redis, + // 致 A 的 cache 持有的 client 失效 "redis: client is closed")。此 App 不 owns previous, + // 仅清理回滚引用。单 App 下 previous 为 init() 空默认(无 client,Close 本就是 no-op), + // 不关闭无泄漏。用户若手动 InitRedis/InitDB 后再 App.Init,旧 client 的关闭由用户负责。 + if a.previousDB != nil { + a.previousDB = nil + } + if a.previousRedis != nil { + a.previousRedis = nil + } + if a.previousStorage != nil { + // StorageManager 无 Close;Init 成功后仅清理回滚引用,旧实例交由 GC。 + a.previousStorage = nil + } + if a.previousScheduler != nil { + // App 调度器已 Start 并接管全局默认;旧默认(init 或上一 App 的)无 goroutine 需停 + // (init 默认从未 Start;上一 App 的已在其 Shutdown 停止)。仅清理回滚引用。 + a.previousScheduler = nil + } + if a.previousCache != nil { + // CacheManager 无 Close(client 由 redisManager 持有);仅清理回滚引用。 + a.previousCache = nil + } + if a.previousRateLimitRegistry != nil { + // Registry 的 limiter 由 App.Shutdown 的 registry.Stop() 停止;此处仅清理回滚引用。 + a.previousRateLimitRegistry = nil + } + if a.loggerSnapshot != nil { + // Phase 4 多 App 修复(与 previousDB/previousRedis 对齐):不关闭 loggerSnapshot 捕获的 + // 旧 writers。多 App 下该 snapshot 可能捕获的是另一个 App 的活跃 logger writers--关闭会 + // 误杀它(B 的 commit 关掉 A 的 app.log/api.log writer,A 后续写日志失败/丢失)。此 App + // 不 owns 旧 writers:init() 默认为 Nop(无 writer,不泄漏);另一 App 的 writers 由其自身 + // Shutdown 关闭;用户手动配置 logger 后再 App.Init 的旧 writer 由用户负责。仅清理回滚引用。 + a.loggerSnapshot = nil + } +} + +func (a *App) stopCron(timeout time.Duration) error { + if !a.initializedCron { + return nil + } + a.initializedCron = false + if a.scheduler == nil { + return nil + } + // 停 App 专属调度器(Phase 3),不再调全局 cron.StopGlobalWithTimeout。 + if !a.scheduler.StopWithTimeout(timeout) { + return fmt.Errorf("cron 调度器停止超时(%s)", timeout) + } + return nil +} + +func (a *App) shutdownAfterStartFailure(startErr error) error { + if startErr == nil { + return nil + } + if shutErr := a.Shutdown(); shutErr != nil { + return fmt.Errorf("%w (关闭错误: %v)", startErr, shutErr) + } + return startErr +} + +func (a *App) runOnStopHook(ctx context.Context, h Hook) error { + if h.OnStop == nil { + return nil + } + done := make(chan error, 1) + go func() { + done <- h.OnStop(a) + }() + select { + case err := <-done: + if err != nil { + return fmt.Errorf("OnStop hook %q 失败: %w", h.Name, err) + } + return nil + case <-ctx.Done(): + return fmt.Errorf("OnStop hook %q 超时: %w", h.Name, ctx.Err()) + } +} + +// doInit 执行实际初始化流程。仅在 Init 持有 initMu 并完成状态切换后调用,不可直接调用。 +func (a *App) doInit() error { + cfg, err := a.resolveConfig() + if err != nil { + return err + } + if cfg.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + + // trace 先于其它组件初始化:失败即早退,避免已建 DB/Redis 等资源因 trace 失败回滚。 + // OTel 进程全局,Init 安装全局 TracerProvider;Enabled=false 时为 Noop(安全)。 + if a.enableTrace { + if err := trace.Init(buildTraceConfig(cfg)); err != nil { + return fmt.Errorf("初始化 trace 失败: %w", err) + } + a.initializedTrace = true + } + + if a.enableLogger { + lm := logger.NewLogManager() + snapshot := logger.SnapshotDefault() + if err := lm.InitPreservingPrevious(cfg); err != nil { + return fmt.Errorf("初始化日志失败: %w", err) + } + logger.SetDefaultLogManager(lm) + a.loggerManager = lm + a.loggerSnapshot = snapshot + a.initializedLogger = true + } + + if a.enableMySQL { + dbm := database.NewManager(cfg) + if err := dbm.InitDB(a.rootCtx, cfg); err != nil { + return fmt.Errorf("初始化数据库失败: %w", err) + } + a.previousDB = database.SwapDefaultManager(dbm) + a.dbManager = dbm + a.initializedMySQL = true + a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "mysql", Check: func(ctx context.Context) error { + // 优先读探活缓存标记(#21),避免每次探针都同步 ping + if !dbm.IsHealthy() { + return errors.New("mysql 主库探活不健康") + } + return dbm.HealthCheck(ctx) + }}) + } + + if a.enableRedis { + rm := database.NewRedisManager() + if err := rm.Init(cfg); err != nil { + return fmt.Errorf("初始化 Redis 失败: %w", err) + } + a.previousRedis = database.SwapDefaultRedisManager(rm) + a.redisManager = rm + a.initializedRedis = true + a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "redis", Check: rm.HealthCheck}) + } + + if a.enableStorage { + sm := storage.NewStorageManager() + if err := sm.Init(&cfg.Storage); err != nil { + return fmt.Errorf("初始化存储失败: %w", err) + } + // 实例化 + 提升为全局默认(照 db/redis 的 Swap 模式)。StorageManager 无 Close + // (LocalStorage/OSSStorage 无 closeable 资源),Shutdown 不关、仅 Init 失败时 swap 回退。 + a.previousStorage = storage.SwapDefaultStorageManager(sm) + a.storageManager = sm + a.initializedStorage = true + } + + if a.enableRedis { + // Redis 就绪后初始化缓存。Phase 4:注入 App 的 redisManager.Client(),使缓存走 per-App + // Redis 而非全局 database.GetRedis()(修复多 App 跨 Redis 串越)。提升为全局默认供包级 + // facade(cache.Get 等)代理到此。CacheManager 无 Close(client 由 redisManager 持有)。 + cm := cache.NewCacheManager() + cm.InitWithRedis(a.redisManager.Client()) + a.previousCache = cache.SwapDefaultCacheManager(cm) + a.cacheManager = cm + a.initializedCache = true + } + + if a.enableAutoMigrate && len(a.migrators) > 0 { + if !a.enableMySQL { + return errors.New("注册了数据库迁移但未启用 MySQL") + } + db := database.GetDB() + if db == nil { + return errors.New("MySQL 未初始化,无法执行数据库迁移") + } + for _, migrator := range a.migrators { + if err := migrator(db); err != nil { + return fmt.Errorf("数据库迁移失败: %w", err) + } + } + } + + // 全局中间件链:RequestID 必须最先装入,保证后续 Recovery/日志/响应都能拿到 request_id(#24) + a.router.Use(middleware.RequestID()) + a.router.Use(middleware.Recover()) + // trace 中间件装在 Recover 之后:Recover 兜底捕获 trace 内 panic,span 覆盖业务 handler。 + // Enabled=false 时为 Noop tracer,Middleware 不导出;noop span 无 TraceID 则不写 X-Trace-ID。 + if a.enableTrace { + // 用与 buildTraceConfig 一致的回退名(ServiceName 空时取 cfg.App.Name),避免中间件 + // span 属性与 provider resource 的 service.name 来源不一致。 + svcName := a.config.Trace.ServiceName + if svcName == "" { + svcName = a.config.App.Name + } + a.router.Use(trace.Middleware(svcName)) + } + // 请求级超时(#19),配置后装入,下游走 c.Request.Context() 级联取消 + if a.requestTimeout > 0 { + a.router.Use(middleware.Timeout(a.requestTimeout)) + } + + for _, staticRoute := range a.staticRoutes { + a.router.Static(staticRoute.relativePath, staticRoute.root) + } + + if a.enableSwagger { + router.RegisterSwaggerRoutes(a.router) + } + if a.enableHealth { + router.RegisterHealthRoute(a.router, a.healthChecks...) + } + if a.enableLiveness { + router.RegisterLivenessRoute(a.router) + } + if a.enableReadiness { + router.RegisterReadinessRoute(a.router, a.healthChecks...) + } + if a.enableMetrics { + // 采集中间件交给注册中心,Apply 时作为首个全局中间件装入(H8c), + // 覆盖所有经注册中心注册的业务路由,不依赖调用顺序。 + a.registry.SetMetricsMiddleware(middleware.Metrics()) + if a.metricsPath != "" { + router.RegisterMetricsRoute(a.router, a.metricsPath) + } else { + router.RegisterMetricsRoute(a.router) + } + } + + a.registry.Apply() + + // 响应模式:默认 business(全 200 + 业务码),可配置 rest(按错误码映射 HTTP status)(#15) + if mode := strings.TrimSpace(strings.ToLower(a.config.Server.ResponseMode)); mode != "" { + switch mode { + case "rest": + response.SetMode(response.ModeREST) + case "business": + response.SetMode(response.ModeBusiness) + } + } + + // 错误 Detail 暴露策略(P1 #15):仅开发环境向客户端输出 Error.Detail, + // 生产环境隐藏,避免内部错误细节(SQL/堆栈等)经 Detail 泄露给客户端。 + response.SetExposeDetail(cfg.IsDevelopment()) + + // in-flight goroutine 根 ctx 在 New() 时已初始化(#22) + + // 启动主库/从库探活后台循环(#21),ctx 在 Shutdown 时取消 + if a.enableMySQL { + a.Go(a.dbManager.StartProbing) + } + + // 启动 App 专属 cron 调度器(Phase 3 实例化):创建实例 -> 注册 WithCronTask 收集的任务 -> + // 提升为全局默认(包级 cron.AddTask 代理到此)-> Start。Shutdown 时 StopWithTimeout。 + if a.enableCron { + a.scheduler = cron.NewScheduler() + for _, ct := range a.cronTasks { + a.scheduler.AddTask(ct.name, ct.schedule, ct.handler) + } + a.previousScheduler = cron.SwapDefaultScheduler(a.scheduler) + a.scheduler.Start() + a.initializedCron = true + } + + // Phase 5:把 New 时预创建的 App 专属 Registry 提升为全局默认,供包级 facade(LoginRateLimit + // 等)代理。app-bound 中间件(app.RateLimitRegistry().LoginRateLimit())直接捕获此实例, + // 不查全局默认,实现多 App per-App 计数隔离。不预 Init(懒创建);Shutdown 时 Stop。 + a.previousRateLimitRegistry = middleware.SwapDefaultRateLimitRegistry(a.rateLimitRegistry) + + // OnInit hooks:组件初始化完成后触发(#12) + for _, h := range a.hooks { + if h.OnInit != nil { + if err := h.OnInit(a); err != nil { + return fmt.Errorf("OnInit hook %q 失败: %w", h.Name, err) + } + } + } + a.commitReplacedResources() + return nil +} + +func (a *App) resolveConfig() (*config.Config, error) { + if a.config != nil { + cfg := a.config.Clone() + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("配置校验失败: %w", err) + } + a.config = cfg + return cfg, nil + } + if a.configManager == nil && a.configPath != "" { + a.configManager = config.NewManager(a.configPath) + } + if a.configManager != nil { + cfg, err := a.configManager.Load() + if err != nil { + return nil, err + } + // 让 App 持有的 manager 成为全局默认, + // 使 config.Get / config.GetString 等便捷函数仍能取到正确配置。 + config.SetDefaultManager(a.configManager) + a.config = cfg + return cfg, nil + } + cfg := config.Get() + if cfg == nil { + return nil, config.ErrConfigNotLoaded + } + a.config = cfg + return cfg, nil +} + +// buildTraceConfig 把 config.TraceConfig 映射为 trace.Config,并补友好默认值: +// - ServiceName 空时回退 cfg.App.Name(业务侧常已配 app.name,免重复填); +// - ExporterType 空时默认 "otlp-http"(最常用),避免 trace.Init 对空值报"不支持的导出器类型"。 +// +// 不默认 Endpoint/SampleRatio/Propagator:Endpoint 空时 OTEL 客户端走自身默认(localhost:4318); +// SampleRatio=0 是合法值(不采样),不应被默认覆盖;Propagator 空时 trace.Init 已按 w3c 处理。 +// Enabled 完全透传--由 config.Trace.Enabled 决定是否真正导出,WithTrace 只管生命周期。 +func buildTraceConfig(cfg *config.Config) trace.Config { + tc := cfg.Trace + exporter := tc.ExporterType + if exporter == "" { + exporter = "otlp-http" + } + name := tc.ServiceName + if name == "" { + name = cfg.App.Name + } + return trace.Config{ + ServiceName: name, + ServiceVersion: tc.ServiceVersion, + Environment: tc.Environment, + ExporterType: exporter, + Endpoint: tc.Endpoint, + Insecure: tc.Insecure, + SampleRatio: tc.SampleRatio, + Enabled: tc.Enabled, + Propagator: tc.Propagator, + } +} + +// Run 启动应用 +func (a *App) Run() error { + if err := a.Init(); err != nil { + return err + } + return a.shutdownAfterStartFailure(a.StartServer()) +} + +// StartServer 启动 HTTP 服务器(支持优雅关闭) +func (a *App) StartServer() error { + if a.config == nil { + return config.ErrConfigNotLoaded + } + srvCfg := a.config.Server + + a.server = &http.Server{ + Handler: a.router, + ReadTimeout: srvCfg.EffectiveReadTimeout(), + WriteTimeout: srvCfg.EffectiveWriteTimeout(), + IdleTimeout: srvCfg.EffectiveIdleTimeout(), + MaxHeaderBytes: srvCfg.EffectiveMaxHeaderBytes(), + } + + useUnix := strings.TrimSpace(srvCfg.UnixSocket) != "" + if useUnix { + a.server.Addr = srvCfg.UnixSocket + } else { + // Host 为空时监听所有接口(":port");设值时绑定指定地址("host:port"), + // 用于仅本机(127.0.0.1)或绑定内网网卡的场景 + a.server.Addr = fmt.Sprintf("%s:%d", srvCfg.Host, srvCfg.Port) + } + + // OnStart hooks:监听端口前 + for _, h := range a.hooks { + if h.OnStart != nil { + if err := h.OnStart(a); err != nil { + return a.shutdownAfterStartFailure(fmt.Errorf("OnStart hook %q 失败: %w", h.Name, err)) + } + } + } + + network := "tcp" + address := a.server.Addr + if useUnix { + network = "unix" + address = srvCfg.UnixSocket + } + ln, err := net.Listen(network, address) + if err != nil { + return a.shutdownAfterStartFailure(fmt.Errorf("服务器监听失败: %w", err)) + } + if srvCfg.TLS.Enabled { + cert, err := tls.LoadX509KeyPair(srvCfg.TLS.CertFile, srvCfg.TLS.KeyFile) + if err != nil { + _ = ln.Close() + return a.shutdownAfterStartFailure(fmt.Errorf("服务器 TLS 配置无效: %w", err)) + } + ln = tls.NewListener(ln, &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }) + } + + serverErr := make(chan error, 1) + serveStarted := make(chan struct{}) + go func() { + close(serveStarted) + if useUnix { + logger.Infof("服务器启动,监听 unix socket %s", srvCfg.UnixSocket) + } else if srvCfg.Host != "" { + logger.Infof("服务器启动,监听 %s:%d", srvCfg.Host, srvCfg.Port) + } else { + logger.Infof("服务器启动,监听端口 %d(所有接口)", srvCfg.Port) + } + serveErr := a.server.Serve(ln) + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + serverErr <- serveErr + return + } + serverErr <- nil + }() + <-serveStarted + select { + case err := <-serverErr: + if err != nil { + return a.shutdownAfterStartFailure(fmt.Errorf("服务器启动失败: %w", err)) + } + return nil + default: + } + + // OnReady hooks:listener 已成功创建,Serve goroutine 已启动(M1)。 + for _, h := range a.hooks { + if h.OnReady != nil { + if err := h.OnReady(a); err != nil { + logger.Errorf("OnReady hook %q 失败: %v", h.Name, err) + // H-1 修复:失败时走 Shutdown 释放 HTTP 端口、后台 goroutine 与已初始化资源, + // 避免监听 goroutine 永久阻塞在 Serve 致端口/goroutine 泄漏。 + // 不再向 serverErr 写入——监听 goroutine 在 Shutdown 后会写 nil 到 serverErr(cap=1), + // 若此处也写则第二次写阻塞致 goroutine 泄漏。 + if shutErr := a.Shutdown(); shutErr != nil { + return fmt.Errorf("OnReady hook %q 失败: %w (关闭错误: %v)", h.Name, err, shutErr) + } + _ = ln.Close() + return fmt.Errorf("OnReady hook %q 失败: %w", h.Name, err) + } + } + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + defer signal.Stop(quit) + + select { + case err := <-serverErr: + if err != nil { + return a.shutdownAfterStartFailure(fmt.Errorf("服务器启动失败: %w", err)) + } + return nil + case sig := <-quit: + logger.Infof("收到信号 %v,开始优雅关闭...", sig) + return a.Shutdown() + } +} + +// Shutdown 优雅关闭应用(M1:幂等 + 与 Init 互斥)。 +// shutdownOnce 保证 doShutdown 单次执行,并发调用者阻塞至首个完成后返回同一 shutdownErr。 +// initMu 串行化 doShutdown 与 doInit,避免并发改资源。 +// +// 注意:生命周期 hook 不允许重入调用 Init/Shutdown/Run——initMu 非重入,hook 内调用会自锁。 +func (a *App) Shutdown() error { + a.shutdownOnce.Do(func() { + a.initMu.Lock() + defer a.initMu.Unlock() + + a.lifecycleMu.Lock() + if a.state >= stateStopping { + // shutdownOnce 已保证单次,此处不可达;防御性直接返回。 + a.lifecycleMu.Unlock() + return + } + wasInitialized := a.state == stateInitialized + a.state = stateStopping + a.lifecycleMu.Unlock() + + a.shutdownErr = a.doShutdown(wasInitialized) + + a.lifecycleMu.Lock() + a.state = stateStopped + a.lifecycleMu.Unlock() + }) + return a.shutdownErr +} + +// doShutdown 执行实际关闭流程。仅在 Shutdown(持 initMu)中调用。 +// 顺序(M1 调整):OnStop(仅 Init 曾成功时)→ cancel rootCtx → server.Shutdown → +// wg.Wait → cron / 限流器 / db / redis / logger。OnStop 在 shutdown 开头、关 HTTP 之前, +// 保有 cancel 前协调资源的机会;state=Stopping 已阻新 Go,无需先 cancel/wait 再跑 hook。 +// OnStop 也受同一个 shutdownTimeout 预算约束,避免 hook 卡死拖住整个进程退出。 +func (a *App) doShutdown(wasInitialized bool) error { + shutdownTimeout := 30 * time.Second + if a.config != nil { + shutdownTimeout = a.config.Server.EffectiveShutdownTimeout() + } + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + var errs []error + + // OnStop hooks:Shutdown 开头,关 HTTP 之前触发(#12)。仅 Init 曾成功时执行(M1)—— + // Init 失败/未 Init 时 HTTP 从未启动,OnStop 语义不适用。 + if wasInitialized { + for _, h := range a.hooks { + if err := a.runOnStopHook(ctx, h); err != nil { + errs = append(errs, err) + if ctx.Err() != nil { + break + } + } + } + } + + // 取消根 ctx,通知 App.Go 启动的后台 goroutine 退出(#22) + if a.cancel != nil { + a.cancel() + } + + if a.server != nil { + logger.Info("关闭 HTTP 服务器...") + if err := a.server.Shutdown(ctx); err != nil { + logger.Warnf("HTTP 服务器关闭超时: %v", err) + errs = append(errs, err) + if closeErr := a.server.Close(); closeErr != nil { + errs = append(errs, closeErr) + } + } + } + + // 等待业务 in-flight goroutine 退出(#22),受 shutdownTimeout 约束 + waitDone := make(chan struct{}) + go func() { + a.wg.Wait() + close(waitDone) + }() + select { + case <-waitDone: + case <-ctx.Done(): + logger.Warnf("等待后台 goroutine 退出超时") + } + + // 停止 cron 全局调度器(P1 #10),在剩余 shutdown 预算内等待在跑任务退出。 + if a.initializedCron { + logger.Info("停止 cron 调度器...") + remaining := time.Second + if dl, ok := ctx.Deadline(); ok { + if r := time.Until(dl); r > 0 { + remaining = r + } + } + if err := a.stopCron(remaining); err != nil { + logger.Warnf("cron 调度器停止超时,可能有任务未响应 ctx 取消") + } + } + + // Phase 5:停 App 专属限流器 Registry(不再调全局 middleware.StopRateLimiters,避免误停其他 App)。 + if a.rateLimitRegistry != nil { + logger.Info("停止限流器...") + a.rateLimitRegistry.Stop() + } + + // trace.Close 刷出 exporter 批量 span 并释放后台 goroutine/连接(H-14 后幂等)。 + // 放在 db/redis/logger 关闭之前,避免 exporter 依赖已关闭的日志/连接。 + if a.initializedTrace { + logger.Info("关闭 trace...") + if err := trace.Close(ctx); err != nil { + logger.Warnf("trace 关闭失败: %v", err) + errs = append(errs, err) + } + } + + // db/redis/logger 经 closeResources 统一关闭(M1)。注意:closeResources 仅供 + // Shutdown 路径使用;Init 失败的回滚走 failAfterInit 的 rollbackReplacedResources, + // 两者不复用同一路径(Init 失败需恢复默认 manager,Shutdown 直接关闭)。 + // 先记最后一条 "应用已优雅关闭",再 Close(关闭后写日志会 fall back 到 nop)。 + logger.Info("关闭数据库连接/Redis/日志...") + logger.Info("应用已优雅关闭") + if err := a.closeResources(); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +// Go 启动一个受 App 生命周期管理的后台 goroutine(#22)。 +// fn 收到的 ctx 在 Shutdown 时被 cancel,fn 应在 ctx.Done() 时及时退出。 +// Shutdown/Init 失败后调用为 no-op(M1:state>=stateStopping 拒绝 wg.Add,避免与 +// Shutdown 的 wg.Wait 竞争 WaitGroup 契约——Add 由 lifecycleMu.RLock 保护 happen-before Wait)。 +func (a *App) Go(fn func(ctx context.Context)) { + if fn == nil { + return + } + a.lifecycleMu.RLock() + if a.state >= stateStopping { + a.lifecycleMu.RUnlock() + return + } + ctx := a.rootCtx + if ctx == nil { + ctx = context.Background() + } + a.wg.Add(1) + a.lifecycleMu.RUnlock() + go func() { + defer a.wg.Done() + fn(ctx) + }() +} + +// GetRegistry 获取路由注册中心(用于动态注册) +func (a *App) GetRegistry() *router.Registry { + return a.registry +} + +// GetRouter 获取 Gin Engine(用于高级自定义) +func (a *App) GetRouter() *gin.Engine { + return a.router +} + +// GetServer 获取 HTTP Server(用于高级自定义) +func (a *App) GetServer() *http.Server { + return a.server +} + +// Scheduler 返回 App 专属的 cron 调度器(Phase 3)。Init 后非 nil,用于动态注册任务 +// (app.Scheduler().AddTask(...))。未启用 cron 或 Init 前返回 nil。 +func (a *App) Scheduler() *cron.Scheduler { + return a.scheduler +} + +// Cache 返回 App 专属的缓存服务(Phase 4)。Init 后非 nil,用于直接操作 App 的缓存 +// (绕过全局 facade,多 App 隔离场景下确保走 App 自己的 Redis)。未启用 Redis 或 Init 前返回 nil。 +func (a *App) Cache() cache.CacheService { + if a.cacheManager == nil { + return nil + } + return a.cacheManager.Get() +} + +// RedisClient 返回 App 专属的 Redis 客户端(Phase 5),用于注入到需要 redis client 的 +// 组件(如 middleware.NewRedisRateLimiter(..., middleware.WithRedisClient(app.RedisClient()))), +// 使其走 App 的 Redis 而非全局。未启用 Redis 或 Init 前返回 nil。 +func (a *App) RedisClient() *redis.Client { + if a.redisManager == nil { + return nil + } + return a.redisManager.Client() +} + +// RateLimitRegistry 返回 App 专属的限流器 Registry(Phase 5)。New 后即非 nil,用于装配 +// app-bound 限流中间件(app.RateLimitRegistry().LoginRateLimit() 等)--此类中间件捕获 App +// 自己的 Registry,请求时不查全局默认,实现多 App per-App 计数隔离。 +func (a *App) RateLimitRegistry() *middleware.RateLimitRegistry { + return a.rateLimitRegistry +} diff --git a/app_cache_test.go b/app_cache_test.go new file mode 100644 index 0000000..499b53b --- /dev/null +++ b/app_cache_test.go @@ -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") + } +} diff --git a/app_config_test.go b/app_config_test.go new file mode 100644 index 0000000..248919a --- /dev/null +++ b/app_config_test.go @@ -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) + } +} diff --git a/app_cron_test.go b/app_cron_test.go new file mode 100644 index 0000000..269e73f --- /dev/null +++ b/app_cron_test.go @@ -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()) + } +} diff --git a/app_go_test.go b/app_go_test.go new file mode 100644 index 0000000..aff75bc --- /dev/null +++ b/app_go_test.go @@ -0,0 +1,43 @@ +package xlgo_test + +import ( + "context" + "testing" + "time" + + xlgo "github.com/EthanCodeCraft/xlgo-core" +) + +func TestAppGoWaitsOnShutdown(t *testing.T) { + app := xlgo.New() + + started := make(chan struct{}) + exited := make(chan struct{}) + app.Go(func(ctx context.Context) { + close(started) + <-ctx.Done() + // 模拟收尾 + time.Sleep(10 * time.Millisecond) + close(exited) + }) + + <-started + + // Shutdown 应 cancel ctx 并等待 goroutine 退出 + done := make(chan error, 1) + go func() { done <- app.Shutdown() }() + select { + case <-exited: + // goroutine 在 cancel 后退出,符合预期 + case <-time.After(2 * time.Second): + t.Fatal("App.Go goroutine did not exit after Shutdown cancel") + } + select { + case err := <-done: + if err != nil { + t.Errorf("Shutdown returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Shutdown did not return") + } +} diff --git a/app_hdb1_test.go b/app_hdb1_test.go new file mode 100644 index 0000000..d58e736 --- /dev/null +++ b/app_hdb1_test.go @@ -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() +} diff --git a/app_m1_test.go b/app_m1_test.go new file mode 100644 index 0000000..3aa0c65 --- /dev/null +++ b/app_m1_test.go @@ -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) + } +} diff --git a/app_ratelimit_bound_test.go b/app_ratelimit_bound_test.go new file mode 100644 index 0000000..6b6c3da --- /dev/null +++ b/app_ratelimit_bound_test.go @@ -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") + } +} diff --git a/app_ratelimit_test.go b/app_ratelimit_test.go new file mode 100644 index 0000000..eab4257 --- /dev/null +++ b/app_ratelimit_test.go @@ -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") + } +} diff --git a/app_storage_test.go b/app_storage_test.go new file mode 100644 index 0000000..1ab8cd8 --- /dev/null +++ b/app_storage_test.go @@ -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) + } +} diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..8f21f17 --- /dev/null +++ b/app_test.go @@ -0,0 +1,361 @@ +package xlgo_test + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + xlgo "github.com/EthanCodeCraft/xlgo-core" + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/router" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func testConfig(port int) *config.Config { + return &config.Config{ + App: config.AppConfig{Env: "test"}, + Server: config.ServerConfig{Port: port, Mode: "development"}, + Log: config.LogConfig{Dir: filepath.Join(os.TempDir(), "xlgo_app_test_logs"), MaxSize: 1, MaxBackups: 1, MaxAge: 1}, + } +} + +func writeConfig(t *testing.T, name string, port int) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, name) + content := "app:\n env: test\nserver:\n port: " + strconv.Itoa(port) + "\n mode: development\nlog:\n dir: " + filepath.ToSlash(filepath.Join(dir, "logs")) + "\n max_size: 1\n max_backups: 1\n max_age: 1\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func TestAppWithConfigPath(t *testing.T) { + path := writeConfig(t, "config.yaml", 18081) + app := xlgo.New(xlgo.WithConfigPath(path)) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + if app.GetServer() != nil { + t.Fatal("Init should not start server") + } +} + +func TestAppWithConfigNoDefaultRoutes(t *testing.T) { + app := xlgo.New(xlgo.WithConfig(testConfig(18082))) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + app.GetRouter().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected /health 404 without health routes, got %d", w.Code) + } +} + +func TestAppWithHealthRoutes(t *testing.T) { + app := xlgo.New(xlgo.WithConfig(testConfig(18083)), xlgo.WithHealthRoutes()) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + app.GetRouter().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected /health 200, got %d", w.Code) + } +} + +func TestAppWithHealthCheckFailure(t *testing.T) { + app := xlgo.New( + xlgo.WithConfig(testConfig(18084)), + xlgo.WithHealthRoutes(), + xlgo.WithHealthCheck("custom", func(_ context.Context) error { return errors.New("down") }), + ) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + app.GetRouter().ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected /health 503, got %d", w.Code) + } +} + +func TestAppMigratorRequiresMySQL(t *testing.T) { + app := xlgo.New( + xlgo.WithConfig(testConfig(18085)), + xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }), + ) + err := app.Init() + if err == nil || !strings.Contains(err.Error(), "未启用 MySQL") { + t.Fatalf("expected mysql disabled migrator error, got %v", err) + } +} + +func TestAppRoutesModule(t *testing.T) { + app := xlgo.New( + xlgo.WithConfig(testConfig(18086)), + xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) { + r.GET("/hello", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"hello": "world"}) }) + })), + ) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/hello", nil) + app.GetRouter().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected module route 200, got %d", w.Code) + } +} + +func TestAppWithoutDefaultRoutesOverrides(t *testing.T) { + app := xlgo.New( + xlgo.WithConfig(testConfig(18087)), + xlgo.WithDefaultRoutes(), + xlgo.WithoutDefaultRoutes(), + ) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + app.GetRouter().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected /health 404 after WithoutDefaultRoutes, got %d", w.Code) + } +} + +// TestAppWithSwaggerRoutesOnly 验证可以单独启用 Swagger 而不开启 health。 +func TestAppWithSwaggerRoutesOnly(t *testing.T) { + app := xlgo.New( + xlgo.WithConfig(testConfig(18088)), + xlgo.WithSwaggerRoutes(), + ) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + + // /health 不应注册 + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + app.GetRouter().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected /health 404 when only Swagger enabled, got %d", w.Code) + } + + // /swagger/* 应注册(具体子路径返回什么取决于 swag 文档是否生成,这里只验证未 404) + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil) + app.GetRouter().ServeHTTP(w, req) + if w.Code == http.StatusNotFound { + t.Fatalf("expected /swagger/index.html to be registered, got 404") + } +} + +// TestAppWithoutAutoMigrateOverride 验证 WithoutAutoMigrate 能覆盖 +// 之前通过 WithMigrator 隐式开启的迁移,不再要求 MySQL 已启用。 +func TestAppWithoutAutoMigrateOverride(t *testing.T) { + migratorCalled := false + app := xlgo.New( + xlgo.WithConfig(testConfig(18089)), + xlgo.WithMigrator(func(_ *gorm.DB) error { + migratorCalled = true + return nil + }), + xlgo.WithoutAutoMigrate(), + ) + if err := app.Init(); err != nil { + t.Fatalf("Init should succeed when AutoMigrate is disabled, got %v", err) + } + if migratorCalled { + t.Fatal("migrator must not be called when WithoutAutoMigrate is applied") + } +} + +// TestAppDefaultsLightweight 验证 v1.0.2 起 xlgo.New 默认是轻量应用: +// 不启 MySQL/Redis/Storage、不注册 health/swagger 路由。 +func TestAppDefaultsLightweight(t *testing.T) { + app := xlgo.New(xlgo.WithConfig(testConfig(18090))) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + + r := app.GetRouter() + + for _, path := range []string{"/health", "/swagger/index.html"} { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected %s 404 by default, got %d", path, w.Code) + } + } + + // 默认未注册 MySQL/Redis 健康检查项,故 healthChecks 应为空 + // 这里通过启用 health 路由后只返回 status:ok 来间接验证 + app2 := xlgo.New(xlgo.WithConfig(testConfig(18091)), xlgo.WithHealthRoutes()) + if err := app2.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + app2.GetRouter().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected /health 200 with no checks, got %d", w.Code) + } +} + +// TestAppWithConfigPathDrivesManager 验证 WithConfigPath 真正驱动 App 自己的 +// config.Manager,并把它推到全局 default,使 config.Get 能取到加载后的配置。 +func TestAppWithConfigPathDrivesManager(t *testing.T) { + path := writeConfig(t, "config_drive.yaml", 18092) + app := xlgo.New(xlgo.WithConfigPath(path)) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + + cfg := config.Get() + if cfg == nil { + t.Fatal("config.Get returned nil after WithConfigPath; manager not promoted to default") + } + if cfg.Server.Port != 18092 { + t.Fatalf("expected port 18092 from loaded config, got %d", cfg.Server.Port) + } + if config.GetInt("server.port") != 18092 { + t.Fatalf("expected GetInt(server.port)=18092, got %d", config.GetInt("server.port")) + } +} + +// TestAppMetricsInstrumentsRegistryRoutes_H8c 端到端验证 H8c:经注册中心注册的业务路由 +// 被指标中间件采集,不依赖 RegisterMetricsRoute 的调用顺序。修复前 RegisterMetricsRoute +// 用 r.Use 仅采集其后注册的路由;修复后采集中间件在 Apply 内作首个全局中间件装入。 +func TestAppMetricsInstrumentsRegistryRoutes_H8c(t *testing.T) { + app := xlgo.New( + xlgo.WithConfig(testConfig(18093)), + xlgo.WithMetricsRoute(), + xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) { + r.GET("/h8c-biz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + })), + ) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + + r := app.GetRouter() + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8c-biz", nil)) + if w.Code != http.StatusOK { + t.Fatalf("/h8c-biz status = %d, want 200", w.Code) + } + } + + // 拉取 /metrics,断言 /h8c-biz 路由被采集(route 标签存在)。 + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + if w.Code != http.StatusOK { + t.Fatalf("/metrics status = %d, want 200", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, `route="/h8c-biz"`) { + t.Fatalf("metrics output should contain route=\"/h8c-biz\" series, got:\n%s", body) + } +} + +// freePort 返回一个当前空闲的 TCP 端口。 +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("freePort: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// TestAppOnReadyFailureReleasesPort H-1 回归:OnReady 失败时 Run 应返回错误, +// 且 HTTP 端口被释放(监听 goroutine 不泄漏)。 +// 修复前 OnReady 失败直接 return,监听 goroutine 仍阻塞在 ListenAndServe, +// 端口不释放、goroutine 泄漏。 +func TestAppOnReadyFailureReleasesPort(t *testing.T) { + port := freePort(t) + app := xlgo.New( + xlgo.WithConfig(testConfig(port)), + xlgo.WithHook(xlgo.Hook{ + Name: "fail-on-ready", + OnReady: func(*xlgo.App) error { return errors.New("boom") }, + }), + ) + + errCh := make(chan error, 1) + go func() { errCh <- app.Run() }() + + select { + case err := <-errCh: + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected OnReady failure error, got %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("Run did not return after OnReady failure (server goroutine leaked?)") + } + + // 端口应已释放:能重新 bind。容忍 TIME_WAIT 短暂残留。 + deadline := time.Now().Add(3 * time.Second) + for { + l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port)) + if err == nil { + l.Close() + break + } + if time.Now().After(deadline) { + t.Fatalf("port %d not released 3s after Run returned: %v (server goroutine leaked)", port, err) + } + time.Sleep(50 * time.Millisecond) + } +} + +// TestAppInitFailureCancelsGoGoroutine H-2 回归:App.Go 启动的 goroutine 在 Init 失败后 +// 应因 rootCtx cancel 而退出,不泄漏。 +// 修复前 Init 失败不 cancel rootCtx,goroutine 永久阻塞在 ctx.Done()。 +func TestAppInitFailureCancelsGoGoroutine(t *testing.T) { + app := xlgo.New( + xlgo.WithConfig(testConfig(18087)), + // WithMigrator 无 WithMySQL → Init 确定性失败("未启用 MySQL") + xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }), + ) + + ctxDone := make(chan struct{}) + app.Go(func(ctx context.Context) { + <-ctx.Done() + close(ctxDone) + }) + + if err := app.Init(); err == nil { + t.Fatal("expected Init error") + } + + select { + case <-ctxDone: + // 好:goroutine 在 Init 失败后退出 + case <-time.After(5 * time.Second): + t.Fatal("App.Go goroutine did not exit after Init failure (rootCtx not cancelled, leaked)") + } +} diff --git a/app_trace_test.go b/app_trace_test.go new file mode 100644 index 0000000..6815284 --- /dev/null +++ b/app_trace_test.go @@ -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) + } +} diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 0000000..e41a9de --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,279 @@ +package cache + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "time" + + "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, 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 检查缓存是否存在;未命中返回 (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)。 +// 改为每次操作实时取 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 客户端:注入优先,否则回退全局 database.GetRedis()。 +func (c *redisCache) client() *redis.Client { + if c != nil && c.injectedClient != nil { + return c.injectedClient + } + return database.GetRedis() +} + +// NewRedisCache 创建 Redis 缓存实例(懒取全局 Redis,兼容 standalone 用法)。 +func NewRedisCache() CacheService { + return &redisCache{} +} + +// NewRedisCacheWithRedis 创建使用指定 Redis 客户端的缓存实例(多 Redis/测试隔离)。 +func NewRedisCacheWithRedis(client *redis.Client) CacheService { + return &redisCache{injectedClient: client} +} + +// Get 获取缓存值。命中返回 (true, nil);未命中返回 (false, nil); +// Redis 未就绪返回 (false, ErrRedisNotReady);Redis 命令错误或反序列化失败返回 (false, err)。 +func (c *redisCache) Get(ctx context.Context, key string, dest any) (bool, error) { + cli := c.client() + if cli == nil { + return false, ErrRedisNotReady + } + + val, err := cli.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return false, nil + } + return false, err + } + + if err := json.Unmarshal([]byte(val), dest); err != nil { + return false, err + } + + 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 ErrRedisNotReady + } + + data, err := json.Marshal(value) + if err != nil { + logger.Warn("缓存序列化失败", zap.String("key", key), zap.Error(err)) + return err + } + + if err := cli.Set(ctx, key, data, ttl).Err(); err != nil { + logger.Warn("缓存设置失败", zap.String("key", key), zap.Error(err)) + return err + } + + return nil +} + +// Delete 删除缓存 +func (c *redisCache) Delete(ctx context.Context, key string) error { + cli := c.client() + if cli == nil { + return ErrRedisNotReady + } + + if err := cli.Del(ctx, key).Err(); err != nil { + logger.Warn("缓存删除失败", zap.String("key", key), zap.Error(err)) + return err + } + + return nil +} + +// DeleteByPattern 按模式删除缓存(使用 SCAN 避免阻塞 Redis) +func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error { + cli := c.client() + if cli == nil { + return ErrRedisNotReady + } + + var cursor uint64 + var deleted int + + for { + // 使用 SCAN 命令迭代查找匹配的键 + keys, nextCursor, err := cli.Scan(ctx, cursor, pattern, 100).Result() + if err != nil { + logger.Warn("缓存键扫描失败", zap.String("pattern", pattern), zap.Error(err)) + return err + } + + // 删除找到的键 + if len(keys) > 0 { + if err := cli.Del(ctx, keys...).Err(); err != nil { + logger.Warn("缓存批量删除失败", zap.Strings("keys", keys), zap.Error(err)) + return err + } + deleted += len(keys) + } + + // 更新游标 + cursor = nextCursor + + // 游标为 0 表示遍历完成 + if cursor == 0 { + break + } + } + + if deleted > 0 { + logger.Debug("缓存批量删除完成", zap.String("pattern", pattern), zap.Int("count", deleted)) + } + + return nil +} + +// Exists 检查缓存是否存在。命中返回 (true, nil);未命中返回 (false, nil); +// Redis 未就绪返回 (false, ErrRedisNotReady);命令错误返回 (false, err)。 +func (c *redisCache) Exists(ctx context.Context, key string) (bool, error) { + cli := c.client() + if cli == nil { + return false, ErrRedisNotReady + } + + n, err := cli.Exists(ctx, key).Result() + if err != nil { + return false, err + } + return n > 0, nil +} + +// CacheManager 缓存管理器(#10)。照 database.Manager 模式: +// 实例化 + DefaultCache 全局默认 + 包级 facade 代理,支持测试注入 mock 实现。 +type CacheManager struct { + mu sync.Mutex + svc CacheService +} + +// defaultCachePtr 全局默认缓存管理器(CK3 修复:atomic.Pointer 替代裸指针)。 +var defaultCachePtr atomic.Pointer[CacheManager] + +func init() { + defaultCachePtr.Store(NewCacheManager()) +} + +// NewCacheManager 创建缓存管理器实例。 +func NewCacheManager() *CacheManager { return &CacheManager{} } + +// GetDefaultCache 返回全局默认缓存管理器(并发安全,CK3 修复)。 +func GetDefaultCache() *CacheManager { + return defaultCachePtr.Load() +} + +// SetDefaultCacheManager 提升指定 CacheManager 为全局默认(atomic 置换,并发安全)。 +func SetDefaultCacheManager(m *CacheManager) { + if m != nil { + defaultCachePtr.Store(m) + } +} + +// SwapDefaultCacheManager 将指定 CacheManager 置为全局默认,返回被替换的旧 Manager。 +// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存(照 +// SwapDefaultRedisManager / SwapDefaultStorageManager 模式)。nil 被忽略,返回当前默认。 +func SwapDefaultCacheManager(m *CacheManager) *CacheManager { + if m == nil { + return defaultCachePtr.Load() + } + return defaultCachePtr.Swap(m) +} + +// Init 初始化缓存服务(基于 DefaultRedis 的客户端)。 +func (m *CacheManager) Init() { + m.mu.Lock() + defer m.mu.Unlock() + m.svc = NewRedisCache() +} + +// InitWithRedis 用指定 Redis 客户端初始化缓存服务(多 Redis/测试隔离)。 +func (m *CacheManager) InitWithRedis(client *redis.Client) { + m.mu.Lock() + defer m.mu.Unlock() + m.svc = NewRedisCacheWithRedis(client) +} + +// Set 设置缓存服务实现(用于注入 mock 或自定义实现)。 +func (m *CacheManager) Set(svc CacheService) { + m.mu.Lock() + defer m.mu.Unlock() + m.svc = svc +} + +// Get 返回缓存服务(未初始化时延迟初始化)。 +func (m *CacheManager) Get() CacheService { + m.mu.Lock() + defer m.mu.Unlock() + if m.svc == nil { + m.svc = NewRedisCache() + } + return m.svc +} + +// --- 包级 facade(代理到 GetDefaultCache(),CK3 修复) --- + +// Init 初始化全局缓存实例 +func Init() { + GetDefaultCache().Init() +} + +// GetCache 获取全局缓存实例 +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) +} diff --git a/cache/cache_m10_internal_test.go b/cache/cache_m10_internal_test.go new file mode 100644 index 0000000..0f94ec4 --- /dev/null +++ b/cache/cache_m10_internal_test.go @@ -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) + } +} diff --git a/cache/cache_phase4_internal_test.go b/cache/cache_phase4_internal_test.go new file mode 100644 index 0000000..8b837ba --- /dev/null +++ b/cache/cache_phase4_internal_test.go @@ -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) + } +} diff --git a/cache/keybuilder.go b/cache/keybuilder.go new file mode 100644 index 0000000..969bdf8 --- /dev/null +++ b/cache/keybuilder.go @@ -0,0 +1,297 @@ +package cache + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" +) + +// KeyBuilder 缓存键名构建器(CK2 修复:mu 保护并发访问)。 +// 使用场景: 多个小项目共用一台 Redis 服务器,每个项目设置不同前缀 +type KeyBuilder struct { + mu sync.Mutex + prefix string // 项目/站点前缀,如 "site_a" + _separator string // 分隔符,默认 ":" + _cacheType string // 缓存类型标识,如 "cache" +} + +// KeyBuilderOption 配置选项 +type KeyBuilderOption func(*KeyBuilder) + +// WithPrefix 设置前缀(项目/站点别名) +// 示例: WithPrefix("site_a") -> 所有 key 自动添加 "site_a" 前缀 +func WithPrefix(prefix string) KeyBuilderOption { + return func(kb *KeyBuilder) { + if kb == nil { + return + } + kb.prefix = prefix + } +} + +// WithSeparator 设置分隔符 +// 示例: WithSeparator(":") -> "site_a:user:1" +func WithSeparator(separator string) KeyBuilderOption { + return func(kb *KeyBuilder) { + if kb == nil { + return + } + kb._separator = separator + } +} + +// WithCacheType 设置缓存类型标识 +// 示例: WithCacheType("session") -> "session:site_a:user:1" +func WithCacheType(cacheType string) KeyBuilderOption { + return func(kb *KeyBuilder) { + if kb == nil { + return + } + kb._cacheType = cacheType + } +} + +// NewKeyBuilder 创建键名构建器 +func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder { + kb := &KeyBuilder{ + prefix: "", + _separator: ":", + _cacheType: "cache", + } + for _, opt := range opts { + if opt == nil { + continue + } + opt(kb) + } + return kb +} + +// Build 构建完整键名(CK2 修复:mu 读保护)。 +// 格式: {cacheType}{separator}{prefix}{separator}{key} +// 示例: kb.Build("user:1") -> "cache:site_a:user:1" +func (kb *KeyBuilder) Build(key string) string { + kb.mu.Lock() + defer kb.mu.Unlock() + parts := []string{} + if kb._cacheType != "" { + parts = append(parts, kb._cacheType) + } + if kb.prefix != "" { + parts = append(parts, kb.prefix) + } + parts = append(parts, key) + return strings.Join(parts, kb._separator) +} + +// BuildTemp 构建临时缓存键名(CK2 修复:mu 读保护)。 +// 格式: "temp{separator}{prefix}{separator}{key}" +func (kb *KeyBuilder) BuildTemp(key string) string { + kb.mu.Lock() + defer kb.mu.Unlock() + parts := []string{"temp"} + if kb.prefix != "" { + parts = append(parts, kb.prefix) + } + parts = append(parts, key) + return strings.Join(parts, kb._separator) +} + +// BuildPerm 构建永久缓存键名(CK2 修复:mu 读保护)。 +// 格式: "perm{separator}{prefix}{separator}{key}" +func (kb *KeyBuilder) BuildPerm(key string) string { + kb.mu.Lock() + defer kb.mu.Unlock() + parts := []string{"perm"} + if kb.prefix != "" { + parts = append(parts, kb.prefix) + } + parts = append(parts, key) + return strings.Join(parts, kb._separator) +} + +// BuildLock 构建分布式锁键名(CK2 修复:mu 读保护)。 +// 格式: "lock{separator}{prefix}{separator}{key}" +func (kb *KeyBuilder) BuildLock(key string) string { + kb.mu.Lock() + defer kb.mu.Unlock() + parts := []string{"lock"} + if kb.prefix != "" { + parts = append(parts, kb.prefix) + } + parts = append(parts, key) + return strings.Join(parts, kb._separator) +} + +// BuildCounter 构建计数器键名(CK2 修复:mu 读保护)。 +// 格式: "counter{separator}{prefix}{separator}{key}" +func (kb *KeyBuilder) BuildCounter(key string) string { + kb.mu.Lock() + defer kb.mu.Unlock() + parts := []string{"counter"} + if kb.prefix != "" { + parts = append(parts, kb.prefix) + } + parts = append(parts, key) + return strings.Join(parts, kb._separator) +} + +// BuildSession 构建会话键名(CK2 修复:mu 读保护)。 +// 格式: "session{separator}{prefix}{separator}{key}" +func (kb *KeyBuilder) BuildSession(key string) string { + kb.mu.Lock() + defer kb.mu.Unlock() + parts := []string{"session"} + if kb.prefix != "" { + parts = append(parts, kb.prefix) + } + parts = append(parts, key) + return strings.Join(parts, kb._separator) +} + +// BuildPattern 构建匹配模式(用于 SCAN/Keys)(CK2 修复:mu 读保护)。 +// 示例: kb.BuildPattern("user:*") -> "cache:site_a:user:*" +func (kb *KeyBuilder) BuildPattern(pattern string) string { + return kb.Build(pattern) +} + +// GetPrefix 获取当前前缀(CK2 修复:mu 读保护)。 +func (kb *KeyBuilder) GetPrefix() string { + kb.mu.Lock() + defer kb.mu.Unlock() + return kb.prefix +} + +// SetPrefix 动态设置前缀(CK2 修复:mu 写保护,并发安全)。 +func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder { + kb.mu.Lock() + defer kb.mu.Unlock() + kb.prefix = prefix + return kb +} + +// ===== 全局键名构建器 ===== + +// globalKeyBuilder 全局构建器,受 globalKBMu 保护;globalKBOnce 保证自动初始化只执行一次(M13)。 +var ( + globalKeyBuilder *KeyBuilder + globalKBMu sync.RWMutex + globalKBOnce sync.Once +) + +// InitKeyBuilder 初始化全局键名构建器 +// 参数: prefix 站点别名,如果为空则自动从配置读取 +// 示例: +// +// InitKeyBuilder("site_a") // 手动指定 +// InitKeyBuilder("") // 自动从配置读取 +// InitKeyBuilder("", WithSeparator(":")) // 自动读取 + 自定义分隔符 +func InitKeyBuilder(prefix string, opts ...KeyBuilderOption) { + // 如果 prefix 为空,尝试从配置读取 + if prefix == "" { + cfg := config.Get() + if cfg != nil { + prefix = cfg.GetSiteName() + } + } + + opts = append([]KeyBuilderOption{WithPrefix(prefix)}, opts...) + kb := NewKeyBuilder(opts...) + globalKBMu.Lock() + globalKeyBuilder = kb + globalKBMu.Unlock() +} + +// AutoInitKeyBuilder 自动从配置初始化键名构建器 +// 配置示例: +// +// app: +// site_name: "site_a" +// env: "prod" +func AutoInitKeyBuilder(opts ...KeyBuilderOption) { + InitKeyBuilder("", opts...) +} + +// GetKeyBuilder 获取全局键名构建器,未初始化时用 sync.Once 自动初始化一次(M13)。 +func GetKeyBuilder() *KeyBuilder { + globalKBOnce.Do(func() { + // 仅在仍为 nil 时自动初始化(已由 InitKeyBuilder 设置则跳过)。 + globalKBMu.RLock() + kb := globalKeyBuilder + globalKBMu.RUnlock() + if kb == nil { + AutoInitKeyBuilder() + } + }) + globalKBMu.RLock() + defer globalKBMu.RUnlock() + return globalKeyBuilder +} + +// K 快捷构建键名(使用全局构建器) +// 示例: cache.K("user:1") -> 自动添加前缀 +func K(key string) string { + return GetKeyBuilder().Build(key) +} + +// KTemp 快捷构建临时缓存键名 +func KTemp(key string) string { + return GetKeyBuilder().BuildTemp(key) +} + +// KPerm 快捷构建永久缓存键名 +func KPerm(key string) string { + return GetKeyBuilder().BuildPerm(key) +} + +// KLock 快捷构建锁键名 +func KLock(key string) string { + return GetKeyBuilder().BuildLock(key) +} + +// KCounter 快捷构建计数器键名 +func KCounter(key string) string { + return GetKeyBuilder().BuildCounter(key) +} + +// KSession 快捷构建会话键名 +func KSession(key string) string { + return GetKeyBuilder().BuildSession(key) +} + +// ===== 带键名构建器的缓存操作 ===== + +// SetWithPrefix 带前缀的缓存设置 +func SetWithPrefix(ctx context.Context, key string, value any, ttl time.Duration, prefix string) error { + kb := NewKeyBuilder(WithPrefix(prefix)) + return GetCache().Set(ctx, kb.Build(key), value, ttl) +} + +// GetWithPrefix 带前缀的缓存获取。命中返回 (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) +} + +// DeleteWithPrefix 带前缀的缓存删除 +func DeleteWithPrefix(ctx context.Context, key string, prefix string) error { + kb := NewKeyBuilder(WithPrefix(prefix)) + return GetCache().Delete(ctx, kb.Build(key)) +} + +// LockWithPrefix 带前缀的分布式锁 +func LockWithPrefix(ctx context.Context, key string, ttl time.Duration, prefix string) (bool, error) { + kb := NewKeyBuilder(WithPrefix(prefix)) + return Lock(ctx, kb.BuildLock(key), ttl) +} + +// UnlockWithPrefix 带前缀的锁释放 +// 注意: 此函数不检查 Token,仅用于向后兼容,建议使用 NewLock/Unlock 组合 +func UnlockWithPrefix(ctx context.Context, key string, prefix string) error { + kb := NewKeyBuilder(WithPrefix(prefix)) + return UnlockByKey(ctx, kb.BuildLock(key)) +} diff --git a/cache/keybuilder_m13_internal_test.go b/cache/keybuilder_m13_internal_test.go new file mode 100644 index 0000000..348b0e7 --- /dev/null +++ b/cache/keybuilder_m13_internal_test.go @@ -0,0 +1,31 @@ +package cache + +import ( + "sync" + "testing" +) + +// TestGlobalKeyBuilderConcurrentInitGet_M13:并发 InitKeyBuilder/GetKeyBuilder/K 不触发 data race +// 且不 nil-panic(M13:sync.Once + RWMutex 保护全局构建器)。须配合 -race 运行。 +func TestGlobalKeyBuilderConcurrentInitGet_M13(t *testing.T) { + // 预置一个已知前缀,避免依赖 config.Get。 + InitKeyBuilder("race_site") + t.Cleanup(func() { globalKBMu.Lock(); globalKeyBuilder = nil; globalKBMu.Unlock() }) + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(3) + go func() { defer wg.Done(); InitKeyBuilder("race_site") }() + go func() { defer wg.Done(); _ = GetKeyBuilder() }() + go func() { defer wg.Done(); _ = K("user:1") }() + } + wg.Wait() + + // 最终 GetKeyBuilder 非 nil,K 返回带前缀键。 + if GetKeyBuilder() == nil { + t.Fatal("GetKeyBuilder nil after concurrent init") + } + if got := K("user:1"); got == "user:1" { + t.Errorf("K returned unprefixed key %q (prefix not applied)", got) + } +} diff --git a/cache/keybuilder_test.go b/cache/keybuilder_test.go new file mode 100644 index 0000000..0ec6eb8 --- /dev/null +++ b/cache/keybuilder_test.go @@ -0,0 +1,225 @@ +package cache_test + +import ( + "testing" + + "github.com/EthanCodeCraft/xlgo-core/cache" +) + +func TestKeyBuilder(t *testing.T) { + kb := cache.NewKeyBuilder( + cache.WithPrefix("test_site"), + cache.WithSeparator(":"), + cache.WithCacheType("cache"), + ) + + // 测试 Build + result := kb.Build("user:1") + expected := "cache:test_site:user:1" + if result != expected { + t.Errorf("Build = %s, want %s", result, expected) + } + + // 测试 BuildTemp + tempResult := kb.BuildTemp("token") + if tempResult != "temp:test_site:token" { + t.Errorf("BuildTemp = %s, want temp:test_site:token", tempResult) + } + + // 测试 BuildPerm + permResult := kb.BuildPerm("config") + if permResult != "perm:test_site:config" { + t.Errorf("BuildPerm = %s, want perm:test_site:config", permResult) + } + + // 测试 BuildLock + lockResult := kb.BuildLock("order:123") + if lockResult != "lock:test_site:order:123" { + t.Errorf("BuildLock = %s, want lock:test_site:order:123", lockResult) + } + + // 测试 BuildCounter + counterResult := kb.BuildCounter("visit") + if counterResult != "counter:test_site:visit" { + t.Errorf("BuildCounter = %s, want counter:test_site:visit", counterResult) + } + + // 测试 BuildSession + sessionResult := kb.BuildSession("sid123") + if sessionResult != "session:test_site:sid123" { + t.Errorf("BuildSession = %s, want session:test_site:sid123", sessionResult) + } +} + +func TestKeyBuilderNoPrefix(t *testing.T) { + // 无前缀的构建器 + kb := cache.NewKeyBuilder() + + result := kb.Build("user:1") + expected := "cache:user:1" + if result != expected { + t.Errorf("Build without prefix = %s, want %s", result, expected) + } +} + +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")) + + // 动态修改前缀 + kb.SetPrefix("site_b") + + result := kb.Build("user:1") + if result != "cache:site_b:user:1" { + t.Errorf("SetPrefix failed: %s", result) + } + + // 验证链式调用 + kb2 := kb.SetPrefix("site_c") + if kb2 != kb { + t.Error("SetPrefix should return same builder") + } +} + +func TestKeyBuilderGetPrefix(t *testing.T) { + kb := cache.NewKeyBuilder(cache.WithPrefix("my_site")) + + prefix := kb.GetPrefix() + if prefix != "my_site" { + t.Errorf("GetPrefix = %s, want my_site", prefix) + } +} + +func TestKeyBuilderBuildPattern(t *testing.T) { + kb := cache.NewKeyBuilder(cache.WithPrefix("site_a")) + + result := kb.BuildPattern("user:*") + if result != "cache:site_a:user:*" { + t.Errorf("BuildPattern = %s, want cache:site_a:user:*", result) + } +} + +func TestKeyBuilderCustomSeparator(t *testing.T) { + kb := cache.NewKeyBuilder( + cache.WithPrefix("site"), + cache.WithSeparator("_"), + ) + + result := kb.Build("user:1") + if result != "cache_site_user:1" { + t.Errorf("Custom separator = %s, want cache_site_user:1", result) + } +} + +func TestKeyBuilderCustomCacheType(t *testing.T) { + kb := cache.NewKeyBuilder( + cache.WithPrefix("site"), + cache.WithCacheType("session"), + ) + + result := kb.Build("user:1") + if result != "session:site:user:1" { + t.Errorf("Custom cache type = %s, want session:site:user:1", result) + } +} + +func TestGlobalKeyBuilder(t *testing.T) { + // 初始化全局构建器 + cache.InitKeyBuilder("global_site") + + // 测试 K 函数 + result := cache.K("user:1") + if result != "cache:global_site:user:1" { + t.Errorf("K = %s, want cache:global_site:user:1", result) + } + + // 测试 KTemp + temp := cache.KTemp("token") + if temp != "temp:global_site:token" { + t.Errorf("KTemp = %s, want temp:global_site:token", temp) + } + + // 测试 KPerm + perm := cache.KPerm("config") + if perm != "perm:global_site:config" { + t.Errorf("KPerm = %s, want perm:global_site:config", perm) + } + + // 测试 KLock + lock := cache.KLock("order:123") + if lock != "lock:global_site:order:123" { + t.Errorf("KLock = %s, want lock:global_site:order:123", lock) + } + + // 测试 KCounter + counter := cache.KCounter("visit") + if counter != "counter:global_site:visit" { + t.Errorf("KCounter = %s, want counter:global_site:visit", counter) + } + + // 测试 KSession + session := cache.KSession("sid") + if session != "session:global_site:sid" { + t.Errorf("KSession = %s, want session:global_site:sid", session) + } +} + +func TestGetKeyBuilder(t *testing.T) { + // 获取全局构建器(如果未初始化会自动初始化) + kb := cache.GetKeyBuilder() + if kb == nil { + t.Error("GetKeyBuilder should not return nil") + } +} + +func TestWithPrefixFunc(t *testing.T) { + opt := cache.WithPrefix("test") + kb := cache.NewKeyBuilder() + opt(kb) + + if kb.GetPrefix() != "test" { + t.Errorf("WithPrefix failed") + } +} + +func TestWithSeparatorFunc(t *testing.T) { + opt := cache.WithSeparator("_") + kb := cache.NewKeyBuilder() + opt(kb) + + result := kb.Build("key") + // 分隔符应该是 _ + if !contains(result, "_") { + t.Errorf("WithSeparator failed, result: %s", result) + } +} + +func TestWithCacheTypeFunc(t *testing.T) { + opt := cache.WithCacheType("custom") + kb := cache.NewKeyBuilder() + opt(kb) + + result := kb.Build("key") + if !contains(result, "custom") { + t.Errorf("WithCacheType failed, result: %s", result) + } +} + +func contains(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/cache/lock.go b/cache/lock.go new file mode 100644 index 0000000..d358854 --- /dev/null +++ b/cache/lock.go @@ -0,0 +1,428 @@ +package cache + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/EthanCodeCraft/xlgo-core/utils" +) + +// 分布式锁错误 +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)。 +// go-redis 对整数返回 int64,但 nil/错误响应下可能为其他类型。 +func toInt64(v any) (int64, error) { + n, ok := v.(int64) + if !ok { + return 0, fmt.Errorf("脚本返回类型 %T: %w", v, ErrLockUnexpectedResult) + } + return n, nil +} + +func ttlMillis(ttl time.Duration) (int64, error) { + if ttl < time.Millisecond { + return 0, ErrInvalidLockTTL + } + return int64(ttl / time.Millisecond), nil +} + +// LockToken 锁令牌(用于安全释放锁)。 +// +// 安全说明(C1d 设计局限):Token 是随机 UUID(非单调递增的 fencing token)。 +// 本实现基于 Redis SET PX + Lua CAS,保证"持有者才能解锁/续期",但**无法防 TTL 到期后的 +// 双 worker 并发**:若 worker A 因 GC/网络停滞超过 TTL,锁过期后 worker B 获得锁, +// A 恢复后仍可能写过期数据。完整的 fencing token 防护需:① 用 Redis INCR 生成单调 token, +// ② 下游存储层(DB/外部服务)记录已见最大 token 并拒绝旧 token 写入。 +// 框架无法单方面保证②,需下游配合,故本类型仅提供 UUID token。对 TTL 到期敏感的场景, +// 请确保 ttl >> 业务最长执行时间,或下游实现 fencing token 校验。 +type LockToken struct { + Key string // 锁的键名 + Token string // 锁的唯一标识(UUID) +} + +// lockScript 加锁 Lua 脚本 +// 返回: 1 表示成功加锁,0 表示锁已被占用 +const lockScript = ` +if redis.call("exists", KEYS[1]) == 0 then + redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2]) + return 1 +else + return 0 +end +` + +// unlockScript 解锁 Lua 脯本 +// 只有持有正确 Token 的客户端才能解锁 +// 返回: 1 表示成功解锁,0 表示 Token 不匹配(锁不属于该客户端) +const unlockScript = ` +if redis.call("get", KEYS[1]) == ARGV[1] then + redis.call("del", KEYS[1]) + return 1 +else + return 0 +end +` + +// extendScript 续期 Lua 脚本 +// 只有持有正确 Token 的客户端才能续期 +// 返回: 1 表示成功续期,0 表示 Token 不匹配或锁不存在 +const extendScript = ` +if redis.call("get", KEYS[1]) == ARGV[1] then + redis.call("pexpire", KEYS[1], ARGV[2]) + return 1 +else + return 0 +end +` + +// NewLock 创建分布式锁 +// 参数: key 锁名称,ttl 锁定时长 +// 返回: LockToken 用于后续解锁或续期 +func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, error) { + rdb := database.GetRedis() + if rdb == nil { + return nil, ErrRedisNotReady + } + + token := utils.UUID() + ttlMs, err := ttlMillis(ttl) + if err != nil { + return nil, err + } + + result, err := rdb.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result() + if err != nil { + return nil, err + } + + n, err := toInt64(result) + if err != nil { + return nil, err + } + if n == 1 { + return &LockToken{Key: key, Token: token}, nil + } + + return nil, nil // 锁已被其他客户端持有 +} + +// Lock 简化的加锁函数(返回 bool) +// 注意: 使用此函数无法安全释放锁,建议使用 NewLock +func Lock(ctx context.Context, key string, ttl time.Duration) (bool, error) { + token, err := NewLock(ctx, key, ttl) + if err != nil { + return false, err + } + return token != nil, nil +} + +// Unlock 安全释放锁 +func Unlock(ctx context.Context, token *LockToken) error { + rdb := database.GetRedis() + if rdb == nil { + return ErrRedisNotReady + } + + if token == nil { + return ErrLockNotHeld + } + + result, err := rdb.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result() + if err != nil { + return err + } + + n, err := toInt64(result) + if err != nil { + return err + } + if n == 0 { + return ErrLockNotHeld + } + + return nil +} + +// UnlockByKey 按键名释放锁(不安全,仅用于旧代码兼容) +// 注意: 此函数不检查 Token,任何客户端都能释放锁 +func UnlockByKey(ctx context.Context, key string) error { + rdb := database.GetRedis() + if rdb == nil { + return ErrRedisNotReady + } + return rdb.Del(ctx, key).Err() +} + +// ExtendLock 续期锁 +// 参数: token 锁令牌,ttl 新的过期时间 +func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error { + rdb := database.GetRedis() + if rdb == nil { + return ErrRedisNotReady + } + + if token == nil { + return ErrLockNotHeld + } + + 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 { + return err + } + + n, err := toInt64(result) + if err != nil { + return err + } + if n == 0 { + return ErrLockNotHeld + } + + return nil +} + +// 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 { + return nil, err + } + if token != nil { + return token, nil + } + // 响应 ctx 取消,避免最长阻塞 maxRetry*retryInterval(C1c:禁止 time.Sleep 无视 ctx)。 + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(retryInterval): + } + } + return nil, ErrLockNotAcquired +} + +// WithLock 使用分布式锁执行函数(自动管理锁)。 +// 参数: key 锁名称,ttl 锁定时长,fn 业务函数 +// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend。 +// +// 解锁用独立 Background ctx(C1a 一致性修复):fn 返回或 panic 后,原 ctx 可能已被 +// 调用方取消,用其解锁会失败导致锁泄漏到 TTL。fn panic 时 defer 也保证解锁执行。 +func WithLock(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) error) error { + if fn == nil { + return ErrLockFuncNil + } + token, err := NewLock(ctx, key, ttl) + if err != nil { + return err + } + if token == nil { + return ErrLockNotAcquired + } + defer func() { + unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = Unlock(unlockCtx, token) + }() + + return fn(ctx) +} + +// WithLockAutoExtend 使用分布式锁执行函数(自动续期)。 +// 参数: key 锁名称,initialTTL 初始锁定时长,extendInterval 续期间隔,fn 业务函数 +// +// 并发安全说明(C1a 修复):续期 goroutine 与父用"父关停 + 子 ack"双 channel 协调—— +// 父用 close(stop) 通知子退出(close 由唯一所有者执行,安全),子用 close(finished) ack。 +// 避免旧实现 done 无缓冲 + 子 defer close(done) + 父 done<-struct{}{} 的 send-on-closed panic +// (ctx 取消或 ExtendLock 失败时 done 已 closed,父再 send 即 panic,Unlock 不执行、锁泄漏到 TTL)。 +// Unlock 用 context.Background() 派生超时,避免原 ctx 已取消致 Unlock 失败再泄漏。 +func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func(context.Context) error) error { + if fn == nil { + return ErrLockFuncNil + } + if extendInterval <= 0 { + return ErrInvalidLockRetryInterval + } + token, err := NewLock(ctx, key, initialTTL) + if err != nil { + return err + } + if token == nil { + return ErrLockNotAcquired + } + + // 父关停信号(仅父 close)与子 ack 信号(仅子 close)。 + stop := make(chan struct{}) + finished := make(chan struct{}) + + go func() { + defer close(finished) // 子退出时 ack,父等待 finished + ticker := time.NewTicker(extendInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-stop: + return + case <-ticker.C: + // 续期锁(每次续期为 initialTTL)。续期失败则停止续期,fn 应尽快结束。 + if err := ExtendLock(ctx, token, initialTTL); err != nil { + return + } + } + } + }() + + // defer 兜底:fn panic 时也要停止续期 goroutine 并释放锁(C1a panic 路径修复)。 + // 无 defer 时 fn panic 会导致 close(stop) 不执行 → 续期 goroutine 永久泄漏,且 Unlock 不执行 → 锁泄漏到 TTL。 + defer func() { + close(stop) + <-finished + unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = Unlock(unlockCtx, token) + }() + + // 执行业务函数。fn 必须接收 ctx,避免请求取消后业务 loader/DB/HTTP 调用继续运行。 + err = fn(ctx) + + return err +} + +// IsLocked 检查锁是否被占用(不获取锁)。 +// +// M-E 修复(失败语义统一):Redis 未初始化时返回 (false, ErrRedisNotReady) 而非 (false, nil)—— +// 后者与"锁确实未被占用"不可区分,调用方可能误以为可获取锁而进入临界区(正确性 bug)。 +// 调用方应 errors.Is(err, ErrRedisNotReady) 区分"Redis 不可用"与"锁未占用"。 +// L-G 修复:用 .Result() 显式返回 Redis 错误(原 .Val() 吞错致故障被当"未占用")。 +// +// 契约说明:锁操作有正确性影响(无锁进入临界区 = bug),故 Redis 不可用时显式返错; +// 与 cache.Get/Set 等数据操作(性能层、best-effort 静默 no-op)区分。 +func IsLocked(ctx context.Context, key string) (bool, error) { + rdb := database.GetRedis() + if rdb == nil { + return false, ErrRedisNotReady + } + n, err := rdb.Exists(ctx, key).Result() + if err != nil { + return false, err + } + return n > 0, nil +} + +// GetLockTTL 获取锁的剩余过期时间。 +// +// M-E 修复:Redis 未初始化时返回 (0, ErrRedisNotReady) 而非 (0, nil),调用方可区分 +// "Redis 不可用"与"锁不存在/已过期"(后者 TTL=-1/-2)。 +func GetLockTTL(ctx context.Context, key string) (time.Duration, error) { + rdb := database.GetRedis() + if rdb == nil { + return 0, ErrRedisNotReady + } + return rdb.TTL(ctx, key).Result() +} + +// ForceUnlock 强制释放锁(危险操作,仅用于管理场景) +// 注意: 此函数不检查 Token,强制删除锁 +// +// M-E 修复:Redis 未初始化时返回 ErrRedisNotReady 而非 nil——原 nil 让调用方误以为 +// 已解锁成功、实则从未操作。管理脚本应据此重试或告警,而非假设成功。 +func ForceUnlock(ctx context.Context, key string) error { + rdb := database.GetRedis() + if rdb == nil { + return ErrRedisNotReady + } + return rdb.Del(ctx, key).Err() +} + +// ===== 计数器操作 ===== + +// Incr 自增计数器 +func Incr(ctx context.Context, key string) (int64, error) { + rdb := database.GetRedis() + if rdb == nil { + return 0, ErrRedisNotReady + } + return rdb.Incr(ctx, key).Result() +} + +// IncrBy 指定增量自增 +func IncrBy(ctx context.Context, key string, value int64) (int64, error) { + rdb := database.GetRedis() + if rdb == nil { + return 0, ErrRedisNotReady + } + return rdb.IncrBy(ctx, key, value).Result() +} + +// Decr 自减计数器 +func Decr(ctx context.Context, key string) (int64, error) { + rdb := database.GetRedis() + if rdb == nil { + return 0, ErrRedisNotReady + } + return rdb.Decr(ctx, key).Result() +} + +// GetTTL 获取键的剩余过期时间 +func GetTTL(ctx context.Context, key string) (time.Duration, error) { + rdb := database.GetRedis() + if rdb == nil { + return 0, ErrRedisNotReady + } + return rdb.TTL(ctx, key).Result() +} + +// SetExpire 设置键的过期时间 +func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) { + rdb := database.GetRedis() + if rdb == nil { + return false, ErrRedisNotReady + } + return rdb.Expire(ctx, key, ttl).Result() +} + +// GetRaw 获取原始字符串值(不反序列化) +func GetRaw(ctx context.Context, key string) (string, error) { + rdb := database.GetRedis() + if rdb == nil { + return "", ErrRedisNotReady + } + return rdb.Get(ctx, key).Result() +} + +// SetRaw 设置原始值(不序列化) +func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error { + rdb := database.GetRedis() + if rdb == nil { + return ErrRedisNotReady + } + return rdb.Set(ctx, key, value, ttl).Err() +} diff --git a/cache/lock_concurrency_test.go b/cache/lock_concurrency_test.go new file mode 100644 index 0000000..6ab3b87 --- /dev/null +++ b/cache/lock_concurrency_test.go @@ -0,0 +1,413 @@ +package cache_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/cache" + "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +// setupMiniRedis 启动一个 miniredis 实例并把它注入 database 内部,返回清理函数。 +func setupMiniRedis(t *testing.T) *miniredis.Miniredis { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + // 通过 SetTestRedisClient 注入 miniredis 客户端到 database 包的内部 redisClient + database.SetTestRedisClient(client) + t.Cleanup(func() { database.SetTestRedisClient(nil) }) + return mr +} + +// ===== C1a:WithLockAutoExtend 不 panic / 不泄漏锁 ===== + +// 回归 C1a:ctx 取消时 WithLockAutoExtend 不 send-on-closed panic,且锁被释放。 +// 旧实现:ctx 取消时子 goroutine defer close(done),父 fn() 后 done<-struct{}{} 即 panic, +// Unlock 不执行,锁泄漏到 TTL。 +func TestWithLockAutoExtendCtxCancelNoPanic(t *testing.T) { + mr := setupMiniRedis(t) + _ = mr + + ctx, cancel := context.WithCancel(context.Background()) + key := "c1a:cancel" + + ran := make(chan struct{}) + var panicked any + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + defer func() { panicked = recover() }() + err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(runCtx context.Context) error { + close(ran) + // 阻塞直到 ctx 被取消,模拟长任务。 + <-runCtx.Done() + return runCtx.Err() + }) + // WithLockAutoExtend 返回 fn 的错误(ctx.Err),不应 panic。 + if err != nil && !errors.Is(err, context.Canceled) { + t.Errorf("unexpected err: %v", err) + } + }() + + // 等 fn 启动 + 至少一次续期 ticker,再取消 ctx,制造最大竞态窗口。 + <-ran + time.Sleep(250 * time.Millisecond) + cancel() + wg.Wait() + + if panicked != nil { + t.Fatalf("WithLockAutoExtend panicked on ctx cancel (C1a send-on-closed): %v", panicked) + } + // 锁必须被释放(Unlock 用 Background ctx 执行)。 + locked, err := cache.IsLocked(context.Background(), key) + if err != nil { + t.Fatalf("IsLocked: %v", err) + } + if locked { + t.Error("lock leaked after ctx cancel (Unlock not executed)") + } +} + +// 回归 C1a:fn 正常返回时锁被释放,无 panic。 +func TestWithLockAutoExtendNormalRelease(t *testing.T) { + setupMiniRedis(t) + ctx := context.Background() + key := "c1a:normal" + + called := false + err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error { + called = true + return nil + }) + if err != nil { + t.Fatalf("WithLockAutoExtend: %v", err) + } + if !called { + t.Error("fn not called") + } + locked, _ := cache.IsLocked(ctx, key) + if locked { + t.Error("lock not released after normal fn return") + } +} + +// 回归 C1a:fn 执行超过续期间隔,锁被续期不丢失(续期 goroutine 工作)。 +func TestWithLockAutoExtendExtendsLock(t *testing.T) { + mr := setupMiniRedis(t) + ctx := context.Background() + key := "c1a:extend" + + // initialTTL=500ms,extendInterval=100ms。fn 执行 800ms,期间应多次续期。 + // 若续期失效,锁会在 500ms 过期,另一 worker 可获取。 + err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func(context.Context) error { + time.Sleep(800 * time.Millisecond) + return nil + }) + if err != nil { + t.Fatalf("WithLockAutoExtend: %v", err) + } + + // 期间另一 worker 应无法获取锁(续期生效)。 + // 此断言在 fn 执行中验证更准;这里用 fn 返回后锁已释放验证基础闭环。 + locked, _ := cache.IsLocked(ctx, key) + if locked { + t.Error("lock not released after fn") + } + _ = mr +} + +// 回归 C1a:fn 执行中另一 worker 拿不到锁(续期保活)。 +func TestWithLockAutoExtendBlocksContender(t *testing.T) { + setupMiniRedis(t) + ctx := context.Background() + key := "c1a:block" + + fnStarted := make(chan struct{}) + fnDone := make(chan struct{}) + lockDone := make(chan struct{}) + var contenderGotLock atomic.Bool + + go func() { + defer close(lockDone) + cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func(context.Context) error { + close(fnStarted) + time.Sleep(600 * time.Millisecond) + close(fnDone) + return nil + }) + }() + + <-fnStarted + // 期间尝试获取锁,应失败(nil)。 + token, err := cache.NewLock(ctx, key, 2*time.Second) + if err != nil { + t.Fatalf("NewLock contender: %v", err) + } + if token != nil { + contenderGotLock.Store(true) + _ = cache.Unlock(ctx, token) + } + <-fnDone + <-lockDone + + if contenderGotLock.Load() { + t.Error("contender acquired lock while auto-extend active (extend failed)") + } +} + +// 回归 C1a:fn panic 时锁仍被释放、续期 goroutine 不泄漏(defer 兜底)。 +// 旧实现(无 defer)fn panic → close(stop) 不执行 → 续期 goroutine 永久泄漏 + Unlock 不执行 → 锁泄漏。 +func TestWithLockAutoExtendFnPanicReleasesLock(t *testing.T) { + setupMiniRedis(t) + ctx := context.Background() + key := "c1a:panic" + + var panicked any + func() { + defer func() { panicked = recover() }() + _ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error { + time.Sleep(150 * time.Millisecond) // 让续期 ticker 至少触发一次 + panic("boom") + }) + }() + + if panicked == nil { + t.Fatal("expected fn panic to propagate") + } + + // 锁必须被释放(defer Unlock 执行)。 + locked, _ := cache.IsLocked(ctx, key) + if locked { + t.Error("lock leaked after fn panic (defer Unlock not executed)") + } +} + +// 回归 C1a/HIGH:WithLock 在 ctx 取消后仍能解锁(Unlock 用 Background ctx)。 +// 旧实现 defer Unlock(ctx, token) 用原 ctx,ctx 取消致 Unlock 失败、锁泄漏到 TTL。 +func TestWithLockCtxCancelReleasesLock(t *testing.T) { + setupMiniRedis(t) + ctx, cancel := context.WithCancel(context.Background()) + key := "c1a:withlock" + + fnStarted := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + _ = cache.WithLock(ctx, key, 10*time.Second, func(runCtx context.Context) error { + close(fnStarted) + <-runCtx.Done() + return runCtx.Err() + }) + }() + + <-fnStarted + cancel() + <-done + + locked, _ := cache.IsLocked(context.Background(), key) + if locked { + t.Error("lock leaked after ctx cancel (WithLock Unlock should use Background ctx)") + } +} + +func TestWithLockPassesContextToFunction(t *testing.T) { + setupMiniRedis(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + key := "m10:ctx-aware" + + var seen context.Context + err := cache.WithLock(ctx, key, time.Second, func(runCtx context.Context) error { + seen = runCtx + cancel() + <-runCtx.Done() + return runCtx.Err() + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("WithLock ctx-aware err = %v, want context.Canceled", err) + } + if seen != ctx { + t.Fatal("WithLock 应把调用方 ctx 传入业务函数") + } +} + +func TestWithLockRejectsNilFunction(t *testing.T) { + setupMiniRedis(t) + ctx := context.Background() + + if err := cache.WithLock(ctx, "m10:nil-fn", time.Second, nil); !errors.Is(err, cache.ErrLockFuncNil) { + t.Fatalf("WithLock nil fn err = %v, want ErrLockFuncNil", err) + } + if err := cache.WithLockAutoExtend(ctx, "m10:nil-fn-auto", time.Second, 100*time.Millisecond, nil); !errors.Is(err, cache.ErrLockFuncNil) { + t.Fatalf("WithLockAutoExtend nil fn err = %v, want ErrLockFuncNil", err) + } +} + +// ===== C1b:类型断言不 panic ===== + +// 回归 C1b:NewLock/Unlock/ExtendLock 在正常路径返回正确结果(Lua 脚本返 int64)。 +// 此用例锁定正常路径不被 toInt64 改坏;裸断言 panic 路径需构造非 int64 返回, +// miniredis Lua 恒返整数,故 panic 路径由 toInt64 的 comma-ok 防护(代码审查保证)。 +func TestLockUnlockExtendCycle(t *testing.T) { + setupMiniRedis(t) + ctx := context.Background() + key := "c1b:cycle" + + // 加锁 + token, err := cache.NewLock(ctx, key, 5*time.Second) + if err != nil || token == nil { + t.Fatalf("NewLock: err=%v token=%v", err, token) + } + // 重复加锁应失败(返回 nil token) + t2, err := cache.NewLock(ctx, key, 5*time.Second) + if err != nil { + t.Fatalf("second NewLock err: %v", err) + } + if t2 != nil { + t.Error("second NewLock should return nil token (lock held)") + _ = cache.Unlock(ctx, t2) + } + // 续期 + if err := cache.ExtendLock(ctx, token, 5*time.Second); err != nil { + t.Errorf("ExtendLock: %v", err) + } + // 用错误 token 解锁应失败 + wrong := &cache.LockToken{Key: key, Token: "wrong-token"} + if err := cache.Unlock(ctx, wrong); !errors.Is(err, cache.ErrLockNotHeld) { + t.Errorf("Unlock wrong token err = %v, want ErrLockNotHeld", err) + } + // 正确解锁 + if err := cache.Unlock(ctx, token); err != nil { + t.Errorf("Unlock: %v", err) + } + // 解锁后另一方可获取 + t3, err := cache.NewLock(ctx, key, 5*time.Second) + if err != nil || t3 == nil { + t.Fatalf("NewLock after unlock: err=%v token=%v", err, t3) + } + _ = cache.Unlock(ctx, t3) +} + +// ===== C1c:TryLock 响应 ctx 取消 ===== + +// 回归 C1c:TryLock 在 ctx 取消时立即返回,不阻塞 maxRetry*retryInterval。 +// 旧实现 time.Sleep 不响应 ctx,取消后仍要等满所有重试。 +func TestTryLockRespectsCtxCancel(t *testing.T) { + setupMiniRedis(t) + // 先占用锁,使 TryLock 必然重试。 + holder, err := cache.NewLock(context.Background(), "c1c:try", 10*time.Second) + if err != nil || holder == nil { + t.Fatalf("setup holder: err=%v token=%v", err, holder) + } + defer cache.Unlock(context.Background(), holder) + + ctx, cancel := context.WithCancel(context.Background()) + // retryInterval=200ms,maxRetry=10 → 旧实现取消后最长阻塞 2s。 + start := time.Now() + go func() { + time.Sleep(150 * time.Millisecond) + cancel() + }() + + token, err := cache.TryLock(ctx, "c1c:try", 5*time.Second, 200*time.Millisecond, 10) + elapsed := time.Since(start) + + if !errors.Is(err, context.Canceled) { + t.Errorf("TryLock err = %v, want context.Canceled", err) + } + if token != nil { + t.Error("TryLock should return nil token (held by other)") + _ = cache.Unlock(context.Background(), token) + } + // 取消应在 ~150ms 后返回,远小于 2s。 + if elapsed > 1*time.Second { + t.Errorf("TryLock took %v, should return shortly after ctx cancel (C1c: time.Sleep ignored ctx)", elapsed) + } +} + +func TestLockRejectsSubMillisecondTTL(t *testing.T) { + setupMiniRedis(t) + ctx := context.Background() + + if token, err := cache.NewLock(ctx, "m10:ttl", time.Nanosecond); !errors.Is(err, cache.ErrInvalidLockTTL) || token != nil { + t.Fatalf("NewLock sub-ms ttl token=%v err=%v, want ErrInvalidLockTTL", token, err) + } + + token, err := cache.NewLock(ctx, "m10:ttl-valid", time.Second) + if err != nil || token == nil { + t.Fatalf("NewLock valid setup token=%v err=%v", token, err) + } + defer cache.Unlock(ctx, token) + + if err := cache.ExtendLock(ctx, token, 0); !errors.Is(err, cache.ErrInvalidLockTTL) { + t.Errorf("ExtendLock zero ttl err = %v, want ErrInvalidLockTTL", err) + } +} + +func TestWithLockAutoExtendRejectsNonPositiveInterval(t *testing.T) { + setupMiniRedis(t) + called := false + + err := cache.WithLockAutoExtend(context.Background(), "m10:interval", time.Second, 0, func(context.Context) error { + called = true + return nil + }) + if !errors.Is(err, cache.ErrInvalidLockRetryInterval) { + t.Fatalf("WithLockAutoExtend zero interval err = %v, want ErrInvalidLockRetryInterval", err) + } + if called { + t.Fatal("WithLockAutoExtend should not run fn with invalid interval") + } +} + +func TestWithLockHeldReturnsErrLockNotAcquired(t *testing.T) { + setupMiniRedis(t) + ctx := context.Background() + key := "m10:not-acquired" + + holder, err := cache.NewLock(ctx, key, time.Second) + if err != nil || holder == nil { + t.Fatalf("setup holder token=%v err=%v", holder, err) + } + defer cache.Unlock(ctx, holder) + + called := false + err = cache.WithLock(ctx, key, time.Second, func(context.Context) error { + called = true + return nil + }) + if !errors.Is(err, cache.ErrLockNotAcquired) { + t.Fatalf("WithLock held err = %v, want ErrLockNotAcquired", err) + } + if called { + t.Fatal("WithLock should not run fn when lock is held") + } +} + +func TestTryLockExhaustedReturnsErrLockNotAcquired(t *testing.T) { + setupMiniRedis(t) + ctx := context.Background() + key := "m10:try-exhausted" + + holder, err := cache.NewLock(ctx, key, time.Second) + if err != nil || holder == nil { + t.Fatalf("setup holder token=%v err=%v", holder, err) + } + defer cache.Unlock(ctx, holder) + + token, err := cache.TryLock(ctx, key, time.Second, time.Millisecond, 2) + if !errors.Is(err, cache.ErrLockNotAcquired) { + t.Fatalf("TryLock exhausted err = %v, want ErrLockNotAcquired", err) + } + if token != nil { + t.Fatalf("TryLock exhausted token = %v, want nil", token) + } +} diff --git a/cache/lock_test.go b/cache/lock_test.go new file mode 100644 index 0000000..821b15d --- /dev/null +++ b/cache/lock_test.go @@ -0,0 +1,151 @@ +package cache_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/cache" +) + +func TestLockToken(t *testing.T) { + token := &cache.LockToken{ + Key: "test_lock", + Token: "abc123", + } + + if token.Key != "test_lock" { + t.Error("LockToken Key failed") + } + if token.Token != "abc123" { + t.Error("LockToken Token failed") + } +} + +func TestLockErrors(t *testing.T) { + ctx := context.Background() + + // Test ErrLockNotHeld - 当 Redis 未初始化时,先检查 nil token + err := cache.Unlock(ctx, nil) + // 当 Redis 未初始化时,会返回 ErrRedisNotReady + if err != cache.ErrLockNotHeld && err != cache.ErrRedisNotReady { + t.Errorf("Unlock with nil token should return ErrLockNotHeld or ErrRedisNotReady, got %v", err) + } + + // Test IsLocked without Redis — M-E:应返回 (false, ErrRedisNotReady), + // 让调用方可区分"Redis 不可用"与"锁未占用"(原 (false,nil) 与"未占用"不可区分)。 + locked, err := cache.IsLocked(ctx, "test_key") + if !errors.Is(err, cache.ErrRedisNotReady) { + t.Errorf("IsLocked without Redis should return ErrRedisNotReady, got %v", err) + } + if locked { + t.Error("IsLocked should return false without Redis") + } + + // Test GetLockTTL without Redis — M-E:应返回 (0, ErrRedisNotReady)。 + ttl, err := cache.GetLockTTL(ctx, "test_key") + if !errors.Is(err, cache.ErrRedisNotReady) { + t.Errorf("GetLockTTL without Redis should return ErrRedisNotReady, got %v", err) + } + if ttl != 0 { + t.Error("GetLockTTL should return 0 without Redis") + } + + if err := cache.UnlockByKey(ctx, "test_key"); !errors.Is(err, cache.ErrRedisNotReady) { + t.Errorf("UnlockByKey without Redis should return ErrRedisNotReady, got %v", err) + } +} + +func TestIncrDecr(t *testing.T) { + ctx := context.Background() + + // Redis 未初始化时应显式返回 ErrRedisNotReady,避免调用方误判为计数器值为 0。 + n, err := cache.Incr(ctx, "counter") + 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") + } + + n, err = cache.IncrBy(ctx, "counter", 10) + 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") + } + + n, err = cache.Decr(ctx, "counter") + 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") + } +} + +func TestSetExpire(t *testing.T) { + ctx := context.Background() + + ok, err := cache.SetExpire(ctx, "test_key", time.Minute) + 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") + } +} + +func TestGetRawSetRaw(t *testing.T) { + ctx := context.Background() + + // Test SetRaw + err := cache.SetRaw(ctx, "test_key", "test_value", time.Minute) + 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 !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") + } +} + +func TestKFunctions(t *testing.T) { + // Test various K functions + key := cache.K("user:1") + if key == "" { + t.Error("K should not return empty string") + } + + tempKey := cache.KTemp("token") + if tempKey == "" { + t.Error("KTemp should not return empty string") + } + + permKey := cache.KPerm("config") + if permKey == "" { + t.Error("KPerm should not return empty string") + } + + lockKey := cache.KLock("order:123") + if lockKey == "" { + t.Error("KLock should not return empty string") + } + + counterKey := cache.KCounter("visit") + if counterKey == "" { + t.Error("KCounter should not return empty string") + } + + sessionKey := cache.KSession("sid") + if sessionKey == "" { + t.Error("KSession should not return empty string") + } +} diff --git a/cmd/xlgo/commands.go b/cmd/xlgo/commands.go new file mode 100644 index 0000000..af12bf2 --- /dev/null +++ b/cmd/xlgo/commands.go @@ -0,0 +1,336 @@ +package main + +import ( + "fmt" + "os" + "strings" + "text/template" + "time" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + xlgo "github.com/EthanCodeCraft/xlgo-core" +) + +func createProject(name string) error { + // P1 #21:校验项目名,拒绝路径穿越与非法 Go 包名。 + if err := validateProjectName(name); err != nil { + return err + } + // #nosec G703 -- name was validated by validateProjectName: no path separators, no "..", no leading dot, Go identifier only. + if _, err := os.Stat(name); !os.IsNotExist(err) { + return fmt.Errorf("目录 %s 已存在", name) + } + + // 解析 --template 与 --module 参数(默认 template=api) + tmplName := "api" + module := name + args := os.Args[3:] + for i := 0; i < len(args); i++ { + switch args[i] { + case "--template", "-t": + if i+1 >= len(args) { + return fmt.Errorf("%s 缺少参数值", args[i]) + } + tmplName = args[i+1] + i++ + case "--module", "-m": + if i+1 >= len(args) { + return fmt.Errorf("%s 缺少参数值", args[i]) + } + module = args[i+1] + i++ + default: + // P1 #21:未知参数显式报错,不再静默忽略。 + return fmt.Errorf("未知参数: %s", args[i]) + } + } + + // P1 #21:校验 module 路径,避免模板元字符 {{ }} 经 Sprintf 进 go.mod 后触发 Parse 报错。 + if err := validateModulePath(module); err != nil { + return err + } + + // 校验模板名 + switch tmplName { + case "minimal", "api", "fullstack": + // ok + default: + return fmt.Errorf("未知模板: %s(可选: minimal / api / fullstack)", tmplName) + } + + // minimal 模板目录结构最小化;api/fullstack 含完整分层目录 + var dirs []string + dirs = append(dirs, name, name+"/public", name+"/logs") + if tmplName != "minimal" { + dirs = append(dirs, + name+"/config", + name+"/handler", + name+"/model", + name+"/repository", + name+"/service", + name+"/middleware", + ) + } + + for _, dir := range dirs { + // #nosec G301,G703 -- dir is built from validated project name plus fixed scaffold subdirectories; 0755 is intentional for generated project dirs. + if err := os.MkdirAll(dir, 0755); err != nil { + // #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back. + _ = os.RemoveAll(name) // P1 #21:清理半成品 + return fmt.Errorf("创建目录失败: %w", err) + } + } + + caser := cases.Title(language.English) + data := TemplateData{ + Package: caser.String(name), + Name: caser.String(name), + NameLower: strings.ToLower(name), + Module: module, + Year: time.Now().Year(), + } + + // 按模板选择 main.go 与 config.yaml + var mainTmpl, configTmpl string + switch tmplName { + case "minimal": + mainTmpl, configTmpl = templates.MainMinimal, templates.ConfigMinimal + case "fullstack": + mainTmpl, configTmpl = templates.MainFull, templates.ConfigFull + default: // api + mainTmpl, configTmpl = templates.Main, templates.Config + } + + // 创建文件 + files := map[string]string{ + name + "/main.go": mainTmpl, + name + "/config.yaml": configTmpl, + name + "/go.mod": fmt.Sprintf(templates.GoMod, module, xlgo.Version), + name + "/Makefile": templates.Makefile, + name + "/.gitignore": templates.Gitignore, + } + // api/fullstack 模板带示例 handler + if tmplName != "minimal" { + files[name+"/handler/home.go"] = templates.Handler + } + + for path, content := range files { + if err := renderTemplateFile(path, content, data); err != nil { + // #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back. + _ = os.RemoveAll(name) // P1 #21:部分失败回滚,避免留下半成品项目 + return err + } + } + + fmt.Printf("✓ 项目 %s 创建成功(模板: %s)\n", name, tmplName) + fmt.Println("\n下一步:") + fmt.Printf(" cd %s\n", name) + fmt.Println(" go mod tidy") + fmt.Println(" go run main.go") + return nil +} + +// renderTemplateFile 解析并渲染单个模板文件,每次调用显式关闭句柄 +// (P1 #21:不在循环内 defer 累积句柄,且 Close 错误纳入返回)。 +func renderTemplateFile(path, content string, data TemplateData) (retErr error) { + tmpl, err := template.New(path).Parse(content) + if err != nil { + return fmt.Errorf("解析模板 %s 失败: %w", path, err) + } + // #nosec G304 -- path is from createProject's files map built from validated project name plus fixed filenames. + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("创建文件 %s 失败: %w", path, err) + } + defer func() { + if cerr := file.Close(); cerr != nil && retErr == nil { + retErr = fmt.Errorf("关闭文件 %s 失败: %w", path, cerr) + } + }() + if err := tmpl.Execute(file, data); err != nil { + return fmt.Errorf("写入文件 %s 失败: %w", path, err) + } + return nil +} + +// validateProjectName 校验项目名(P1 #21):非空、无路径分隔符/.. /前导点, +// 且可派生为合法 Go 包名(须以字母开头,仅含字母/数字/下划线)。 +func validateProjectName(name string) error { + if name == "" { + return fmt.Errorf("项目名不能为空") + } + if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { + return fmt.Errorf("项目名不能包含路径分隔符或 ..(防止在预期目录外创建文件): %q", name) + } + if strings.HasPrefix(name, ".") { + return fmt.Errorf("项目名不能以 . 开头: %q", name) + } + if !isValidGoIdentifier(name) { + return fmt.Errorf("项目名 %q 无法生成合法 Go 包名:须以字母开头,仅含字母、数字、下划线", name) + } + return nil +} + +// isValidGoIdentifier 判断 s 是否为脚手架接受的 ASCII 标识符:字母开头,后续允许字母、数字、下划线。 +func isValidGoIdentifier(s string) bool { + if s == "" { + return false + } + for i, r := range s { + isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + isNum := r >= '0' && r <= '9' + if i == 0 { + if !isLetter { + return false + } + } else if !isLetter && !isNum && r != '_' { + return false + } + } + return true +} + +// validateModulePath 校验 --module(P1 #21):非空、不含模板元字符与空白。 +func validateModulePath(module string) error { + if module == "" { + return fmt.Errorf("模块路径不能为空") + } + if strings.Contains(module, "{{") || strings.Contains(module, "}}") { + return fmt.Errorf("模块路径不能包含模板元字符 {{ }}: %q", module) + } + if strings.ContainsAny(module, " \t\r\n") { + return fmt.Errorf("模块路径不能包含空白字符: %q", module) + } + return nil +} + +func makeFile(fileType, name string) error { + if err := validateMakeName(name); err != nil { + return err + } + name = strings.ToLower(name) + nameTitle := makeNameTitle(name) + + switch fileType { + case "handler": + return createHandler(name, nameTitle) + case "repository": + return createRepository(name, nameTitle) + case "model": + return createModel(name, nameTitle) + case "service": + return createService(name, nameTitle) + default: + return fmt.Errorf("未知类型: %s(可用类型: handler, repository, model, service)", fileType) + } +} + +// validateMakeName 校验 xlgo make 的资源名,避免路径穿越或生成不可编译的 Go 代码。 +func validateMakeName(name string) error { + if name == "" { + return fmt.Errorf("名称不能为空") + } + if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { + return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name) + } + if strings.HasPrefix(name, ".") { + return fmt.Errorf("名称不能以 . 开头: %q", name) + } + if !isValidGoIdentifier(name) { + return fmt.Errorf("名称 %q 必须是合法标识符:须以字母开头,仅含字母、数字、下划线", name) + } + return nil +} + +// makeNameTitle 把 snake_case 名称转为导出的 CamelCase 类型名。 +func makeNameTitle(name string) string { + caser := cases.Title(language.English) + nameTitle := caser.String(strings.ReplaceAll(name, "_", " ")) + return strings.ReplaceAll(nameTitle, " ", "") +} + +func createHandler(name, nameTitle string) error { + path := fmt.Sprintf("handler/%s.go", name) + if fileExists(path) { + return fmt.Errorf("文件 %s 已存在", path) + } + + content := fmt.Sprintf(templates.HandlerMake, + nameTitle, name, nameTitle, + nameTitle, name, nameTitle, nameTitle, nameTitle, + name, nameTitle, name, nameTitle, + nameTitle, name, name, nameTitle, + nameTitle, name, name, nameTitle, + nameTitle, name, name, nameTitle, + nameTitle, name, name, nameTitle, + ) + content = replaceModuleImports(content) + + if err := writeFile(path, content); err != nil { + return err + } + fmt.Printf("✓ 创建处理器: %s\n", path) + return nil +} + +func createRepository(name, nameTitle string) error { + path := fmt.Sprintf("repository/%s_repository.go", name) + if fileExists(path) { + return fmt.Errorf("文件 %s 已存在", path) + } + + content := fmt.Sprintf(templates.RepositoryMake, + nameTitle, name, nameTitle, nameTitle, + nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle, + nameTitle, nameTitle, + ) + content = replaceModuleImports(content) + + if err := writeFile(path, content); err != nil { + return err + } + fmt.Printf("✓ 创建仓库: %s\n", path) + return nil +} + +func createModel(name, nameTitle string) error { + path := fmt.Sprintf("model/%s.go", name) + if fileExists(path) { + return fmt.Errorf("文件 %s 已存在", path) + } + + content := fmt.Sprintf(templates.ModelMake, + nameTitle, name, nameTitle, nameTitle, name, + ) + + if err := writeFile(path, content); err != nil { + return err + } + fmt.Printf("✓ 创建模型: %s\n", path) + return nil +} + +func createService(name, nameTitle string) error { + path := fmt.Sprintf("service/%s_service.go", name) + if fileExists(path) { + return fmt.Errorf("文件 %s 已存在", path) + } + + content := fmt.Sprintf(templates.ServiceMake, + nameTitle, name, nameTitle, nameTitle, + nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, + nameTitle, nameTitle, + nameTitle, nameTitle, + nameTitle, nameTitle, + nameTitle, nameTitle, + ) + content = replaceModuleImports(content) + + if err := writeFile(path, content); err != nil { + return err + } + fmt.Printf("✓ 创建服务: %s\n", path) + return nil +} diff --git a/cmd/xlgo/commands_test.go b/cmd/xlgo/commands_test.go new file mode 100644 index 0000000..3f66992 --- /dev/null +++ b/cmd/xlgo/commands_test.go @@ -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) + } +} diff --git a/cmd/xlgo/main.go b/cmd/xlgo/main.go new file mode 100644 index 0000000..acc5b4f --- /dev/null +++ b/cmd/xlgo/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "os" + + xlgo "github.com/EthanCodeCraft/xlgo-core" +) + +func printUsage() { + fmt.Println(`xlgo - Go Web 框架脚手架工具 + +用法: + xlgo new <项目名> [--template <模板>] [--module <模块路径>] 创建新项目 + xlgo make handler <名称> 创建处理器 + xlgo make repository <名称> 创建仓库 + xlgo make model <名称> 创建模型 + xlgo make service <名称> 创建服务 + xlgo version 显示版本号 + +模板 (xlgo new --template <名称>): + minimal 轻量 HTTP 服务,不依赖 MySQL/Redis(默认入门) + api 标准业务 API,含 MySQL/Redis/JWT 与 handler/model/repository/service 分层(默认) + fullstack 全组件,一键启用 MySQL/Redis/Storage/Swagger/AutoMigrate + +示例: + xlgo new myapp + xlgo new myapp --template minimal + xlgo new myapp --template fullstack --module github.com/me/myapp + xlgo make handler user + xlgo make repository user`) +} + +func main() { + if len(os.Args) < 2 { + printUsage() + return + } + + command := os.Args[1] + + // P1 #21:命令执行失败时以非零码退出,便于 `xlgo new x && cd x` 等脚本/CI 正确中断。 + switch command { + case "new": + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "用法: xlgo new <项目名>") + os.Exit(2) + } + if err := createProject(os.Args[2]); err != nil { + fmt.Fprintf(os.Stderr, "错误: %s\n", err) + os.Exit(1) + } + + case "make": + if len(os.Args) < 4 { + fmt.Fprintln(os.Stderr, "用法: xlgo make <类型> <名称>") + fmt.Fprintln(os.Stderr, "类型: handler, repository, model, service") + os.Exit(2) + } + if err := makeFile(os.Args[2], os.Args[3]); err != nil { + fmt.Fprintf(os.Stderr, "错误: %s\n", err) + os.Exit(1) + } + + case "version": + fmt.Printf("xlgo v%s\n", xlgo.Version) + + default: + printUsage() + } +} diff --git a/cmd/xlgo/templates.go b/cmd/xlgo/templates.go new file mode 100644 index 0000000..77cc370 --- /dev/null +++ b/cmd/xlgo/templates.go @@ -0,0 +1,571 @@ +package main + +// Templates 存放所有代码生成模板 +var templates = struct { + Main string // api 模板:标准业务 API(mysql+redis+jwt+分层) + MainMinimal string // minimal 模板:轻量 HTTP,无外部依赖 + MainFull string // fullstack 模板:全组件 + Config string // api 模板配置 + ConfigMinimal string + ConfigFull string + GoMod string + Makefile string + Gitignore string + Handler string + HandlerMake string + + RepositoryMake string + ModelMake string + ServiceMake string +}{ + // Main 新项目主文件模板 + Main: `package main + +import ( + "flag" + "fmt" + "os" + + xlgo "github.com/EthanCodeCraft/xlgo-core" + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/EthanCodeCraft/xlgo-core/router" + "github.com/gin-gonic/gin" +) + +var configPath string + +func init() { + flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径") +} + +func main() { + flag.Parse() + + app := xlgo.New( + xlgo.WithConfigPath(configPath), + xlgo.WithLogger(), + xlgo.WithHealthRoutes(), + // 如需 Swagger 文档:xlgo.WithSwaggerRoutes() 或 xlgo.WithDefaultRoutes() + // 如需 MySQL/Redis/Storage:xlgo.WithMySQL() / xlgo.WithRedis() / xlgo.WithStorage() + // 一键启用全部默认组件:使用 xlgo.NewFullStack(...) 替代 xlgo.New(...) + xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()), + xlgo.WithModules(router.ModuleFunc(registerRoutes)), + ) + + if err := app.Run(); err != nil { + fmt.Printf("启动失败: %v\n", err) + os.Exit(1) + } +} + +func registerRoutes(r *gin.RouterGroup) { + api := r.Group("/api/v1") + api.GET("/", func(c *gin.Context) { + response.Success(c, gin.H{"message": "Welcome to {{.Name}}!"}) + }) +} +`, + + // Config 配置文件模板 + Config: `app: + name: "{{.Name}}" + site_name: "{{.NameLower}}" # 站点别名,用于缓存键前缀、日志标识、多站点区分 + version: "1.0.0" + env: "dev" # dev/test/prod + debug: true + base_url: "http://localhost:8080" + +server: + host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机;内网IP=绑定指定网卡 + port: 8080 + mode: development # development 或 production + read_timeout: 15s # 读超时 + write_timeout: 30s # 写超时 + idle_timeout: 60s # 空闲超时 + shutdown_timeout: 30s # 优雅关闭超时 + response_mode: business # business(默认,全200+业务码) 或 rest(按错误码映射HTTP status) + +database: + driver: mysql # mysql(默认)或 postgres + host: localhost + port: 3306 + user: root + password: your_password + name: {{.NameLower}} + max_idle_conns: 10 + max_open_conns: 100 + # dsn: "自定义连接字符串,设置后优先于上面的字段" + +redis: + host: localhost + port: 6379 + password: "" + db: 0 + +jwt: + secret: your_jwt_secret_key_change_me_to_32_bytes_at_least + expire: "24h" # time.Duration,支持 "24h"/"30m" 等 + refresh_expire: "168h" # 刷新 token 过期时间 + issuer: xlgo + algorithm: HS256 # HS256(默认)/HS384/HS512 + +storage: + driver: local + local: + path: ./public + base_url: http://localhost:8080/public + +log: + dir: ./logs + max_size: 100 + max_backups: 30 + max_age: 30 + compress: true +`, + + // MainMinimal minimal 模板:轻量 HTTP 服务,不依赖 MySQL/Redis/Storage + MainMinimal: `package main + +import ( + "flag" + "fmt" + "os" + + xlgo "github.com/EthanCodeCraft/xlgo-core" + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/EthanCodeCraft/xlgo-core/router" + "github.com/gin-gonic/gin" +) + +var configPath string + +func init() { + flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径") +} + +func main() { + flag.Parse() + + app := xlgo.New( + xlgo.WithConfigPath(configPath), + xlgo.WithLogger(), + xlgo.WithHealthRoutes(), + xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()), + xlgo.WithModules(router.ModuleFunc(registerRoutes)), + ) + + if err := app.Run(); err != nil { + fmt.Printf("启动失败: %v\n", err) + os.Exit(1) + } +} + +func registerRoutes(r *gin.RouterGroup) { + api := r.Group("/api/v1") + api.GET("/", func(c *gin.Context) { + response.Success(c, gin.H{"message": "Hello {{.Name}}!"}) + }) +} +`, + + // ConfigMinimal minimal 模板配置:仅 app + server + log,无数据库/Redis + ConfigMinimal: `app: + name: "{{.Name}}" + site_name: "{{.NameLower}}" + version: "1.0.0" + env: "dev" + debug: true + base_url: "http://localhost:8080" + +server: + port: 8080 + mode: development + +log: + dir: ./logs + max_size: 100 + max_backups: 30 + max_age: 30 + compress: true +`, + + // MainFull fullstack 模板:全组件(FullStack),含 Swagger + Storage + MainFull: `package main + +import ( + "flag" + "fmt" + "os" + + xlgo "github.com/EthanCodeCraft/xlgo-core" + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/EthanCodeCraft/xlgo-core/router" + "github.com/gin-gonic/gin" +) + +var configPath string + +func init() { + flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径") +} + +func main() { + flag.Parse() + + // NewFullStack 一键启用全部组件:Logger/MySQL/Redis/Storage/Wire/Health/Swagger/AutoMigrate + // 如需排除个别组件,追加对应 Without* Option,例如 xlgo.WithoutSwaggerRoutes() + app := xlgo.NewFullStack( + xlgo.WithConfigPath(configPath), + xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()), + xlgo.WithModules(router.ModuleFunc(registerRoutes)), + // xlgo.WithModels(&User{}, &Order{}), // 注册模型以启用自动迁移 + ) + + if err := app.Run(); err != nil { + fmt.Printf("启动失败: %v\n", err) + os.Exit(1) + } +} + +func registerRoutes(r *gin.RouterGroup) { + api := r.Group("/api/v1") + api.GET("/", func(c *gin.Context) { + response.Success(c, gin.H{"message": "Welcome to {{.Name}} (fullstack)!"}) + }) +} +`, + + // ConfigFull fullstack 模板配置:全组件配置 + ConfigFull: `app: + name: "{{.Name}}" + site_name: "{{.NameLower}}" + version: "1.0.0" + env: "dev" + debug: true + base_url: "http://localhost:8080" + +server: + host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机 + port: 8080 + mode: development + read_timeout: 15s + write_timeout: 30s + idle_timeout: 60s + shutdown_timeout: 30s + response_mode: business + +database: + driver: mysql # mysql(默认)或 postgres + host: localhost + port: 3306 + user: root + password: your_password + name: {{.NameLower}} + max_idle_conns: 10 + max_open_conns: 100 + +redis: + host: localhost + port: 6379 + password: "" + db: 0 + +jwt: + secret: your_jwt_secret_key_change_me_to_32_bytes_at_least + expire: "24h" # time.Duration,支持 "24h"/"30m" 等 + refresh_expire: "168h" # 刷新 token 过期时间 + issuer: xlgo + algorithm: HS256 # HS256(默认)/HS384/HS512 + +storage: + driver: local + local: + path: ./public + base_url: http://localhost:8080/public + +log: + dir: ./logs + max_size: 100 + max_backups: 30 + max_age: 30 + compress: true +`, + + // GoMod go.mod 文件模板 + // %s: module 名称;%s: xlgo 框架版本(来自 xlgo.Version,避免字面量散落) + GoMod: `module %s + +go 1.25 + +require ( + github.com/EthanCodeCraft/xlgo-core v%s + github.com/gin-gonic/gin v1.9.1 +) +`, + + // Makefile 模板 + Makefile: `.PHONY: build run test clean tidy swagger + +build: + go build -o bin/server . + +run: + go run main.go + +test: + go test ./... + +clean: + rm -rf bin/ + rm -rf logs/ + +tidy: + go mod tidy + +swagger: + swag init -g main.go -o ./swagger +`, + + // Gitignore 模板 + Gitignore: `# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test files +*.test +*.out +coverage.txt + +# Go workspace file +go.work + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Environment +.env +.env.local + +# Logs +logs/ +*.log + +# Config (keep example) +config.local.yaml +`, + + // Handler 新项目默认处理器模板 + Handler: `package handler + +import ( + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" +) + +// HealthCheck 健康检查 +func HealthCheck(c *gin.Context) { + response.Success(c, gin.H{ + "status": "ok", + }) +} + +// Home 首页 +func Home(c *gin.Context) { + response.Success(c, gin.H{ + "message": "Welcome to {{.Name}}!", + }) +} +`, + + // HandlerMake make handler 命令模板 + HandlerMake: `package handler + +import ( + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" +) + +// %sHandler %s 处理器 +type %sHandler struct { + // 可以注入 service +} + +// New%sHandler 创建 %s 处理器 +func New%sHandler() *%sHandler { + return &%sHandler{} +} + +// List 获取列表 +// @Summary 获取%s列表 +// @Tags %s +// @Accept json +// @Produce json +// @Success 200 {object} response.Response +// @Router /api/v1/%ss [get] +func (h *%sHandler) List(c *gin.Context) { + response.Success(c, gin.H{ + "Items": []string{}, + }) +} + +// Get 获取详情 +// @Summary 获取%s详情 +// @Tags %s +// @Accept json +// @Produce json +// @Param id path int true "ID" +// @Success 200 {object} response.Response +// @Router /api/v1/%ss/{id} [get] +func (h *%sHandler) Get(c *gin.Context) { + response.Success(c, gin.H{ + "id": c.Param("id"), + }) +} + +// Create 创建 +// @Summary 创建%s +// @Tags %s +// @Accept json +// @Produce json +// @Success 200 {object} response.Response +// @Router /api/v1/%ss [post] +func (h *%sHandler) Create(c *gin.Context) { + response.Success(c, nil) +} + +// Update 更新 +// @Summary 更新%s +// @Tags %s +// @Accept json +// @Produce json +// @Param id path int true "ID" +// @Success 200 {object} response.Response +// @Router /api/v1/%ss/{id} [put] +func (h *%sHandler) Update(c *gin.Context) { + response.Success(c, nil) +} + +// Delete 删除 +// @Summary 删除%s +// @Tags %s +// @Accept json +// @Produce json +// @Param id path int true "ID" +// @Success 200 {object} response.Response +// @Router /api/v1/%ss/{id} [delete] +func (h *%sHandler) Delete(c *gin.Context) { + response.Success(c, nil) +} +`, + + // RepositoryMake make repository 命令模板 + RepositoryMake: `package repository + +import ( + "context" + + "github.com/EthanCodeCraft/xlgo-core/database" + xlrepo "github.com/EthanCodeCraft/xlgo-core/repository" + "xlgo/model" +) + +// %sRepository %s 仓库 +type %sRepository struct { + *xlrepo.BaseRepo[model.%s] +} + +// New%sRepository 创建 %s 仓库 +func New%sRepository() *%sRepository { + return &%sRepository{ + BaseRepo: xlrepo.NewBaseRepo[model.%s](database.GetDB()), + } +} + +// FindByName 根据名称查询。 +// +// 走 BaseRepo.FindOne → readConn 读连接:默认路由到从库(读写分离,H6c), +// 需读主库用 database.UseMaster(ctx),事务内自动 join 外层事务。 +// 不要用 r.GetDB() 自行查询——它返回注入的主库且不参与路由(M-35 footgun)。 +func (r *%sRepository) FindByName(ctx context.Context, name string) (*model.%s, error) { + return r.FindOne(ctx, "name = ?", name) +} +`, + + // ModelMake make model 命令模板 + ModelMake: `package model + +import xlmodel "github.com/EthanCodeCraft/xlgo-core/model" + +// %s %s 模型 +type %s struct { + xlmodel.BaseModel + Name string ` + "`" + `gorm:"size:100;not null" json:"name"` + "`" + ` + Description string ` + "`" + `gorm:"size:500" json:"description"` + "`" + ` + Status int ` + "`" + `gorm:"default:1" json:"status"` + "`" + ` // 1: 启用, 0: 禁用 +} + +// TableName 表名 +func (%s) TableName() string { + return "%ss" +} +`, + + // ServiceMake make service 命令模板 + ServiceMake: `package service + +import ( + "context" + + "xlgo/model" + "xlgo/repository" +) + +// %sService %s 服务 +type %sService struct { + repo *repository.%sRepository +} + +// New%sService 创建 %s 服务 +func New%sService() *%sService { + return &%sService{ + repo: repository.New%sRepository(), + } +} + +// List 获取列表 +func (s *%sService) List(ctx context.Context, page, pageSize int) ([]model.%s, int64, error) { + // TODO: 实现列表查询 + return nil, 0, nil +} + +// GetByID 根据 ID 获取 +func (s *%sService) GetByID(ctx context.Context, id uint) (*model.%s, error) { + return s.repo.FindByID(ctx, id) +} + +// Create 创建 +func (s *%sService) Create(ctx context.Context, m *model.%s) error { + return s.repo.Create(ctx, m) +} + +// Update 更新 +func (s *%sService) Update(ctx context.Context, m *model.%s) error { + return s.repo.Update(ctx, m) +} + +// Delete 删除 +func (s *%sService) Delete(ctx context.Context, id uint) error { + return s.repo.Delete(ctx, id) +} +`, +} diff --git a/cmd/xlgo/types.go b/cmd/xlgo/types.go new file mode 100644 index 0000000..df6bab2 --- /dev/null +++ b/cmd/xlgo/types.go @@ -0,0 +1,10 @@ +package main + +// TemplateData 模板数据 +type TemplateData struct { + Package string + Name string + NameLower string + Module string + Year int +} diff --git a/cmd/xlgo/utils.go b/cmd/xlgo/utils.go new file mode 100644 index 0000000..9d2d1c7 --- /dev/null +++ b/cmd/xlgo/utils.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +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) + } + return nil +} + +func fileExists(path string) bool { + // #nosec G703 -- path is built by makeFile from fixed scaffold directories and a name that rejects separators/"..". + _, err := os.Stat(path) + if err == nil { + return true + } + // 仅当"不存在"才返回 false;权限错误等其它错误视为"可能存在/不可覆写", + // 避免把无权限访问的路径误判为可创建(M20:原 !os.IsNotExist 把权限错误当存在, + // 反而安全;但语义模糊,显式区分更清晰)。 + return !os.IsNotExist(err) +} + +func currentModule() string { + data, err := os.ReadFile("go.mod") + if err != nil { + return "xlgo" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")) + } + } + return "xlgo" +} + +func replaceModuleImports(content string) string { + module := currentModule() + content = strings.ReplaceAll(content, "\"xlgo/model\"", fmt.Sprintf("\"%s/model\"", module)) + content = strings.ReplaceAll(content, "\"xlgo/repository\"", fmt.Sprintf("\"%s/repository\"", module)) + content = strings.ReplaceAll(content, "\"xlgo/database\"", fmt.Sprintf("\"%s/database\"", module)) + content = strings.ReplaceAll(content, "\"xlgo/response\"", "\"github.com/EthanCodeCraft/xlgo-core/response\"") + return content +} diff --git a/compress/compress.go b/compress/compress.go new file mode 100644 index 0000000..1a52644 --- /dev/null +++ b/compress/compress.go @@ -0,0 +1,403 @@ +package compress + +import ( + "archive/zip" + "bytes" + "compress/gzip" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// 解压安全相关错误。 +var ( + // ErrPathTraversal zip 条目名逃逸目标目录(C5a Zip-Slip)。 + ErrPathTraversal = errors.New("zip entry escapes destination directory") + // ErrSymlinkEntry zip 条目为符号链接,已拒绝(防经软链二次穿越)。 + ErrSymlinkEntry = errors.New("zip entry is a symlink, rejected") + // ErrDecompressLimit 解压大小超过上限(C5b 解压炸弹)。 + ErrDecompressLimit = errors.New("decompress size limit exceeded") +) + +const ( + // defaultDecompressLimit 单流/单条目默认解压上限(100MB),防 OOM / 磁盘耗尽(C5b)。 + defaultDecompressLimit int64 = 100 * 1024 * 1024 + // defaultDecompressTotalLimit Unzip 默认累计解压上限(1GB)。 + defaultDecompressTotalLimit int64 = 1 * 1024 * 1024 * 1024 +) + +// DecompressOptions 解压安全选项。Zip-Slip 防护(前缀锚定 + 拒绝符号链接)始终启用,无需配置; +// 本选项仅控制解压大小上限以防解压炸弹(C5b)。 +type DecompressOptions struct { + // MaxBytes 单流 / 单条目解压大小上限(字节)。0 = 默认 100MB,-1 = 不限制。 + MaxBytes int64 + // MaxTotalBytes Unzip 累计解压大小上限(字节)。0 = 默认 1GB,-1 = 不限制。 + // 仅 Unzip 生效。 + MaxTotalBytes int64 +} + +// resolveLimit 解析大小上限:n<0 不限,n==0 用 def,n>0 用 n。 +func resolveLimit(n, def int64) int64 { + if n < 0 { + return -1 + } + if n == 0 { + return def + } + return n +} + +// minLimit 返回两个上限中较小者;-1 视为无限。 +func minLimit(a, b int64) int64 { + if a < 0 { + return b + } + if b < 0 { + return a + } + if a < b { + return a + } + return b +} + +// GzipCompress 压缩数据 +func GzipCompress(data []byte) ([]byte, error) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(data); err != nil { + _ = gz.Close() + return nil, err + } + if err := gz.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// GzipDecompress 解压缩数据。默认上限 100MB 防解压炸弹 OOM(C5b); +// 需解压更大文件请用 GzipDecompressWithOptions。 +func GzipDecompress(data []byte) ([]byte, error) { + return GzipDecompressWithOptions(data, DecompressOptions{}) +} + +// GzipDecompressWithOptions 解压缩数据,可配置大小上限(C5b)。 +func GzipDecompressWithOptions(data []byte, opts DecompressOptions) ([]byte, error) { + buf := bytes.NewReader(data) + gz, err := gzip.NewReader(buf) + if err != nil { + return nil, err + } + defer gz.Close() + + limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit) + var reader io.Reader = gz + if limit > 0 { + // 多读 1 字节用于判断是否超限。 + reader = io.LimitReader(gz, limit+1) + } + out, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + if limit > 0 && int64(len(out)) > limit { + return nil, fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit) + } + return out, nil +} + +// GzipCompressFile 压缩文件 +func GzipCompressFile(src, dst string) error { + // #nosec G304 -- src/dst 为调用方提供的本地文件路径,压缩 API 固有语义,非不可信输入 + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + // #nosec G304 -- 同上,dst 为调用方指定输出路径 + dstFile, err := os.Create(dst) + if err != nil { + return err + } + + gz := gzip.NewWriter(dstFile) + + // 保留原文件名和时间戳 + if info, err := srcFile.Stat(); err == nil { + gz.Name = info.Name() + gz.ModTime = info.ModTime() + } + + _, copyErr := io.Copy(gz, srcFile) + // gz.Close 刷出 gzip 尾部(含 CRC/大小校验),失败说明归档损坏,必须向上传播(M16/B18)。 + closeErr := gz.Close() + // dstFile.Close 失败(如延迟写盘)同样意味着归档可能不完整。 + dstErr := dstFile.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + return dstErr +} + +// GzipDecompressFile 解压文件。默认上限 100MB 防磁盘耗尽(C5b); +// 需解压更大文件请用 GzipDecompressFileWithOptions。 +func GzipDecompressFile(src, dst string) error { + return GzipDecompressFileWithOptions(src, dst, DecompressOptions{}) +} + +// GzipDecompressFileWithOptions 解压文件,可配置大小上限(C5b)。 +func GzipDecompressFileWithOptions(src, dst string, opts DecompressOptions) (err error) { + // #nosec G304 -- src 为调用方提供的本地文件路径,解压 API 固有语义,非不可信输入 + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + gz, err := gzip.NewReader(srcFile) + if err != nil { + return err + } + defer gz.Close() + + // #nosec G304 -- dst 为调用方指定输出路径,解压 API 固有语义 + dstFile, err := os.Create(dst) + if err != nil { + return err + } + // L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 dst,避免被拒炸弹遗留残片; + // 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。 + defer func() { + if cerr := dstFile.Close(); cerr != nil { + err = errors.Join(err, cerr) + } + if err != nil { + _ = os.Remove(dst) + } + }() + + limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit) + var written int64 + if limit > 0 { + // CopyN 最多读 limit+1 字节,超限即判定为炸弹。 + written, err = io.CopyN(dstFile, gz, limit+1) + } else { + // #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹 + written, err = io.Copy(dstFile, gz) + } + if err != nil && err != io.EOF { + return err + } + if limit > 0 && written > limit { + return fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit) + } + return nil +} + +// Zip 压缩文件或目录 +// 参数: zipPath 目标zip文件路径,paths 要压缩的文件或目录列表 +func Zip(zipPath string, paths []string) error { + // 创建目标目录 + if err := os.MkdirAll(filepath.Dir(zipPath), 0750); err != nil { + return err + } + + // 创建 zip 文件 + // #nosec G304 -- zipPath 为调用方指定输出路径,压缩 API 固有语义 + archive, err := os.Create(zipPath) + if err != nil { + return err + } + + zipWriter := zip.NewWriter(archive) + + walkErr := func() error { + for _, srcPath := range paths { + srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator)) + + // #nosec G122 -- 压缩调用方提供的源路径,非解压不可信输入;symlink 跟随是压缩场景的可接受行为 + err := filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + // 创建文件头 + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Method = zip.Deflate + + // 设置相对路径 + header.Name, err = filepath.Rel(filepath.Dir(srcPath), path) + if err != nil { + return err + } + if info.IsDir() { + header.Name += string(os.PathSeparator) + } + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + // #nosec G304,G122 -- path 为 Walk 遍历调用方源路径产生,非不可信输入;压缩场景接受 symlink 跟随语义。 + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + return err + }) + if err != nil { + return err + } + } + return nil + }() + + // zipWriter.Close 刷出中央目录记录,失败说明归档损坏,必须向上传播(M16/B18)。 + zipCloseErr := zipWriter.Close() + archiveCloseErr := archive.Close() + if walkErr != nil { + return walkErr + } + if zipCloseErr != nil { + return zipCloseErr + } + return archiveCloseErr +} + +// Unzip 解压 zip 文件。默认启用 Zip-Slip 防护(前缀锚定 + 拒绝符号链接), +// 单条目上限 100MB、累计上限 1GB 防解压炸弹(C5b);需自定义上限请用 UnzipWithOptions。 +func Unzip(zipPath, dstDir string) error { + return UnzipWithOptions(zipPath, dstDir, DecompressOptions{}) +} + +// UnzipWithOptions 解压 zip 文件,可配置大小上限(C5b)。Zip-Slip 防护始终启用。 +func UnzipWithOptions(zipPath, dstDir string, opts DecompressOptions) error { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer reader.Close() + + // 用绝对路径作目标锚定根,避免相对路径 + `..` 组合绕过前缀校验(C5a)。 + absDst, err := filepath.Abs(dstDir) + if err != nil { + return fmt.Errorf("resolve unzip destination failed: %w", err) + } + absDst = filepath.Clean(absDst) + + entryLimit := resolveLimit(opts.MaxBytes, defaultDecompressLimit) + totalLimit := resolveLimit(opts.MaxTotalBytes, defaultDecompressTotalLimit) + var total int64 + + for _, file := range reader.File { + written, err := unzipFile(file, absDst, entryLimit, totalLimit, total) + if err != nil { + return err + } + total += written + } + return nil +} + +// unzipFile 解压单个 zip 条目到 absDst 下。 +// entryLimit: 单条目上限(-1 不限);totalLimit: 累计上限(-1 不限);accrued: 已解压累计字节。 +// 返回本条目写入字节数。 +func unzipFile(file *zip.File, absDst string, entryLimit, totalLimit, accrued int64) (written int64, err error) { + // 拒绝符号链接条目,防经软链二次穿越(C5a)。 + if file.Mode()&os.ModeSymlink != 0 { + return 0, fmt.Errorf("条目 %s 为符号链接: %w", file.Name, ErrSymlinkEntry) + } + + // zip 条目名规范用正斜杠;转成当前平台分隔符后再 Join,并以前缀锚定拒绝 `..` 逃逸(C5a)。 + name := filepath.FromSlash(file.Name) + // 拒绝绝对路径与以分隔符开头的条目(非标准、可疑,避免平台语义差异)。 + // filepath.IsAbs 在 Windows 不认 "/x"(无盘符)为绝对路径,故补充分隔符前缀检查。 + if filepath.IsAbs(name) || strings.HasPrefix(name, string(os.PathSeparator)) || strings.HasPrefix(file.Name, "/") { + return 0, fmt.Errorf("条目 %s 为绝对路径: %w", file.Name, ErrPathTraversal) + } + target := filepath.Join(absDst, name) + if target == absDst || !strings.HasPrefix(target, absDst+string(os.PathSeparator)) { + return 0, fmt.Errorf("条目 %s 逃逸目标目录: %w", file.Name, ErrPathTraversal) + } + + if file.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0750); err != nil { + return 0, err + } + return 0, nil + } + + // 创建父目录 + if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil { + return 0, err + } + + // 打开 zip 中的文件 + rc, err := file.Open() + if err != nil { + return 0, err + } + defer rc.Close() + + // 创建目标文件 + // #nosec G304 -- target 经前缀锚定校验(absDst+sep),已防 Zip-Slip 逃逸 + dstFile, err := os.Create(target) + if err != nil { + return 0, err + } + // L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 target,避免被拒炸弹遗留残片; + // 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。 + defer func() { + if cerr := dstFile.Close(); cerr != nil { + err = errors.Join(err, cerr) + } + if err != nil { + _ = os.Remove(target) + } + }() + + // 计算本次拷贝上限:单条目上限与累计剩余上限中较小者(-1 视为无限)。 + // remaining: 累计剩余(-1 表示累计不限);totalLimit>0 时若已无剩余,直接判超限。 + remaining := int64(-1) + if totalLimit > 0 { + remaining = totalLimit - accrued + if remaining <= 0 { + return 0, fmt.Errorf("累计解压超过上限 %d 字节: %w", totalLimit, ErrDecompressLimit) + } + } + cap := minLimit(entryLimit, remaining) + // cap 为 -1(两者皆不限)或 >0(有限上限,剩余已保证 >0),不会是 0。 + + if cap > 0 { + written, err = io.CopyN(dstFile, rc, cap+1) + } else { + // #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹 + written, err = io.Copy(dstFile, rc) + } + if err != nil && err != io.EOF { + return written, err + } + if cap > 0 && written > cap { + return written, fmt.Errorf("条目 %s 超过解压上限 %d 字节: %w", file.Name, cap, ErrDecompressLimit) + } + return written, nil +} diff --git a/compress/compress_security_test.go b/compress/compress_security_test.go new file mode 100644 index 0000000..fdfbe6c --- /dev/null +++ b/compress/compress_security_test.go @@ -0,0 +1,347 @@ +package compress_test + +import ( + "archive/zip" + "bytes" + "compress/gzip" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/compress" +) + +// makeZipAt 构造一个 zip 文件,entries 为 name -> content;dir 条目用空 content 且 IsDir=true。 +func makeZipAt(t *testing.T, zipPath string, entries []struct { + name string + content string + isDir bool +}) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(zipPath), 0750); err != nil { + t.Fatalf("mkdir: %v", err) + } + f, err := os.Create(zipPath) + if err != nil { + t.Fatalf("create zip: %v", err) + } + defer f.Close() + zw := zip.NewWriter(f) + for _, e := range entries { + hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate} + if e.isDir { + hdr.SetMode(os.ModeDir | 0750) + } + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatalf("CreateHeader %s: %v", e.name, err) + } + if !e.isDir { + if _, err := w.Write([]byte(e.content)); err != nil { + t.Fatalf("write %s: %v", e.name, err) + } + } + } + if err := zw.Close(); err != nil { + t.Fatalf("close zip writer: %v", err) + } +} + +// ===== C5a:Zip-Slip ===== + +// 回归 C5a:zip 条目名含 `..` 逃逸目标目录必须被拒绝,且不在 dst 外创建/覆盖文件。 +func TestUnzipZipSlipRejected(t *testing.T) { + dir := t.TempDir() + zipPath := filepath.Join(dir, "evil.zip") + // 在 dst 的父目录放一个蜜罐文件,确保穿越不会覆盖它。 + canary := filepath.Join(dir, "canary.txt") + if err := os.WriteFile(canary, []byte("original"), 0644); err != nil { + t.Fatalf("write canary: %v", err) + } + + dst := filepath.Join(dir, "out") + makeZipAt(t, zipPath, []struct { + name string + content string + isDir bool + }{ + {name: "../canary.txt", content: "pwned"}, + }) + + err := compress.Unzip(zipPath, dst) + if !errors.Is(err, compress.ErrPathTraversal) { + t.Errorf("Unzip with ../ entry err = %v, want ErrPathTraversal", err) + } + // 蜜罐文件必须未被覆盖。 + data, err := os.ReadFile(canary) + if err != nil { + t.Fatalf("read canary: %v", err) + } + if string(data) != "original" { + t.Errorf("canary overwritten by Zip-Slip: got %q", string(data)) + } +} + +// 回归 C5a:绝对路径条目必须被拒绝。 +func TestUnzipAbsolutePathRejected(t *testing.T) { + dir := t.TempDir() + zipPath := filepath.Join(dir, "abs.zip") + dst := filepath.Join(dir, "out") + makeZipAt(t, zipPath, []struct { + name string + content string + isDir bool + }{ + {name: "/etc/evil.txt", content: "x"}, + }) + if err := compress.Unzip(zipPath, dst); !errors.Is(err, compress.ErrPathTraversal) { + t.Errorf("Unzip absolute path err = %v, want ErrPathTraversal", err) + } +} + +// 回归 C5a:合法相对路径条目不误伤(含子目录)。 +func TestUnzipNormalEntriesWork(t *testing.T) { + dir := t.TempDir() + zipPath := filepath.Join(dir, "ok.zip") + dst := filepath.Join(dir, "out") + makeZipAt(t, zipPath, []struct { + name string + content string + isDir bool + }{ + {name: "sub/", isDir: true}, + {name: "sub/a.txt", content: "hello"}, + {name: "top.txt", content: "world"}, + }) + if err := compress.Unzip(zipPath, dst); err != nil { + t.Fatalf("Unzip normal: %v", err) + } + if b, err := os.ReadFile(filepath.Join(dst, "sub", "a.txt")); err != nil || string(b) != "hello" { + t.Errorf("sub/a.txt = %q, err=%v, want 'hello'", string(b), err) + } + if b, err := os.ReadFile(filepath.Join(dst, "top.txt")); err != nil || string(b) != "world" { + t.Errorf("top.txt = %q, err=%v, want 'world'", string(b), err) + } +} + +// 回归 C5a:符号链接条目必须被拒绝(防经软链二次穿越)。 +func TestUnzipSymlinkRejected(t *testing.T) { + dir := t.TempDir() + zipPath := filepath.Join(dir, "symlink.zip") + dst := filepath.Join(dir, "out") + makeZipAt(t, zipPath, []struct { + name string + content string + isDir bool + }{ + {name: "lnk", content: "/etc/passwd"}, // content 为链接目标,mode 设为 symlink + }) + // 把条目 mode 改成符号链接:重建 zip 时设置 ModeSymlink。 + // makeZipAt 不支持 symlink mode,这里直接用底层 API 重建。 + zipPath2 := filepath.Join(dir, "symlink2.zip") + f, err := os.Create(zipPath2) + if err != nil { + t.Fatalf("create: %v", err) + } + defer f.Close() + zw := zip.NewWriter(f) + hdr := &zip.FileHeader{Name: "lnk", Method: zip.Deflate} + hdr.SetMode(os.ModeSymlink | 0777) + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatalf("CreateHeader: %v", err) + } + if _, err := w.Write([]byte("/etc/passwd")); err != nil { + t.Fatalf("write: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + if err := compress.Unzip(zipPath2, dst); !errors.Is(err, compress.ErrSymlinkEntry) { + t.Errorf("Unzip symlink err = %v, want ErrSymlinkEntry", err) + } +} + +// ===== C5b:解压炸弹 ===== + +// 回归 C5b:GzipDecompress 超限返回错误而非 OOM。 +// 用显式小上限验证限流代码路径(与默认 100MB 走同一 io.LimitReader + 超限判定逻辑)。 +func TestGzipDecompressBombLimit(t *testing.T) { + // 2MB 解压后数据。 + big := bytes.Repeat([]byte("A"), 2*1024*1024) + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(big); err != nil { + t.Fatalf("gzip write: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + compressed := buf.Bytes() + + // 显式上限 1MB → 必须拒绝(2MB 超限)。 + if _, err := compress.GzipDecompressWithOptions(compressed, compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}); !errors.Is(err, compress.ErrDecompressLimit) { + t.Errorf("GzipDecompress over-limit err = %v, want ErrDecompressLimit", err) + } + + // 显式 -1 不限 → 成功解压完整 2MB。 + out, err := compress.GzipDecompressWithOptions(compressed, compress.DecompressOptions{MaxBytes: -1}) + if err != nil { + t.Errorf("GzipDecompress unlimited err = %v", err) + } + if len(out) != len(big) { + t.Errorf("unlimited decompressed len = %d, want %d", len(out), len(big)) + } + + // 默认上限(100MB)放行 2MB 正常数据。 + if _, err := compress.GzipDecompress(compressed); err != nil { + t.Errorf("GzipDecompress default err = %v (2MB should pass default 100MB limit)", err) + } +} + +// 回归 C5b:Unzip 单条目上限——超大条目被拒。 +func TestUnzipEntryBombLimit(t *testing.T) { + dir := t.TempDir() + zipPath := filepath.Join(dir, "bomb.zip") + dst := filepath.Join(dir, "out") + + // 构造一个含 2MB(解压后)条目的 zip。 + f, err := os.Create(zipPath) + if err != nil { + t.Fatalf("create: %v", err) + } + defer f.Close() + zw := zip.NewWriter(f) + w, err := zw.Create("big.txt") + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := w.Write(bytes.Repeat([]byte("A"), 2 * 1024 * 1024)); err != nil { + t.Fatalf("write: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + // 显式单条目上限 1MB → 必须拒绝。 + opts := compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024} + if err := compress.UnzipWithOptions(zipPath, dst, opts); !errors.Is(err, compress.ErrDecompressLimit) { + t.Errorf("Unzip entry bomb err = %v, want ErrDecompressLimit", err) + } +} + +// 回归 C5b:Unzip 累计上限——多个条目累计超限被拒。 +func TestUnzipTotalBombLimit(t *testing.T) { + dir := t.TempDir() + zipPath := filepath.Join(dir, "many.zip") + dst := filepath.Join(dir, "out") + + // 5 个 1MB 条目 = 累计 5MB;单条目上限 2MB(不超),累计上限 3MB → 第 4 个累计 4MB 超限。 + f, err := os.Create(zipPath) + if err != nil { + t.Fatalf("create: %v", err) + } + defer f.Close() + zw := zip.NewWriter(f) + chunk := bytes.Repeat([]byte("B"), 1*1024*1024) + for i := 0; i < 5; i++ { + w, err := zw.Create("file" + string(rune('0'+i)) + ".dat") + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := w.Write(chunk); err != nil { + t.Fatalf("write: %v", err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + opts := compress.DecompressOptions{MaxBytes: 2 * 1024 * 1024, MaxTotalBytes: 3 * 1024 * 1024} + if err := compress.UnzipWithOptions(zipPath, dst, opts); !errors.Is(err, compress.ErrDecompressLimit) { + t.Errorf("Unzip total bomb err = %v, want ErrDecompressLimit", err) + } +} + +// 回归 C5b:GzipDecompressFile 单流封顶——超限返回错误而非磁盘耗尽。 +func TestGzipDecompressFileBombLimit(t *testing.T) { + dir := t.TempDir() + // 2MB 解压后数据写入 gzip 文件。 + big := bytes.Repeat([]byte("A"), 2*1024*1024) + srcGz := filepath.Join(dir, "src.gz") + { + f, err := os.Create(srcGz) + if err != nil { + t.Fatalf("create: %v", err) + } + gz := gzip.NewWriter(f) + if _, err := gz.Write(big); err != nil { + t.Fatalf("write: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gz: %v", err) + } + f.Close() + } + + // 显式上限 1MB → 必须拒绝(2MB 超限)。 + dst1 := filepath.Join(dir, "out1.txt") + if err := compress.GzipDecompressFileWithOptions(srcGz, dst1, compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}); !errors.Is(err, compress.ErrDecompressLimit) { + t.Errorf("GzipDecompressFile over-limit err = %v, want ErrDecompressLimit", err) + } + + // 显式 -1 不限 → 成功解压完整 2MB。 + dst2 := filepath.Join(dir, "out2.txt") + if err := compress.GzipDecompressFileWithOptions(srcGz, dst2, compress.DecompressOptions{MaxBytes: -1}); err != nil { + t.Errorf("GzipDecompressFile unlimited err = %v", err) + } + b, err := os.ReadFile(dst2) + if err != nil { + t.Fatalf("read out: %v", err) + } + if len(b) != len(big) { + t.Errorf("unlimited out len = %d, want %d", len(b), len(big)) + } + + // 默认上限(100MB)放行 2MB。 + dst3 := filepath.Join(dir, "out3.txt") + if err := compress.GzipDecompressFile(srcGz, dst3); err != nil { + t.Errorf("GzipDecompressFile default err = %v (2MB should pass default 100MB limit)", err) + } +} + +// 兼容性回归:原有 Zip/Unzip 闭环(合法归档)在默认防护下仍正常。 +func TestZipUnzipRoundTripStillWorks(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.txt") + content := "round trip content" + if err := os.WriteFile(src, []byte(content), 0644); err != nil { + t.Fatalf("write: %v", err) + } + zipPath := filepath.Join(dir, "rt.zip") + if err := compress.Zip(zipPath, []string{src}); err != nil { + t.Fatalf("Zip: %v", err) + } + dst := filepath.Join(dir, "out") + if err := compress.Unzip(zipPath, dst); err != nil { + t.Fatalf("Unzip: %v", err) + } + // Zip 用 filepath.Rel(src 的父目录, src) = "src.txt"。 + b, err := os.ReadFile(filepath.Join(dst, "src.txt")) + if err != nil { + // 目录结构可能因平台分隔符略有差异,尝试找 src.txt。 + _ = filepath.Walk(dst, func(p string, _ os.FileInfo, _ error) error { + if strings.HasSuffix(p, "src.txt") { + b, err = os.ReadFile(p) + return filepath.SkipDir + } + return nil + }) + } + if string(b) != content { + t.Errorf("round trip = %q, want %q", string(b), content) + } +} diff --git a/compress/compress_test.go b/compress/compress_test.go new file mode 100644 index 0000000..61c8c94 --- /dev/null +++ b/compress/compress_test.go @@ -0,0 +1,244 @@ +package compress_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/compress" + "github.com/EthanCodeCraft/xlgo-core/utils" +) + +func fileExists(path string) bool { + return utils.FileExists(path) +} + +func getTempDir() string { + return filepath.Join(os.TempDir(), "xlgo_compress_test") +} + +func setupTestFiles(t *testing.T) (string, string, string) { + dir := getTempDir() + os.MkdirAll(dir, 0755) + + // 创建测试文件 + srcFile := filepath.Join(dir, "test.txt") + content := "Hello, this is a test file for compression!" + os.WriteFile(srcFile, []byte(content), 0644) + + // 创建子目录和文件 + subDir := filepath.Join(dir, "subdir") + os.MkdirAll(subDir, 0755) + subFile := filepath.Join(subDir, "sub.txt") + os.WriteFile(subFile, []byte("Subdir content"), 0644) + + return dir, srcFile, content +} + +func cleanupTestDir(t *testing.T) { + os.RemoveAll(getTempDir()) +} + +func TestGzipCompress(t *testing.T) { + defer cleanupTestDir(t) + + data := []byte("Hello World, this is test data for gzip compression!") + compressed, err := compress.GzipCompress(data) + if err != nil { + t.Fatalf("GzipCompress error: %v", err) + } + + if len(compressed) == 0 { + t.Error("Compressed data is empty") + } + + // 压缩后的数据应该比原数据小(对于重复数据) + // 但对于很短的数据可能更大,所以不做严格长度比较 + + // 解压缩验证 + decompressed, err := compress.GzipDecompress(compressed) + if err != nil { + t.Fatalf("GzipDecompress error: %v", err) + } + + if string(decompressed) != string(data) { + t.Errorf("Decompressed data mismatch: got %s, want %s", decompressed, data) + } +} + +func TestGzipDecompressInvalid(t *testing.T) { + // 测试无效数据解压 + _, err := compress.GzipDecompress([]byte("invalid gzip data")) + if err == nil { + t.Error("GzipDecompress should fail with invalid data") + } +} + +func TestGzipCompressEmpty(t *testing.T) { + data := []byte("") + compressed, err := compress.GzipCompress(data) + if err != nil { + t.Fatalf("GzipCompress empty error: %v", err) + } + + decompressed, err := compress.GzipDecompress(compressed) + if err != nil { + t.Fatalf("GzipDecompress empty error: %v", err) + } + + if len(decompressed) != 0 { + t.Error("Decompressed empty data should be empty") + } +} + +func TestGzipCompressFile(t *testing.T) { + defer cleanupTestDir(t) + dir, srcFile, content := setupTestFiles(t) + + dstFile := filepath.Join(dir, "test.txt.gz") + + // 压缩文件 + err := compress.GzipCompressFile(srcFile, dstFile) + if err != nil { + t.Fatalf("GzipCompressFile error: %v", err) + } + + // 验证压缩文件存在 + if !fileExists(dstFile) { + t.Error("Compressed file not created") + } + + // 解压文件 + outFile := filepath.Join(dir, "test_out.txt") + err = compress.GzipDecompressFile(dstFile, outFile) + if err != nil { + t.Fatalf("GzipDecompressFile error: %v", err) + } + + // 验证解压内容 + outContent, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + + if string(outContent) != content { + t.Errorf("Decompressed content mismatch: got %s, want %s", outContent, content) + } +} + +func TestGzipCompressFileNonexistent(t *testing.T) { + err := compress.GzipCompressFile("/nonexistent/file.txt", "/tmp/out.gz") + if err == nil { + t.Error("GzipCompressFile should fail with nonexistent source") + } +} + +func TestZip(t *testing.T) { + defer cleanupTestDir(t) + dir, srcFile, _ := setupTestFiles(t) + + zipPath := filepath.Join(dir, "test.zip") + paths := []string{srcFile, filepath.Join(dir, "subdir")} + + // 创建 zip + err := compress.Zip(zipPath, paths) + if err != nil { + t.Fatalf("Zip error: %v", err) + } + + // 验证 zip 文件存在 + if !fileExists(zipPath) { + t.Error("Zip file not created") + } + + // 解压 zip + dstDir := filepath.Join(dir, "unzipped") + err = compress.Unzip(zipPath, dstDir) + if err != nil { + t.Fatalf("Unzip error: %v", err) + } + + // 验证解压后的文件 + outFile := filepath.Join(dstDir, "test.txt") + if !fileExists(outFile) { + t.Error("Unzipped file not found") + } +} + +func TestZipSingleFile(t *testing.T) { + defer cleanupTestDir(t) + dir, srcFile, content := setupTestFiles(t) + + zipPath := filepath.Join(dir, "single.zip") + + err := compress.Zip(zipPath, []string{srcFile}) + if err != nil { + t.Fatalf("Zip single file error: %v", err) + } + + dstDir := filepath.Join(dir, "single_unzipped") + err = compress.Unzip(zipPath, dstDir) + if err != nil { + t.Fatalf("Unzip error: %v", err) + } + + // 验证内容 + outContent, err := os.ReadFile(filepath.Join(dstDir, "test.txt")) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + + if string(outContent) != content { + t.Error("Unzipped content mismatch") + } +} + +func TestUnzipInvalid(t *testing.T) { + defer cleanupTestDir(t) + dir := getTempDir() + os.MkdirAll(dir, 0755) + + // 无效 zip 文件 + invalidZip := filepath.Join(dir, "invalid.zip") + os.WriteFile(invalidZip, []byte("not a zip file"), 0644) + + dstDir := filepath.Join(dir, "out") + err := compress.Unzip(invalidZip, dstDir) + if err == nil { + t.Error("Unzip should fail with invalid zip file") + } +} + +func TestUnzipNonexistent(t *testing.T) { + err := compress.Unzip("/nonexistent/file.zip", "/tmp/out") + if err == nil { + t.Error("Unzip should fail with nonexistent file") + } +} + +// ===== Benchmarks ===== + +func BenchmarkGzipCompress(b *testing.B) { + data := make([]byte, 1024) + for i := range data { + data[i] = byte(i % 256) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + compress.GzipCompress(data) + } +} + +func BenchmarkGzipDecompress(b *testing.B) { + data := make([]byte, 1024) + for i := range data { + data[i] = byte(i % 256) + } + compressed, _ := compress.GzipCompress(data) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + compress.GzipDecompress(compressed) + } +} \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..81299a7 --- /dev/null +++ b/config/config.go @@ -0,0 +1,1161 @@ +package config + +import ( + "context" + "errors" + "fmt" + "log" + "net/url" + "path/filepath" + "runtime/debug" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/mitchellh/mapstructure" + "github.com/spf13/viper" +) + +// 配置错误 +var ( + // L-config-4:无格式化的哨兵错误用 errors.New(fmt.Errorf 无动词等价但语义上后者暗示格式化)。 + ErrConfigNotLoaded = errors.New("配置未加载") + ErrInvalidConfig = errors.New("配置非法") +) + +// Config 全局配置结构体 +type Config struct { + App AppConfig `mapstructure:"app"` + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Redis RedisConfig `mapstructure:"redis"` + JWT JWTConfig `mapstructure:"jwt"` + SMS SMSConfig `mapstructure:"sms"` + Storage StorageConfig `mapstructure:"storage"` + Upload UploadConfig `mapstructure:"upload"` + Log LogConfig `mapstructure:"log"` + CORS CORSConfig `mapstructure:"cors"` + Trace TraceConfig `mapstructure:"trace"` +} + +// Clone 返回 Config 的深拷贝(M-G 修复)。 +// +// 标量字段与子结构体经结构体值拷贝独立;所有切片字段(CORS 的 4 个列表、Upload 的 2 个 +// 类型白名单、Storage.Local/OSS 上传策略的扩展名/MIME 白名单)深拷贝底层数组,使返回值 +// 可被调用方安全修改(含 append/sort/改元素)而不污染框架内部配置、不与其他读者竞态。 +// +// 用于需要可变配置副本的场景。Load/Get/回调均返回 Clone,避免调用方误改全局配置。 +func (c *Config) Clone() *Config { + if c == nil { + return nil + } + cp := *c // 浅拷贝:标量与子结构体独立,切片字段仍共享底层数组(下方逐个深拷贝) + // CORS + cp.CORS.AllowedOrigins = cloneStrings(c.CORS.AllowedOrigins) + cp.CORS.AllowedMethods = cloneStrings(c.CORS.AllowedMethods) + cp.CORS.AllowedHeaders = cloneStrings(c.CORS.AllowedHeaders) + cp.CORS.ExposedHeaders = cloneStrings(c.CORS.ExposedHeaders) + // Upload + cp.Upload.AllowedImageTypes = cloneStrings(c.Upload.AllowedImageTypes) + cp.Upload.AllowedVideoTypes = cloneStrings(c.Upload.AllowedVideoTypes) + // Storage.Local.Upload / Storage.OSS.Upload + cp.Storage.Local.Upload.AllowedExts = cloneStrings(c.Storage.Local.Upload.AllowedExts) + cp.Storage.Local.Upload.AllowedMIMEs = cloneStrings(c.Storage.Local.Upload.AllowedMIMEs) + cp.Storage.OSS.Upload.AllowedExts = cloneStrings(c.Storage.OSS.Upload.AllowedExts) + cp.Storage.OSS.Upload.AllowedMIMEs = cloneStrings(c.Storage.OSS.Upload.AllowedMIMEs) + return &cp +} + +// cloneStrings 返回字符串切片的深拷贝(nil 保持 nil 语义,避免把 nil 变成空切片)。 +func cloneStrings(s []string) []string { + if s == nil { + return nil + } + out := make([]string, len(s)) + copy(out, s) + return out +} + +func cloneStringAnyMap(in map[string]any) map[string]any { + if in == nil { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = cloneAny(v) + } + return out +} + +func cloneAny(v any) any { + switch x := v.(type) { + case map[string]any: + return cloneStringAnyMap(x) + case []any: + out := make([]any, len(x)) + for i, item := range x { + out[i] = cloneAny(item) + } + return out + case []string: + return cloneStrings(x) + default: + return x + } +} + +// AppConfig 应用配置 +// 使用场景: +// - 缓存键名前缀: cache:{site_name}:user:1 +// - 日志标识: [site_a] 2024-01-01 10:00:00 ... +// - 站点追踪: Request-ID 带站点标识 +// - 分布式锁: lock:{site_name}:order:123 +type AppConfig struct { + Name string `mapstructure:"name"` // 应用名称,如 "用户管理系统" + SiteName string `mapstructure:"site_name"` // 站点别名,如 "site_a"、"user_api" + Version string `mapstructure:"version"` // 应用版本 + Env string `mapstructure:"env"` // 运行环境: dev/test/prod + Debug bool `mapstructure:"debug"` // 是否开启调试模式 + BaseURL string `mapstructure:"base_url"` // 应用基础URL +} + +// GetSiteName 获取站点别名,如果未设置则返回空字符串 +func (c *AppConfig) GetSiteName() string { + if c == nil { + return "" + } + return c.SiteName +} + +// GetCachePrefix 获取缓存键名前缀 +func (c *AppConfig) GetCachePrefix() string { + return c.GetSiteName() +} + +// IsDebug 是否调试模式 +func (c *AppConfig) IsDebug() bool { + if c == nil { + return false + } + return c.Debug +} + +// IsDev 是否开发环境 +func (c *AppConfig) IsDev() bool { + if c == nil { + return false + } + return c.Env == "dev" || c.Env == "development" +} + +// IsProd 是否生产环境 +func (c *AppConfig) IsProd() bool { + if c == nil { + return false + } + return c.Env == "prod" || c.Env == "production" +} + +// TLSConfig HTTPS/TLS 配置 +type TLSConfig struct { + Enabled bool `mapstructure:"enabled"` + CertFile string `mapstructure:"cert_file"` + KeyFile string `mapstructure:"key_file"` +} + +// ServerConfig 服务配置 +type ServerConfig struct { + Host string `mapstructure:"host"` // 绑定地址,空=监听所有接口(0.0.0.0);"127.0.0.1"=仅本机;内网IP=绑定指定网卡 + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` // development 或 production + ReadTimeout time.Duration `mapstructure:"read_timeout"` // 读超时,如 "15s" + WriteTimeout time.Duration `mapstructure:"write_timeout"` // 写超时,如 "30s" + IdleTimeout time.Duration `mapstructure:"idle_timeout"` // 空闲超时,如 "60s" + 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 + ResponseMode string `mapstructure:"response_mode"` // business(默认) 或 rest,见 response.SetMode +} + +// 默认值常量(ServerConfig 字段为零值时回退使用) +const ( + defaultReadTimeout = 15 * time.Second + defaultWriteTimeout = 30 * time.Second + defaultIdleTimeout = 60 * time.Second + defaultShutdownTimeout = 30 * time.Second + defaultMaxHeaderBytes = 1 << 20 // 1MB +) + +// EffectiveReadTimeout 返回生效的读超时(零值回退默认) +func (c ServerConfig) EffectiveReadTimeout() time.Duration { + if c.ReadTimeout > 0 { + return c.ReadTimeout + } + return defaultReadTimeout +} + +// EffectiveWriteTimeout 返回生效的写超时(零值回退默认) +func (c ServerConfig) EffectiveWriteTimeout() time.Duration { + if c.WriteTimeout > 0 { + return c.WriteTimeout + } + return defaultWriteTimeout +} + +// EffectiveIdleTimeout 返回生效的空闲超时(零值回退默认) +func (c ServerConfig) EffectiveIdleTimeout() time.Duration { + if c.IdleTimeout > 0 { + return c.IdleTimeout + } + return defaultIdleTimeout +} + +// EffectiveShutdownTimeout 返回生效的关闭超时(零值回退默认) +func (c ServerConfig) EffectiveShutdownTimeout() time.Duration { + if c.ShutdownTimeout > 0 { + return c.ShutdownTimeout + } + return defaultShutdownTimeout +} + +// EffectiveMaxHeaderBytes 返回生效的最大请求头字节数(零值回退默认) +func (c ServerConfig) EffectiveMaxHeaderBytes() int { + if c.MaxHeaderBytes > 0 { + return c.MaxHeaderBytes + } + return defaultMaxHeaderBytes +} + +// 数据库驱动常量 +const ( + DriverMySQL = "mysql" + 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 + +var ( + dsnBuildersMu sync.RWMutex + dsnBuilders = map[string]DSNBuilder{} +) + +// RegisterDSNBuilder 为指定驱动注册 DSN 构建器(驱动名大小写不敏感)。 +// aliases 用于注册同一驱动的别名,例如 postgres 的 "postgresql"、"pg"。 +// 通常由 database 包通过 database.RegisterDialect 间接调用, +// 应用代码也可直接使用以扩展自定义驱动。 +func RegisterDSNBuilder(name string, builder DSNBuilder, aliases ...string) { + if builder == nil { + return + } + dsnBuildersMu.Lock() + defer dsnBuildersMu.Unlock() + for _, n := range append([]string{name}, aliases...) { + key := strings.ToLower(strings.TrimSpace(n)) + if key != "" { + dsnBuilders[key] = builder + } + } +} + +// LookupDSNBuilder 查找已注册的 DSN 构建器 +func LookupDSNBuilder(name string) (DSNBuilder, bool) { + key := strings.ToLower(strings.TrimSpace(name)) + dsnBuildersMu.RLock() + defer dsnBuildersMu.RUnlock() + b, ok := dsnBuilders[key] + return b, ok +} + +// RegisteredDrivers 返回所有已注册 DSN 构建器的驱动名(用于诊断) +func RegisteredDrivers() []string { + dsnBuildersMu.RLock() + defer dsnBuildersMu.RUnlock() + names := make([]string, 0, len(dsnBuilders)) + for k := range dsnBuilders { + names = append(names, k) + } + return names +} + +func init() { + // 内置 MySQL / PostgreSQL 的 DSN 构建器 + RegisterDSNBuilder(DriverMySQL, func(c *DatabaseConfig) string { return c.MySQLDSN() }) + RegisterDSNBuilder(DriverPostgres, func(c *DatabaseConfig) string { return c.PostgresDSN() }, "postgresql", "pg") +} + +// DatabaseConfig 数据库配置 +type DatabaseConfig struct { + // Driver 数据库驱动,支持 mysql(默认)与 postgres + Driver string `mapstructure:"driver"` + // Host 数据库主机 + Host string `mapstructure:"host"` + // Port 数据库端口 + Port int `mapstructure:"port"` + // User 数据库用户名 + User string `mapstructure:"user"` + // Password 数据库密码 + Password string `mapstructure:"password"` + // Name 数据库名 + Name string `mapstructure:"name"` + // Timezone 连接时区。MySQL 用作 loc 参数、Postgres 用作 TimeZone 参数。 + // 空时 MySQL 默认 "Local"、Postgres 默认 "Asia/Shanghai"(向后兼容,M9)。 + Timezone string `mapstructure:"timezone"` + // CustomDSN 自定义连接字符串,设置后优先于由 Host/Port 等字段生成的 DSN + CustomDSN string `mapstructure:"dsn"` + // MaxIdleConns 最大空闲连接数 + MaxIdleConns int `mapstructure:"max_idle_conns"` + // MaxOpenConns 最大打开连接数 + MaxOpenConns int `mapstructure:"max_open_conns"` + // ConnMaxIdleTime 连接最大空闲时间,如 "5m"(#21)。0 表示用驱动默认 + ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"` + // HealthCheckInterval 主库探活间隔,如 "30s"(#21)。0 表示用默认 30s + 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 处理;非空但未注册的 Driver 返回空字符串, +// 避免把拼写错误静默当作 MySQL 连接,正常加载路径会由 Validate 提前报错。 +func (c *DatabaseConfig) DSN() string { + if c == nil { // L-config-6:防御 nil receiver + return "" + } + if c.CustomDSN != "" { + return c.CustomDSN + } + driver := strings.TrimSpace(c.Driver) + if driver == "" { // L-config-5:去除原重复的 TrimSpace(driver) + driver = DriverMySQL + } + if builder, ok := LookupDSNBuilder(driver); ok { + return builder(c) + } + 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" + } + 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。 +// 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" + } + 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 配置 +type RedisConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` +} + +// Addr 返回 Redis 地址 +func (c *RedisConfig) Addr() string { + if c == nil { // L-config-6 + return "" + } + return fmt.Sprintf("%s:%d", c.Host, c.Port) +} + +// JWTConfig JWT 配置 +type JWTConfig struct { + Secret string `mapstructure:"secret"` + Expire time.Duration `mapstructure:"expire"` // 过期时间,如 "24h"(time.Duration) + RefreshExpire time.Duration `mapstructure:"refresh_expire"` // 刷新 token 过期时间,如 "168h" + Issuer string `mapstructure:"issuer"` // 签发者 + Algorithm string `mapstructure:"algorithm"` // 签名算法:HS256(默认)/HS384/HS512;非 HMAC 算法(如 RS256)会被 jwt.signingMethod 拒绝(ErrUnsupportedAlgorithm) +} + +// SMSConfig 短信配置 +type SMSConfig struct { + Enabled bool `mapstructure:"enabled"` + Provider string `mapstructure:"provider"` + AccessKeyID string `mapstructure:"access_key_id"` + AccessKeySecret string `mapstructure:"access_key_secret"` + SignName string `mapstructure:"sign_name"` + TemplateCode string `mapstructure:"template_code"` +} + +// StorageConfig 文件存储配置 +type StorageConfig struct { + Driver string `mapstructure:"driver"` // local 或 oss + Local LocalStorageConfig `mapstructure:"local"` + OSS OSSStorageConfig `mapstructure:"oss"` +} + +// UploadPolicy 上传安全策略(C4b)。零值表示不限制,向后兼容; +// 生产环境强烈建议显式配置 MaxSizeBytes 与 AllowedExts / AllowedMIMEs。 +type UploadPolicy struct { + // MaxSizeBytes 单文件大小上限(字节)。0 = 不限制。 + MaxSizeBytes int64 `mapstructure:"max_size_bytes"` + // AllowedExts 允许的扩展名白名单(小写、含点,如 ".jpg")。空 = 不限制。 + AllowedExts []string `mapstructure:"allowed_exts"` + // AllowedMIMEs 允许的 MIME 类型白名单(小写,如 "image/jpeg")。 + // 非空时用 http.DetectContentType 嗅探文件前 512 字节校验。空 = 不嗅探。 + AllowedMIMEs []string `mapstructure:"allowed_mime_types"` +} + +// LocalStorageConfig 本地存储配置 +type LocalStorageConfig struct { + Path string `mapstructure:"path"` + BaseURL string `mapstructure:"base_url"` + // Upload 上传安全策略(可选,零值不限制)。 + Upload UploadPolicy `mapstructure:"upload"` + // MaxReadBytes Get 读取单文件上限(字节)。0 = 默认 100MB,-1 = 不限制。 + MaxReadBytes int64 `mapstructure:"max_read_bytes"` +} + +// OSSStorageConfig OSS 存储配置 +type OSSStorageConfig struct { + Endpoint string `mapstructure:"endpoint"` + Bucket string `mapstructure:"bucket"` + AccessKeyID string `mapstructure:"access_key_id"` + AccessKeySecret string `mapstructure:"access_key_secret"` + BaseURL string `mapstructure:"base_url"` + // Upload 上传安全策略(可选,零值不限制)。 + Upload UploadPolicy `mapstructure:"upload"` + // MaxReadBytes Get 读取单文件上限(字节)。0 = 默认 100MB,-1 = 不限制。 + MaxReadBytes int64 `mapstructure:"max_read_bytes"` +} + +// LogConfig 日志配置 +type LogConfig struct { + Dir string `mapstructure:"dir"` + MaxSize int `mapstructure:"max_size"` // MB + MaxBackups int `mapstructure:"max_backups"` + MaxAge int `mapstructure:"max_age"` // 天 + Compress bool `mapstructure:"compress"` +} + +// UploadConfig 上传配置 +type UploadConfig struct { + MaxFileSize int `mapstructure:"max_file_size"` // 最大图片大小(MB) + MaxVideoSize int `mapstructure:"max_video_size"` // 最大视频大小(MB) + MaxAvatarSize int `mapstructure:"max_avatar_size"` // 最大头像大小(MB) + AllowedImageTypes []string `mapstructure:"allowed_image_types"` // 允许的图片 MIME 类型 + AllowedVideoTypes []string `mapstructure:"allowed_video_types"` // 允许的视频 MIME 类型 +} + +// CORSConfig CORS 跨域配置 +type CORSConfig struct { + AllowedOrigins []string `mapstructure:"allowed_origins"` // 允许的域名列表 + AllowedMethods []string `mapstructure:"allowed_methods"` // 允许的方法 + AllowedHeaders []string `mapstructure:"allowed_headers"` // 允许的请求头 + ExposedHeaders []string `mapstructure:"exposed_headers"` // 暴露的响应头 + AllowCredentials bool `mapstructure:"allow_credentials"` // 是否允许携带凭证 + 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 { + return []string{} + } + return c.AllowedOrigins +} + +// GetAllowedMethods 获取允许的方法列表 +func (c *CORSConfig) GetAllowedMethods() []string { + if c == nil || len(c.AllowedMethods) == 0 { + return []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} + } + return c.AllowedMethods +} + +// GetAllowedHeaders 获取允许的请求头列表 +func (c *CORSConfig) GetAllowedHeaders() []string { + if c == nil || len(c.AllowedHeaders) == 0 { + return []string{"Origin", "Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", "X-Requested-With"} + } + return c.AllowedHeaders +} + +// GetExposedHeaders 获取暴露的响应头列表 +func (c *CORSConfig) GetExposedHeaders() []string { + if c == nil || len(c.ExposedHeaders) == 0 { + return []string{"Content-Length", "Access-Control-Allow-Origin", "Access-Control-Allow-Headers", "Content-Type"} + } + return c.ExposedHeaders +} + +// GetMaxAge 获取预检请求缓存时间 +func (c *CORSConfig) GetMaxAge() int { + if c == nil || c.MaxAge <= 0 { + return 86400 // 默认 24 小时 + } + return c.MaxAge +} + +// Manager 配置管理器 +type Manager struct { + mu sync.RWMutex + path string + v *viper.Viper + cfg *Config + callbacks []func(*Config) + // watcher 是自管的 fsnotify 监听器(C10d)。nil 表示未启用文件监听。 + // 由 StartWatcher 创建、StopWatcher 关闭,避免依赖 viper 内部无法停止的 watcher。 + watcher *fsnotify.Watcher + // watchDone 在监听 goroutine 退出时被 close,供 StopWatcher 等待退出确认。 + watchDone chan struct{} + // watchCancel 取消 watchLoop 的 ctx(L-config-2)。watchLoop 同时监听 ctx.Done 与 w.Events, + // 提供 fsnotify 致命错误且 Events 未关闭时的逃生通道,避免监听 goroutine 永驻。 + // StopWatcher 时 cancel + Close(w) 双重退出保障。 + watchCancel context.CancelFunc +} + +// defaultManager 是包级默认管理器(C10a)。改用 atomic.Pointer 保护读写, +// 消除原裸指针置换与请求 goroutine 无锁读之间的数据竞争。 +var defaultManager atomic.Pointer[Manager] + +func init() { + defaultManager.Store(NewManager("")) +} + +// NewManager 创建配置管理器 +func NewManager(configPath string) *Manager { + return &Manager{path: configPath} +} + +func newViper(configPath string) *viper.Viper { + v := viper.New() + v.SetConfigFile(configPath) + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + 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 { + return v.Unmarshal(cfg, viper.DecodeHook(mapstructure.StringToTimeDurationHookFunc())) +} + +// Load 加载配置文件 +func (m *Manager) Load() (*Config, error) { + if m == nil || m.path == "" { + return nil, ErrConfigNotLoaded + } + + v := newViper(m.path) + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("读取配置文件失败: %w", err) + } + + var cfg Config + if err := unmarshalConfig(v, &cfg); err != nil { + return nil, fmt.Errorf("解析配置文件失败: %w", err) + } + if err := cfg.Validate(); err != nil { + return nil, err + } + + m.mu.Lock() + m.v = v + m.cfg = &cfg + m.mu.Unlock() + + // M-G 修复:返回深拷贝(Clone),标量与切片字段均独立,调用方可安全修改。 + // 原"防御性拷贝"为浅拷贝(out := cfg)——切片字段(CORS.AllowedOrigins 等)仍与 + // 内部 m.cfg 共享底层数组,调用方 append/sort/改元素会污染全局并与其他读者竞态。 + // 内部 m.cfg 保留独立的 &cfg,不受返回值修改影响。 + return cfg.Clone(), nil +} + +// LoadWithWatch 加载配置文件并启用热更新 +func (m *Manager) LoadWithWatch(onChange func(*Config)) (*Config, error) { + cfg, err := m.Load() + if err != nil { + return nil, err + } + if onChange != nil { + m.RegisterCallback(onChange) + } + if err := m.StartWatcher(); err != nil { + return nil, fmt.Errorf("启动配置监听失败: %w", err) + } + return cfg, nil +} + +// RegisterCallback 注册配置变更回调 +func (m *Manager) RegisterCallback(cb func(*Config)) { + if m == nil || cb == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.callbacks = append(m.callbacks, cb) +} + +// StartWatcher 启动配置文件监听。使用自管的 fsnotify.Watcher(监听配置文件 +// 所在目录以兼容编辑器改写/k8s ConfigMap 原子替换),文件变更时去抖后重新加载。 +// 幂等:重复调用不会创建多个监听 goroutine。 +func (m *Manager) StartWatcher() error { + if m == nil { + return ErrConfigNotLoaded + } + + m.mu.Lock() + if m.watcher != nil { + // 已在监听,幂等返回 + m.mu.Unlock() + return nil + } + if m.v == nil || m.path == "" { + m.mu.Unlock() + return ErrConfigNotLoaded + } + w, err := fsnotify.NewWatcher() + if err != nil { + m.mu.Unlock() + return fmt.Errorf("创建文件监听失败: %w", err) + } + // 监听父目录而非文件本身:vim/k8s 等通过"写临时文件 + rename"替换配置, + // 直接监听文件会在 rename 后丢失。监听目录并按文件名过滤更稳健。 + dir := filepath.Dir(m.path) + if err := w.Add(dir); err != nil { + m.mu.Unlock() + _ = w.Close() + return fmt.Errorf("监听配置目录失败: %w", err) + } + m.watcher = w + m.watchDone = make(chan struct{}) + // L-config-2:为 watchLoop 创建可取消 ctx,作为 w.Events 关闭之外的逃生通道。 + ctx, cancel := context.WithCancel(context.Background()) + m.watchCancel = cancel + target := filepath.Base(m.path) + done := m.watchDone + m.mu.Unlock() + + go m.watchLoop(ctx, w, target, done) + return nil +} + +// watchLoop 是文件监听 goroutine 主体。文件变更经去抖后调用 reload; +// 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 + 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 + } + if filepath.Base(ev.Name) != target { + continue + } + if !ev.Has(fsnotify.Create) && !ev.Has(fsnotify.Write) && + !ev.Has(fsnotify.Remove) && !ev.Has(fsnotify.Rename) { + continue + } + // 去抖:合并编辑器/工具的连续写事件,仅最后一次触发重载。 + if !timer.Stop() && timerC != nil { + select { + case <-timer.C: + default: + } + } + timer.Reset(debounce) + timerC = timer.C + case _, ok := <-w.Errors: + if !ok { + return + } + // 非致命错误:继续监听。 + case <-timerC: + timerC = nil + // reload 内部对非法配置保留旧配置(C10b),错误被忽略—— + // 监听路径无法向上传播错误,保留旧配置即正确语义。 + _ = m.reload() + } + } +} + +// StopWatcher 停止配置文件监听并释放 watcher(C10d)。幂等。 +// cancel ctx 与关闭 fsnotify watcher 双重退出(L-config-2),等待 watchDone 确认 goroutine 退出。 +func (m *Manager) StopWatcher() { + if m == nil { + return + } + m.mu.Lock() + w := m.watcher + done := m.watchDone + cancel := m.watchCancel + m.watcher = nil + m.watchDone = nil + m.watchCancel = nil + m.mu.Unlock() + if cancel != nil { + cancel() // L-config-2:先 cancel 让 watchLoop 经 ctx.Done 退出 + } + if w == nil { + return + } + _ = w.Close() // 再 Close watcher 让 w.Events 关闭,双重保障 + if done != nil { + <-done + } +} + +// Get 获取配置副本。返回值可由调用方自由修改,不会污染 Manager 内部配置。 +func (m *Manager) Get() *Config { + if m == nil { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + return m.cfg.Clone() +} + +// 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 cloneViper(m.v, m.path) +} + +// GetString 获取字符串配置。 +func (m *Manager) GetString(key string) string { + if m == nil { + 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.Clone() + if cfg == nil { + m.v = nil + } else { + m.v = viperFromConfig(m.path, cfg) + } + return nil +} + +// Reload 重新加载配置文件。读取、解析、校验(C10b)任一步失败均保留旧配置并返回错误; +// 仅当新配置通过 Validate 后才替换 m.cfg 并触发回调。 +func (m *Manager) Reload() error { + if m == nil { + return ErrConfigNotLoaded + } + return m.reload() +} + +// reload 是 Reload 与文件监听共享的重载实现。全程持写锁以串行化对 viper 的 +// ReadInConfig 访问(viper 非完全并发安全),并在替换前强制 Validate(C10b)。 +func (m *Manager) reload() error { + m.mu.Lock() + v := m.v + if v == nil { + m.mu.Unlock() + return ErrConfigNotLoaded + } + if err := v.ReadInConfig(); err != nil { + m.mu.Unlock() + return fmt.Errorf("读取配置文件失败: %w", err) + } + var newCfg Config + if err := unmarshalConfig(v, &newCfg); err != nil { + m.mu.Unlock() + return fmt.Errorf("解析配置文件失败: %w", err) + } + if err := newCfg.Validate(); err != nil { + // 非法配置保留旧配置,不得静默发布(C10b) + m.mu.Unlock() + return err + } + m.cfg = &newCfg + cbs := make([]func(*Config), len(m.callbacks)) + copy(cbs, m.callbacks) + m.mu.Unlock() + + // M-G:回调传入 newCfg 的深拷贝,避免回调修改切片字段与 Get() 读者(持有 &newCfg)竞态。 + // 回调为 onChange 通知语义,应观察而非改写配置;改写副本不影响内部 m.cfg。 + // M-config-1:每个回调独立 recover,单个回调 panic 不得杀掉 watcher 或阻断后续回调。 + // config 是叶子包(logger 依赖 config),不能用框架 zap,故用标准库 log 记录 + 堆栈。 + for _, cb := range cbs { + 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 +} + +// pkgLoadMu 串行化包级 Load/LoadWithWatch 对 defaultManager 的"停旧 watcher → 建新 → 置换" +// 序列(P1 #8)。原实现该序列非原子:两个 goroutine 并发调用可能都读到 old、都 StopWatcher、 +// 都 Store,遗留一个已 StartWatcher 的 manager 无引用可停(goroutine 泄漏)。 +var pkgLoadMu sync.Mutex + +// Load 加载配置文件。 +// P1 #8:全程持 pkgLoadMu 串行化。新配置加载成功后才替换默认 Manager 并停止旧 watcher; +// 加载失败会保留旧 Manager 与旧 watcher,避免一次错误配置导致热更新链路断掉。 +func Load(configPath string) (*Config, error) { + pkgLoadMu.Lock() + defer pkgLoadMu.Unlock() + m := NewManager(configPath) + cfg, err := m.Load() + if err != nil { + return nil, err + } + old := defaultManager.Load() + defaultManager.Store(m) + if old != nil && old != m { + old.StopWatcher() + } + return cfg, nil +} + +// LoadWithWatch 加载配置文件并启用热更新。 +// P1 #8:全程持 pkgLoadMu 串行化,且新 manager 在其 watcher 成功启动后才置换为默认; +// 启动失败则保留旧 Manager 与旧 watcher,并停掉新 manager 可能半启动的 watcher。 +func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) { + pkgLoadMu.Lock() + defer pkgLoadMu.Unlock() + m := NewManager(configPath) + cfg, err := m.LoadWithWatch(onChange) + if err != nil { + m.StopWatcher() // 清理可能已半启动的 watcher,避免孤儿 goroutine + return nil, err + } + old := defaultManager.Load() + defaultManager.Store(m) + if old != nil && old != m { + old.StopWatcher() + } + return cfg, nil +} + +// RegisterCallback 注册配置变更回调 +func RegisterCallback(cb func(*Config)) { + defaultManager.Load().RegisterCallback(cb) +} + +// StartWatcher 启动配置文件监听 +func StartWatcher() error { + return defaultManager.Load().StartWatcher() +} + +// StopWatcher 停止配置文件监听 +func StopWatcher() { + defaultManager.Load().StopWatcher() +} + +// Get 获取全局配置 +func Get() *Config { + return defaultManager.Load().Get() +} + +// GetViper 获取 viper 实例(用于扩展配置) +func GetViper() *viper.Viper { + return defaultManager.Load().GetViper() +} + +// Set 手动设置配置(用于测试或动态修改)。非 nil 配置会先校验并复制。 +func Set(cfg *Config) error { + return defaultManager.Load().Set(cfg) +} + +// Reload 重新加载配置文件 +func Reload() error { + return defaultManager.Load().Reload() +} + +// SetDefaultManager 替换全局默认配置管理器。 +// 主要供应用层(如 App)在持有自己的 Manager 时使用, +// 使 config.Get / config.GetString 等便捷函数仍然能取到正确的配置。 +// 传入 nil 表示重置为空管理器。 +// +// C10a:经 atomic.Pointer.Store 原子置换,消除与并发读取(Get 等)的数据竞争。 +// 置换后会停止旧 Manager 的 watcher,避免全局默认 manager 切换后遗留热更新 goroutine。 +func SetDefaultManager(m *Manager) { + old := defaultManager.Load() + if m == nil { + m = NewManager("") + } + defaultManager.Store(m) + if old != nil && old != m { + old.StopWatcher() + } +} + +// GetString 获取字符串配置 +func GetString(key string) string { + return defaultManager.Load().GetString(key) +} + +// GetInt 获取整数配置 +func GetInt(key string) int { + return defaultManager.Load().GetInt(key) +} + +// GetBool 获取布尔配置 +func GetBool(key string) bool { + return defaultManager.Load().GetBool(key) +} + +// GetStringMap 获取字符串映射配置 +func GetStringMap(key string) map[string]any { + return defaultManager.Load().GetStringMap(key) +} + +// IsDevelopment 是否开发环境 +func (c *Config) IsDevelopment() bool { + if c == nil { + return false + } + // 优先使用 App.Env + if c.App.Env != "" { + return c.App.IsDev() + } + return c.Server.Mode == "development" +} + +// IsProduction 是否生产环境 +func (c *Config) IsProduction() bool { + if c == nil { + return false + } + // 优先使用 App.Env + if c.App.Env != "" { + return c.App.IsProd() + } + return c.Server.Mode == "production" +} + +// GetAppName 获取应用名称 +func (c *Config) GetAppName() string { + if c == nil { + return "" + } + return c.App.Name +} + +// GetSiteName 获取站点别名 +func (c *Config) GetSiteName() string { + if c == nil { + return "" + } + return c.App.GetSiteName() +} diff --git a/config/config_c10_test.go b/config/config_c10_test.go new file mode 100644 index 0000000..9554a04 --- /dev/null +++ b/config/config_c10_test.go @@ -0,0 +1,499 @@ +package config_test + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" +) + +// writeConfig 写入临时配置文件并返回路径。 +func writeConfig(t *testing.T, name, content string) string { + t.Helper() + dir := filepath.Join(os.TempDir(), "xlgo_c10_test") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return p +} + +func validConfigYAML(port int) string { + return "app:\n name: c10\n env: dev\nserver:\n port: " + itoa(port) + "\n" +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} + +// TestSetDefaultManagerConcurrent 并发置换默认 Manager 与并发读取, +// 必须经 -race 无竞争(C10a)。 +func TestSetDefaultManagerConcurrent(t *testing.T) { + // 准备若干可加载的 Manager + paths := make([]string, 4) + for i := range paths { + paths[i] = writeConfig(t, "c10_concurrent_"+itoa(i)+".yaml", validConfigYAML(9000+i)) + } + defer func() { + for _, p := range paths { + os.Remove(p) + } + }() + + var wg sync.WaitGroup + stop := make(chan struct{}) + // 写者:并发 SetDefaultManager + for i := 0; i < 4; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + m := config.NewManager(paths[idx]) + _, _ = m.Load() + config.SetDefaultManager(m) + } + }(i) + } + // 读者:并发 Get / GetViper / GetString + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _ = config.Get() + _ = config.GetViper() + _ = config.GetString("server.port") + } + }() + } + // 跑足够长以让 -race 采到 + time.Sleep(150 * time.Millisecond) + close(stop) + wg.Wait() + + // 还原全局状态,避免污染其他测试 + config.SetDefaultManager(nil) +} + +// TestLoadReturnsDefensiveCopy Load 返回的配置与 Get() 内部指针独立, +// 修改返回值不污染全局(C10c)。 +func TestLoadReturnsDefensiveCopy(t *testing.T) { + p := writeConfig(t, "c10_defensive.yaml", validConfigYAML(8081)) + defer os.Remove(p) + + config.Set(nil) + cfg, err := config.Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Port != 8081 { + t.Fatalf("port = %d, want 8081", cfg.Server.Port) + } + + // 调用方修改返回值 + cfg.Server.Port = 1 + cfg.App.Name = "mutated" + + // 全局读取不受影响 + got := config.Get() + if got == nil { + t.Fatal("Get returned nil") + } + if got.Server.Port != 8081 { + t.Errorf("global port polluted = %d, want 8081 (C10c)", got.Server.Port) + } + if got.App.Name == "mutated" { + t.Errorf("global app name polluted (C10c)") + } + + config.SetDefaultManager(nil) +} + +// TestLoadDefensiveCopySliceContract 固化 M-G 的深拷贝语义契约: +// 标量字段与切片字段均独立(修改不污染全局)。M-G 修复后 Load() 返回 Clone() 深拷贝, +// 切片字段不再共享底层数组——调用方可安全修改切片元素。本测试锁定该行为,防止回退到浅拷贝。 +func TestLoadDefensiveCopySliceContract(t *testing.T) { + content := "app:\n name: c10slice\nserver:\n port: 8090\ncors:\n allowed_origins:\n - https://a.example.com\n - https://b.example.com\n" + p := writeConfig(t, "c10_slice.yaml", content) + defer os.Remove(p) + + config.Set(nil) + cfg, err := config.Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + + // 标量独立 + cfg.Server.Port = 1 + if got := config.Get().Server.Port; got != 8090 { + t.Errorf("scalar polluted = %d, want 8090", got) + } + + // M-G:切片底层数组独立(深拷贝)——修改切片元素不污染全局。 + cfg.CORS.AllowedOrigins[0] = "https://mutated.example.com" + if got := config.Get().CORS.AllowedOrigins[0]; got != "https://a.example.com" { + t.Errorf("slice should be deep-copied (independent), global polluted to %q, want %q", got, "https://a.example.com") + } + + // append 也不应污染全局(深拷贝后调用方持独立切片) + cfg.CORS.AllowedOrigins = append(cfg.CORS.AllowedOrigins, "https://c.example.com") + if got := config.Get().CORS.AllowedOrigins; len(got) != 2 { + t.Errorf("global slice length should be unaffected by caller append, got %d, want 2", len(got)) + } + + config.SetDefaultManager(nil) +} +func TestReloadInvalidConfigKeepsOld(t *testing.T) { + p := writeConfig(t, "c10_reload_bad.yaml", validConfigYAML(8082)) + defer os.Remove(p) + + m := config.NewManager(p) + if _, err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := m.Get().Server.Port; got != 8082 { + t.Fatalf("initial port = %d, want 8082", got) + } + + // 覆盖为非法配置(端口越界) + if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + err := m.Reload() + if err == nil { + t.Fatal("Reload invalid config should return error (C10b)") + } + if !strings.Contains(err.Error(), "server.port") { + t.Errorf("error should mention server.port, got %v", err) + } + // 旧配置保留 + if got := m.Get().Server.Port; got != 8082 { + t.Errorf("old config not preserved = %d, want 8082 (C10b)", got) + } +} + +// TestHotReloadInvalidConfigKeepsOld 文件监听路径遇非法配置保留旧配置, +// 且不触发回调;监听仍存活,后续合法变更正常生效(C10b + C10d 监听健壮性)。 +func TestHotReloadInvalidConfigKeepsOld(t *testing.T) { + p := writeConfig(t, "c10_watch_bad.yaml", validConfigYAML(8083)) + defer os.Remove(p) + + m := config.NewManager(p) + if _, err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + changes := make(chan int, 16) + m.RegisterCallback(func(c *config.Config) { + select { + case changes <- c.Server.Port: + default: + } + }) + if err := m.StartWatcher(); err != nil { + t.Fatalf("StartWatcher: %v", err) + } + defer m.StopWatcher() + + // 1) 写入非法配置:应保留旧配置、不触发回调 + if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil { + t.Fatalf("WriteFile invalid: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if got := m.Get().Server.Port; got != 8083 { + t.Errorf("invalid config leaked into global = %d, want 8083 (C10b)", got) + break + } + time.Sleep(20 * time.Millisecond) + } + select { + case port := <-changes: + t.Errorf("callback fired for invalid config with port %d (C10b)", port) + default: + } + + // 2) 写入合法配置:监听仍存活,应触发回调且全局更新 + if err := os.WriteFile(p, []byte(validConfigYAML(8084)), 0644); err != nil { + t.Fatalf("WriteFile valid: %v", err) + } + deadline = time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if got := m.Get().Server.Port; got == 8084 { + break + } + time.Sleep(20 * time.Millisecond) + } + if got := m.Get().Server.Port; got != 8084 { + t.Fatalf("watcher did not reload valid config = %d, want 8084", got) + } +} + +// TestStopWatcherReleasesGoroutine StopWatcher 后监听 goroutine 退出,无泄漏(C10d)。 +func TestStopWatcherReleasesGoroutine(t *testing.T) { + p := writeConfig(t, "c10_stop.yaml", validConfigYAML(8085)) + defer os.Remove(p) + + m := config.NewManager(p) + if _, err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if err := m.StartWatcher(); err != nil { + t.Fatalf("StartWatcher: %v", err) + } + + // 等待监听 goroutine 就绪 + time.Sleep(100 * time.Millisecond) + before := runtime.NumGoroutine() + + m.StopWatcher() + + // 轮询确认 goroutine 退出 + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if runtime.NumGoroutine() < before { + break + } + time.Sleep(20 * time.Millisecond) + } + if after := runtime.NumGoroutine(); after >= before { + t.Errorf("watcher goroutine not released: before=%d after=%d (C10d)", before, after) + } + + // 幂等:再次 Stop 不 panic + m.StopWatcher() +} + +// TestStartWatcherIdempotent 重复 StartWatcher 不创建多个监听 goroutine(幂等)。 +func TestStartWatcherIdempotent(t *testing.T) { + p := writeConfig(t, "c10_idem.yaml", validConfigYAML(8086)) + defer os.Remove(p) + + m := config.NewManager(p) + if _, err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if err := m.StartWatcher(); err != nil { + t.Fatalf("StartWatcher 1: %v", err) + } + time.Sleep(80 * time.Millisecond) + once := runtime.NumGoroutine() + + if err := m.StartWatcher(); err != nil { + t.Fatalf("StartWatcher 2: %v", err) + } + if err := m.StartWatcher(); err != nil { + t.Fatalf("StartWatcher 3: %v", err) + } + time.Sleep(80 * time.Millisecond) + twice := runtime.NumGoroutine() + if twice > once { + t.Errorf("idempotent StartWatcher leaked goroutine: once=%d twice=%d", once, twice) + } + m.StopWatcher() +} + +func TestStopWatcherWaitsInFlightReload(t *testing.T) { + p := writeConfig(t, "c10_stop_wait.yaml", validConfigYAML(8091)) + defer os.Remove(p) + + m := config.NewManager(p) + if _, err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + entered := make(chan struct{}) + release := make(chan struct{}) + m.RegisterCallback(func(*config.Config) { + close(entered) + <-release + }) + if err := m.StartWatcher(); err != nil { + t.Fatalf("StartWatcher: %v", err) + } + + if err := os.WriteFile(p, []byte(validConfigYAML(8092)), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("热更新回调未触发") + } + + stopped := make(chan struct{}) + go func() { + m.StopWatcher() + close(stopped) + }() + select { + case <-stopped: + t.Fatal("StopWatcher 不应在 reload 回调结束前返回") + case <-time.After(120 * time.Millisecond): + } + close(release) + select { + case <-stopped: + case <-time.After(2 * time.Second): + t.Fatal("StopWatcher 未等待到 reload 回调结束") + } +} + +func TestLoadWithWatchFailureKeepsOldWatcher(t *testing.T) { + p := writeConfig(t, "c10_keep_old.yaml", validConfigYAML(8093)) + defer os.Remove(p) + + changes := make(chan int, 4) + if _, err := config.LoadWithWatch(p, func(c *config.Config) { + changes <- c.Server.Port + }); err != nil { + t.Fatalf("LoadWithWatch old: %v", err) + } + defer config.StopWatcher() + + missing := filepath.Join(filepath.Dir(p), "missing.yaml") + if _, err := config.LoadWithWatch(missing, nil); err == nil { + t.Fatal("加载缺失配置应失败") + } + + if err := os.WriteFile(p, []byte(validConfigYAML(8094)), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + select { + case port := <-changes: + if port != 8094 { + t.Fatalf("旧 watcher 回调端口错误: %d", port) + } + case <-time.After(3 * time.Second): + t.Fatal("加载失败后旧 watcher 不应被停止") + } +} + +func TestSetDefaultManagerStopsOldWatcher(t *testing.T) { + oldPath := writeConfig(t, "c10_old_default.yaml", validConfigYAML(8095)) + newPath := writeConfig(t, "c10_new_default.yaml", validConfigYAML(8096)) + defer os.Remove(oldPath) + defer os.Remove(newPath) + + oldManager := config.NewManager(oldPath) + if _, err := oldManager.Load(); err != nil { + t.Fatalf("old Load: %v", err) + } + changes := make(chan int, 4) + oldManager.RegisterCallback(func(c *config.Config) { + changes <- c.Server.Port + }) + if err := oldManager.StartWatcher(); err != nil { + t.Fatalf("old StartWatcher: %v", err) + } + config.SetDefaultManager(oldManager) + + newManager := config.NewManager(newPath) + if _, err := newManager.Load(); err != nil { + t.Fatalf("new Load: %v", err) + } + config.SetDefaultManager(newManager) + defer config.SetDefaultManager(nil) + + if err := os.WriteFile(oldPath, []byte(validConfigYAML(8097)), 0644); err != nil { + t.Fatalf("WriteFile old: %v", err) + } + select { + case port := <-changes: + t.Fatalf("旧 watcher 已停止,不应收到端口 %d", port) + case <-time.After(500 * time.Millisecond): + } +} + +func TestSetGetAndViperReturnCopies(t *testing.T) { + cfg := &config.Config{ + App: config.AppConfig{Name: "copy", Env: "dev"}, + CORS: config.CORSConfig{ + AllowedOrigins: []string{"https://a.example.com"}, + }, + } + if err := config.Set(cfg); err != nil { + t.Fatalf("Set: %v", err) + } + defer config.SetDefaultManager(nil) + + cfg.App.Name = "mutated-input" + cfg.CORS.AllowedOrigins[0] = "https://mutated-input.example.com" + got := config.Get() + if got.App.Name != "copy" { + t.Fatalf("Set 应保存副本,实际 App.Name=%q", got.App.Name) + } + if got.CORS.AllowedOrigins[0] != "https://a.example.com" { + t.Fatalf("Set 应深拷贝切片,实际 origin=%q", got.CORS.AllowedOrigins[0]) + } + + got.App.Name = "mutated-get" + got.CORS.AllowedOrigins[0] = "https://mutated-get.example.com" + gotAgain := config.Get() + if gotAgain.App.Name != "copy" || gotAgain.CORS.AllowedOrigins[0] != "https://a.example.com" { + t.Fatalf("Get 应返回副本,实际 %+v", gotAgain) + } + + if err := config.Set(&config.Config{Server: config.ServerConfig{Port: 99999}}); err == nil { + t.Fatal("Set 非法配置应返回错误") + } + if got := config.Get().App.Name; got != "copy" { + t.Fatalf("Set 非法配置不应覆盖旧配置,实际 App.Name=%q", got) + } +} + +func TestGetViperReturnsSnapshot(t *testing.T) { + p := writeConfig(t, "c10_viper_snapshot.yaml", validConfigYAML(8098)) + defer os.Remove(p) + + if _, err := config.Load(p); err != nil { + t.Fatalf("Load: %v", err) + } + defer config.SetDefaultManager(nil) + + v := config.GetViper() + if v == nil { + t.Fatal("GetViper returned nil") + } + v.Set("app.name", "mutated") + if got := config.GetString("app.name"); got != "c10" { + t.Fatalf("GetViper 应返回快照,不应污染内部 viper,实际 app.name=%q", got) + } +} diff --git a/config/config_fix_test.go b/config/config_fix_test.go new file mode 100644 index 0000000..06603d1 --- /dev/null +++ b/config/config_fix_test.go @@ -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"}, + }, + }, + }, + } +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..bef5051 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,441 @@ +package config_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/config" +) + +func getTempDir() string { + dir := os.TempDir() + return filepath.Join(dir, "xlgo_test") +} + +func setupTempFile(name, content string) (string, error) { + dir := getTempDir() + os.MkdirAll(dir, 0755) + path := filepath.Join(dir, name) + err := os.WriteFile(path, []byte(content), 0644) + return path, err +} + +func TestAppConfig(t *testing.T) { + // 测试 AppConfig 方法 + app := config.AppConfig{ + Name: "TestApp", + SiteName: "test_site", + Version: "1.0.0", + Env: "dev", + Debug: true, + BaseURL: "https://test.example.com", + } + + // GetSiteName + if app.GetSiteName() != "test_site" { + t.Error("GetSiteName failed") + } + + // IsDebug + if !app.IsDebug() { + t.Error("IsDebug failed") + } + + // IsDev + if !app.IsDev() { + t.Error("IsDev failed") + } + + // IsProd + if app.IsProd() { + t.Error("IsProd should be false for dev") + } + + // 测试 nil 安全性 + var nilApp *config.AppConfig + if nilApp.GetSiteName() != "" { + t.Error("nil GetSiteName should return empty") + } + if nilApp.IsDebug() { + t.Error("nil IsDebug should return false") + } +} + +func TestAppConfigIsProd(t *testing.T) { + app := config.AppConfig{Env: "prod"} + if !app.IsProd() { + t.Error("IsProd failed for prod") + } + + app2 := config.AppConfig{Env: "production"} + if !app2.IsProd() { + t.Error("IsProd failed for production") + } +} + +func TestDatabaseConfigDSN(t *testing.T) { + db := config.DatabaseConfig{ + Host: "localhost", + Port: 3306, + User: "root", + Password: "password", + Name: "testdb", + } + + dsn := db.DSN() + expected := "root:password@tcp(localhost:3306)/testdb?charset=utf8mb4&parseTime=True&loc=Local" + if dsn != expected { + t.Errorf("DSN = %s, want %s", dsn, expected) + } +} + +func TestDatabaseConfigPostgresDSN(t *testing.T) { + db := config.DatabaseConfig{ + Driver: config.DriverPostgres, + Host: "localhost", + Port: 5432, + User: "postgres", + Password: "password", + Name: "testdb", + } + + dsn := db.DSN() + expected := "host='localhost' port=5432 user='postgres' password='password' dbname='testdb' sslmode=prefer TimeZone='Asia/Shanghai'" + if dsn != expected { + t.Errorf("Postgres DSN = %s, want %s", dsn, expected) + } + + // 显式 MySQL DSN 不受 Driver 影响 + if db.MySQLDSN() == "" { + t.Error("MySQLDSN should not be empty") + } +} + +func TestDatabaseConfigUnknownDriverDoesNotFallback(t *testing.T) { + db := config.DatabaseConfig{ + Driver: "no-such-driver", + Host: "localhost", + Port: 3306, + User: "root", + Password: "password", + Name: "testdb", + } + + if dsn := db.DSN(); dsn != "" { + t.Fatalf("未知 driver 不应静默回退 MySQL,实际 DSN=%q", dsn) + } + + cfg := &config.Config{Database: db} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "database.driver") { + t.Fatalf("未知 driver 应在 Validate 阶段报错,实际: %v", err) + } +} + +// TestDatabaseConfigDSNPasswordEscape_M9:含特殊字符的密码须被转义,不破坏 DSN。 +func TestDatabaseConfigDSNPasswordEscape_M9(t *testing.T) { + // MySQL:密码含 @/:/空格 → url.QueryEscape + mysql := config.DatabaseConfig{ + Driver: config.DriverMySQL, + Host: "localhost", + Port: 3306, + User: "root", + Password: "p@ss w:ord", + Name: "testdb", + } + mdsn := mysql.MySQLDSN() + // 密码段应被转义,@ 不应与 DSN 的 @ 分隔符混淆 + if !strings.Contains(mdsn, "root:p%40ss+w%3Aord@tcp") { + t.Errorf("MySQL DSN password not escaped: %s", mdsn) + } + + // Postgres:密码含单引号 → 翻倍转义 + pg := config.DatabaseConfig{ + Driver: config.DriverPostgres, + Host: "localhost", + Port: 5432, + User: "postgres", + Password: "p'ord", + Name: "testdb", + } + pdsn := pg.PostgresDSN() + if !strings.Contains(pdsn, `password='p\'ord'`) { + t.Errorf("Postgres DSN password not escaped: %s", pdsn) + } + + // Timezone 可配置 + pg2 := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n", Timezone: "UTC"} + if !strings.Contains(pg2.PostgresDSN(), "TimeZone='UTC'") { + t.Errorf("Postgres DSN should honor Timezone=UTC: %s", pg2.PostgresDSN()) + } + mysql2 := config.DatabaseConfig{Driver: config.DriverMySQL, Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", Timezone: "UTC"} + if !strings.Contains(mysql2.MySQLDSN(), "loc=UTC") { + t.Errorf("MySQL DSN should honor Timezone=UTC: %s", mysql2.MySQLDSN()) + } +} + +func TestDatabaseConfigCustomDSN(t *testing.T) { + db := config.DatabaseConfig{ + Driver: config.DriverPostgres, + CustomDSN: "custom-connection-string", + } + if db.DSN() != "custom-connection-string" { + t.Errorf("CustomDSN should take precedence, got %s", db.DSN()) + } +} + +func TestRedisConfigAddr(t *testing.T) { + redis := config.RedisConfig{ + Host: "localhost", + Port: 6379, + } + + addr := redis.Addr() + if addr != "localhost:6379" { + t.Errorf("Addr = %s, want localhost:6379", addr) + } +} + +func TestConfigLoad(t *testing.T) { + // 创建临时配置文件 + content := ` +app: + name: "测试应用" + site_name: "test_api" + version: "1.0.0" + env: "dev" + debug: true + +server: + port: 8080 + mode: "development" + +database: + host: "localhost" + port: 3306 + user: "root" + password: "test" + name: "testdb" + +redis: + host: "localhost" + port: 6379 + password: "" + db: 0 + +jwt: + secret: "test-secret-12345678901234567890123456789012" + expire: "1h" +` + tmpFile, err := setupTempFile("test_config.yaml", content) + if err != nil { + t.Fatalf("WriteFile error: %v", err) + } + defer os.Remove(tmpFile) + defer os.Remove(getTempDir()) + + // 重置全局状态 + config.Set(nil) + + // 加载配置 + cfg, err := config.Load(tmpFile) + if err != nil { + t.Fatalf("Load error: %v", err) + } + + // 验证配置 + if cfg.App.Name != "测试应用" { + t.Errorf("App.Name = %s", cfg.App.Name) + } + if cfg.App.SiteName != "test_api" { + t.Errorf("App.SiteName = %s", cfg.App.SiteName) + } + if cfg.Server.Port != 8080 { + t.Errorf("Server.Port = %d", cfg.Server.Port) + } + if cfg.Database.Host != "localhost" { + t.Errorf("Database.Host = %s", cfg.Database.Host) + } + + // 测试 Get + cfg2 := config.Get() + if cfg2 == nil { + t.Error("Get returned nil") + } + + // 测试 GetSiteName + if cfg.GetSiteName() != "test_api" { + t.Errorf("GetSiteName = %s", cfg.GetSiteName()) + } + + // 测试 GetAppName + if cfg.GetAppName() != "测试应用" { + t.Errorf("GetAppName = %s", cfg.GetAppName()) + } + + // 测试 IsDevelopment + if !cfg.IsDevelopment() { + t.Error("IsDevelopment should be true") + } + + // 测试 IsProduction + if cfg.IsProduction() { + t.Error("IsProduction should be false") + } + + // 测试 GetString/GetInt (子测试) + t.Run("GetString", func(t *testing.T) { + val := config.GetString("app.site_name") + if val != "test_api" { + t.Errorf("GetString = %s, want test_api", val) + } + }) + + t.Run("GetInt", func(t *testing.T) { + port := config.GetInt("server.port") + if port != 8080 { + t.Errorf("GetInt = %d, want 8080", port) + } + }) + + t.Run("GetBool", func(t *testing.T) { + if config.GetBool("nonexistent") != false { + t.Error("GetBool should return false for nonexistent") + } + }) +} + +func TestConfigGetString(_ *testing.T) { + // 已在 TestConfigLoad 中作为子测试完成 + // 此函数保留为占位符,避免删除后影响其他测试引用 +} + +func TestConfigLoadReloadsDifferentFiles(t *testing.T) { + first, err := setupTempFile("first_config.yaml", "app:\n name: first\nserver:\n port: 1001\n") + if err != nil { + t.Fatalf("WriteFile first error: %v", err) + } + second, err := setupTempFile("second_config.yaml", "app:\n name: second\nserver:\n port: 1002\n") + if err != nil { + t.Fatalf("WriteFile second error: %v", err) + } + defer os.Remove(first) + defer os.Remove(second) + + cfg, err := config.Load(first) + if err != nil { + t.Fatalf("Load first error: %v", err) + } + if cfg.App.Name != "first" || cfg.Server.Port != 1001 { + t.Fatalf("unexpected first config: %+v", cfg) + } + + cfg, err = config.Load(second) + if err != nil { + t.Fatalf("Load second error: %v", err) + } + if cfg.App.Name != "second" || cfg.Server.Port != 1002 { + t.Fatalf("unexpected second config: %+v", cfg) + } +} + +func TestConfigManagerIsolation(t *testing.T) { + first, err := setupTempFile("manager_first.yaml", "app:\n name: manager_first\n") + if err != nil { + t.Fatalf("WriteFile first error: %v", err) + } + second, err := setupTempFile("manager_second.yaml", "app:\n name: manager_second\n") + if err != nil { + t.Fatalf("WriteFile second error: %v", err) + } + defer os.Remove(first) + defer os.Remove(second) + + m1 := config.NewManager(first) + m2 := config.NewManager(second) + cfg1, err := m1.Load() + if err != nil { + t.Fatalf("m1 Load error: %v", err) + } + cfg2, err := m2.Load() + if err != nil { + t.Fatalf("m2 Load error: %v", err) + } + if cfg1.App.Name != "manager_first" || cfg2.App.Name != "manager_second" { + t.Fatalf("managers should be isolated: %+v %+v", cfg1, cfg2) + } +} + +func TestConfigSet(t *testing.T) { + cfg := &config.Config{ + App: config.AppConfig{ + Name: "Manual", + SiteName: "manual_site", + }, + } + + config.Set(cfg) + + if config.Get().App.Name != "Manual" { + t.Error("Set failed") + } +} + +func TestConfigMethodsOnNil(t *testing.T) { + // 测试 nil Config 的方法安全性 + var nilCfg *config.Config + + if nilCfg.IsDevelopment() { + t.Error("nil IsDevelopment should be false") + } + if nilCfg.IsProduction() { + t.Error("nil IsProduction should be false") + } + if nilCfg.GetAppName() != "" { + t.Error("nil GetAppName should return empty") + } + if nilCfg.GetSiteName() != "" { + t.Error("nil GetSiteName should return empty") + } +} + +func TestStorageConfig(t *testing.T) { + cfg := config.StorageConfig{ + Driver: "local", + Local: config.LocalStorageConfig{ + Path: "/uploads", + BaseURL: "http://localhost/uploads", + }, + } + + if cfg.Driver != "local" { + t.Error("StorageConfig Driver failed") + } +} + +func TestLogConfig(t *testing.T) { + log := config.LogConfig{ + Dir: "/logs", + MaxSize: 100, + MaxBackups: 5, + MaxAge: 30, + Compress: true, + } + + if log.Dir != "/logs" { + t.Error("LogConfig Dir failed") + } +} + +func TestUploadConfig(t *testing.T) { + upload := config.UploadConfig{ + MaxFileSize: 10, + MaxVideoSize: 100, + AllowedImageTypes: []string{"image/jpeg", "image/png"}, + } + + if upload.MaxFileSize != 10 { + t.Error("UploadConfig failed") + } +} diff --git a/config/validate.go b/config/validate.go new file mode 100644 index 0000000..6011c7b --- /dev/null +++ b/config/validate.go @@ -0,0 +1,109 @@ +package config + +import ( + "fmt" + "strings" + "time" +) + +// Validate 校验配置完整性与取值合法性(#16)。 +// 在 Manager.Load 解析后自动调用,把"运行时第一次请求才暴露"的配置错误 +// 提前到进程启动期。返回的 error 描述具体字段,便于定位。 +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("配置为空") + } + var problems []string + + // Server + if c.Server.Port < 0 || c.Server.Port > 65535 { + problems = append(problems, fmt.Sprintf("server.port 超出范围(0-65535): %d", c.Server.Port)) + } + if c.Server.TLS.Enabled { + if strings.TrimSpace(c.Server.TLS.CertFile) == "" || strings.TrimSpace(c.Server.TLS.KeyFile) == "" { + problems = append(problems, "server.tls 启用后必须同时配置 cert_file 与 key_file") + } + } + if !validDuration(c.Server.ReadTimeout) || !validDuration(c.Server.WriteTimeout) || + !validDuration(c.Server.IdleTimeout) || !validDuration(c.Server.ShutdownTimeout) { + problems = append(problems, "server 的 timeout 配置不能为负值") + } + + // JWT:仅当配置了 secret 时校验(未启用 jwt 的项目可留空) + if c.JWT.Secret != "" { + if len(c.JWT.Secret) < 32 { + problems = append(problems, fmt.Sprintf("jwt.secret 长度不足 32 字节(当前 %d),HMAC 密钥过短不安全", len(c.JWT.Secret))) + } + if c.JWT.Expire < 0 || c.JWT.RefreshExpire < 0 { + problems = append(problems, "jwt.expire / jwt.refresh_expire 不能为负值") + } + } + + // Database:出现任一数据库字段时视为启用;driver 为空按 MySQL 兼容处理。 + if c.Database.isConfigured() { + driver := strings.TrimSpace(c.Database.Driver) + if driver != "" { + if _, ok := LookupDSNBuilder(driver); !ok { + problems = append(problems, fmt.Sprintf("database.driver 未注册: %s", driver)) + } + } + if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Host) == "" { + problems = append(problems, "database.host 启用数据库后必填") + } + if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Name) == "" { + problems = append(problems, "database.name 启用数据库后必填") + } + if strings.TrimSpace(c.Database.CustomDSN) == "" && (c.Database.Port <= 0 || c.Database.Port > 65535) { + problems = append(problems, fmt.Sprintf("database.port 超出范围(1-65535): %d", c.Database.Port)) + } + // L-config-3:连接池配置交叉校验。MaxOpenConns>0 时 MaxIdleConns 不应超过它, + // 否则 database/sql 会按 MaxOpenConns 截断空闲连接,配置意图与实际不符。 + if c.Database.MaxOpenConns > 0 && c.Database.MaxIdleConns > c.Database.MaxOpenConns { + problems = append(problems, fmt.Sprintf( + "database.max_idle_conns(%d) 不应大于 max_open_conns(%d)", c.Database.MaxIdleConns, c.Database.MaxOpenConns)) + } + // M-config-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 时校验 + if strings.TrimSpace(c.Redis.Host) != "" { + if c.Redis.Port <= 0 || c.Redis.Port > 65535 { + problems = append(problems, fmt.Sprintf("redis.port 超出范围(1-65535): %d", c.Redis.Port)) + } + } + + // Trace:仅当启用时校验采样比例。ExporterType/Propagator 的枚举校验委托 trace.Init + // (随 OTel 版本可能扩展,避免 config 与 trace 双源枚举漂移)。 + if c.Trace.Enabled { + if c.Trace.SampleRatio < 0 || c.Trace.SampleRatio > 1 { + problems = append(problems, fmt.Sprintf("trace.sample_ratio 超出范围(0.0-1.0): %v", c.Trace.SampleRatio)) + } + } + + if len(problems) > 0 { + return fmt.Errorf("配置校验失败: %s", strings.Join(problems, "; ")) + } + return nil +} + +// validDuration 校验 Duration 非负(0 表示未配置/用默认,合法)。 +func validDuration(d time.Duration) bool { return d >= 0 } + +// validPostgresSSLMode 校验 PostgreSQL sslmode 取值(M-config-2)。 +func validPostgresSSLMode(s string) bool { + switch s { + case "disable", "allow", "prefer", "require", "verify-ca", "verify-full": + return true + } + return false +} diff --git a/config/validate_test.go b/config/validate_test.go new file mode 100644 index 0000000..863e05f --- /dev/null +++ b/config/validate_test.go @@ -0,0 +1,81 @@ +package config_test + +import ( + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/config" +) + +func validBase() *config.Config { + return &config.Config{ + Server: config.ServerConfig{Port: 8080}, + } +} + +func TestValidateOK(t *testing.T) { + if err := validBase().Validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateServerPort(t *testing.T) { + c := validBase() + c.Server.Port = 99999 + if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "server.port") { + t.Fatalf("expected server.port error, got %v", err) + } +} + +func TestValidateJWTSecretTooShort(t *testing.T) { + c := validBase() + c.JWT.Secret = "short" + if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "jwt.secret") { + t.Fatalf("expected jwt.secret error, got %v", err) + } +} + +func TestValidateJWTSecretAbsentSkipped(t *testing.T) { + c := validBase() + // secret 为空时不校验(未启用 jwt) + if err := c.Validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateTraceSampleRatioWhenEnabled(t *testing.T) { + c := validBase() + c.Trace.Enabled = true + c.Trace.SampleRatio = 1.5 + if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "trace.sample_ratio") { + t.Fatalf("expected trace.sample_ratio error, got %v", err) + } +} + +func TestValidateTraceDisabledSkipsRatio(t *testing.T) { + c := validBase() + // Enabled=false 时即使 SampleRatio 非法也不校验(trace 未启用) + c.Trace.SampleRatio = 1.5 + if err := c.Validate(); err != nil { + t.Fatalf("unexpected error when trace disabled: %v", err) + } +} + +func TestValidateDatabaseMissingHost(t *testing.T) { + c := validBase() + c.Database.Driver = "mysql" + c.Database.Port = 3306 + c.Database.Name = "db" + // Host 留空 + if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "database.host") { + t.Fatalf("expected database.host error, got %v", err) + } +} + +func TestValidateTLSMissingCert(t *testing.T) { + c := validBase() + c.Server.TLS.Enabled = true + if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "cert_file") { + t.Fatalf("expected tls cert_file error, got %v", err) + } +} diff --git a/console/console.go b/console/console.go new file mode 100644 index 0000000..5891e51 --- /dev/null +++ b/console/console.go @@ -0,0 +1,292 @@ +package console + +import ( + "fmt" + "io" + "os" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" +) + +// writeMu 串行化所有 Console 的写出序列(P1 #7)。 +// 用包级锁而非 per-Console 锁:多个 Console 实例可能共享同一底层 writer(如 stdout), +// per-Console 锁无法阻止跨实例交错;且 Windows printColor 的"设色→写→复位"三步必须整体 +// 原子,否则并发彩色输出会串色。控制台输出非热路径,单一全局写锁的串行化开销可忽略。 +var writeMu sync.Mutex + +// Level 日志级别 +type Level int32 + +const ( + // LevelDebug 调试级别(最低)。 + LevelDebug Level = iota + // LevelInfo 普通信息。 + LevelInfo + // LevelSuccess 成功信息。 + LevelSuccess + // LevelWarn 警告。 + LevelWarn + // LevelError 错误(最高常规级别)。 + LevelError + + // LevelSilent 完全静默:所有调用都不输出 + LevelSilent Level = 127 +) + +// String 返回级别名称 +func (l Level) String() string { + if c, ok := colors[l]; ok { + return c.Name + } + if l == LevelSilent { + return "Silent" + } + return "Unknown" +} + +// Color 颜色定义 +type Color struct { + Code string + Name string +} + +var colors = map[Level]Color{ + LevelDebug: {Code: "0;36", Name: "Debug"}, // 青色 + LevelInfo: {Code: "0;37", Name: "Info"}, // 白色 + LevelSuccess: {Code: "0;92", Name: "Success"}, // 亮绿色 + LevelWarn: {Code: "1;93", Name: "Warn"}, // 亮黄色 + LevelError: {Code: "1;31", Name: "Error"}, // 亮红色 +} + +// Console 控制台打印器。 +// +// console 包定位:开发期彩色 stdout 工具,跟 fmt.Println 同级。 +// 不写文件、不感知运行环境、不做任何隐式 level 切换—— +// 所有 level 行为都由调用方显式控制(SetLevel / WithLevel)。 +// +// 业务可观测信息(用户登录、订单状态变更等"上线后必须保留的事件") +// 请使用 logger 包;console 仅用于开发期肉眼调试。 +type Console struct { + output io.Writer + isColor bool + showTime bool + showCall bool + timeFmt string + skipCall int + + // level 通过 atomic 访问,支持运行期热切换且并发安全。 + // 用 int32 存储 Level,0 = LevelDebug,与零值默认对齐。 + level atomic.Int32 +} + +// Option 配置选项 +type Option func(*Console) + +// WithOutput 设置输出目标 +func WithOutput(w io.Writer) Option { + return func(c *Console) { + c.output = w + } +} + +// WithColor 设置是否启用颜色 +func WithColor(enable bool) Option { + return func(c *Console) { + c.isColor = enable + } +} + +// WithTime 设置是否显示时间 +func WithTime(show bool) Option { + return func(c *Console) { + c.showTime = show + } +} + +// WithCaller 设置是否显示调用位置。 +// skip 可选,默认 2(直接调用方);自封装一层时传 3。 +func WithCaller(show bool, skip ...int) Option { + return func(c *Console) { + c.showCall = show + if len(skip) > 0 && skip[0] > 0 { + c.skipCall = skip[0] + } + } +} + +// WithTimeFormat 设置时间格式 +func WithTimeFormat(fmt string) Option { + return func(c *Console) { + c.timeFmt = fmt + } +} + +// WithLevel 设置最低输出级别。低于该级别的调用会被静默丢弃。 +// +// 例:WithLevel(LevelWarn) 只输出 Warn 与 Error; +// +// WithLevel(LevelSilent) 完全静默,常用于压测或上线观察期临时关闭调试输出。 +func WithLevel(l Level) Option { + return func(c *Console) { + c.level.Store(int32(l)) + } +} + +// New 创建控制台打印器 +func New(opts ...Option) *Console { + c := &Console{ + output: os.Stdout, + isColor: true, + showTime: true, + showCall: true, + timeFmt: "15:04:05.000", + skipCall: 2, + } + // 默认 LevelDebug:开发期所有级别都打印。生产期请显式 SetLevel/WithLevel。 + c.level.Store(int32(LevelDebug)) + for _, opt := range opts { + opt(c) + } + return c +} + +// SetLevel 运行期切换最低输出级别。并发安全。 +func (c *Console) SetLevel(l Level) { + c.level.Store(int32(l)) +} + +// Level 返回当前最低输出级别 +func (c *Console) Level() Level { + return Level(c.level.Load()) +} + +// Default 默认控制台 +var Default = New() + +// SetLevel 设置默认控制台的最低输出级别。并发安全。 +// +// 典型用法(在 main 中根据 cfg 显式切换): +// +// if cfg.IsProduction() { +// console.SetLevel(console.LevelWarn) // 生产期只看 Warn / Error +// } +// +// 框架不会自动根据环境模式切换,选择权完全在调用方。 +func SetLevel(l Level) { + Default.SetLevel(l) +} + +// GetLevel 返回默认控制台当前最低输出级别。 +// (命名加 Get 前缀是因为 Level 已被类型占用,Go 不允许同名函数。) +func GetLevel() Level { + return Default.Level() +} + +// print 内部打印函数 +func (c *Console) print(level Level, s ...any) { + // 级别过滤:低于阈值或调用方显式 LevelSilent 时直接返回,零开销 + if level < c.Level() { + return + } + + var sb strings.Builder + + // 时间 + if c.showTime { + sb.WriteString(time.Now().Format(c.timeFmt)) + sb.WriteString(" ") + } + + // 级别名称 + color := colors[level] + sb.WriteString("[") + sb.WriteString(color.Name) + sb.WriteString("] ") + + // 调用位置 + if c.showCall { + sb.WriteString(c.getCaller()) + sb.WriteString(" ") + } + + // 内容 + sb.WriteString(fmt.Sprint(s...)) + + // 输出。P1 #7:全局写锁串行化整个写序列,避免并发交错输出; + // Windows 下 printColor 的"设色→写→复位"三步也被此锁整体保护,防止串色。 + writeMu.Lock() + if c.isColor { + c.printColor(color.Code, sb.String()) + } else { + fmt.Fprintln(c.output, sb.String()) + } + writeMu.Unlock() +} + +// getCaller 获取调用位置 +func (c *Console) getCaller() string { + _, file, line, ok := runtime.Caller(c.skipCall) + if !ok { + return "" + } + // 只取文件名(兼容 / 与 \ 分隔) + if idx := strings.LastIndexAny(file, "/\\"); idx >= 0 { + file = file[idx+1:] + } + return fmt.Sprintf("%s:%d", file, line) +} + +// Debug 打印调试信息(青色) +func (c *Console) Debug(s ...any) { + c.print(LevelDebug, s...) +} + +// Info 打印普通信息(白色) +func (c *Console) Info(s ...any) { + c.print(LevelInfo, s...) +} + +// Success 打印成功信息(绿色) +func (c *Console) Success(s ...any) { + c.print(LevelSuccess, s...) +} + +// Warn 打印警告信息(黄色) +func (c *Console) Warn(s ...any) { + c.print(LevelWarn, s...) +} + +// Error 打印错误信息(红色) +func (c *Console) Error(s ...any) { + c.print(LevelError, s...) +} + +// ===== 包级别便捷函数 ===== + +// Debug 使用默认控制台打印调试信息 +func Debug(s ...any) { + Default.print(LevelDebug, s...) +} + +// Info 使用默认控制台打印普通信息 +func Info(s ...any) { + Default.print(LevelInfo, s...) +} + +// Success 使用默认控制台打印成功信息 +func Success(s ...any) { + Default.print(LevelSuccess, s...) +} + +// Warn 使用默认控制台打印警告信息 +func Warn(s ...any) { + Default.print(LevelWarn, s...) +} + +// Error 使用默认控制台打印错误信息 +func Error(s ...any) { + Default.print(LevelError, s...) +} diff --git a/console/console_test.go b/console/console_test.go new file mode 100644 index 0000000..3f445c2 --- /dev/null +++ b/console/console_test.go @@ -0,0 +1,152 @@ +package console_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/console" +) + +func TestConsole(t *testing.T) { + // 使用默认控制台 + console.Debug("这是一条调试信息") + console.Info("这是一条普通信息") + console.Success("这是一条成功信息") + console.Warn("这是一条警告信息") + console.Error("这是一条错误信息") + + // 创建自定义控制台 + c := console.New( + console.WithColor(true), + console.WithTime(true), + console.WithCaller(true, 2), + ) + c.Debug("自定义控制台 - Debug") + c.Info("自定义控制台 - Info") + c.Success("自定义控制台 - Success") + c.Warn("自定义控制台 - Warn") + c.Error("自定义控制台 - Error") +} + +// TestConsoleLevelFilter 验证显式 level 屏蔽:低于阈值的调用不输出。 +// 这是方案 A 的核心契约——用户显式控制何时屏蔽,框架不做隐式行为。 +func TestConsoleLevelFilter(t *testing.T) { + var buf bytes.Buffer + c := console.New( + console.WithOutput(&buf), + console.WithColor(false), + console.WithTime(false), + console.WithCaller(false), + console.WithLevel(console.LevelWarn), + ) + + c.Debug("DEBUG_MARK") + c.Info("INFO_MARK") + c.Success("SUCCESS_MARK") + c.Warn("WARN_MARK") + c.Error("ERROR_MARK") + + out := buf.String() + + // Warn / Error 必须输出 + if !strings.Contains(out, "WARN_MARK") { + t.Errorf("Warn should be printed at LevelWarn, got: %q", out) + } + if !strings.Contains(out, "ERROR_MARK") { + t.Errorf("Error should be printed at LevelWarn, got: %q", out) + } + + // Debug / Info / Success 必须被静默 + for _, mark := range []string{"DEBUG_MARK", "INFO_MARK", "SUCCESS_MARK"} { + if strings.Contains(out, mark) { + t.Errorf("%s should be filtered at LevelWarn, but found in: %q", mark, out) + } + } +} + +// TestConsoleLevelSilent 验证 LevelSilent 完全静默所有调用。 +func TestConsoleLevelSilent(t *testing.T) { + var buf bytes.Buffer + c := console.New( + console.WithOutput(&buf), + console.WithColor(false), + console.WithLevel(console.LevelSilent), + ) + + c.Debug("D") + c.Info("I") + c.Success("S") + c.Warn("W") + c.Error("E") + + if buf.Len() != 0 { + t.Errorf("LevelSilent should suppress all output, got %d bytes: %q", buf.Len(), buf.String()) + } +} + +// TestConsoleSetLevel 验证运行期热切换 level。 +func TestConsoleSetLevel(t *testing.T) { + var buf bytes.Buffer + c := console.New( + console.WithOutput(&buf), + console.WithColor(false), + console.WithTime(false), + console.WithCaller(false), + ) + + // 默认 LevelDebug,Debug 应输出 + c.Debug("FIRST") + if !strings.Contains(buf.String(), "FIRST") { + t.Errorf("Debug should print at default LevelDebug, got: %q", buf.String()) + } + + buf.Reset() + + // 切到 LevelError 后,Debug 应静默 + c.SetLevel(console.LevelError) + if got := c.Level(); got != console.LevelError { + t.Errorf("Level() = %v, want LevelError", got) + } + c.Debug("SECOND") + if buf.Len() != 0 { + t.Errorf("Debug should be filtered after SetLevel(LevelError), got: %q", buf.String()) + } + + // Error 仍然输出 + c.Error("THIRD") + if !strings.Contains(buf.String(), "THIRD") { + t.Errorf("Error should print at LevelError, got: %q", buf.String()) + } +} + +// TestConsolePackageLevelAPI 验证包级 SetLevel / GetLevel 操作的是 Default 实例。 +func TestConsolePackageLevelAPI(t *testing.T) { + original := console.GetLevel() + t.Cleanup(func() { console.SetLevel(original) }) + + console.SetLevel(console.LevelWarn) + if got := console.GetLevel(); got != console.LevelWarn { + t.Errorf("GetLevel() = %v, want LevelWarn", got) + } + if got := console.Default.Level(); got != console.LevelWarn { + t.Errorf("Default.Level() = %v, want LevelWarn (package SetLevel must affect Default)", got) + } +} + +// TestConsoleLevelString 验证 Level.String 输出可读名称(错误信息 / 日志会用到)。 +func TestConsoleLevelString(t *testing.T) { + cases := map[console.Level]string{ + console.LevelDebug: "Debug", + console.LevelInfo: "Info", + console.LevelSuccess: "Success", + console.LevelWarn: "Warn", + console.LevelError: "Error", + console.LevelSilent: "Silent", + } + for l, want := range cases { + if got := l.String(); got != want { + t.Errorf("Level(%d).String() = %q, want %q", l, got, want) + } + } +} diff --git a/console/console_unix.go b/console/console_unix.go new file mode 100644 index 0000000..11b7eb0 --- /dev/null +++ b/console/console_unix.go @@ -0,0 +1,15 @@ +//go:build linux || darwin + +package console + +import ( + "fmt" +) + +// printColor 彩色打印(使用ANSI转义序列) +func (c *Console) printColor(code, msg string) { + // \033[ 是ANSI转义序列起始 + // %sm 是颜色代码 + // \033[0m 是重置颜色 + fmt.Fprintf(c.output, "\033[%sm%s\033[0m\n", code, msg) +} diff --git a/console/console_windows.go b/console/console_windows.go new file mode 100644 index 0000000..baa4b2d --- /dev/null +++ b/console/console_windows.go @@ -0,0 +1,69 @@ +//go:build windows + +package console + +import ( + "fmt" + "os" + "syscall" + "unsafe" +) + +var kernel32 = syscall.NewLazyDLL("kernel32.dll") + +// 颜色映射:ANSI -> Windows控制台颜色 +// 0 黑色, 1 蓝色, 2 绿色, 3 青色, 4 红色, 5 紫色, 6 黄色, 7 淡灰色 +// 8 灰色, 9 亮蓝色, 10 亮绿色, 11 亮青色, 12 亮红色, 13 亮紫色, 14 亮黄色, 15 白色 +var colorMap = map[string]uintptr{ + "0;36": 11, // 青色 -> 亮青色 + "0;37": 15, // 白色 -> 白色 + "0;92": 10, // 亮绿色 + "1;93": 14, // 亮黄色 + "1;31": 12, // 亮红色 +} + +// printColor 彩色打印。 +// +// 修复 M17:原实现对 syscall.Stdout 设置颜色、却把文本写到 c.output—— +// 当 c.output 非 stdout(如 WithOutput 指向文件)时,颜色落到错误句柄、文本落文件, +// 二者分裂。现按 c.output 实际类型取句柄:*os.File 用其 Fd(),否则放弃着色只写文本。 +func (c *Console) printColor(code, msg string) { + color := colorMap[code] + if color == 0 { + color = 7 // 默认淡灰色 + } + + handle, ok := consoleHandle(c.output) + if !ok { + // 非 *os.File(如 bytes.Buffer/文件),无法设置控制台属性,退化为纯文本。 + fmt.Fprintln(c.output, msg) + return + } + + proc := kernel32.NewProc("SetConsoleTextAttribute") + _, _, _ = proc.Call(handle, color) + fmt.Fprintln(c.output, msg) + _, _, _ = proc.Call(handle, 7) // 恢复默认颜色 +} + +// consoleHandle 从 io.Writer 取 Windows 控制台句柄;非 *os.File 返回 (0, false)。 +func consoleHandle(w interface{ Write([]byte) (int, error) }) (uintptr, bool) { + f, ok := w.(*os.File) + if !ok { + return 0, false + } + fd := f.Fd() + switch fd { + case uintptr(syscall.Stdout), uintptr(syscall.Stderr), uintptr(syscall.Stdin): + return fd, true + default: + // 重定向到文件/管道的 *os.File,SetConsoleTextAttribute 无意义,退化为纯文本。 + return 0, false + } +} + +// (M17:原 EnableVirtualTerminal 为死代码且从未被调用,已移除。 +// Windows 10+ 默认支持 ANSI/VT 着色;需要 VT 的调用方应自行调 kernel32。) + +// 保留 unsafe 引用以备将来 VT 扩展;当前 printColor 不再需要,故显式抑制未用告警。 +var _ = unsafe.Sizeof(uintptr(0)) diff --git a/cron/cron.go b/cron/cron.go new file mode 100644 index 0000000..a5a8fb1 --- /dev/null +++ b/cron/cron.go @@ -0,0 +1,840 @@ +package cron + +import ( + "context" + "fmt" + "runtime/debug" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/EthanCodeCraft/xlgo-core/logger" + "go.uber.org/zap" +) + +// Task 定时任务 +type Task struct { + Name string // 任务名称 + Schedule Schedule // 调度规则 + Handler TaskHandler // 任务处理函数 + Enabled bool // 是否启用 + LastRun time.Time // 上次运行时间 + NextRun time.Time // 下次运行时间 + RunCount int // 运行次数 + LastError error // CR3 修复:最近一次执行错误 + + // running 防止长任务跨 tick 重叠执行(C12b)。 + // 用指针以便 GetTask/ListTasks 返回 Task 拷贝时不触发 atomic.Bool 值类型的 + // copylocks 警告;拷贝共享同一守卫状态,但该字段未导出,外部无法操作。 + // nil 表示未初始化(仅非 AddTask 构造的 Task),checkAndRun/RunTask 以 nil 守卫跳过。 + running *atomic.Bool +} + +// TaskHandler 任务处理函数 +type TaskHandler func(ctx context.Context) error + +// Schedule 调度接口 +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 + 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 := newSchedulerContext() + return &Scheduler{ + tasks: make(map[string]*Task), + ctx: ctx, + cancel: cancel, + } +} + +// AddTask 添加任务 +func (s *Scheduler) AddTask(name string, schedule Schedule, handler TaskHandler) *Task { + if schedule == nil { + panic("cron: AddTask requires a non-nil schedule") + } + if handler == nil { + panic("cron: AddTask requires a non-nil handler") + } + validateSchedule(schedule) + + task := &Task{ + Name: name, + Schedule: schedule, + Handler: handler, + Enabled: true, + NextRun: schedule.Next(time.Now()), + running: &atomic.Bool{}, + } + + s.mu.Lock() + s.tasks[name] = task + s.mu.Unlock() + + return task +} + +// RemoveTask 移除任务 +func (s *Scheduler) RemoveTask(name string) { + s.mu.Lock() + delete(s.tasks, name) + s.mu.Unlock() +} + +// EnableTask 启用任务 +func (s *Scheduler) EnableTask(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + + task, ok := s.tasks[name] + if !ok { + return fmt.Errorf("任务不存在: %s", name) + } + + task.Enabled = true + task.NextRun = task.Schedule.Next(time.Now()) + return nil +} + +// DisableTask 禁用任务 +func (s *Scheduler) DisableTask(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + + task, ok := s.tasks[name] + if !ok { + return fmt.Errorf("任务不存在: %s", name) + } + + task.Enabled = false + return nil +} + +// GetTask 获取任务(返回拷贝快照,避免外部并发读 live 指针,C12a)。 +func (s *Scheduler) GetTask(name string) (*Task, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + task, ok := s.tasks[name] + if !ok { + return nil, fmt.Errorf("任务不存在: %s", name) + } + cp := *task + return &cp, nil +} + +// ListTasks 获取所有任务(返回拷贝快照,C12a)。 +func (s *Scheduler) ListTasks() []*Task { + s.mu.RLock() + defer s.mu.RUnlock() + + tasks := make([]*Task, 0, len(s.tasks)) + for _, task := range s.tasks { + cp := *task + tasks = append(tasks, &cp) + } + 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)。 +// 不推进 NextRun(手动触发不影响调度节奏,C12c)。LastRun/RunCount 在锁内更新(C12a)。 +// +// L-J:handler 收到的 ctx 为调度器 s.ctx。Stop 后 s.ctx 已取消,handler 会收到 canceled +// ctx——这是预期行为(手动触发在调度器停止后不应继续执行长任务)。调用方若需在 Stop 后 +// 仍运行一次性任务,应在自己的 ctx 下执行而非依赖调度器。 +func (s *Scheduler) RunTask(name string) error { + s.mu.Lock() + task, ok := s.tasks[name] + s.mu.Unlock() + + if !ok { + return fmt.Errorf("任务不存在: %s", name) + } + + // 占用 running 守卫(nil 守卫直接放行,仅防御非 AddTask 构造的 Task)。 + if task.running != nil && !task.running.CompareAndSwap(false, true) { + return fmt.Errorf("任务正在执行中: %s", name) + } + defer func() { + if task.running != nil { + task.running.Store(false) + } + }() + + err := s.executeTask(task) + + s.mu.Lock() + task.LastRun = time.Now() + task.RunCount++ + task.LastError = err // M13: 手动路径也记 LastError(与调度路径对齐) + s.mu.Unlock() + + return err +} + +// 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() + + go s.run(ctx) +} + +// Stop 停止调度器并无限等待在跑任务退出(要求 handler 尊重 ctx.Done)。 +// 若担心某 handler 不响应 ctx 而永久阻塞,请用 StopWithTimeout。 +func (s *Scheduler) Stop() { + s.StopWithTimeout(0) +} + +// StopWithTimeout 停止调度器,最多等待 timeout 让在跑任务退出(P1 #10)。 +// 返回 true 表示所有任务已退出;false 表示超时(仍有任务未响应 ctx.Done 而运行)。 +// timeout<=0 等价于无限等待(同 Stop)。幂等:未运行时直接返回 true。 +func (s *Scheduler) StopWithTimeout(timeout time.Duration) bool { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return true + } + s.running = false + cancel := s.cancel + s.mu.Unlock() + + cancel() + if timeout <= 0 { + s.wg.Wait() + return true + } + done := make(chan struct{}) + go func() { s.wg.Wait(); close(done) }() + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +// run 运行调度循环 +func (s *Scheduler) run(ctx context.Context) { + defer s.wg.Done() + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.checkAndRun(ctx) + } + } +} + +// checkAndRun 检查并运行到期任务。 +// +// C12b:per-task running 守卫(CAS)防止长任务跨 tick 重叠 spawn。 +// C12c:占用守卫后**先推进 NextRun**(以上次 NextRun 锚定,非 time.Now()), +// 避免每周期累积 handler 时长致调度漂移;推进在 spawn 前,下次 tick 不会重复 spawn。 +// C12a:NextRun 推进在写锁内;LastRun/RunCount 在 goroutine 内写锁更新。 +// +// M-H 修复:锁内只收集到期任务(CAS 占用 + 推进 NextRun + wg.Add),锁外再 spawn。 +// 原实现整个遍历+spawn 持写锁,task 多时阻塞 GetTask/AddTask/RunTask 等管理 API。 +// +// wg.Add 必须在锁内完成:Stop 先持锁置 running=false 再 wg.Wait,若 wg.Add 在锁外, +// Stop 可能在两个 due 任务之间读到计数器 0 并提前返回,留下后启动的 goroutine 在调度器 +// 停止后运行(其 wg.Done 还会触发计数器下溢 panic)。锁内 Add 保证 Stop 的 wg.Wait +// 一定能等到本批全部 goroutine。收集阶段已 CAS 占用守卫并推进 NextRun,故 spawn 必须 +// 执行(否则守卫不释放、NextRun 已推进却未跑)。spawn 用 s.ctx——Stop 后 ctx 已取消, +// handler 收到 canceled ctx 应及时退出。 +func (s *Scheduler) checkAndRun(ctxs ...context.Context) { + now := time.Now() + var ctx context.Context + enforceRunning := len(ctxs) > 0 && ctxs[0] != nil + if len(ctxs) > 0 && ctxs[0] != nil { + ctx = ctxs[0] + } else { + s.mu.RLock() + ctx = s.ctx + s.mu.RUnlock() + } + + s.mu.Lock() + if enforceRunning && (!s.running || ctx.Err() != nil) { + s.mu.Unlock() + return + } + var due []*Task + for _, task := range s.tasks { + if !task.Enabled || task.NextRun.IsZero() || !now.After(task.NextRun) { + continue + } + // 占用 running 守卫;正在执行则跳过本轮(防重叠 C12b)。 + if task.running != nil && !task.running.CompareAndSwap(false, true) { + continue + } + // 先推进 NextRun(以上次 NextRun 锚定防漂移 C12c),再收集待 spawn。 + task.NextRun = task.Schedule.Next(task.NextRun) + s.wg.Add(1) // 锁内 Add,保证 Stop 的 wg.Wait 等到本批(见上方注释) + due = append(due, task) + } + s.mu.Unlock() + + for _, t := range due { + go func(t *Task) { + defer s.wg.Done() + defer func() { + if t.running != nil { + t.running.Store(false) + } + }() + + err := s.executeTaskWithContext(ctx, t) + + s.mu.Lock() + t.LastRun = time.Now() + t.RunCount++ + t.LastError = err // CR3 修复:记录错误不再静默丢弃 + s.mu.Unlock() + if err != nil { + logger.Error("cron task failed", + zap.String("task", t.Name), + zap.Error(err)) + } + }(t) + } +} + +// IntervalSchedule 固定间隔调度 +type IntervalSchedule struct { + Interval time.Duration +} + +// Next 计算下次运行时间 +func (s *IntervalSchedule) Next(now time.Time) time.Time { + return now.Add(s.Interval) +} + +// Every 每隔指定时间运行 +func Every(interval time.Duration) *IntervalSchedule { + validateInterval(interval) + return &IntervalSchedule{Interval: interval} +} + +// DailySchedule 每日定时调度 +type DailySchedule struct { + Hour int + Minute int +} + +// Next 计算下次运行时间 +func (s *DailySchedule) Next(now time.Time) time.Time { + next := time.Date(now.Year(), now.Month(), now.Day(), s.Hour, s.Minute, 0, 0, now.Location()) + if next.Before(now) || next.Equal(now) { + next = next.Add(24 * time.Hour) + } + return next +} + +// Daily 每日指定时间运行 +func Daily(hour, minute int) *DailySchedule { + validateClock(hour, minute, "Daily") + return &DailySchedule{Hour: hour, Minute: minute} +} + +// WeeklySchedule 每周定时调度 +type WeeklySchedule struct { + Day time.Weekday + Hour int + Minute int +} + +// Next 计算下次运行时间。 +// +// C12d:原实现 `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻, +// 当天目标时刻未到(如周一 9:00 目标、当前周一 12:00 之前的 8:00)被错误跳一周。 +// 改为:先算今天的目标时刻,按 `((day-now)+7)%7` 加天数,再与 now 比较—— +// 当天未到点则本周,当天已过则下周。 +func (s *WeeklySchedule) Next(now time.Time) time.Time { + next := time.Date(now.Year(), now.Month(), now.Day(), s.Hour, s.Minute, 0, 0, now.Location()) + daysUntil := (int(s.Day) - int(now.Weekday()) + 7) % 7 + next = next.AddDate(0, 0, daysUntil) + if !next.After(now) { + next = next.AddDate(0, 0, 7) + } + return next +} + +// Weekly 每周指定时间运行 +func Weekly(day time.Weekday, hour, minute int) *WeeklySchedule { + validateWeekday(day) + validateClock(hour, minute, "Weekly") + return &WeeklySchedule{Day: day, Hour: hour, Minute: minute} +} + +// FullCronSchedule 完整 Cron 表达式调度 +// 格式: "分钟 小时 日 月 星期" (5字段) +// 示例: "0 12 * * *" 每天12点 +// +// "0 0 1 * *" 每月1号凌晨 +// "0 9-17 * * 1-5" 周一到周五 9点到17点每小时 +type FullCronSchedule struct { + Minute string // 分钟: 0-59, "*", "*/n", "a-b", "a,b,c" + Hour string // 小时: 0-23, "*", "*/n", "a-b", "a,b,c" + Day string // 日: 1-31, "*", "*/n", "a-b", "a,b,c" + Month string // 月: 1-12, "*", "*/n", "a-b", "a,b,c" + Weekday string // 星期: 0-6 (周日=0), "*", "*/n", "a-b", "a,b,c" +} + +// Next 计算下次运行时间 +func (s *FullCronSchedule) Next(now time.Time) time.Time { + // 从下一分钟开始查找 + next := now.Add(time.Minute) + next = time.Date(next.Year(), next.Month(), next.Day(), next.Hour(), next.Minute(), 0, 0, next.Location()) + + // 最多查找一年(防止无效表达式死循环) + maxAttempts := 366 * 24 * 60 + for i := 0; i < maxAttempts; i++ { + if s.match(next) { + return next + } + next = next.Add(time.Minute) + } + return time.Time{} +} + +// match 检查时间是否匹配表达式 +func (s *FullCronSchedule) match(t time.Time) bool { + return s.matchField(s.Minute, t.Minute(), 0, 59) && + s.matchField(s.Hour, t.Hour(), 0, 23) && + s.matchField(s.Day, t.Day(), 1, 31) && + s.matchField(s.Month, int(t.Month()), 1, 12) && + s.matchField(s.Weekday, int(t.Weekday()), 0, 6) +} + +// matchField 匹配单个字段(C12e 重写)。 +// +// 旧实现先判 `-` 再判 `*/` 最后列表,且 parseInt 忽略非数字逐位累积: +// - `1-5,8` 因整字段含 `-` 被当范围,parseInt("5,8")=58 → 范围被破坏为 1..58,列表项丢失。 +// - `garbage` → parseInt=0,分/时/周字段 value=0 时误触发。 +// - `*/garbage` → step=0 → return true 匹配全部。 +// - 周日 `7` 不匹配(Go Sunday=0)。 +// +// 新实现:先按逗号拆列表,每项独立判 `*/n` / `a-b/n` / `a-b` / 单值(列表分支独立于范围分支); +// 全部用 strconv.Atoi 返错;weekday 字段(min=0,max=6)7→0,范围 lo>hi 环绕。 +func (s *FullCronSchedule) matchField(field string, value int, min, max int) bool { + if field == "*" { + return true + } + for _, raw := range strings.Split(field, ",") { + item := strings.TrimSpace(raw) + if item == "" { + continue + } + if matchCronItem(item, value, min, max) { + return true + } + } + return false +} + +// matchCronItem 处理单个 cron 字段项(已按逗号拆分)。 +func matchCronItem(item string, value, min, max int) bool { + isWeekday := min == 0 && max == 6 + + // 步长 "*/n" 或 "a-b/n" + if idx := strings.Index(item, "/"); idx >= 0 { + base := item[:idx] + step, err := strconv.Atoi(item[idx+1:]) + if err != nil || step <= 0 { + return false + } + lo, hi := min, max + if base != "*" { + rlo, rhi, err := parseCronRange(base, min, max) + if err != nil { + return false + } + lo, hi = rlo, rhi + } + return value >= lo && value <= hi && (value-lo)%step == 0 + } + + // 范围 "a-b" + if strings.Contains(item, "-") { + lo, hi, err := parseCronRange(item, min, max) + if err != nil { + return false + } + if isWeekday && lo > hi { + // 环绕:lo..6 ∪ 0..hi(如 "6-1" = 周六、周日、周一) + return (value >= lo && value <= max) || (value >= min && value <= hi) + } + return value >= lo && value <= hi + } + + // 单值 + v, err := strconv.Atoi(item) + if err != nil { + return false + } + if isWeekday && v == 7 { + v = 0 + } + if v < min || v > max { + return false + } + return v == value +} + +// parseCronRange 解析 "a-b" 范围,含边界校验与 weekday 7→0 归一化。 +func parseCronRange(s string, min, max int) (int, int, error) { + parts := strings.SplitN(s, "-", 2) + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid range %q", s) + } + lo, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + hi, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil { + return 0, 0, fmt.Errorf("invalid range %q", s) + } + isWeekday := min == 0 && max == 6 + if isWeekday { + // 7 → 0(周日)。但若两端归一化后都为 0 而原始值不同(如 "0-7"/"7-0"), + // 语义为"整周"却坍缩成"仅周日",属歧义范围,拒绝以免静默错误匹配。 + nlo, nhi := lo, hi + if nlo == 7 { + nlo = 0 + } + if nhi == 7 { + nhi = 0 + } + if nlo == nhi && lo != hi { + return 0, 0, fmt.Errorf("ambiguous weekday range %q (0 与 7 均为周日)", s) + } + lo, hi = nlo, nhi + } + if lo < min || lo > max || hi < min || hi > max { + return 0, 0, fmt.Errorf("range out of bounds %q", s) + } + return lo, hi, nil +} + +// ParseCron 解析完整 Cron 表达式。 +// 格式: "分钟 小时 日 月 星期" +// 示例: +// +// "0 12 * * *" - 每天12:00 +// "*/15 * * * *" - 每15分钟 +// "0 9-17 * * 1-5" - 工作日9-17点每小时 +// "0 0 1 * *" - 每月1号凌晨 +// "0 0 * * 0" - 每周日凌晨 +// +// 非法表达式会 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 + } + return &FullCronSchedule{"*", "*", "*", "*", "*"} +} + +// ParseCronStrict 严格解析 Cron 表达式,校验字段数与各字段范围,非法返 error。 +// 字段范围:分钟 0-59,小时 0-23,日 1-31,月 1-12,星期 0-6(周日=0,7 归一为 0)。 +func ParseCronStrict(expr string) (*FullCronSchedule, error) { + fields := strings.Fields(expr) + if len(fields) != 5 { + return nil, fmt.Errorf("cron: 需要 5 个字段,实际 %d", len(fields)) + } + specs := []struct { + val string + min, max int + name string + }{ + {fields[0], 0, 59, "minute"}, + {fields[1], 0, 23, "hour"}, + {fields[2], 1, 31, "day"}, + {fields[3], 1, 12, "month"}, + {fields[4], 0, 6, "weekday"}, + } + for _, sp := range specs { + if err := validateCronField(sp.val, sp.min, sp.max, sp.name); err != nil { + return nil, err + } + } + return &FullCronSchedule{ + Minute: fields[0], + Hour: fields[1], + Day: fields[2], + Month: fields[3], + Weekday: fields[4], + }, nil +} + +// validateCronField 校验单个 cron 字段语法与范围。 +func validateCronField(field string, min, max int, name string) error { + if field == "*" { + return nil + } + for _, raw := range strings.Split(field, ",") { + item := strings.TrimSpace(raw) + if item == "" { + return fmt.Errorf("cron %s: 空列表项", name) + } + if err := validateCronItem(item, min, max, name); err != nil { + return err + } + } + return nil +} + +func validateCronItem(item string, min, max int, name string) error { + isWeekday := min == 0 && max == 6 + norm := func(v int) int { + if isWeekday && v == 7 { + return 0 + } + return v + } + if idx := strings.Index(item, "/"); idx >= 0 { + base := item[:idx] + step, err := strconv.Atoi(item[idx+1:]) + if err != nil || step <= 0 { + return fmt.Errorf("cron %s: 非法步长 %q", name, item) + } + if base == "*" { + return nil + } + lo, hi, err := parseCronRange(base, min, max) + if err != nil { + return fmt.Errorf("cron %s: %v", name, err) + } + _ = lo + _ = hi + return nil + } + if strings.Contains(item, "-") { + if _, _, err := parseCronRange(item, min, max); err != nil { + return fmt.Errorf("cron %s: %v", name, err) + } + return nil + } + v, err := strconv.Atoi(item) + if err != nil { + return fmt.Errorf("cron %s: 非数字 %q", name, item) + } + if v = norm(v); v < min || v > max { + return fmt.Errorf("cron %s: %d 超出范围 [%d,%d]", name, v, min, max) + } + return nil +} + +// CronSchedule 简化 Cron 调度(仅分钟和小时) +type CronSchedule struct { + Minute string // 分钟: "*" 或具体值如 "0,15,30" + Hour string // 小时: "*" 或具体值如 "8,12" +} + +// Next 计算下次运行时间 +func (s *CronSchedule) Next(now time.Time) time.Time { + for i := 1; i <= 60*24; i++ { // 最多查找24小时 + next := now.Add(time.Duration(i) * time.Minute) + if s.matchMinute(next.Minute()) && s.matchHour(next.Hour()) { + return next + } + } + return time.Time{} +} + +func (s *CronSchedule) matchMinute(minute int) bool { + return s.Minute == "*" || s.matchValue(s.Minute, minute) +} + +func (s *CronSchedule) matchHour(hour int) bool { + return s.Hour == "*" || s.matchValue(s.Hour, hour) +} + +func (s *CronSchedule) matchValue(pattern string, value int) bool { + for _, p := range splitPattern(pattern) { + if p == value { + return true + } + } + return false +} + +// splitPattern 将逗号分隔的模式解析为值列表(C12e:用 strconv.Atoi,非法项跳过)。 +func splitPattern(pattern string) []int { + var values []int + for _, p := range strings.Split(pattern, ",") { + v, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + continue + } + values = append(values, v) + } + return values +} + +// Cron 创建类 Cron 调度 +func Cron(minute, hour string) *CronSchedule { + return &CronSchedule{Minute: minute, Hour: hour} +} + +// 全局调度器。init 时预创建默认实例(与 storage.DefaultStorage / cache.defaultCachePtr +// 对齐),使 SwapDefaultScheduler 返回非 nil、App 回滚总能恢复一个有效默认。用 atomic.Pointer +// 保护并发读写,消除原 once+裸指针读写竞态。GetScheduler 仍保留懒初始化分支作防御。 +var globalScheduler atomic.Pointer[Scheduler] + +func init() { + globalScheduler.Store(NewScheduler()) +} + +// GetScheduler 获取全局调度器(并发安全;init 后通常非 nil,保留懒初始化作防御)。 +func GetScheduler() *Scheduler { + if s := globalScheduler.Load(); s != nil { + return s + } + ns := NewScheduler() + if globalScheduler.CompareAndSwap(nil, ns) { + return ns + } + return globalScheduler.Load() +} + +// SwapDefaultScheduler 将指定 Scheduler 置为全局默认,并返回被替换的旧调度器。 +// 旧调度器不会被停止,供 App 初始化这类需要失败回滚的生命周期流程暂存(照 +// database.SwapDefaultManager / SwapDefaultRedisManager 模式)。nil 被忽略,返回当前默认。 +func SwapDefaultScheduler(s *Scheduler) *Scheduler { + if s == nil { + return globalScheduler.Load() + } + return globalScheduler.Swap(s) +} + +// AddTask 添加任务到全局调度器 +func AddTask(name string, schedule Schedule, handler TaskHandler) *Task { + return GetScheduler().AddTask(name, schedule, handler) +} + +// Start 启动全局调度器 +func Start() { + GetScheduler().Start() +} + +// Stop 停止全局调度器(无限等待,见 Scheduler.Stop)。 +func Stop() { + GetScheduler().Stop() +} + +// StopGlobalWithTimeout 停止全局调度器并最多等待 timeout(P1 #10)。 +// 全局调度器尚未创建时无操作返回 true,不会因此惰性创建它——供 App.Shutdown 安全调用。 +func StopGlobalWithTimeout(timeout time.Duration) bool { + if s := globalScheduler.Load(); s != nil { + return s.StopWithTimeout(timeout) + } + return true +} diff --git a/cron/cron_c12_internal_test.go b/cron/cron_c12_internal_test.go new file mode 100644 index 0000000..7965bb0 --- /dev/null +++ b/cron/cron_c12_internal_test.go @@ -0,0 +1,190 @@ +package cron + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +// stepSchedule 是一个固定步长调度,Next(now)=now+step,用于确定性测试 C12c 锚定。 +type stepSchedule struct{ step time.Duration } + +func (s stepSchedule) Next(now time.Time) time.Time { return now.Add(s.step) } + +// TestC12bNoOverlapManualDrive 验证 per-task running 守卫防止重叠执行。 +// +// 修复前:checkAndRun 无 running 守卫,handler 未完成时下一次 checkAndRun(NextRun 仍为过去) +// 会再次 spawn 同一任务,并发执行。 +// 手动驱动两轮 checkAndRun,handler 阻塞至释放——修复后第二轮被 running 守卫拦截,仅 1 次 spawn。 +func TestC12bNoOverlapManualDrive(t *testing.T) { + s := NewScheduler() + var started int32 + release := make(chan struct{}) + task := s.AddTask("block", stepSchedule{step: time.Microsecond}, func(ctx context.Context) error { + atomic.AddInt32(&started, 1) + <-release + return nil + }) + task.NextRun = time.Now().Add(-time.Hour) // 过去 → 到期 + + s.checkAndRun() // 第一轮:spawn,handler 阻塞 + for atomic.LoadInt32(&started) == 0 { + time.Sleep(time.Millisecond) + } + + s.checkAndRun() // 第二轮:修复后 running 守卫拦截,不再 spawn + time.Sleep(50 * time.Millisecond) // 给潜在二次 spawn 启动时间 + + if n := atomic.LoadInt32(&started); n != 1 { + t.Errorf("task overlapped: started = %d, want 1 (C12b)", n) + } + + close(release) + deadline := time.Now().Add(2 * time.Second) + for task.running != nil && task.running.Load() { + if time.Now().After(deadline) { + t.Fatalf("running guard never released") + } + time.Sleep(time.Millisecond) + } +} + +// TestC12cNextRunAnchoredOnPrevious 验证 NextRun 以上次 NextRun 锚定(不漂移)。 +// +// 修复前:NextRun = schedule.Next(time.Now())(handler 完成后的 now),每周期累积 handler 时长。 +// 修复后:NextRun = schedule.Next(task.NextRun)(上次计划时间锚定)。 +// +// 将 NextRun 设到过去的固定锚点 T0,手动驱动 3 轮 checkAndRun(每轮等 handler 完成), +// 断言 NextRun == T0 + 3*step(锚定)。漂移实现下 NextRun ≈ now+step(远大于 T0+3*step)。 +func TestC12cNextRunAnchoredOnPrevious(t *testing.T) { + s := NewScheduler() + step := 100 * time.Millisecond + + done := make(chan struct{}, 16) + task := s.AddTask("anchored", stepSchedule{step: step}, func(ctx context.Context) error { + // 模拟 handler 耗时——修复后不应影响 NextRun 锚定。 + time.Sleep(50 * time.Millisecond) + done <- struct{}{} + return nil + }) + + // 将 NextRun 设到过去的固定锚点 T0(远早于 now,确保每轮都到期触发)。 + T0 := time.Now().Add(-10 * time.Second) + task.NextRun = T0 + + for i := 1; i <= 3; i++ { + s.checkAndRun() + // 等 handler 执行;若 NextRun 未锚定(漂移实现下 NextRun 跳到未来, + // 后续 checkAndRun 不再到期),done 不会收到 → 超时明确失败而非挂起。 + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("iter %d: handler did not fire (NextRun drifted to future, not anchored on previous) (C12c)", i) + } + // 等 running 守卫释放(defer 在 handler 返回后置 false)。 + deadline := time.Now().Add(2 * time.Second) + for task.running != nil && task.running.Load() { + if time.Now().After(deadline) { + t.Fatalf("running guard never released (iter %d)", i) + } + time.Sleep(time.Millisecond) + } + } + + got := task.NextRun + want := T0.Add(3 * step) + if diff := got.Sub(want); diff < -5*time.Millisecond || diff > 5*time.Millisecond { + t.Errorf("NextRun not anchored on previous: got %v, want %v (diff %v, C12c drift)", got, want, diff) + } +} + +// C12e 直接测试 matchField(未导出,故 internal test)。 +// +// 修复前:matchField 先判 `-` 把整字段当范围,parseInt 忽略非数字逐位累积: +// - "1-5,8" 含 `-` → parseInt("5,8")=58 → 范围 1..58 全匹配,列表项 8 丢失。 +// - "garbage" → parseInt=0,value=0 误触发。 +// - "*/garbage" → step=0 → return true 匹配全部。 + +var schedForMatch = &FullCronSchedule{} + +func TestC12eMatchFieldListAndRangeIndependent(t *testing.T) { + // "1-5,8" 仅匹配 1,2,3,4,5,8。 + for _, v := range []int{1, 2, 3, 4, 5, 8} { + if !schedForMatch.matchField("1-5,8", v, 0, 59) { + t.Errorf("1-5,8 should match %d (C12e)", v) + } + } + for _, v := range []int{0, 6, 7, 9, 30, 58} { + if schedForMatch.matchField("1-5,8", v, 0, 59) { + t.Errorf("1-5,8 should NOT match %d (C12e range broken to 1..58)", v) + } + } +} + +func TestC12eMatchFieldGarbageNotMatch(t *testing.T) { + for v := 0; v <= 59; v++ { + if schedForMatch.matchField("garbage", v, 0, 59) { + t.Errorf("garbage should not match any value, matched %d (C12e)", v) + } + } +} + +func TestC12eMatchFieldStarSlashGarbageNotMatchAll(t *testing.T) { + // 修复前 step=0 → return true 匹配全部。 + matched := 0 + for v := 0; v <= 59; v++ { + if schedForMatch.matchField("*/garbage", v, 0, 59) { + matched++ + } + } + if matched != 0 { + t.Errorf("*/garbage should match nothing, matched %d (C12e step=0 bug)", matched) + } +} + +func TestC12eMatchFieldWeekday7IsSunday(t *testing.T) { + // weekday 字段 7 → 0(周日)。 + if !schedForMatch.matchField("7", 0, 0, 6) { + t.Error("weekday 7 should match Sunday(0) (C12e)") + } + if schedForMatch.matchField("7", 7, 0, 6) { + t.Error("weekday 7 should not match value 7 (out of range after normalize)") + } +} + +func TestC12eMatchFieldRangeAndStepAndList(t *testing.T) { + // 范围 9-17。 + for _, v := range []int{9, 12, 17} { + if !schedForMatch.matchField("9-17", v, 0, 23) { + t.Errorf("9-17 should match %d", v) + } + } + for _, v := range []int{8, 18} { + if schedForMatch.matchField("9-17", v, 0, 23) { + t.Errorf("9-17 should NOT match %d", v) + } + } + // 步长 */15。 + for _, v := range []int{0, 15, 30, 45} { + if !schedForMatch.matchField("*/15", v, 0, 59) { + t.Errorf("*/15 should match %d", v) + } + } + for _, v := range []int{1, 7, 16} { + if schedForMatch.matchField("*/15", v, 0, 59) { + t.Errorf("*/15 should NOT match %d", v) + } + } + // 范围步长 9-17/2 → 9,11,13,15,17。 + for _, v := range []int{9, 11, 13, 15, 17} { + if !schedForMatch.matchField("9-17/2", v, 0, 23) { + t.Errorf("9-17/2 should match %d", v) + } + } + for _, v := range []int{10, 12, 14, 16} { + if schedForMatch.matchField("9-17/2", v, 0, 23) { + t.Errorf("9-17/2 should NOT match %d", v) + } + } +} diff --git a/cron/cron_c12_test.go b/cron/cron_c12_test.go new file mode 100644 index 0000000..c514fd9 --- /dev/null +++ b/cron/cron_c12_test.go @@ -0,0 +1,321 @@ +package cron_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/cron" +) + +// ============================================================ +// C12a:runTask 计数写入无锁 + GetTask/ListTasks 返回 live 指针 → data race +// ============================================================ + +// TestC12aConcurrentReadWriteNoRace 验证并发 RunTask/GetTask/ListTasks + 调度运行 +// 无数据竞争(-race)。 +// +// 修复前:runTask 无锁写 LastRun/RunCount,GetTask/ListTasks 返回 live 指针并发读 → +// -race 必采到 DATA RACE。修复后:写入纳入锁、Getter 返回拷贝。 +func TestC12aConcurrentReadWriteNoRace(t *testing.T) { + scheduler := cron.NewScheduler() + + scheduler.AddTask("t1", cron.Every(50*time.Millisecond), func(ctx context.Context) error { + return nil + }) + scheduler.AddTask("t2", cron.Every(80*time.Millisecond), func(ctx context.Context) error { + return nil + }) + + scheduler.Start() + t.Cleanup(scheduler.Stop) + + stop := make(chan struct{}) + var wg sync.WaitGroup + + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + // 读取返回拷贝的字段——若写侧无锁,与此处读竞争(C12a)。 + if g, err := scheduler.GetTask("t1"); err == nil { + _ = g.RunCount + _ = g.LastRun + } + for _, tk := range scheduler.ListTasks() { + _ = tk.RunCount + _ = tk.LastRun + } + } + }() + } + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _ = scheduler.RunTask("t2") + } + }() + + time.Sleep(300 * time.Millisecond) + close(stop) + wg.Wait() +} + +// TestC12aGetTaskReturnsCopy 验证 GetTask 返回拷贝,修改返回值不影响内部状态。 +func TestC12aGetTaskReturnsCopy(t *testing.T) { + scheduler := cron.NewScheduler() + scheduler.AddTask("t", cron.Every(time.Minute), func(ctx context.Context) error { + return nil + }) + + got, err := scheduler.GetTask("t") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + got.RunCount = 9999 + got.Enabled = false + + again, _ := scheduler.GetTask("t") + if again.RunCount == 9999 { + t.Error("GetTask returned live pointer (RunCount mutated internally)") + } + if !again.Enabled { + t.Error("GetTask returned live pointer (Enabled mutated internally)") + } +} + +// TestC12aListTasksReturnsCopies 验证 ListTasks 元素为拷贝。 +func TestC12aListTasksReturnsCopies(t *testing.T) { + scheduler := cron.NewScheduler() + scheduler.AddTask("t", cron.Every(time.Minute), func(ctx context.Context) error { + return nil + }) + + list := scheduler.ListTasks() + list[0].RunCount = 777 + + again := scheduler.ListTasks() + if again[0].RunCount == 777 { + t.Error("ListTasks returned live pointer (RunCount mutated internally)") + } +} + +// ============================================================ +// C12b:长任务跨 tick 重叠执行 +// ============================================================ + +// TestC12bRunTaskConcurrentManualTriggerReturnsError 验证手动 RunTask 占用守卫期间, +// 再次 RunTask 返"任务正在执行中"错误。 +func TestC12bRunTaskConcurrentManualTriggerReturnsError(t *testing.T) { + scheduler := cron.NewScheduler() + + started := make(chan struct{}) + release := make(chan struct{}) + scheduler.AddTask("block", cron.Every(time.Hour), func(ctx context.Context) error { + close(started) + <-release + return nil + }) + + var firstErr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + firstErr = scheduler.RunTask("block") + }() + + <-started + + secondErr := scheduler.RunTask("block") + if secondErr == nil { + t.Error("second RunTask should fail while first is running (C12b running guard)") + } + + close(release) + wg.Wait() + if firstErr != nil { + t.Errorf("first RunTask error: %v", firstErr) + } +} + +// ============================================================ +// C12d:Weekly 当天未到点目标被跳一周 +// ============================================================ + +func TestC12dWeeklySameDayBeforeTarget(t *testing.T) { + schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0} + + // 周一 8:00(目标 9:00 未到)→ 应返回本周一 9:00,不跳周。 + now := time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC) // 2026-06-29 是周一 + if now.Weekday() != time.Monday { + t.Fatalf("test fixture: expected Monday, got %v", now.Weekday()) + } + next := schedule.Next(now) + want := time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC) + if !next.Equal(want) { + t.Errorf("Weekly same-day-before-target: got %v, want %v (C12d skip-week bug)", next, want) + } +} + +func TestC12dWeeklySameDayAfterTarget(t *testing.T) { + schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0} + + // 周一 10:00(目标 9:00 已过)→ 下周一 9:00。 + now := time.Date(2026, 6, 29, 10, 0, 0, 0, time.UTC) + next := schedule.Next(now) + want := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC) + if !next.Equal(want) { + t.Errorf("Weekly same-day-after-target: got %v, want %v", next, want) + } +} + +func TestC12dWeeklyCrossWeek(t *testing.T) { + schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0} + + // 周三 → 下周一。 + now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) // 周三 + if now.Weekday() != time.Wednesday { + t.Fatalf("test fixture: expected Wednesday, got %v", now.Weekday()) + } + next := schedule.Next(now) + want := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC) + if !next.Equal(want) { + t.Errorf("Weekly cross-week: got %v, want %v", next, want) + } +} + +// ============================================================ +// C12e:cron 解析缺陷(周日 7 / 范围环绕 / 严格解析) +// (1-5,8 / garbage / */garbage 的 matchField 直接测试见 cron_c12_internal_test.go) +// ============================================================ + +func TestC12eWeekdaySundayAs7(t *testing.T) { + schedule := cron.FullCronSchedule{ + Minute: "0", + Hour: "0", + Day: "*", + Month: "*", + Weekday: "7", + } + // 0 0 * * 7 → 每周日凌晨。从周六 23:00 找下一个匹配应落周日 00:00。 + now := time.Date(2026, 6, 27, 23, 0, 0, 0, time.UTC) // 周六 + if now.Weekday() != time.Saturday { + t.Fatalf("test fixture: expected Saturday, got %v", now.Weekday()) + } + next := schedule.Next(now) + if next.Weekday() != time.Sunday { + t.Errorf("0 0 * * 7 should land on Sunday, got %v (C12e 7≠Sunday)", next.Weekday()) + } + if next.Hour() != 0 || next.Minute() != 0 { + t.Errorf("0 0 * * 7 should land on 00:00, got %v", next) + } +} + +func TestC12eWeekdayRangeWraparound(t *testing.T) { + // "6-1" 在 weekday 环绕:周六(6)、周日(0)、周一(1)。 + schedule := cron.FullCronSchedule{ + Minute: "0", + Hour: "0", + Day: "*", + Month: "*", + Weekday: "6-1", + } + for _, w := range []time.Weekday{time.Saturday, time.Sunday, time.Monday} { + tt := time.Date(2026, 6, 29, 0, 0, 0, 0, time.UTC) // 周一 + for tt.Weekday() != w { + tt = tt.AddDate(0, 0, 1) + } + next := schedule.Next(tt.Add(-1 * time.Minute)) + if next.Weekday() != w { + t.Errorf("6-1 should match %v, got %v", w, next.Weekday()) + } + } + // 周二不应被 6-1 匹配,下一个应是周六。 + tt := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) // 周二 + if tt.Weekday() != time.Tuesday { + t.Fatalf("test fixture: expected Tuesday, got %v", tt.Weekday()) + } + next := schedule.Next(tt.Add(-1 * time.Minute)) + if next.Weekday() != time.Saturday { + t.Errorf("6-1 should skip Tuesday, next match %v, want Saturday", next.Weekday()) + } +} + +// TestC12eParseCronStrict 验证严格解析。 +func TestC12eParseCronStrict(t *testing.T) { + cases := []struct { + expr string + ok bool + }{ + {"0 12 * * *", true}, + {"*/15 * * * *", true}, + {"0 9-17 * * 1-5", true}, + {"0 0 1 * 7", true}, // 周日 7 合法 + {"0 0 * * 0-7", false}, + {"invalid", false}, + {"1-5,8 0 * * *", true}, + {"60 0 * * *", false}, // 分钟越界 + {"0 25 * * *", false}, // 小时越界 + {"0 0 0 * *", false}, // 日越界 + {"0 0 * 13 *", false}, // 月越界 + {"0 0 * * 9", false}, // 周越界 + {"garbage 0 * * *", false}, + {"*/0 * * * *", false}, // step=0 非法 + } + for _, c := range cases { + _, err := cron.ParseCronStrict(c.expr) + if c.ok && err != nil { + t.Errorf("ParseCronStrict(%q) unexpected error: %v", c.expr, err) + } + if !c.ok && err == nil { + t.Errorf("ParseCronStrict(%q) expected error, got nil", c.expr) + } + } +} + +// TestC12eParseCronFallback verifies ParseCron now fails fast on invalid input. +func TestC12eParseCronFallback(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("ParseCron(invalid) should panic") + } + }() + _ = cron.ParseCron("invalid") +} + +func TestC12eParseCronOrDefaultFallback(t *testing.T) { + s := cron.ParseCronOrDefault("invalid") + if s.Minute != "*" || s.Hour != "*" || s.Day != "*" || s.Month != "*" || s.Weekday != "*" { + t.Errorf("ParseCronOrDefault(invalid) should fall back to all-*, got %+v", s) + } + // 合法表达式不回退。 + s = cron.ParseCron("1-5,8 0 * * *") + if s.Minute != "1-5,8" { + t.Errorf("ParseCron valid: Minute = %q, want 1-5,8", s.Minute) + } +} + +// TestC12eCronScheduleGarbageNoMatch 验证简化 Cron 不再 parseInt 容错为 0。 +func TestC12eCronScheduleGarbageNoMatch(t *testing.T) { + bad := cron.CronSchedule{Minute: "garbage", Hour: "9"} + // garbage → splitPattern 返空 → matchMinute 返 false → 24h 内无匹配。 + now := time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC) + next := bad.Next(now) + if !next.IsZero() { + t.Errorf("CronSchedule garbage minute should not match, got next=%v (C12e)", next) + } +} diff --git a/cron/cron_m13_internal_test.go b/cron/cron_m13_internal_test.go new file mode 100644 index 0000000..6cfa41c --- /dev/null +++ b/cron/cron_m13_internal_test.go @@ -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) + } +} diff --git a/cron/cron_test.go b/cron/cron_test.go new file mode 100644 index 0000000..b22881e --- /dev/null +++ b/cron/cron_test.go @@ -0,0 +1,268 @@ +package cron_test + +import ( + "context" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/cron" +) + +func TestTask(t *testing.T) { + task := cron.Task{ + Name: "test_task", + Enabled: true, + RunCount: 0, + } + + if task.Name != "test_task" { + t.Error("Task Name failed") + } + if !task.Enabled { + t.Error("Task Enabled failed") + } +} + +func TestNewScheduler(t *testing.T) { + scheduler := cron.NewScheduler() + if scheduler == nil { + t.Error("NewScheduler should not return nil") + } +} + +func TestAddTask(t *testing.T) { + scheduler := cron.NewScheduler() + task := scheduler.AddTask("test", cron.Every(time.Minute), func(ctx context.Context) error { + return nil + }) + + if task == nil { + t.Error("AddTask should return task") + } + if task.Name != "test" { + t.Errorf("Task name = %s, want test", task.Name) + } +} + +func TestRemoveTask(t *testing.T) { + scheduler := cron.NewScheduler() + scheduler.AddTask("test", cron.Every(time.Minute), func(ctx context.Context) error { + return nil + }) + + scheduler.RemoveTask("test") + + task, err := scheduler.GetTask("test") + if err == nil { + t.Error("RemoveTask should remove task") + } + if task != nil { + t.Error("Removed task should be nil") + } +} + +func TestEnableDisableTask(t *testing.T) { + scheduler := cron.NewScheduler() + scheduler.AddTask("test", cron.Every(time.Minute), func(ctx context.Context) error { + return nil + }) + + err := scheduler.DisableTask("test") + if err != nil { + t.Errorf("DisableTask error: %v", err) + } + + task, _ := scheduler.GetTask("test") + if task.Enabled { + t.Error("Task should be disabled") + } + + err = scheduler.EnableTask("test") + if err != nil { + t.Errorf("EnableTask error: %v", err) + } + + task, _ = scheduler.GetTask("test") + if !task.Enabled { + t.Error("Task should be enabled") + } +} + +func TestListTasks(t *testing.T) { + scheduler := cron.NewScheduler() + scheduler.AddTask("task1", cron.Every(time.Minute), func(ctx context.Context) error { return nil }) + scheduler.AddTask("task2", cron.Every(time.Hour), func(ctx context.Context) error { return nil }) + + tasks := scheduler.ListTasks() + if len(tasks) != 2 { + t.Errorf("ListTasks length = %d, want 2", len(tasks)) + } +} + +func TestIntervalSchedule(t *testing.T) { + schedule := cron.IntervalSchedule{Interval: time.Minute} + now := time.Now() + next := schedule.Next(now) + + if next.Before(now) { + t.Error("Next should be after now") + } +} + +func TestEvery(t *testing.T) { + schedule := cron.Every(5 * time.Minute) + if schedule.Interval != 5*time.Minute { + t.Errorf("Every interval = %v, want 5m", schedule.Interval) + } +} + +func TestDailySchedule(t *testing.T) { + schedule := cron.DailySchedule{Hour: 9, Minute: 30} + now := time.Now() + next := schedule.Next(now) + + if next.Hour() != 9 || next.Minute() != 30 { + t.Errorf("DailySchedule next = %v, want 09:30", next) + } +} + +func TestDaily(t *testing.T) { + schedule := cron.Daily(10, 0) + if schedule.Hour != 10 || schedule.Minute != 0 { + t.Error("Daily failed") + } +} + +func TestWeeklySchedule(t *testing.T) { + schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0} + now := time.Now() + next := schedule.Next(now) + + if next.Hour() != 9 { + t.Errorf("WeeklySchedule hour = %d, want 9", next.Hour()) + } +} + +func TestWeekly(t *testing.T) { + schedule := cron.Weekly(time.Monday, 10, 0) + if schedule.Day != time.Monday { + t.Error("Weekly Day failed") + } +} + +func TestCronSchedule(t *testing.T) { + schedule := cron.CronSchedule{Minute: "0,15,30", Hour: "9"} + now := time.Now() + next := schedule.Next(now) + + if next.IsZero() { + t.Error("CronSchedule should find next time") + } +} + +func TestCron(t *testing.T) { + schedule := cron.Cron("0", "9") + if schedule.Minute != "0" || schedule.Hour != "9" { + t.Error("Cron failed") + } +} + +func TestGetScheduler(t *testing.T) { + scheduler := cron.GetScheduler() + if scheduler == nil { + t.Error("GetScheduler should not return nil") + } +} + +func TestGlobalAddTask(t *testing.T) { + task := cron.AddTask("global_test", cron.Every(time.Hour), func(ctx context.Context) error { + return nil + }) + if task == nil { + t.Error("Global AddTask should return task") + } +} + +func TestTaskHandler(t *testing.T) { + var handler cron.TaskHandler = func(ctx context.Context) error { + return nil + } + if handler == nil { + t.Error("TaskHandler should not be nil") + } +} + +func TestFullCronSchedule(t *testing.T) { + // 测试每15分钟 + schedule := cron.ParseCron("*/15 * * * *") + now := time.Now() + next := schedule.Next(now) + if next.IsZero() { + t.Error("FullCronSchedule */15 should find next time") + } + // 验证分钟是0,15,30,45之一 + minute := next.Minute() + if minute%15 != 0 { + t.Errorf("FullCronSchedule minute = %d, should be divisible by 15", minute) + } + + // 测试每天12点 + schedule = cron.ParseCron("0 12 * * *") + next = schedule.Next(now) + if next.IsZero() { + t.Error("FullCronSchedule daily noon should find next time") + } + if next.Hour() != 12 || next.Minute() != 0 { + t.Errorf("FullCronSchedule noon = %v, want 12:00", next) + } + + // 测试工作日9-17点 + schedule = cron.ParseCron("0 9-17 * * 1-5") + next = schedule.Next(now) + if next.IsZero() { + t.Error("FullCronSchedule workday should find next time") + } +} + +func TestParseCron(t *testing.T) { + // 正常5字段表达式 + schedule := cron.ParseCron("0 12 * * *") + if schedule.Minute != "0" || schedule.Hour != "12" { + t.Error("ParseCron 5 fields failed") + } + + // 无效表达式返回默认 + func() { + defer func() { + if recover() == nil { + t.Fatal("ParseCron invalid should panic") + } + }() + _ = cron.ParseCron("invalid") + }() +} + +func TestFullCronScheduleMatch(t *testing.T) { + schedule := cron.FullCronSchedule{ + Minute: "*/5", + Hour: "*", + Day: "*", + Month: "*", + Weekday: "*", + } + + // 测试匹配 - 通过Next验证 + testTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + next := schedule.Next(testTime) + // 10:00 下一个应该是 10:05 + if next.Minute() != 5 || next.Hour() != 10 { + t.Errorf("Next after 10:00 should be 10:05, got %v", next) + } + + testTime = time.Date(2024, 1, 15, 10, 7, 0, 0, time.UTC) + next = schedule.Next(testTime) + // 10:07 下一个应该是 10:10 + if next.Minute() != 10 || next.Hour() != 10 { + t.Errorf("Next after 10:07 should be 10:10, got %v", next) + } +} diff --git a/database/dialect.go b/database/dialect.go new file mode 100644 index 0000000..b609649 --- /dev/null +++ b/database/dialect.go @@ -0,0 +1,184 @@ +package database + +import ( + "errors" + "fmt" + "strings" + "sync" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/logger" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" +) + +// 内置驱动常量(更多驱动可通过 RegisterDialect 扩展) +const ( + DriverMySQL = config.DriverMySQL + DriverPostgres = config.DriverPostgres +) + +// DialectorFactory 根据 DSN 返回 GORM Dialector +type DialectorFactory func(dsn string) gorm.Dialector + +// DialectSpec 描述一种数据库方言:如何建立连接 + 如何拼接 DSN +type DialectSpec struct { + // Name 驱动主名称(如 "mysql"、"postgres"、"sqlite"),大小写不敏感 + Name string + // Aliases 驱动别名(如 postgres 的 "postgresql"、"pg") + Aliases []string + // Dialector 由 DSN 构造 GORM Dialector + Dialector DialectorFactory + // DSN 由 DatabaseConfig 拼接连接字符串。可选。 + // 不提供时仅 CustomDSN 能直接生效;需要由配置字段拼接连接串的自定义驱动应显式提供该函数。 + DSN config.DSNBuilder +} + +var ( + dialectsMu sync.RWMutex + dialects = map[string]DialectorFactory{} +) + +// RegisterDialect 注册一种数据库方言。 +// 同时把 DSN 构建器登记到 config 包,使 cfg.Database.DSN() 也能识别新驱动。 +// 已注册的同名驱动会被覆盖。 +// +// 用法示例(接入 SQLite): +// +// import "gorm.io/driver/sqlite" +// +// database.RegisterDialect(database.DialectSpec{ +// Name: "sqlite", +// Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) }, +// DSN: func(c *config.DatabaseConfig) string { return c.Name }, // 文件路径 +// }) +func RegisterDialect(spec DialectSpec) { + if spec.Dialector == nil || strings.TrimSpace(spec.Name) == "" { + return + } + + dialectsMu.Lock() + for _, n := range append([]string{spec.Name}, spec.Aliases...) { + key := normalizeDriver(n) + if key != "" { + dialects[key] = spec.Dialector + } + } + dialectsMu.Unlock() + + if spec.DSN != nil { + config.RegisterDSNBuilder(spec.Name, spec.DSN, spec.Aliases...) + } +} + +// LookupDialect 查找已注册的 Dialector 工厂 +func LookupDialect(driver string) (DialectorFactory, bool) { + key := normalizeDriver(driver) + dialectsMu.RLock() + defer dialectsMu.RUnlock() + f, ok := dialects[key] + return f, ok +} + +// RegisteredDialects 返回所有已注册的驱动名(用于诊断) +func RegisteredDialects() []string { + dialectsMu.RLock() + defer dialectsMu.RUnlock() + names := make([]string, 0, len(dialects)) + for k := range dialects { + names = append(names, k) + } + return names +} + +// Dialector 根据配置返回 GORM Dialector。 +// 驱动由 cfg.Database.Driver 决定;未指定时默认 MySQL,非空但未注册时返回会初始化失败的 +// Dialector,避免拼写错误静默回退到 MySQL。 +func Dialector(cfg *config.Config) gorm.Dialector { + if cfg == nil { + logger.Warn("database: 配置为空,回退到 MySQL 空 DSN") + return mysql.Open("") + } + return dialectorForDSN(cfg.Database.Driver, cfg.Database.DSN()) +} + +// dialectorForDSN 根据驱动名和 DSN 返回 Dialector +func dialectorForDSN(driver, dsn string) gorm.Dialector { + normalized := normalizeDriver(driver) + if normalized == "" { + normalized = DriverMySQL + } + if f, ok := LookupDialect(normalized); ok { + return f(dsn) + } + logger.Warnf("database: 驱动 %q 未注册(已注册: %s),拒绝静默回退到 MySQL;请修正配置或先注册方言", + normalized, strings.Join(RegisteredDialects(), ", ")) + return errorDialector{ + name: "invalid", + err: fmt.Errorf("数据库驱动未注册: %s", normalized), + } +} + +type errorDialector struct { + name string + err error +} + +func (d errorDialector) Name() string { return d.name } + +func (d errorDialector) Initialize(*gorm.DB) error { + if d.err == nil { + return errors.New("数据库驱动未注册") + } + return d.err +} + +func (d errorDialector) Migrator(*gorm.DB) gorm.Migrator { return nil } + +func (d errorDialector) DataTypeOf(*schema.Field) string { return "" } + +func (d errorDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil } + +func (d errorDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {} + +func (d errorDialector) QuoteTo(writer clause.Writer, str string) { + _, _ = writer.WriteString(str) +} + +func (d errorDialector) Explain(sql string, _ ...any) string { return sql } + +// normalizeDriver 规范化驱动名(小写、去空白) +func normalizeDriver(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +// driverDescription 返回带别名提示的驱动描述(用于错误信息和日志) +func driverDescription(driver string) string { + key := normalizeDriver(driver) + if key == "" { + return DriverMySQL + " (default)" + } + if _, ok := LookupDialect(key); ok { + return key + } + return fmt.Sprintf("%s (unregistered)", key) +} + +func init() { + // 内置 MySQL + RegisterDialect(DialectSpec{ + Name: DriverMySQL, + Dialector: func(dsn string) gorm.Dialector { return mysql.Open(dsn) }, + DSN: func(c *config.DatabaseConfig) string { return c.MySQLDSN() }, + }) + // 内置 PostgreSQL + RegisterDialect(DialectSpec{ + Name: DriverPostgres, + Aliases: []string{"postgresql", "pg"}, + Dialector: func(dsn string) gorm.Dialector { return postgres.Open(dsn) }, + DSN: func(c *config.DatabaseConfig) string { return c.PostgresDSN() }, + }) +} diff --git a/database/manager.go b/database/manager.go new file mode 100644 index 0000000..86f4bab --- /dev/null +++ b/database/manager.go @@ -0,0 +1,914 @@ +package database + +import ( + "context" + cryptorand "crypto/rand" + "database/sql" + "errors" + "fmt" + "math/big" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/logger" + + "go.uber.org/zap" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +type dbModeContextKey struct{} + +// txContextKey 携带外层事务的 *gorm.DB,使 repository 等上层在调用时能 join 到外层事务 +// (H6c:外层 ctx 事务无法 join)。由 WithTx 注入、TxFromContext 读取。 +type txContextKey struct{} + +const ( + dbModeMaster = "master" + dbModeReplica = "replica" +) + +func normalizeContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} + +// ReplicaPicker 从库选择策略 +type ReplicaPicker interface { + Pick(replicas []*gorm.DB) *gorm.DB +} + +// RoundRobinPicker 轮询选择从库 +type RoundRobinPicker struct { + mu sync.Mutex + counter int +} + +// Pick 轮询选择一个从库 +func (p *RoundRobinPicker) Pick(replicas []*gorm.DB) *gorm.DB { + if len(replicas) == 0 { + return nil + } + p.mu.Lock() + idx := p.counter % len(replicas) + p.counter = (idx + 1) % len(replicas) + p.mu.Unlock() + return replicas[idx] +} + +// RandomPicker 随机选择从库。 +// +// 使用 crypto/rand 生成随机索引,并发安全且不可预测。len(replicas)<=0 返回 nil; +// crypto/rand 失败(极罕见,如熵池耗尽)时回退到 replicas[0],保证可用性。 +type RandomPicker struct{} + +// Pick 随机选择一个从库 +func (p *RandomPicker) Pick(replicas []*gorm.DB) *gorm.DB { + if len(replicas) == 0 { + return nil + } + n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(len(replicas)))) + if err != nil { + return replicas[0] + } + return replicas[int(n.Int64())] +} + +// Manager 数据库管理器,持有主库与从库连接实例 +type Manager struct { + cfg *config.Config + master *gorm.DB + replicas []*gorm.DB + picker ReplicaPicker + mu sync.Mutex + // opMu 串行化 InitDB / InitDBWithReplicas / Close 这类资源生命周期操作。 + // 避免关闭已开始后初始化又发布新连接,或初始化发布后被并发 Close 置空。 + opMu sync.Mutex + + // #21 健康自愈 + healthy atomic.Bool // 主库是否健康 + replicaHealthy []atomic.Bool // 每个从库的健康标记,索引与 replicas 对齐 + probeFailures int // 主库连续探活失败次数 + probeMu sync.Mutex // 保护 probeFailures + replicaHealthSet bool // replicaHealthy 是否已按 replicas 长度初始化 +} + +// NewManager 创建数据库管理器 +func NewManager(cfg *config.Config) *Manager { + return &Manager{cfg: cfg, picker: &RandomPicker{}} +} + +// getCfg 在锁内读取 m.cfg(P1 #11:消除与 InitDB 写 m.cfg 的数据竞争)。 +func (m *Manager) getCfg() *config.Config { + m.mu.Lock() + defer m.mu.Unlock() + return m.cfg +} + +// setCfg 在锁内写入 m.cfg(P1 #11)。 +func (m *Manager) setCfg(cfg *config.Config) { + m.mu.Lock() + m.cfg = cfg + m.mu.Unlock() +} + +// SetPicker 设置从库选择策略 +func (m *Manager) SetPicker(p ReplicaPicker) { + if p == nil { + return + } + m.mu.Lock() + m.picker = p + m.mu.Unlock() +} + +// Picker 返回当前从库选择策略 +func (m *Manager) Picker() ReplicaPicker { + m.mu.Lock() + defer m.mu.Unlock() + return m.picker +} + +// Master 返回主库实例。 +// 经 m.mu 锁保护读取,避免与 InitDB/Close 的写竞争返回已关闭/nil 池(C11d)。 +func (m *Manager) Master() *gorm.DB { + m.mu.Lock() + defer m.mu.Unlock() + return m.master +} + +// Replicas 返回所有从库实例的拷贝。 +// 经 m.mu 锁保护读取并返回拷贝,避免调用方持活切片与 InitDBWithReplicas/Close 重置竞争(C11d)。 +func (m *Manager) Replicas() []*gorm.DB { + m.mu.Lock() + defer m.mu.Unlock() + if m.replicas == nil { + return nil + } + out := make([]*gorm.DB, len(m.replicas)) + copy(out, m.replicas) + return out +} + +// Replica 按策略选择一个从库;无从库时返回主库。 +// #21:启用探活后,自动过滤不健康的从库;全不健康时回退到全部从库(仍可服务)。 +// 全程持 m.mu 锁,避免 replicas/master 与重建路径写竞争(C11d)。 +func (m *Manager) Replica() *gorm.DB { + m.mu.Lock() + defer m.mu.Unlock() + + if len(m.replicas) == 0 { + return m.master + } + pool := m.replicas + // 启用探活且至少有一个健康标记时,仅从健康从库中选取 + if m.replicaHealthSet { + var healthy []*gorm.DB + for i, r := range m.replicas { + if i < len(m.replicaHealthy) && m.replicaHealthy[i].Load() { + healthy = append(healthy, r) + } + } + if len(healthy) > 0 { + pool = healthy + } + // healthy 为空时回退到全部 replicas,避免读流量完全中断 + } + + if m.picker != nil { + if db := m.picker.Pick(pool); db != nil { + return db + } + } + return pool[0] +} + +// IsHealthy 返回主库当前健康状态(#21)。供 readiness/health 探针联动。 +func (m *Manager) IsHealthy() bool { + return m.healthy.Load() +} + +// initReplicaHealth 按 replicas 数量初始化健康标记(全部为健康)。 +// 已初始化(replicaHealthSet=true)时早返回;重建从库前须先 resetReplicaHealth 重置, +// 否则健康切片长度与新 replicas 错位(C11a)。 +func (m *Manager) initReplicaHealth() { + m.mu.Lock() + defer m.mu.Unlock() + if m.replicaHealthSet { + return + } + m.replicaHealthy = make([]atomic.Bool, len(m.replicas)) + for i := range m.replicaHealthy { + m.replicaHealthy[i].Store(true) + } + m.replicaHealthSet = true +} + +// ensureReplicaHealthLocked 按当前 replicas 重建健康标记。调用方须持有 m.mu。 +func (m *Manager) ensureReplicaHealthLocked() { + m.replicaHealthy = make([]atomic.Bool, len(m.replicas)) + for i := range m.replicaHealthy { + m.replicaHealthy[i].Store(true) + } + m.replicaHealthSet = true +} + +// resetReplicaHealth 清空从库健康标记,使下次 initReplicaHealth 按新 replicas 长度重建。 +// 重建从库(InitDBWithReplicas)/Close 前必须调用,避免健康切片与新 replicas 长度错位(C11a)。 +// 调用方须持有 m.mu。 +func (m *Manager) resetReplicaHealth() { + m.replicaHealthy = nil + m.replicaHealthSet = false +} + +// StartProbing 启动主库与从库的健康探活后台循环(#21)。 +// 阻塞调用方,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。 +// 周期 ping 主库,连续失败达阈值后标记不健康(IsHealthy=false); +// 同时 ping 各从库,失败则从读流量剔除,恢复后自动重新纳入。 +func (m *Manager) StartProbing(ctx context.Context) { + ctx = normalizeContext(ctx) + m.initReplicaHealth() + + cfg := m.getCfg() // P1 #11:锁内快照,避免与 InitDB 写 m.cfg 竞态 + interval := 30 * time.Second + if cfg != nil && cfg.Database.HealthCheckInterval > 0 { + interval = cfg.Database.HealthCheckInterval + } + threshold := 3 + if cfg != nil && cfg.Database.HealthCheckFailureThreshold > 0 { + threshold = cfg.Database.HealthCheckFailureThreshold + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.probeOnce(ctx, threshold) + } + } +} + +// probeOnce 执行一轮主库+从库探活并更新健康标记。 +func (m *Manager) probeOnce(ctx context.Context, threshold int) { + // 主库 + if err := m.HealthCheck(ctx); err != nil { + m.probeMu.Lock() + m.probeFailures++ + if m.probeFailures >= threshold { + if m.healthy.Load() { + logger.Warnf("数据库主库连续探活失败 %d 次,标记为不健康: %v", m.probeFailures, err) + } + m.healthy.Store(false) + } + m.probeMu.Unlock() + } else { + m.probeMu.Lock() + if m.probeFailures >= threshold && !m.healthy.Load() { + logger.Info("数据库主库探活恢复,重新标记为健康") + } + m.probeFailures = 0 + m.probeMu.Unlock() + m.healthy.Store(true) + } + + // 从库 + m.mu.Lock() + replicas := make([]*gorm.DB, len(m.replicas)) + copy(replicas, m.replicas) + if len(replicas) > 0 && !m.replicaHealthSet { + m.ensureReplicaHealthLocked() + } + healthSet := m.replicaHealthSet + replicaHealthy := m.replicaHealthy // 快照切片头,避免与 resetReplicaHealth 写竞争 + m.mu.Unlock() + if !healthSet { + return + } + for i, r := range replicas { + if r == nil { + continue + } + sqlDB, err := r.DB() + if err != nil { + if i < len(replicaHealthy) { + replicaHealthy[i].Store(false) + } + continue + } + // 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) + } + if i < len(replicaHealthy) { + replicaHealthy[i].Store(false) + } + } else { + if i < len(replicaHealthy) { + replicaHealthy[i].Store(true) + } + } + } +} + +// FromContext 根据上下文选择数据库 +func (m *Manager) FromContext(ctx context.Context) *gorm.DB { + ctx = normalizeContext(ctx) + mode, ok := ctx.Value(dbModeContextKey{}).(string) + if !ok { + return m.Replica() + } + switch mode { + case dbModeMaster: + return m.Master() + case dbModeReplica: + return m.Replica() + default: + return m.Replica() + } +} + +// Open 打开主库连接 +func (m *Manager) Open(ctx context.Context) error { + cfg := m.getCfg() // P1 #11:锁内读取,避免与 InitDB 写竞态 + if cfg == nil { + return errors.New("数据库配置未设置") + } + return m.InitDB(ctx, cfg) +} + +// OpenWithReplicas 打开主库与从库连接 +func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) error { + cfg := m.getCfg() // P1 #11:锁内读取 + if cfg == nil { + return errors.New("数据库配置未设置") + } + return m.InitDBWithReplicas(ctx, cfg, replicaDSNs) +} + +// closeDB 关闭 gorm.DB 底层连接池。nil 或未初始化(无 ConnPool)时返回 nil,不 panic。 +// 用于重建/关闭路径释放旧池,避免直接覆盖致泄漏(C11b/C11c)。 +func closeDB(db *gorm.DB) error { + if db == nil { + return nil + } + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +func warnCloseDB(db *gorm.DB, context string) { + if err := closeDB(db); err != nil { + logger.Warnf("%s: %v", context, err) + } +} + +// Close 关闭主库与全部从库连接,并重置从库健康状态。 +// 字段置空在锁内完成(保证新读取得到 nil),实际关闭在锁外执行避免持锁阻塞。 +func (m *Manager) Close() error { + m.opMu.Lock() + defer m.opMu.Unlock() + m.mu.Lock() + master := m.master + replicas := m.replicas + m.master = nil + m.replicas = nil + m.resetReplicaHealth() + m.healthy.Store(false) + m.mu.Unlock() + + var errs []error + if err := closeDB(master); err != nil { + errs = append(errs, err) + } + for _, replica := range replicas { + if err := closeDB(replica); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// HealthCheck 健康检查,主库不可达时返回错误。 +// +// M11 完整覆盖(H-db-1 修复):ping 经 pingWithTimeout 受 healthCheckTimeout(3s) 约束, +// 使后台探活(probeOnce)与 /health 端点(app.go 经本方法)都不会被挂起 DB(连接活但不 +// 响应)无限阻塞。ctx 自带更短 deadline 时优先尊重 ctx。 +func (m *Manager) HealthCheck(ctx context.Context) error { + ctx = normalizeContext(ctx) + m.mu.Lock() + db := m.master + m.mu.Unlock() + if db == nil { + return errors.New("数据库主库未初始化") + } + sqlDB, err := db.DB() + if err != nil { + return err + } + return pingWithTimeout(sqlDB, ctx) +} + +// DefaultManager 默认数据库管理器,包级 facade 代理到它。 +// +// 主线A 修复:改用 atomic.Pointer 保护读写,消除原裸指针(外部直接 +// `database.DefaultManager = ...` 赋值与请求 goroutine 经 facade 读取)之间的数据竞争。 +// 与 config.defaultManager / database.DefaultRedis / storage.DefaultStorage / +// cache.defaultCachePtr / jwt.defaultManager 对齐——框架内包级可变全局一律 atomic.Pointer。 +// +// 类型由 *Manager 变更为 atomic.Pointer[Manager](breaking):下游若直接调用 +// DefaultManager.Init/Master 等方法需改用 InitDB/GetDB 等 facade,或 +// DefaultManager.Load().Init(...),或经 GetDefaultManager() 取实例后再调方法。 +var DefaultManager atomic.Pointer[Manager] + +func init() { + DefaultManager.Store(NewManager(nil)) +} + +// SwapDefaultManager 将指定 Manager 置为全局默认,并返回被替换的旧 Manager。 +// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存。 +// nil 被忽略,以防 facade Load 到 nil panic。 +func SwapDefaultManager(m *Manager) *Manager { + if m == nil { + return DefaultManager.Load() + } + return DefaultManager.Swap(m) +} + +// SetDefaultManager 提升指定 Manager 为全局默认,并关闭被替换的旧 Manager。 +// 用于多实例场景或测试注入。nil 被忽略以防 facade Load 到 nil panic。 +// 若调用方需要保留旧 Manager 用于回滚,请使用 SwapDefaultManager。 +func SetDefaultManager(m *Manager) { + if m == nil { + return + } + old := SwapDefaultManager(m) + if old != nil && old != m { + if err := old.Close(); err != nil { + logger.Warnf("关闭被替换的旧数据库 manager 失败: %v", err) + } + } +} + +// GetDefaultManager 返回全局默认 Manager(atomic 读取,并发安全)。 +// 替代直接读 DefaultManager 包级变量(类型已改为 atomic.Pointer,直接读得到的是 atomic +// 值而非 *Manager)。需直接持有 Manager 调用其方法时用本函数或 DefaultManager.Load()。 +func GetDefaultManager() *Manager { + return DefaultManager.Load() +} + +// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。 +// ctx 控制 Ping 与重试等待;调用方取消 ctx 时初始化会尽快返回。 +func (m *Manager) InitDB(ctx context.Context, cfg *config.Config) error { + ctx = normalizeContext(ctx) + m.opMu.Lock() + defer m.opMu.Unlock() + return m.initDB(ctx, cfg) +} + +func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error { + if cfg == nil { + return errors.New("数据库配置未设置") + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("数据库初始化已取消: %w", err) + } + m.setCfg(cfg) // P1 #11:锁内写入,避免与 StartProbing/Open 读竞态 + + // GORM 日志配置 + var gormLogLevel gormlogger.LogLevel + if cfg.IsDevelopment() { + gormLogLevel = gormlogger.Info + } else { + gormLogLevel = gormlogger.Warn + } + + gormConfig := &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormLogLevel), + // H-db-1:禁用 gorm.Open 的自动 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= 生效。 + // 失败 fail-fast 返回错误,绝不静默回退明文连接。非 MySQL / 未配 CA 为 no-op。 + if err := ensureMySQLTLSRegistered(cfg); err != nil { + return err + } + + // 重试配置 + maxRetries := 5 + retryDelay := time.Second + + var lastErr error + for i := range maxRetries { + if err := ctx.Err(); err != nil { + return fmt.Errorf("数据库初始化已取消: %w", err) + } + // 连接主库:先打开到局部变量,仅 Ping 成功后才安装为 m.master, + // 避免 Ping 失败时下轮覆盖 m.master 泄漏旧池(C11b)。 + db, err := gorm.Open(Dialector(cfg), gormConfig) + if err != nil { + lastErr = err + // 不可恢复的错误(认证失败、未知数据库、DSN 非法等)直接返回,不必重试 + if !isTransientDBError(err) { + return fmt.Errorf("数据库连接失败(不可恢复): %w", err) + } + } else { + sqlDB, err := db.DB() + if err != nil { + lastErr = err + warnCloseDB(db, "关闭刚打开的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮泄漏 + } else { + sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns) + sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns) + sqlDB.SetConnMaxLifetime(time.Hour) + if cfg.Database.ConnMaxIdleTime > 0 { + sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime) + } + + // 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) + warnCloseDB(old, "关闭旧数据库主库连接池失败") + logger.Info("数据库主库连接成功", + zap.String("driver", driverDescription(cfg.Database.Driver)), + zap.String("host", cfg.Database.Host), + zap.Int("port", cfg.Database.Port)) + return nil + } else { + // Ping 失败(如服务端暂时不可达)视作可重试 + lastErr = err + warnCloseDB(db, "关闭 Ping 失败的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮覆盖泄漏 + } + } + } + + logger.Warnf("数据库连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, lastErr) + if i == maxRetries-1 { + break + } + select { + case <-ctx.Done(): + return fmt.Errorf("数据库初始化已取消: %w", ctx.Err()) + case <-time.After(retryDelay): + } + retryDelay *= 2 + if retryDelay > 30*time.Second { + retryDelay = 30 * time.Second + } + } + + return fmt.Errorf("数据库连接失败(重试 %d 次): %w", maxRetries, lastErr) +} + +// isTransientDBError 判断数据库连接错误是否值得重试。 +// 认证失败、未知数据库、非法 DSN/驱动等属于配置类错误,重试无意义,直接返回更友好。 +// D5 修复:覆盖 MySQL 和 PostgreSQL 常见非瞬态错误。 +func isTransientDBError(err error) bool { + if err == nil { + return true + } + msg := err.Error() + nonTransient := []string{ + // MySQL + "Access denied", // 认证失败(用户名/密码错误) + "authentication plugin", // 认证插件不支持 + "Unknown database", // 目标库不存在 + "invalid DSN", // DSN 语法错误 + "unknown driver", // 驱动未注册 + "unsupported driver", // 驱动不支持 + // PostgreSQL(D5 修复;P1 #12:移除过宽的独立 "database" 子串——它会把 + // "the database system is starting up" 等瞬态错误误判为非瞬态而放弃重试。 + // PG 目标库不存在的消息形如 database "x" does not exist,用 "does not exist" 精确匹配)。 + "password authentication failed", // pg 密码错误 + "does not exist", // pg 目标库/角色不存在 + "no pg_hba.conf entry", // pg_hba.conf 拒绝 + } + for _, sub := range nonTransient { + if strings.Contains(msg, sub) { + return false + } + } + return true +} + +// replicaMaxOpenConns 计算从库连接池 MaxOpenConns(H-A 修复)。 +// masterMax>0 时取 max(1, masterMax/2)(从库适当减少,但绝不截断为 0); +// masterMax<=0(未配置/无限)时返回 0,与主库"无限"语义一致。 +func replicaMaxOpenConns(masterMax int) int { + if masterMax <= 0 { + return 0 + } + half := masterMax / 2 + if half < 1 { + half = 1 + } + return half +} + +// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定 +// replicaDSNs: 从库连接字符串列表(需与主库驱动匹配) +func (m *Manager) InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error { + ctx = normalizeContext(ctx) + m.opMu.Lock() + defer m.opMu.Unlock() + if cfg == nil { + return errors.New("数据库配置未设置") + } + // 先初始化主库 + if err := m.initDB(ctx, cfg); err != nil { + return err + } + + // C11c: 重建从库前关闭旧从库池;C11a: 重置健康状态,使下次 initReplicaHealth 按新 replicas 长度重建 + m.mu.Lock() + oldReplicas := m.replicas + m.replicas = nil + m.resetReplicaHealth() + m.mu.Unlock() + for _, r := range oldReplicas { + warnCloseDB(r, "关闭旧数据库从库连接池失败") + } + + // 初始化从库 + if len(replicaDSNs) > 0 { + var gormLogLevel gormlogger.LogLevel + if cfg.IsDevelopment() { + gormLogLevel = gormlogger.Info + } else { + gormLogLevel = gormlogger.Warn + } + + gormConfig := &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormLogLevel), + // H-db-1:禁用 gorm.Open 自动 Ping(同 initDB),框架用 pingWithTimeout 自管 replica 启动 ping。 + DisableAutomaticPing: true, + } + + // 先构建到局部切片,全部成功后再安装,避免部分构建期间外部读到中间态 + var newReplicas []*gorm.DB + for i, dsn := range replicaDSNs { + if err := ctx.Err(); err != nil { + for _, r := range newReplicas { + warnCloseDB(r, "关闭已打开的数据库从库连接池失败") + } + return fmt.Errorf("数据库从库初始化已取消: %w", err) + } + replicaDB, err := gorm.Open(dialectorForDSN(cfg.Database.Driver, dsn), gormConfig) + if err != nil { + logger.Warnf("数据库从库 %d 连接失败: %v", i+1, err) + continue + } + + sqlDB, err := replicaDB.DB() + if err != nil { + logger.Warnf("数据库从库 %d 获取连接池失败: %v", i+1, err) + warnCloseDB(replicaDB, "关闭刚打开的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏 + continue + } + + sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns) + // H-A 修复:从库 MaxOpenConns 适当减少,但须避免截断为 0。 + // database/sql 中 SetMaxOpenConns(0) 表示"无限制"——原实现 MaxOpenConns/2 在 + // 配置为 1 时得 0,反而让从库连接池无上限(与"减少"意图相反、高并发下打爆 DB); + // 配置为 0(未配置/无限)时 0/2=0 恰好"无限",与主库一致,保持语义。 + // 现规则:MaxOpenConns>0 时取 max(1, /2);<=0 时从库亦无限(0),与主库对齐。 + sqlDB.SetMaxOpenConns(replicaMaxOpenConns(cfg.Database.MaxOpenConns)) + sqlDB.SetConnMaxLifetime(time.Hour) + + // M11(H-db-1):从库启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞 InitDBWithReplicas。 + if err := pingWithTimeout(sqlDB, ctx); err != nil { + logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err) + warnCloseDB(replicaDB, "关闭 Ping 失败的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏 + continue + } + + newReplicas = append(newReplicas, replicaDB) + logger.Info("数据库从库连接成功", zap.Int("index", i+1)) + } + + m.mu.Lock() + m.replicas = newReplicas + m.ensureReplicaHealthLocked() + m.mu.Unlock() + } + + return nil +} + +// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。 +func InitDB(ctx context.Context, cfg *config.Config) error { + return DefaultManager.Load().InitDB(ctx, cfg) +} + +// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定 +func InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error { + return DefaultManager.Load().InitDBWithReplicas(ctx, cfg, replicaDSNs) +} + +// GetReadDB 获取读库实例(按策略选择从库) +func GetReadDB() *gorm.DB { + return DefaultManager.Load().Replica() +} + +// GetWriteDB 获取写库实例(主库) +func GetWriteDB() *gorm.DB { + return DefaultManager.Load().Master() +} + +// GetDB 获取数据库实例(默认主库,兼容旧代码) +func GetDB() *gorm.DB { + return DefaultManager.Load().Master() +} + +// GetReplicas 获取所有从库实例 +func GetReplicas() []*gorm.DB { + return DefaultManager.Load().Replicas() +} + +// SetReplicaPicker 设置默认管理器的从库选择策略 +func SetReplicaPicker(p ReplicaPicker) { + DefaultManager.Load().SetPicker(p) +} + +// UseMaster 强制使用主库(用于事务或需要实时数据的场景) +func UseMaster(ctx context.Context) context.Context { + ctx = normalizeContext(ctx) + return context.WithValue(ctx, dbModeContextKey{}, dbModeMaster) +} + +// UseReplica 强制使用从库(用于报表查询等场景) +func UseReplica(ctx context.Context) context.Context { + ctx = normalizeContext(ctx) + return context.WithValue(ctx, dbModeContextKey{}, dbModeReplica) +} + +// GetDBFromContext 根据上下文选择数据库 +func GetDBFromContext(ctx context.Context) *gorm.DB { + return DefaultManager.Load().FromContext(ctx) +} + +// WithTx 将外层事务注入 ctx,使上层(如 repository.BaseRepo)在调用时能 join 到该事务 +// 而非另开连接/路由到主从库(H6c)。 +// +// 用法: +// +// err := database.TransactionWithContext(ctx, func(tx *gorm.DB) error { +// ctx2 := database.WithTx(ctx, tx) +// // 传 ctx2 给 repo 方法,repo 内部会优先使用该 tx +// return repo.FindByID(ctx2, id) // 此查询参与外层事务 +// }) +// +// 注意:tx 仅在该 ctx 的生命周期内有效;事务提交/回滚后不得再用该 ctx 携带的 tx。 +func WithTx(ctx context.Context, tx *gorm.DB) context.Context { + ctx = normalizeContext(ctx) + if tx == nil { + return ctx + } + return context.WithValue(ctx, txContextKey{}, tx) +} + +// TxFromContext 取出 ctx 携带的外层事务;无则返回 nil。 +func TxFromContext(ctx context.Context) *gorm.DB { + ctx = normalizeContext(ctx) + if tx, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok { + return tx + } + return nil +} + +// AutoMigrate 自动迁移数据库表结构(由应用通过 WithMigrator/WithModels 注册) +func AutoMigrate() error { + logger.Info("数据库表结构迁移完成") + return nil +} + +// Close 关闭所有数据库连接(主库与从库),等价于 CloseAll。 +// 历史上仅关闭主库、遗留从库池泄漏(C11f),已修正为委托 CloseAll。 +func Close() error { + return CloseAll() +} + +// CloseAll 关闭所有数据库连接(包括从库) +func CloseAll() error { + return DefaultManager.Load().Close() +} + +// Transaction 事务操作(自动使用主库) +func Transaction(fn func(tx *gorm.DB) error) error { + db := DefaultManager.Load().Master() + if db == nil { + return errors.New("数据库未初始化") + } + return db.Transaction(fn) +} + +// TransactionWithContext 带上下文的事务操作 +func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error { + ctx = normalizeContext(ctx) + db := DefaultManager.Load().Master() + if db == nil { + return errors.New("数据库未初始化") + } + return db.WithContext(ctx).Transaction(fn) +} + +// ReadQuery 读查询。遵循 ctx 中的数据库路由标记;未指定时默认走从库。 +func ReadQuery(ctx context.Context, model any, query string, args ...any) error { + ctx = normalizeContext(ctx) + db := GetDBFromContext(ctx) + if db == nil { + return errors.New("数据库未初始化") + } + return db.WithContext(ctx).Where(query, args...).Find(model).Error +} + +// WriteQuery 在主库上执行查询并扫描到 model(强制主库,绕过从库延迟)。 +// 注意:命名沿用历史,实际用 .Find() 扫描结果集(读取语义),并非写操作—— +// 强制主库是为了读到刚写入的最新数据(read-your-writes)。命名误导见 M11。 +func WriteQuery(ctx context.Context, model any, query string, args ...any) error { + ctx = normalizeContext(ctx) + db := DefaultManager.Load().Master() + if db == nil { + return errors.New("数据库未初始化") + } + return db.WithContext(ctx).Where(query, args...).Find(model).Error +} + +// healthCheckTimeout 健康检查单次 ping 超时,避免探针被慢/挂起的 DB 长期阻塞(M11)。 +const healthCheckTimeout = 3 * time.Second + +// pingWithTimeout 带超时的 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) +} + +// HealthCheck 健康检查(主库 + 从库),单次 ping 限 3s 超时(M11)。 +func HealthCheck() map[string]bool { + result := make(map[string]bool) + ctx := context.Background() + m := DefaultManager.Load() + + // 检查主库 + if master := m.Master(); master != nil { + sqlDB, err := master.DB() + if err == nil && pingWithTimeout(sqlDB, ctx) == nil { + result["master"] = true + } else { + result["master"] = false + } + } else { + result["master"] = false + } + + // 检查从库 + for i, replica := range m.Replicas() { + if replica != nil { + sqlDB, err := replica.DB() + if err == nil && pingWithTimeout(sqlDB, ctx) == nil { + result[fmt.Sprintf("replica_%d", i+1)] = true + } else { + result[fmt.Sprintf("replica_%d", i+1)] = false + } + } else { + result[fmt.Sprintf("replica_%d", i+1)] = false + } + } + + return result +} + +// IsDBHealthy 返回主库探活健康状态(#21)。 +// 与 HealthCheck()(实时 ping)不同,这是后台探活维护的缓存标记, +// 供 readiness 探针快速判断是否接流量,避免每次探针都同步 ping。 +func IsDBHealthy() bool { + return DefaultManager.Load().IsHealthy() +} + +// StartDBProbing 启动主库/从库探活后台循环(#21)。 +// 阻塞,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。 +func StartDBProbing(ctx context.Context) { + DefaultManager.Load().StartProbing(ctx) +} diff --git a/database/manager_c11_internal_test.go b/database/manager_c11_internal_test.go new file mode 100644 index 0000000..9e12e53 --- /dev/null +++ b/database/manager_c11_internal_test.go @@ -0,0 +1,404 @@ +package database + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/logger" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" +) + +func init() { + // 部分用例触发 logger.Warnf 路径,确保 logger 已初始化避免 nil deref。 + logger.Close() +} + +// sentinelDB 返回一个可安全调用 .DB() 的 gorm.DB 占位实例。 +// 直接用 &gorm.DB{} 会令内嵌 *Config 为 nil,.DB() 访问提升字段 ConnPool 时 nil deref; +// 这里给定非 nil Config,.DB() 走到 nil ConnPool 分支返回 ErrInvalidDB 而不 panic。 +func sentinelDB() *gorm.DB { + return &gorm.DB{Config: &gorm.Config{}} +} + +// TestC11MasterReplicasConcurrentReadWrite 验证 Master/Replicas/Replica 全程加锁, +// 与并发重建路径(写 master/replicas)无数据竞争(C11d)。 +// 修复前 Master/Replicas 为裸读,-race 必采到竞争。 +func TestC11MasterReplicasConcurrentReadWrite(t *testing.T) { + m := &Manager{picker: &RandomPicker{}} + sentinels := []*gorm.DB{{}, {}, {}} + replicaSets := [][]*gorm.DB{ + {sentinels[0], sentinels[1]}, + {sentinels[2]}, + {}, + } + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // 写者:在锁内置换 master/replicas(模拟 InitDB/InitDBWithReplicas/Close 的写路径) + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + m.mu.Lock() + m.master = sentinels[i%len(sentinels)] + m.replicas = replicaSets[i%len(replicaSets)] + m.mu.Unlock() + } + }() + + // 读者:并发调用读取方法 + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _ = m.Master() + _ = m.Replicas() + _ = m.Replica() + _ = m.FromContext(context.Background()) + } + }() + } + + // 让竞争窗口运行一段时间,确保 -race 能采到(若有未加锁读) + time.Sleep(50 * time.Millisecond) + close(stop) + wg.Wait() +} + +// TestC11ReplicasReturnsCopy 验证 Replicas 返回拷贝,调用方修改不影响内部状态(C11d)。 +func TestC11ReplicasReturnsCopy(t *testing.T) { + m := &Manager{picker: &RandomPicker{}} + m.mu.Lock() + m.replicas = []*gorm.DB{{}, {}} + m.mu.Unlock() + + got := m.Replicas() + if len(got) != 2 { + t.Fatalf("expected 2 replicas, got %d", len(got)) + } + got[0] = nil + + m.mu.Lock() + defer m.mu.Unlock() + if m.replicas[0] == nil { + t.Fatal("Replicas should return a copy, not the live slice") + } +} + +// TestC11ReplicaHealthResetOnRebuild 验证重建从库前重置健康状态, +// 使 initReplicaHealth 按新 replicas 长度重建(C11a)。 +// 修复前 initReplicaHealth 的 replicaHealthSet 早返回导致健康切片与新 replicas 长度错位。 +func TestC11ReplicaHealthResetOnRebuild(t *testing.T) { + m := &Manager{picker: &RandomPicker{}} + + // 首轮:2 个从库 + 健康初始化 + m.mu.Lock() + m.replicas = []*gorm.DB{{}, {}} + m.mu.Unlock() + m.initReplicaHealth() + if !m.replicaHealthSet || len(m.replicaHealthy) != 2 { + t.Fatalf("expected health init for 2 replicas, got set=%v len=%d", m.replicaHealthSet, len(m.replicaHealthy)) + } + + // 重建:3 个从库 + 重置健康状态(C11a/C11c 路径) + m.mu.Lock() + m.replicas = []*gorm.DB{{}, {}, {}} + m.resetReplicaHealth() + m.mu.Unlock() + m.initReplicaHealth() + if !m.replicaHealthSet || len(m.replicaHealthy) != 3 { + t.Fatalf("C11a: expected re-init aligned with 3 new replicas, got set=%v len=%d", m.replicaHealthSet, len(m.replicaHealthy)) + } + for i := range m.replicaHealthy { + if !m.replicaHealthy[i].Load() { + t.Fatal("re-init should mark all replicas healthy") + } + } +} + +func TestM4ProbeOnceReinitializesReplicaHealthAfterRebuild(t *testing.T) { + m := &Manager{picker: &RandomPicker{}} + m.mu.Lock() + m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()} + m.resetReplicaHealth() + m.mu.Unlock() + + m.probeOnce(context.Background(), 1) + + m.mu.Lock() + defer m.mu.Unlock() + if !m.replicaHealthSet { + t.Fatal("probeOnce 应在从库重建后自动重建健康标记") + } + if len(m.replicaHealthy) != 2 { + t.Fatalf("健康标记长度应与从库数量一致,实际 %d", len(m.replicaHealthy)) + } +} + +// TestC11ReplicaHealthStaleWithoutReset 反向验证:不调 resetReplicaHealth 时 +// initReplicaHealth 早返回,健康切片长度不随新 replicas 变化(复现 C11a 缺陷根因)。 +func TestC11ReplicaHealthStaleWithoutReset(t *testing.T) { + m := &Manager{picker: &RandomPicker{}} + m.mu.Lock() + m.replicas = []*gorm.DB{{}, {}} + m.mu.Unlock() + m.initReplicaHealth() + + // 仅换 replicas 不重置健康状态 + m.mu.Lock() + m.replicas = []*gorm.DB{{}, {}, {}} + m.mu.Unlock() + m.initReplicaHealth() // replicaHealthSet 仍为 true → 早返回 + + m.mu.Lock() + defer m.mu.Unlock() + if len(m.replicaHealthy) == 3 { + t.Fatal("without reset, initReplicaHealth should NOT re-align (early-returns); this proves reset is required") + } + if len(m.replicaHealthy) != 2 { + t.Fatalf("expected stale len=2, got %d", len(m.replicaHealthy)) + } +} + +// TestC11ManagerCloseResetsState 验证 Close 清空 master/replicas 并重置健康状态(C11a/C11c/C11d)。 +func TestC11ManagerCloseResetsState(t *testing.T) { + m := &Manager{picker: &RandomPicker{}} + m.mu.Lock() + m.master = sentinelDB() + m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()} + m.replicaHealthy = make([]atomic.Bool, 2) + m.replicaHealthy[0].Store(true) + m.replicaHealthy[1].Store(true) + m.replicaHealthSet = true + m.mu.Unlock() + m.healthy.Store(true) + + // closeDB 对空 gorm.DB{}(无 ConnPool)返回 ErrInvalidDB,Close 收集后 join 返回; + // 这里不关心关闭错误,只断言状态被重置。 + _ = m.Close() + + m.mu.Lock() + defer m.mu.Unlock() + if m.master != nil { + t.Fatal("expected master nil after Close") + } + if m.replicas != nil { + t.Fatal("expected replicas nil after Close") + } + if m.replicaHealthSet { + t.Fatal("expected replicaHealthSet reset after Close") + } + if m.replicaHealthy != nil { + t.Fatal("expected replicaHealthy nil after Close") + } + if m.healthy.Load() { + t.Fatal("expected healthy=false after Close") + } +} + +// TestC11PackageCloseClosesReplicas 验证包级 Close 委托 CloseAll, +// 关闭从库而非仅主库(C11f)。修复前包级 Close 仅关 master,replicas 残留。 +func TestC11PackageCloseClosesReplicas(t *testing.T) { + // 保存并恢复 DefaultManager 状态,避免污染其他用例 + defer func() { + DefaultManager.Load().mu.Lock() + DefaultManager.Load().master = nil + DefaultManager.Load().replicas = nil + DefaultManager.Load().resetReplicaHealth() + DefaultManager.Load().healthy.Store(false) + DefaultManager.Load().mu.Unlock() + }() + + DefaultManager.Load().mu.Lock() + DefaultManager.Load().master = sentinelDB() + DefaultManager.Load().replicas = []*gorm.DB{sentinelDB(), sentinelDB()} + DefaultManager.Load().mu.Unlock() + + // 包级 Close → CloseAll → DefaultManager.Load().Close(),应同时清空 master 与 replicas + _ = Close() + + DefaultManager.Load().mu.Lock() + master := DefaultManager.Load().master + repl := DefaultManager.Load().replicas + DefaultManager.Load().mu.Unlock() + + if master != nil { + t.Fatal("C11f: expected master nil after package Close") + } + if repl != nil { + t.Fatal("C11f: package Close should close replicas too (delegates to CloseAll), got residual replicas") + } +} + +// TestC11HealthCheckLockedRead 验证 HealthCheck 在锁内快照 master,未初始化时返错(C11d)。 +func TestC11HealthCheckLockedRead(t *testing.T) { + m := &Manager{picker: &RandomPicker{}} + if err := m.HealthCheck(context.Background()); err == nil { + t.Fatal("expected error when health checking uninitialized master") + } + + // 空 gorm.DB(无 ConnPool):DB() 返 ErrInvalidDB,HealthCheck 返该错而非 panic + m.mu.Lock() + m.master = sentinelDB() + m.mu.Unlock() + if err := m.HealthCheck(context.Background()); err == nil { + t.Fatal("expected error for gorm.DB without ConnPool") + } +} + +func TestSetDefaultManagerClosesReplacedManager(t *testing.T) { + old := NewManager(nil) + old.mu.Lock() + old.master = sentinelDB() + old.replicas = []*gorm.DB{sentinelDB()} + old.replicaHealthy = make([]atomic.Bool, 1) + old.replicaHealthy[0].Store(true) + old.replicaHealthSet = true + old.mu.Unlock() + old.healthy.Store(true) + + orig := SwapDefaultManager(old) + t.Cleanup(func() { + current := SwapDefaultManager(orig) + if current != nil && current != orig { + _ = current.Close() + } + }) + + next := NewManager(nil) + SetDefaultManager(next) + if GetDefaultManager() != next { + t.Fatal("SetDefaultManager 应安装新的默认 manager") + } + + old.mu.Lock() + defer old.mu.Unlock() + if old.master != nil || old.replicas != nil || old.replicaHealthy != nil || old.replicaHealthSet { + t.Fatal("SetDefaultManager 应关闭并清空被替换的旧 manager,避免连接池泄漏") + } + if old.healthy.Load() { + t.Fatal("SetDefaultManager 关闭旧 manager 后健康状态应为 false") + } +} + +func TestSwapDefaultManagerPreservesReplacedManager(t *testing.T) { + old := NewManager(nil) + old.mu.Lock() + old.master = sentinelDB() + old.replicas = []*gorm.DB{sentinelDB()} + old.mu.Unlock() + + orig := SwapDefaultManager(old) + t.Cleanup(func() { + current := SwapDefaultManager(orig) + if current != nil && current != orig { + _ = current.Close() + } + _ = old.Close() + }) + + next := NewManager(nil) + previous := SwapDefaultManager(next) + if previous != old { + t.Fatal("SwapDefaultManager 应返回被替换的旧 manager") + } + if GetDefaultManager() != next { + t.Fatal("SwapDefaultManager 应安装新的默认 manager") + } + + old.mu.Lock() + defer old.mu.Unlock() + if old.master == nil || len(old.replicas) != 1 { + t.Fatal("SwapDefaultManager 不应关闭旧 manager,旧资源需可用于失败回滚") + } +} + +type transientDialector struct{} + +func (d transientDialector) Name() string { return "m4_transient" } + +func (d transientDialector) Initialize(*gorm.DB) error { + return errors.New("temporary connection failure") +} + +func (d transientDialector) Migrator(*gorm.DB) gorm.Migrator { return nil } + +func (d transientDialector) DataTypeOf(*schema.Field) string { return "" } + +func (d transientDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil } + +func (d transientDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {} + +func (d transientDialector) QuoteTo(writer clause.Writer, str string) { + _, _ = writer.WriteString(str) +} + +func (d transientDialector) Explain(sql string, _ ...any) string { return sql } + +func TestM4InitDBHonorsContextDuringRetrySleep(t *testing.T) { + const driver = "m4_transient_retry" + RegisterDialect(DialectSpec{ + Name: driver, + Dialector: func(string) gorm.Dialector { return transientDialector{} }, + DSN: func(*config.DatabaseConfig) string { return "m4://retry" }, + }) + + m := NewManager(nil) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(30 * time.Millisecond) + cancel() + }() + + start := time.Now() + err := m.InitDB(ctx, &config.Config{Database: config.DatabaseConfig{Driver: driver}}) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("InitDB 应返回 context.Canceled,实际: %v", err) + } + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Fatalf("InitDB 不应等待完整 retry sleep,耗时 %s", elapsed) + } +} + +func TestM4CloseWaitsForLifecycleOperation(t *testing.T) { + m := NewManager(nil) + m.opMu.Lock() + + done := make(chan struct{}) + go func() { + _ = m.Close() + close(done) + }() + + select { + case <-done: + t.Fatal("Close 不应越过正在执行的生命周期操作") + case <-time.After(50 * time.Millisecond): + } + + m.opMu.Unlock() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("释放生命周期锁后 Close 未返回") + } +} diff --git a/database/manager_hdb1_internal_test.go b/database/manager_hdb1_internal_test.go new file mode 100644 index 0000000..e107170 --- /dev/null +++ b/database/manager_hdb1_internal_test.go @@ -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") + } +} diff --git a/database/manager_test.go b/database/manager_test.go new file mode 100644 index 0000000..935f406 --- /dev/null +++ b/database/manager_test.go @@ -0,0 +1,277 @@ +package database_test + +import ( + "context" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/database" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" +) + +func TestCloseAllWithoutInit(t *testing.T) { + if err := database.CloseAll(); err != nil { + t.Fatalf("CloseAll without init should not error: %v", err) + } + if database.GetDB() != nil { + t.Fatal("expected DB nil") + } + if database.GetReadDB() != nil { + t.Fatal("expected read DB nil") + } + if len(database.GetReplicas()) != 0 { + t.Fatal("expected replicas empty") + } +} + +func TestDBContextHelpersWithoutInit(t *testing.T) { + ctx := database.UseMaster(context.Background()) + if db := database.GetDBFromContext(ctx); db != nil { + t.Fatal("expected nil DB without init") + } + + ctx = database.UseReplica(context.Background()) + if db := database.GetDBFromContext(ctx); db != nil { + t.Fatal("expected nil read DB without init") + } +} + +func TestRoundRobinPicker(t *testing.T) { + replicas := []*gorm.DB{{}, {}, {}} + p := &database.RoundRobinPicker{} + + first := p.Pick(replicas) + second := p.Pick(replicas) + third := p.Pick(replicas) + fourth := p.Pick(replicas) + + if first == nil || second == nil || third == nil { + t.Fatal("Picker returned nil for non-empty replicas") + } + if first != replicas[0] || second != replicas[1] || third != replicas[2] { + t.Fatal("RoundRobinPicker should cycle through replicas in order") + } + if fourth != replicas[0] { + t.Fatal("RoundRobinPicker should wrap around to the first replica") + } + if p.Pick(nil) != nil || p.Pick([]*gorm.DB{}) != nil { + t.Fatal("Picker should return nil for empty replicas") + } +} + +func TestRandomPicker(t *testing.T) { + replicas := []*gorm.DB{{}, {}} + p := &database.RandomPicker{} + + picked := p.Pick(replicas) + if picked == nil { + t.Fatal("RandomPicker returned nil for non-empty replicas") + } + if picked != replicas[0] && picked != replicas[1] { + t.Fatal("RandomPicker returned a replica not in the slice") + } + if p.Pick(nil) != nil || p.Pick([]*gorm.DB{}) != nil { + t.Fatal("RandomPicker should return nil for empty replicas") + } +} + +func TestManagerReplicaFallbackToMaster(t *testing.T) { + mgr := database.NewManager(&config.Config{}) + if mgr.Master() != nil { + t.Fatal("expected nil master before init") + } + if mgr.Replicas() != nil { + t.Fatal("expected nil replicas before init") + } + // 无从库时 Replica 应返回 master(此处均为 nil) + if mgr.Replica() != nil { + t.Fatal("expected Replica to fall back to master when no replicas") + } +} + +func TestManagerSetPicker(t *testing.T) { + mgr := database.NewManager(&config.Config{}) + rr := &database.RoundRobinPicker{} + mgr.SetPicker(rr) + if mgr.Picker() != rr { + t.Fatal("SetPicker did not install the picker") + } + // nil 不应覆盖已有 picker + mgr.SetPicker(nil) + if mgr.Picker() != rr { + t.Fatal("SetPicker(nil) should not clear the existing picker") + } +} + +func TestDefaultManagerHealthCheckWithoutInit(t *testing.T) { + if err := database.GetDefaultManager().HealthCheck(context.Background()); err == nil { + t.Fatal("expected error when health checking uninitialized master") + } +} + +func TestNilContextHelpersDoNotPanic(t *testing.T) { + _ = database.CloseAll() + + if ctx := database.UseMaster(nil); ctx == nil { + t.Fatal("UseMaster(nil) 应返回可用 context") + } + if ctx := database.UseReplica(nil); ctx == nil { + t.Fatal("UseReplica(nil) 应返回可用 context") + } + if db := database.GetDBFromContext(nil); db != nil { + t.Fatal("未初始化数据库时 GetDBFromContext(nil) 应返回 nil") + } + if ctx := database.WithTx(nil, nil); ctx == nil { + t.Fatal("WithTx(nil, nil) 应返回可用 context") + } + if tx := database.TxFromContext(nil); tx != nil { + t.Fatal("TxFromContext(nil) 应返回 nil") + } + if err := database.TransactionWithContext(nil, func(tx *gorm.DB) error { return nil }); err == nil { + t.Fatal("未初始化数据库时 TransactionWithContext(nil) 应返回错误") + } + if err := database.ReadQuery(nil, &struct{}{}, "1=1"); err == nil { + t.Fatal("未初始化数据库时 ReadQuery(nil) 应返回错误") + } + if err := database.WriteQuery(nil, &struct{}{}, "1=1"); err == nil { + t.Fatal("未初始化数据库时 WriteQuery(nil) 应返回错误") + } + if got := database.HealthCheck(); !got["master"] { + // HealthCheck 内部使用 background context;未初始化时 master=false 即可。 + return + } + t.Fatal("未初始化数据库时 HealthCheck master 不应为 true") +} + +func TestNilConfigInitializationReturnsError(t *testing.T) { + mgr := database.NewManager(nil) + if err := mgr.InitDB(context.Background(), nil); err == nil { + t.Fatal("InitDB(nil) 应返回错误") + } + if err := mgr.InitDBWithReplicas(context.Background(), nil, nil); err == nil { + t.Fatal("InitDBWithReplicas(nil) 应返回错误") + } + if err := database.InitDB(context.Background(), nil); err == nil { + t.Fatal("包级 InitDB(nil) 应返回错误") + } + if err := database.InitDBWithReplicas(context.Background(), nil, nil); err == nil { + t.Fatal("包级 InitDBWithReplicas(nil) 应返回错误") + } +} + +func TestDialectorNilConfigDoesNotPanic(t *testing.T) { + if d := database.Dialector(nil); d == nil { + t.Fatal("Dialector(nil) 应返回兜底 dialector") + } +} + +func TestDialectorSelectsByDriver(t *testing.T) { + mysqlCfg := &config.Config{Database: config.DatabaseConfig{ + Driver: config.DriverMySQL, Host: "localhost", Port: 3306, + User: "root", Password: "pass", Name: "db", + }} + if name := database.Dialector(mysqlCfg).Name(); name != "mysql" { + t.Fatalf("expected mysql dialector, got %q", name) + } + + pgCfg := &config.Config{Database: config.DatabaseConfig{ + Driver: config.DriverPostgres, Host: "localhost", Port: 5432, + User: "postgres", Password: "pass", Name: "db", + }} + if name := database.Dialector(pgCfg).Name(); name != "postgres" { + t.Fatalf("expected postgres dialector, got %q", name) + } + + // 别名也应解析为 postgres + pgAliasCfg := &config.Config{Database: config.DatabaseConfig{ + Driver: "PG", Host: "localhost", Port: 5432, + User: "postgres", Password: "pass", Name: "db", + }} + if name := database.Dialector(pgAliasCfg).Name(); name != "postgres" { + t.Fatalf("expected postgres dialector via alias, got %q", name) + } + + // 未指定 Driver 时默认 mysql + defaultCfg := &config.Config{Database: config.DatabaseConfig{ + Host: "localhost", Port: 3306, User: "root", Password: "pass", Name: "db", + }} + if name := database.Dialector(defaultCfg).Name(); name != "mysql" { + t.Fatalf("expected default mysql dialector, got %q", name) + } +} + +// stubDialector 是一个用于测试 RegisterDialect 的占位 Dialector。 +type stubDialector struct{ name string } + +func (s stubDialector) Name() string { return s.name } +func (s stubDialector) Initialize(_ *gorm.DB) error { return nil } +func (s stubDialector) Migrator(db *gorm.DB) gorm.Migrator { return nil } +func (s stubDialector) DataTypeOf(*schema.Field) string { return "" } +func (s stubDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil } +func (s stubDialector) BindVarTo(writer clause.Writer, _ *gorm.Statement, _ any) {} +func (s stubDialector) QuoteTo(writer clause.Writer, str string) { _, _ = writer.WriteString(str) } +func (s stubDialector) Explain(sql string, _ ...any) string { return sql } + +func TestRegisterDialectAndCustomDriver(t *testing.T) { + const driver = "stubdb" + + database.RegisterDialect(database.DialectSpec{ + Name: driver, + Aliases: []string{"stub"}, + Dialector: func(dsn string) gorm.Dialector { return stubDialector{name: "stubdb"} }, + DSN: func(c *config.DatabaseConfig) string { + return "stub://" + c.Host + }, + }) + + // Dialector 工厂可以解析主名和别名 + if _, ok := database.LookupDialect(driver); !ok { + t.Fatal("expected stubdb dialector to be registered") + } + if _, ok := database.LookupDialect("STUB"); !ok { + t.Fatal("expected stub alias to be registered (case-insensitive)") + } + + cfg := &config.Config{Database: config.DatabaseConfig{ + Driver: driver, Host: "localhost", + }} + if name := database.Dialector(cfg).Name(); name != "stubdb" { + t.Fatalf("expected stubdb dialector, got %q", name) + } + + // config.DSN() 应使用注册的 DSN 构建器 + if dsn := cfg.Database.DSN(); dsn != "stub://localhost" { + t.Fatalf("expected DSN built by registered builder, got %q", dsn) + } + + // 未知驱动应 fail-closed,避免拼写错误静默连向 MySQL。 + unknownCfg := &config.Config{Database: config.DatabaseConfig{ + Driver: "no-such-driver", Host: "localhost", Port: 3306, + User: "root", Password: "pass", Name: "db", + }} + d := database.Dialector(unknownCfg) + if name := d.Name(); name != "invalid" { + t.Fatalf("expected invalid dialector for unknown driver, got %q", name) + } + if _, err := gorm.Open(d, &gorm.Config{}); err == nil || !strings.Contains(err.Error(), "数据库驱动未注册") { + t.Fatalf("未知 driver 应在 GORM 初始化时报错,实际: %v", err) + } +} + +func TestRegisteredDialectsContainsBuiltins(t *testing.T) { + registered := database.RegisteredDialects() + want := map[string]bool{"mysql": false, "postgres": false, "pg": false} + for _, n := range registered { + if _, ok := want[n]; ok { + want[n] = true + } + } + for k, found := range want { + if !found { + t.Errorf("expected %q to be registered by default", k) + } + } +} diff --git a/database/redis.go b/database/redis.go new file mode 100644 index 0000000..13b7fa5 --- /dev/null +++ b/database/redis.go @@ -0,0 +1,194 @@ +package database + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/logger" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +// RedisManager Redis 连接管理器(#10)。照 database.Manager 模式: +// 实例化 + DefaultRedis 全局默认 + 包级 facade 代理,支持多实例与测试注入。 +type RedisManager struct { + mu sync.Mutex + cfg *config.Config + client *redis.Client +} + +// DefaultRedis 默认 Redis 管理器,包级 facade 代理到它。 +// +// C-1/H-4 修复:改用 atomic.Pointer 保护读写,消除原裸指针置换(SetDefaultRedisManager) +// 与请求 goroutine 无锁读(GetRedis 等 facade)之间的数据竞争。与 config.defaultManager +// 对齐。类型由 *RedisManager 变更为 atomic.Pointer[RedisManager](breaking:下游若直接 +// 调用 DefaultRedis.Init 等方法需改用 InitRedis 等 facade,或 DefaultRedis.Load().Init)。 +var DefaultRedis atomic.Pointer[RedisManager] + +func init() { + DefaultRedis.Store(NewRedisManager()) +} + +// NewRedisManager 创建 Redis 管理器实例。 +func NewRedisManager() *RedisManager { return &RedisManager{} } + +// SwapDefaultRedisManager 将指定 RedisManager 置为全局默认,并返回被替换的旧 Manager。 +// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存。 +// nil 被忽略,以防 facade Load 到 nil panic。 +func SwapDefaultRedisManager(m *RedisManager) *RedisManager { + if m == nil { + return DefaultRedis.Load() + } + return DefaultRedis.Swap(m) +} + +// SetDefaultRedisManager 提升指定 RedisManager 为全局默认,并关闭被替换的旧 Manager。 +// 用于多实例场景或测试注入 mock。并发安全。若调用方需要保留旧 Manager +// 用于回滚,请使用 SwapDefaultRedisManager。 +func SetDefaultRedisManager(m *RedisManager) { + if m == nil { + return + } + old := SwapDefaultRedisManager(m) + if old != nil && old != m { + if err := old.Close(); err != nil { + logger.Warnf("关闭被替换的旧 Redis manager 失败: %v", err) + } + } +} + +// GetDefaultRedisManager 返回当前默认 Redis 管理器。 +func GetDefaultRedisManager() *RedisManager { + return DefaultRedis.Load() +} + +// newRedisClient 构造带 D7 超时(Dial 5s / Read 3s / Write 3s)的 redis.Client。 +// 由 Init 与测试共用,确保所有 client 实例一致具备超时约束(挂起 Redis 不无限阻塞)。 +func newRedisClient(addr, password string, db int) *redis.Client { + return redis.NewClient(&redis.Options{ + Addr: addr, + Password: password, + DB: db, + DialTimeout: 5 * time.Second, // D7 修复:连接超时 + ReadTimeout: 3 * time.Second, // D7 修复:读超时(约束 HealthCheck Ping 不被挂起 Redis 阻塞) + WriteTimeout: 3 * time.Second, // D7 修复:写超时 + }) +} + +// Init 初始化 Redis 连接并 ping 验证。 +func (m *RedisManager) Init(cfg *config.Config) error { + if cfg == nil { + return errors.New("Redis 配置未设置") + } + m.mu.Lock() + defer m.mu.Unlock() + + client := newRedisClient(cfg.Redis.Addr(), cfg.Redis.Password, cfg.Redis.DB) + + pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := client.Ping(pingCtx).Err(); err != nil { + if cerr := client.Close(); cerr != nil { + return errors.Join(fmt.Errorf("Redis 连接失败: %w", err), fmt.Errorf("Redis 关闭失败: %w", cerr)) + } + return fmt.Errorf("Redis 连接失败: %w", err) + } + + old := m.client + m.cfg = cfg + m.client = client + if old != nil { + if err := old.Close(); err != nil { + logger.Warnf("关闭旧 Redis 连接失败: %v", err) + } + } + logger.Info("Redis 连接成功", zap.String("addr", cfg.Redis.Addr())) + return nil +} + +// Close 关闭 Redis 连接。 +func (m *RedisManager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.client == nil { + return nil + } + err := m.client.Close() + m.client = nil + return err +} + +// Client 返回当前 Redis 客户端(未初始化返回 nil)。 +func (m *RedisManager) Client() *redis.Client { + m.mu.Lock() + defer m.mu.Unlock() + return m.client +} + +// setClientForTest 仅测试用:在持锁下替换 manager 的 client,返回旧 client。 +// 供 SetTestRedisClient 注入 miniredis 等 mock,消除原包级 redisClient 双源真相。 +func (m *RedisManager) setClientForTest(c *redis.Client) *redis.Client { + m.mu.Lock() + defer m.mu.Unlock() + old := m.client + m.client = c + return old +} + +// HealthCheck Redis 健康检查。 +func (m *RedisManager) HealthCheck(ctx context.Context) error { + ctx = normalizeContext(ctx) + m.mu.Lock() + client := m.client + m.mu.Unlock() + if client == nil { + return fmt.Errorf("Redis 未初始化") + } + return client.Ping(ctx).Err() +} + +// --- 包级 facade(代理到 DefaultRedis,兼容存量) --- + +// InitRedis 初始化 Redis 连接 +func InitRedis(cfg *config.Config) error { + return DefaultRedis.Load().Init(cfg) +} + +// CloseRedis 关闭 Redis 连接 +func CloseRedis() error { + return DefaultRedis.Load().Close() +} + +// HealthCheckRedis Redis 健康检查 +func HealthCheckRedis(ctx context.Context) error { + return DefaultRedis.Load().HealthCheck(ctx) +} + +// GetRedis 获取 Redis 客户端(未初始化返回 nil)。 +// +// H-4 修复:单源——仅从 DefaultRedis.Load().Client() 取,废弃原包级 redisClient +// 回退路径(其在 manager 替换后会返回 stale client,且无锁读存在竞态)。 +// 测试注入的 mock client 经 SetTestRedisClient 直接设在当前 manager 上,闭环一致。 +func GetRedis() *redis.Client { + return DefaultRedis.Load().Client() +} + +// SetTestRedisClient 供测试注入 miniredis 等 mock 客户端。 +// 返回旧客户端引用以便测试清理时恢复。生产代码严禁调用。 +// +// H-4 修复:改为在当前默认 manager 上持锁替换 client(setClientForTest), +// 不再写独立的包级 redisClient 变量,消除双源真相与无锁写竞态。 +// +// 约束:操作 DefaultRedis.Load() 返回的当前默认 manager。测试应避免在调用本函数 +// 的同时并发 SetDefaultRedisManager 替换默认 manager,否则注入/恢复会作用到不同 +// manager 上。典型用法为 init 阶段注入、t.Cleanup 恢复,串行执行。 +func SetTestRedisClient(c *redis.Client) *redis.Client { + return DefaultRedis.Load().setClientForTest(c) +} diff --git a/database/redis_hdb1_internal_test.go b/database/redis_hdb1_internal_test.go new file mode 100644 index 0000000..b00dec9 --- /dev/null +++ b/database/redis_hdb1_internal_test.go @@ -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) +} diff --git a/database/redis_internal_test.go b/database/redis_internal_test.go new file mode 100644 index 0000000..cd12b17 --- /dev/null +++ b/database/redis_internal_test.go @@ -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,旧资源需可用于失败回滚") + } +} diff --git a/database/redis_manager_internal_test.go b/database/redis_manager_internal_test.go new file mode 100644 index 0000000..62627d9 --- /dev/null +++ b/database/redis_manager_internal_test.go @@ -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") + } +} diff --git a/database/redis_test.go b/database/redis_test.go new file mode 100644 index 0000000..d8f8683 --- /dev/null +++ b/database/redis_test.go @@ -0,0 +1,66 @@ +package database_test + +import ( + "context" + "sync" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/database" +) + +func TestCloseRedisWithoutInit(t *testing.T) { + if err := database.CloseRedis(); err != nil { + t.Fatalf("CloseRedis without init should not error: %v", err) + } + if database.GetRedis() != nil { + t.Fatal("expected Redis client nil") + } +} + +func TestHealthCheckRedisWithoutInit(t *testing.T) { + if err := database.HealthCheckRedis(context.Background()); err == nil { + t.Fatal("expected health check error without Redis init") + } +} + +func TestRedisNilConfigReturnsError(t *testing.T) { + prev := database.SetTestRedisClient(nil) + t.Cleanup(func() { database.SetTestRedisClient(prev) }) + + if err := database.NewRedisManager().Init(nil); err == nil { + t.Fatal("RedisManager.Init(nil) 应返回错误") + } + if err := database.InitRedis(nil); err == nil { + t.Fatal("包级 InitRedis(nil) 应返回错误") + } + if err := database.HealthCheckRedis(nil); err == nil { + t.Fatal("未初始化 Redis 时 HealthCheckRedis(nil) 应返回错误") + } +} + +// TestDefaultRedisConcurrentSetAndGet C-1/H-4 回归:并发 SetDefaultRedisManager +// 与 GetRedis/CloseRedis/HealthCheckRedis facade 读取不应触发数据竞争。 +// 修复前 DefaultRedis 是裸 *Redis.Pointer,SetDefaultRedisManager 无锁写、 +// facade 无锁读,-race 必采。修复后 atomic.Pointer 保护。 +func TestDefaultRedisConcurrentSetAndGet(t *testing.T) { + // 保存并恢复默认 manager,避免污染其他测试。 + orig := database.NewRedisManager() + database.SetDefaultRedisManager(orig) + // 不设 client,GetRedis 返回 nil;本用例只验证读写无竞态。 + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(2) + go func() { + defer wg.Done() + database.SetDefaultRedisManager(database.NewRedisManager()) + }() + go func() { + defer wg.Done() + _ = database.GetRedis() + _ = database.CloseRedis() + _ = database.HealthCheckRedis(context.Background()) + }() + } + wg.Wait() +} diff --git a/database/retry_internal_test.go b/database/retry_internal_test.go new file mode 100644 index 0000000..9310873 --- /dev/null +++ b/database/retry_internal_test.go @@ -0,0 +1,38 @@ +package database + +import ( + "errors" + "testing" +) + +func TestIsTransientDBError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil", nil, true}, + {"access denied", errors.New("Error 1045: Access denied for user 'root'@'localhost'"), false}, + {"auth plugin", errors.New("authentication plugin 'caching_sha2_password' cannot be loaded"), false}, + {"unknown database", errors.New("Error 1049: Unknown database 'foo'"), false}, + {"invalid DSN", errors.New("invalid DSN: missing the slash separating the database name"), false}, + {"unknown driver", errors.New("sql: unknown driver \"foobar\" (forgotten import?)"), false}, + {"connection refused (transient)", errors.New("dial tcp 127.0.0.1:3306: connect: connection refused"), true}, + {"i/o timeout (transient)", errors.New("dial tcp 10.0.0.1:3306: i/o timeout"), true}, + {"empty msg", errors.New(""), true}, + // P1 #12:PG 目标库不存在应视为非瞬态(不重试)。 + {"pg database not exist", errors.New(`FATAL: database "app" does not exist (SQLSTATE 3D000)`), false}, + {"pg password auth failed", errors.New("FATAL: password authentication failed for user \"app\""), false}, + // P1 #12 回归核心:含 "database" 但是瞬态的错误必须仍被判为瞬态(可重试), + // 修复前独立 "database" 子串会把它误判为非瞬态而放弃重试。 + {"pg starting up (transient)", errors.New("FATAL: the database system is starting up (SQLSTATE 57P03)"), true}, + {"pg in recovery (transient)", errors.New("FATAL: the database system is in recovery mode"), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isTransientDBError(tt.err); got != tt.want { + t.Errorf("isTransientDBError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/database/tls.go b/database/tls.go new file mode 100644 index 0000000..fc05376 --- /dev/null +++ b/database/tls.go @@ -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=:引用经 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 +} diff --git a/database/tls_internal_test.go b/database/tls_internal_test.go new file mode 100644 index 0000000..f32d2b0 --- /dev/null +++ b/database/tls_internal_test.go @@ -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) + } +} diff --git a/docs/plans/Version_Update_Plan_v1.0.2.md b/docs/plans/Version_Update_Plan_v1.0.2.md new file mode 100644 index 0000000..d96650d --- /dev/null +++ b/docs/plans/Version_Update_Plan_v1.0.2.md @@ -0,0 +1,1047 @@ +# xlgo v1.0.2 更新计划 + +> 版本定位:面向新框架的激进整理版本。由于当前框架仍处于早期阶段,v1.0.2 不以保守兼容为首要目标,而是优先修正已经发现的架构边界、默认行为、文档一致性和长期演进风险,避免在用户规模扩大后再进行破坏式调整。 + +## 0. 实施状态(2026-06-20) + +✅ **本计划全部 11 项已完成。** + +| 节 | 任务 | 状态 | 主要落点 | +|---|---|---|---| +| 2.1 | 权限中间件去业务化 | ✅ | `middleware/auth.go`、`middleware/auth_test.go` | +| 2.2 | App 启动流程可组合 | ✅ | `app.go`(Option 全套 + `enableAutoMigrate`)、`app_test.go` | +| 2.3 | 框架内部禁止 Fatalf 退出 | ✅ | `app.go` 全部 `return fmt.Errorf(...)` | +| 2.4 | Shutdown 关闭流程修正 | ✅ | `app.go` 使用 `database.CloseAll()` + `errors.Join` | +| 2.5 | 默认路由和 Swagger 可控化 | ✅ | `router/router.go`(`RegisterHealthRoute` / `RegisterSwaggerRoutes`) | +| 2.6 | 健康检查标准化 | ✅ | `/health` 支持 checks + 503,`router/health_test.go` | +| 2.7 | 配置系统 | ✅ | `config.Manager` + `config.SetDefaultManager`,App 真正驱动私有 manager | +| 2.8 | 数据库全局状态治理 | ✅ | `database.Manager`、`ReplicaPicker`、私有 `dbModeContextKey{}`、可插拔方言注册表 | +| 2.9 | AutoMigrate 机制 | ✅ | `WithMigrator` / `WithModels` / `WithAutoMigrate` / `WithoutAutoMigrate` | +| 2.10 | README 更新日志版本错误 | ✅ | README 重写为 v1.0.2 / v1.0.1 / v1.0.0 线 | +| 2.11 | 文档与 CLI 模板同步 | ✅ | GUIDE 1.3 / 3.2 / 4.1 / 8.4.7 / 9.2 更新;CLI 模板补 Swagger/MySQL 注释 | + +### v1.0.2 在计划之外的额外收益 + +- **可插拔方言注册表**:原计划只要求"支持 mysql/postgres",实际实现升级为 `database.RegisterDialect(DialectSpec{...})` 注册表,应用可一次接入任意 GORM 驱动(SQLite、SQL Server、ClickHouse、TiDB...),DSN 构建器同步登记到 `config` 包。 +- **`config.SetDefaultManager`**:让 App 持有的 `config.Manager` 能成为全局便捷函数(`config.Get`/`GetString`...)的数据源,解决 "App 私有 manager 与全局 API 双轨" 的问题。 +- **`WithAutoMigrate` / `WithoutAutoMigrate`**:与 `WithMigrator`/`WithModels` 解耦的迁移开关,覆盖"注册了迁移器但需临时关闭"的场景。 + +### 验证 + +```bash +go build -buildvcs=false ./... # ✅ 通过 +go vet -buildvcs=false ./... # ✅ 通过 +go test -buildvcs=false ./... # ✅ 全部 23 个包 PASS +``` + +--- + +## 1. 版本原则 + +### 1.1 本次版本核心目标 + +v1.0.2 的目标是把 xlgo 从“业务项目沉淀出来的工具集合”进一步整理为“通用 Go Web 框架”: + +1. **框架与业务解耦**:框架层不写死具体业务角色、业务流程和业务默认值。 +2. **启动流程可组合**:MySQL、Redis、Storage、Swagger、默认路由、AutoMigrate 等能力应可显式启用或关闭。 +3. **错误处理专业化**:框架内部不直接 `Fatalf` 退出进程,而是向调用方返回错误。 +4. **生命周期清晰化**:初始化、运行、关闭流程应具备明确边界,并为后续 lifecycle hooks 打基础。 +5. **高可用基础增强**:健康检查、优雅关闭、连接关闭、依赖状态检查等能力逐步标准化。 +6. **文档真实可信**:README、GUIDE、CLI 模板、更新日志与真实版本和代码行为保持一致。 +7. **坚持 Go 1.25**:本项目作为新框架,明确使用 Go 1.25,不背负旧版本兼容包袱,允许使用 Go 1.25 的新特性。 + +### 1.2 兼容策略 + +v1.0.2 可以接受破坏式更新,但破坏必须有明确收益: + +- 可以调整不合理 API。 +- 可以删除或废弃明显业务化的设计。 +- 可以改变默认启动策略,只要文档和迁移说明清晰。 +- 可以统一命名、配置、模块边界。 +- 不为了兼容历史写法牺牲框架长期设计。 + +建议保留部分快捷 API,但应明确其定位为“默认封装”而非“框架唯一模型”。 + +--- + +## 2. 必须修正的问题清单 + +## 2.1 权限中间件去业务化 + +### 当前问题 + +`middleware/auth.go` 中的权限中间件写死了以下用户类型: + +```go +super_admin +admin +staff +``` + +典型代码: + +```go +if ut != "super_admin" && ut != "admin" { + response.Fail(c, "无权限访问") + c.Abort() + return +} +``` + +这属于具体业务系统权限模型,不应该成为通用框架的固定规则。 + +### 目标设计 + +将固定角色改为默认角色,并提供通用权限能力。 + +### 新增默认常量 + +```go +const ( + DefaultUserTypeSuperAdmin = "super_admin" + DefaultUserTypeAdmin = "admin" + DefaultUserTypeStaff = "staff" +) +``` + +这些常量仅作为默认值和快速开始示例使用,不代表用户必须采用这些角色名。 + +### 新增认证用户结构 + +```go +type AuthUser struct { + UserID uint + Username string + Role string + UserType string +} +``` + +### 新增统一获取当前用户方法 + +```go +func GetAuthUser(c *gin.Context) (AuthUser, bool) +``` + +该方法从 Gin Context 中读取: + +- `ContextKeyUserID` +- `ContextKeyUsername` +- `ContextKeyRole` +- `ContextKeyUserType` + +### 新增通用权限中间件 + +#### 按 user_type 判断 + +```go +func RequireUserTypes(userTypes ...string) gin.HandlerFunc +``` + +示例: + +```go +middleware.RequireUserTypes("tenant_admin", "platform_admin") +``` + +#### 按 role 判断 + +```go +func RequireRoles(roles ...string) gin.HandlerFunc +``` + +示例: + +```go +middleware.RequireRoles("owner", "manager") +``` + +#### 自定义权限判断 + +```go +type AuthChecker func(user AuthUser, c *gin.Context) bool + +func RequireAuth(checker AuthChecker, messages ...string) gin.HandlerFunc +``` + +示例: + +```go +middleware.RequireAuth(func(user middleware.AuthUser, c *gin.Context) bool { + return user.UserType == "merchant" && user.Role == "owner" +}) +``` + +### 调整原快捷方法 + +以下方法可以保留,但必须改为基于通用能力实现: + +```go +func AdminRequired() gin.HandlerFunc { + return RequireUserTypes(DefaultUserTypeSuperAdmin, DefaultUserTypeAdmin) +} + +func SuperAdminRequired() gin.HandlerFunc { + return RequireUserTypes(DefaultUserTypeSuperAdmin) +} + +func StaffRequired() gin.HandlerFunc { + return RequireUserTypes(DefaultUserTypeStaff) +} + +func AnyUserRequired() gin.HandlerFunc { + return RequireUserTypes( + DefaultUserTypeSuperAdmin, + DefaultUserTypeAdmin, + DefaultUserTypeStaff, + ) +} +``` + +### 测试要求 + +新增或完善 `middleware/auth_test.go`: + +- 未登录访问被拒绝。 +- Context 中缺少用户信息时被拒绝。 +- `user_type` 类型异常时被拒绝。 +- `RequireUserTypes("tenant_admin")` 可通过自定义用户类型。 +- `RequireUserTypes("tenant_admin")` 会拒绝其他用户类型。 +- `RequireRoles("owner")` 可通过自定义角色。 +- `RequireAuth` 可执行复杂自定义判断。 +- 默认快捷方法仍符合默认常量语义。 + +--- + +## 2.2 App 启动流程重构为可组合模式 + +### 当前问题 + +`app.go` 的 `Run()` 当前强制执行: + +```go +logger.Init(cfg) +database.InitMySQL(cfg) +database.InitRedis(cfg) +database.AutoMigrate() +storage.Init(&cfg.Storage) +wire.InitServices() +router.RegisterDefaultRoutes(a.router) +``` + +这会导致: + +- 纯 HTTP 服务也必须配置 MySQL。 +- 不使用 Redis 的项目也被强制初始化 Redis。 +- 不使用文件上传的项目也被强制初始化 Storage。 +- 用户无法控制 AutoMigrate。 +- 用户无法控制默认路由和 Swagger 暴露。 +- 测试和最小示例成本过高。 + +### 目标设计 + +v1.0.2 应将 App 启动流程改为“显式、可组合、可关闭”。 + +### 建议新增 App 内部字段 + +```go +type App struct { + config *config.Config + router *gin.Engine + registry *router.Registry + server *http.Server + + configPath string + + enableLogger bool + enableMySQL bool + enableRedis bool + enableStorage bool + enableDefaultRoutes bool + enableAutoMigrate bool + enableWire bool +} +``` + +### 默认策略 + +由于可以激进调整,建议从 v1.0.2 开始采用更清晰的默认策略: + +- `logger` 默认开启。 +- `default routes` 默认开启。 +- `wire` 默认开启。 +- `MySQL`、`Redis`、`Storage` 是否默认开启需要结合快速开始体验决定。 + +推荐方案: + +1. `xlgo.New()` 创建的是轻量 App,不强制初始化 MySQL/Redis/Storage。 +2. 用户通过 Option 显式启用组件: + +```go +xlgo.WithMySQL() +xlgo.WithRedis() +xlgo.WithStorage() +xlgo.WithAutoMigrate() +``` + +3. 提供 `xlgo.NewFullStack()` 或 `xlgo.RunFullStack()` 作为 batteries-included 快捷方式。 + +如果希望过渡成本更低,也可以保留 `New()` 默认全量初始化,但必须提供 `WithoutXxx()`。不过从长期框架设计看,更推荐“显式启用依赖”。 + +### 建议新增 Option + +#### 配置相关 + +```go +func WithConfigPath(path string) Option +func WithConfig(cfg *config.Config) Option +``` + +必须修复当前 `WithConfigPath` 空实现问题。 + +#### 组件启用 + +```go +func WithLogger() Option +func WithMySQL() Option +func WithRedis() Option +func WithStorage() Option +func WithAutoMigrate() Option +func WithWire() Option +func WithDefaultRoutes() Option +``` + +#### 组件关闭 + +如果保留默认开启策略,则必须同时提供: + +```go +func WithoutLogger() Option +func WithoutMySQL() Option +func WithoutRedis() Option +func WithoutStorage() Option +func WithoutAutoMigrate() Option +func WithoutWire() Option +func WithoutDefaultRoutes() Option +``` + +### 推荐使用示例 + +#### 最小 HTTP 服务 + +```go +app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithDefaultRoutes(), +) +``` + +#### 标准业务 API + +```go +app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithLogger(), + xlgo.WithMySQL(), + xlgo.WithRedis(), + xlgo.WithAutoMigrate(), + xlgo.WithDefaultRoutes(), + xlgo.WithModules(user.Module{}, order.Module{}), +) +``` + +#### 文件上传服务 + +```go +app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithStorage(), +) +``` + +### 测试要求 + +- `WithConfigPath` 能真实影响配置加载。 +- `WithConfig` 能注入配置并运行。 +- 未启用 MySQL 时不访问 MySQL 配置。 +- 未启用 Redis 时不访问 Redis 配置。 +- 未启用 Storage 时不初始化 Storage。 +- 关闭默认路由后 `/health` 不注册。 +- 显式开启默认路由后 `/health` 可访问。 + +--- + +## 2.3 框架内部禁止直接 Fatalf 退出 + +### 当前问题 + +`app.go` 中存在: + +```go +logger.Fatalf("初始化 MySQL 失败: %v", err) +``` + +框架内部直接退出进程会让调用方无法处理错误。 + +### 改造目标 + +所有初始化错误向上返回。 + +### 改造示例 + +```go +if err := database.InitMySQL(cfg); err != nil { + return fmt.Errorf("初始化 MySQL 失败: %w", err) +} +``` + +需要覆盖: + +- logger 初始化失败。 +- MySQL 初始化失败。 +- Redis 初始化失败。 +- AutoMigrate 失败。 +- Storage 初始化失败。 +- HTTP Server 启动失败。 + +### 要求 + +框架包内部原则上不调用: + +```go +os.Exit +log.Fatal +logger.Fatal +logger.Fatalf +panic +``` + +除非是明确的开发期 helper 或 CLI 命令入口。 + +--- + +## 2.4 Shutdown 关闭流程修正 + +### 当前问题 + +`App.Shutdown()` 当前调用: + +```go +database.Close() +database.CloseRedis() +``` + +在读写分离场景下,从库连接可能不会被关闭。 + +### 改造目标 + +使用: + +```go +database.CloseAll() +database.CloseRedis() +``` + +并聚合错误。 + +### 建议实现 + +Go 1.25 可直接使用 `errors.Join`: + +```go +var errs []error + +if err := database.CloseAll(); err != nil { + errs = append(errs, err) +} +if err := database.CloseRedis(); err != nil { + errs = append(errs, err) +} + +return errors.Join(errs...) +``` + +### 测试要求 + +- 未初始化数据库时 `Shutdown` 不 panic。 +- 初始化 replicas 后 `CloseAll` 会清空 replicas 和 DBRead。 +- 重复关闭不 panic。 +- 关闭错误能向上返回。 + +--- + +## 2.5 默认路由和 Swagger 可控化 + +### 当前问题 + +`router.RegisterDefaultRoutes()` 当前默认注册: + +```go +/swagger/*any +/health +``` + +Swagger 在生产环境中不应无条件暴露。 + +### 改造目标 + +拆分默认路由注册能力。 + +### 建议新增方法 + +```go +func RegisterHealthRoute(r *gin.Engine, checks ...HealthCheck) +func RegisterSwaggerRoutes(r *gin.Engine) +func RegisterDefaultRoutes(r *gin.Engine, checks ...HealthCheck) +``` + +其中 `RegisterDefaultRoutes` 可以继续组合调用前两个方法。 + +### App Option + +```go +func WithHealthRoutes() Option +func WithSwaggerRoutes() Option +func WithDefaultRoutes() Option +func WithoutDefaultRoutes() Option +``` + +### 推荐生产策略 + +- 开发环境可以开启 Swagger。 +- 生产环境默认不自动开启 Swagger,除非用户显式配置或调用 `WithSwaggerRoutes()`。 + +### 测试要求 + +- 健康检查路由可单独开启。 +- Swagger 路由可单独开启。 +- 默认路由可整体开启。 +- 关闭默认路由后不注册 `/health` 和 `/swagger/*any`。 + +--- + +## 2.6 健康检查标准化 + +### 当前问题 + +当前 `/health` 只返回: + +```json +{"status":"ok"} +``` + +高可用服务一般需要区分 liveness 和 readiness。 + +### v1.0.2 目标 + +v1.0.2 只新增并保留一个健康检查接口,避免接口过多导致使用复杂: + +```text +GET /health +``` + +### 初始响应 + +无检查项时: + +```json +{ + "status": "ok" +} +``` + +有检查项时: + +```json +{ + "status": "ok", + "checks": { + "mysql": "ok", + "redis": "disabled" + } +} +``` + +检查失败时 `/health` 返回 HTTP 503,并将 `status` 设为 `error`。 + +### 建议新增健康检查抽象 + +```go +type HealthChecker interface { + Name() string + Check(ctx context.Context) error +} +``` + +或函数式: + +```go +type HealthCheckFunc func(ctx context.Context) error +``` + +App 后续可支持: + +```go +func WithHealthCheck(name string, check HealthCheckFunc) Option +``` + +v1.0.2 可以先做基础实现,为 v1.1.x 扩展预留。 + +--- + +## 2.7 配置系统问题纳入 v1.0.2 改造 + +### 当前问题 + +`config.Load()` 使用 `sync.Once`,导致: + +- 测试中难以重复加载不同配置。 +- 多 App 实例不友好。 +- `WithConfigPath` 难以正确实现。 +- 配置热更新实例边界不清晰。 + +### 激进改造目标 + +v1.0.2 可以开始引入实例化配置管理器。 + +### 建议新增类型 + +```go +type Manager struct { + mu sync.RWMutex + v *viper.Viper + cfg *Config + callbacks []func(*Config) +} +``` + +### 建议 API + +```go +func NewManager(path string) *Manager +func (m *Manager) Load() (*Config, error) +func (m *Manager) LoadWithWatch(onChange func(*Config)) (*Config, error) +func (m *Manager) Get() *Config +func (m *Manager) Reload() error +func (m *Manager) RegisterCallback(cb func(*Config)) +``` + +### 全局兼容层 + +可以保留全局函数,但内部基于默认 manager: + +```go +func Load(path string) (*Config, error) +func Get() *Config +func Set(cfg *Config) +func Reload() error +``` + +### 关键要求 + +- 去除全局 `sync.Once` 对重复加载的限制,或提供 `ResetForTest()`。 +- App 优先持有自己的 config manager,而不是强依赖全局配置。 +- 全局 API 只作为便捷入口。 + +--- + +## 2.8 数据库全局状态治理 + +### 当前问题 + +`database/mysql.go` 使用全局变量: + +```go +var ( + DB *gorm.DB + DBRead *gorm.DB + replicas []*gorm.DB +) +``` + +这对框架长期发展不利。 + +### v1.0.2 目标 + +引入数据库 Manager,逐步替代全局变量。 + +### 建议类型 + +```go +type Manager struct { + master *gorm.DB + replicas []*gorm.DB + picker ReplicaPicker +} +``` + +### 建议 API + +```go +func NewManager(cfg *config.Config) *Manager +func (m *Manager) Open(ctx context.Context) error +func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) error +func (m *Manager) Master() *gorm.DB +func (m *Manager) Replica() *gorm.DB +func (m *Manager) FromContext(ctx context.Context) *gorm.DB +func (m *Manager) Close() error +func (m *Manager) HealthCheck(ctx context.Context) error +``` + +### Context key 修正 + +当前: + +```go +context.WithValue(ctx, "db_mode", "master") +``` + +应改为私有类型 key,避免冲突: + +```go +type dbModeContextKey struct{} +``` + +### Replica 选择策略 + +v1.0.2 可先提供: + +- Round-robin +- Random + +后续版本再增加: + +- 权重 +- 健康摘除 +- 熔断 + +### 全局兼容层 + +可以保留: + +```go +func InitMySQL(cfg *config.Config) error +func GetDB() *gorm.DB +func GetReadDB() *gorm.DB +func CloseAll() error +``` + +但内部应委托给默认 Manager。 + +--- + +## 2.9 AutoMigrate 机制调整 + +### 当前问题 + +`database.AutoMigrate()` 当前是空实现: + +```go +func AutoMigrate() error { + logger.Info("数据库表结构迁移完成") + return nil +} +``` + +但 `App.Run()` 强制调用它,语义不清晰。 + +### 改造目标 + +迁移应由用户显式注册。 + +### 建议 API + +```go +type Migrator func(db *gorm.DB) error + +func WithMigrator(m Migrator) Option +func WithModels(models ...any) Option +``` + +示例: + +```go +app := xlgo.New( + xlgo.WithMySQL(), + xlgo.WithModels(&User{}, &Order{}), +) +``` + +或: + +```go +app := xlgo.New( + xlgo.WithMigrator(func(db *gorm.DB) error { + return db.AutoMigrate(&User{}, &Order{}) + }), +) +``` + +### 要求 + +- 不再强制执行空的 `database.AutoMigrate()`。 +- 如果没有注册 migrator,不执行迁移。 +- 迁移错误返回给调用方。 + +--- + +## 2.10 README 更新日志版本错误修正 + +### 当前问题 + +`README.md` 当前更新日志写成: + +```md +### v2.1.0 (2026-04-30) +... +### v2.0.0 (2026-04-30) +``` + +但当前实际版本应为 `v1.0.1`,本次计划版本为 `v1.0.2`。该日志会给用户造成误解。 + +### 修正目标 + +将 README 更新日志改为真实版本线。 + +### 建议改法 + +本次发布后 README 应包含: + +```md +## 更新日志 + +### v1.0.2 (计划中 / 发布日期按实际填写) + +- 权限中间件通用化,移除业务角色硬编码。 +- App 启动流程改为可组合模式。 +- 修复 WithConfigPath 空实现问题。 +- 框架初始化错误改为返回 error。 +- 默认路由、Swagger、健康检查可配置。 +- Shutdown 关闭全部数据库连接。 +- 文档和 CLI 模板同步更新。 + +### v1.0.1 + +- 根据真实已发布内容整理。 + +### v1.0.0 + +- 初始版本发布。 +``` + +如果 v2.0.0 / v2.1.0 中的内容实际已经存在于当前代码,应归并到 v1.0.1 或 v1.0.0 的历史描述中,而不是继续使用错误的大版本号。 + +### 要求 + +- 删除或修正 `v2.0.0`、`v2.1.0` 错误标题。 +- README 顶部 badge 如有版本信息,也要同步。 +- GUIDE 中如有类似版本描述,统一修正。 + +--- + +## 2.11 文档与 CLI 模板同步 + +### 涉及文件 + +```text +README.md +GUIDE.md +cmd/xlgo/templates.go +``` + +### 必须更新内容 + +1. 最小启动示例。 +2. 标准业务 API 启动示例。 +3. 自定义权限示例。 +4. 显式启用 MySQL/Redis/Storage 的示例。 +5. 关闭或开启 Swagger 的示例。 +6. 健康检查接口说明。 +7. 配置文件完整示例。 +8. Go 版本说明:明确要求 Go 1.25+。 +9. 更新日志修正为 v1.x 版本线。 + +### CLI 模板调整方向 + +生成项目不应默认塞入过多不可控依赖。建议模板生成: + +```go +app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithLogger(), + xlgo.WithDefaultRoutes(), +) +``` + +如果模板选择 API/fullstack 模式,再加入: + +```go +xlgo.WithMySQL() +xlgo.WithRedis() +xlgo.WithAutoMigrate() +``` + +后续 CLI 可支持: + +```bash +xlgo new myproject --template minimal +xlgo new myproject --template api +xlgo new myproject --template fullstack +``` + +--- + +## 3. 推荐实施顺序 + +## Phase 1:修正业务耦合与明显 bug + +1. 改造 `middleware/auth.go`。 +2. 补充 `middleware/auth_test.go`。 +3. 修复 `WithConfigPath` 空实现。 +4. 修正 `README.md` 更新日志版本错误。 + +## Phase 2:重构 App 启动流程 + +1. App 增加组件启用/关闭选项。 +2. MySQL/Redis/Storage/AutoMigrate 改为显式启用或可关闭。 +3. 默认路由、Health、Swagger 拆分。 +4. `Run()` 中所有初始化错误改为返回 error。 + +## Phase 3:生命周期和关闭流程 + +1. `Shutdown()` 改为关闭全部数据库连接。 +2. 关闭 Redis、日志、限流器等组件时聚合错误。 +3. 准备后续 lifecycle hook 的内部结构。 + +## Phase 4:配置和数据库管理器 + +1. 引入 `config.Manager`。 +2. 去除或弱化全局 `sync.Once` 限制。 +3. 引入 `database.Manager`。 +4. 修正 DB context key。 +5. 全局 API 改为 facade。 + +## Phase 5:健康检查和文档模板 + +1. 保持单一 `/health` 接口。 +2. 支持依赖检查状态和失败时 HTTP 503。 +3. 更新 README/GUIDE。 +4. 更新 CLI 模板。 +5. 更新 CHANGELOG 或 README 更新日志。 + +--- + +## 4. v1.0.2 验收标准 + +### 4.1 功能验收 + +- 用户可以创建不依赖 MySQL/Redis/Storage 的最小应用。 +- 用户可以显式启用 MySQL/Redis/Storage。 +- 用户可以关闭 Swagger 或默认路由。 +- 用户可以使用自定义 user_type、role 或自定义函数做权限判断。 +- `super_admin/admin/staff` 只作为默认常量,不再是框架唯一权限模型。 +- `WithConfigPath` 能真实生效。 +- 初始化失败返回 error,不直接退出进程。 +- Shutdown 能关闭主库、从库、Redis 等资源。 +- README 更新日志版本线修正为 v1.x。 + +### 4.2 测试验收 + +必须通过: + +```bash +go test ./... +``` + +建议新增或更新测试覆盖: + +- `middleware/auth_test.go` +- `app_test.go` +- `router/router_test.go` +- `config/config_test.go` +- `database/mysql_test.go` + +### 4.3 文档验收 + +- README 中快速开始示例可运行。 +- GUIDE 中权限示例与真实 API 一致。 +- CLI 模板生成的项目可 `go test ./...` 或 `go run`。 +- README 更新日志不存在错误的 v2.0.0/v2.1.0 表述。 +- 文档明确写明 Go 1.25+。 + +--- + +## 5. 建议发布说明草案 + +```md +## v1.0.2 + +### Breaking Changes + +- App 启动流程调整为更显式的组件启用模式,MySQL、Redis、Storage、AutoMigrate 等能力可通过 Option 控制。 +- 权限中间件不再将 `super_admin`、`admin`、`staff` 作为框架固定权限模型,仅保留为默认用户类型常量。 +- 框架初始化失败时返回 error,不再在框架内部直接 Fatal 退出。 + +### Added + +- 新增 `middleware.AuthUser` 和 `middleware.GetAuthUser()`。 +- 新增 `middleware.RequireUserTypes()`。 +- 新增 `middleware.RequireRoles()`。 +- 新增 `middleware.RequireAuth()`。 +- 新增 App 组件控制 Option:`WithMySQL`、`WithRedis`、`WithStorage`、`WithAutoMigrate`、`WithDefaultRoutes` 等。 +- 新增单一 `/health` 健康检查规划,支持检查项状态。 +- 新增或规划实例化 `config.Manager`、`database.Manager`。 + +### Changed + +- `AdminRequired()`、`SuperAdminRequired()`、`StaffRequired()`、`AnyUserRequired()` 改为基于通用权限中间件实现。 +- `WithConfigPath()` 改为真实生效。 +- `App.Run()` 初始化流程改为可组合、可返回错误。 +- `App.Shutdown()` 改为关闭全部数据库连接。 +- 默认路由和 Swagger 注册逻辑拆分。 +- README/GUIDE/CLI 模板同步新启动方式。 + +### Fixed + +- 修复 README 更新日志错误使用 v2.0.0/v2.1.0 的问题。 +- 修复 `WithConfigPath()` 空实现问题。 +- 修复读写分离场景下从库连接可能未关闭的问题。 +``` + +--- + +## 6. 后续版本预告 + +v1.0.2 之后建议继续推进: + +### v1.1.0 + +- 完整 Lifecycle Hooks:`OnStart`、`OnShutdown`、`OnReady`。 +- 插件系统:`Plugin` / `Module` / `Provider`。 +- 完整 Health Registry。 +- 更完善的数据库 replica 健康摘除。 + +### v1.2.0 + +- RBAC/Permission 扩展包。 +- 多租户支持基础设施。 +- OpenTelemetry 深度集成。 +- CLI 多模板支持。 + +--- + +## 7. 本计划结论 + +v1.0.2 应该是 xlgo 的一次“框架化校准”版本,而不是小修小补版本。 + +本次更新应优先解决: + +1. 权限模型业务耦合。 +2. App 启动流程不可控。 +3. 初始化错误直接退出。 +4. 默认 Swagger 和默认路由不可控。 +5. 配置和数据库全局状态对测试、多实例不友好。 +6. README 更新日志版本错误。 +7. 文档与真实行为不一致。 + +由于 xlgo 仍是新框架,本阶段应果断修正不合理设计,明确 Go 1.25+,为后续用户增长前打好 API 和架构基础。 diff --git a/docs/plans/Version_Update_Plan_v1.1.0.md b/docs/plans/Version_Update_Plan_v1.1.0.md new file mode 100644 index 0000000..66382ea --- /dev/null +++ b/docs/plans/Version_Update_Plan_v1.1.0.md @@ -0,0 +1,128 @@ +# xlgo v1.1.0 实施计划 — HA & Manager 化 + +> 本文件为 v1.1.0 版本的逐项实施计划,对应体检报告(`docs/plans/Version_v1.0.2_report.md`)第三~四章 #10-#24 架构/HA 改进。 +> 范围:13 项(#20 RedisRateLimiter、#23 Recover 带 request_id 已实现,本次核对配合)。 + +## Context + +v1.0.3(bug fix)与 v1.0.4(DX & Docs)已发布推送,GitHub Release 已创建,工作区干净。v1.0.2 体检报告规划的 v1.1.0 阶段共 13 项架构/HA 改进,目标是把 xlgo 从"一半组件可注入、一半是单例"的撕裂状态,贯彻到"通用 / 高可用 / 易上手"。 + +本次一次性推完 13 项,发 v1.1.0。已确认的边界决策: + +- **版本策略**:v1.1.0 接受少量 breaking(#11 删 wire 包、#14 删 `AppConfig.TokenExpire`、删 `StartServerWithPort`/`GracefulShutdown`、`JWTConfig.Expire` 类型变更),在 CHANGELOG「升级说明」章节列出;其余(#15 response)用全局开关默认兼容存量。 +- **#10 Manager 化**:storage/cache/redis/jwt/logger **5 个全做**(含 logger),照 `database.Manager` + 包级 facade 蓝本。 +- **#15 response**:全局 `SetMode(ModeBusiness|ModeREST)`,默认 `ModeBusiness`(现状全 200 + 业务码)兼容;`ModeREST` 下 401/404/500 返回对应 HTTP status,body 仍带业务码。可在 `ServerConfig.ResponseMode` 配置。 +- **#11 wire**:删掉整个 `wire` 包(其事 App Option 已覆盖)。 +- **#18**:接受 `prometheus/client_golang` 新依赖。 +- **#12**:只提供 `WithHook(Hook{...})` 机制,不引入 etcd 依赖、不提供内置示例。 + +## 实现方案(按依赖顺序分 6 组) + +### 组 1:配置层(其余项的基础) + +**#13 Server 参数配置化** — `config/config.go:84-88` +`ServerConfig` 增字段:`ReadTimeout/WriteTimeout/IdleTimeout/ShutdownTimeout/MaxHeaderBytes`、`TLS{Enabled,CertFile,KeyFile}`、`UnixSocket string`、`ResponseMode string`。`app.go:408-414` 的 `http.Server` 改为读这些字段(缺省回退到当前硬编码值)。TLS 开启时用 `ListenAndServeTLS`;`UnixSocket` 非空时优先 unix socket。Shutdown 超时读 `ShutdownTimeout`。 + +**#14 JWTConfig.Expire time.Duration + 删 TokenExpire** — `config/config.go:44, 209-213` +`JWTConfig.Expire` 改 `time.Duration`(mapstructure + viper string 解析 `"24h"`),新增 `RefreshExpire time.Duration`、`Issuer`、`Algorithm`。**删 `AppConfig.TokenExpire`**(breaking)。`jwt/jwt.go` 内过期取值改读 `JWTConfig.Expire`。Duration 解析依赖 mapstructure decode hook。 + +**#16 Config Validate** — 新增 `config/config.go` `(*Config).Validate() error` +校验:`Server.Port` 范围、`JWT.Secret` 非空且 ≥32 字符(启用 jwt 时)、启用 mysql 时关键字段、Duration 非负等。`config.Manager.Load`(`config/manager.go:340`)解析后自动调用,错误包裹返回,把"运行时第一次请求才暴露"提前到启动。 + +**#15 response Mode 开关** — `response/response.go` +新增 `type Mode int`、`ModeBusiness/ModeREST`、包级 `currentMode` + `SetMode(m)`、`Mode()`。`Fail/Unauthorized/NotFound/ServerError/RateLimit/FailWithCode` 内:`ModeREST` 时按错误码/错误类型映射 HTTP status(`ErrUnauthorized→401`、`ErrNotFound→404`、`ErrServer→500`、`ErrRateLimit→429`、参数类→400),`c.JSON(status, Response{...})`;`ModeBusiness` 维持 200。`App.Init` 末尾按 `ServerConfig.ResponseMode` 调 `response.SetMode`。新增 `response.Custom(c, httpStatus, code, data)` 显式 API。 + +### 组 2:组件 Manager 化(#10,5 组件,照 database.Manager 蓝本) + +蓝本参考 `database/manager.go:180-192`(`DefaultManager` 包级实例 + `SetDefaultManager` + 包级 facade 代理 + 实例方法)。每个组件统一模式: +- 新增 `type Manager struct{ ...; mu sync.Mutex }` + `var DefaultXxx = &Manager{}` +- `SetDefaultManager(m)` 提升用户实例到全局 +- 包级 `Init/Get/操作` 函数代理到 `DefaultXxx`(**保留,兼容存量**) +- `App` 持有各 Manager 实例(`App` 加字段),`WithXxx` 时初始化 App 自己的实例并 `SetDefaultXxx` + +顺序(按依赖):**redis → cache → jwt → storage → logger** + +1. **redis** — `database/redis.go`:新增 `type RedisManager struct{ cfg; client *redis.Client; mu }` + `DefaultRedis`。`InitRedis/CloseRedis/GetRedis/HealthCheckRedis` 代理。下游 5 处直接读 `database.RedisClient`(`jwt/jwt.go`、`middleware/ratelimit.go`、`cache/cache.go`、`cache/lock.go` 20+ 处、`app.go`)全部改为 `database.GetRedis()`。`cache/lock.go` 改为接受 `*redis.Client` 参数或内部 `GetRedis()`。 + +2. **cache** — `cache/cache.go`:`type CacheManager struct{ client *redis.Client; svc CacheService; mu }` + `DefaultCache`。`redisCache.client` 从硬编码 `database.RedisClient` 改为构造时注入。`Init/GetCache` 代理。`cache/lock.go` 的分布式锁函数改走 `DefaultCache` 或显式传 client。 + +3. **jwt** — `jwt/jwt.go`:`type Manager struct{ blacklist *TokenBlacklist; cfg *config.JWTConfig; mu }` + `DefaultJWT`。`TokenBlacklist.Add/IsBlacklisted` 内部 `database.RedisClient` 改 `database.GetRedis()`。`GenerateToken/ParseToken/InvalidateToken/RefreshToken/...` 代理到 `DefaultJWT`。 + +4. **storage** — `storage/storage.go`:`type Manager struct{ cfg; current Storage; mu }` + `DefaultStorage`。`Init/GetStorage/SetStorage/Upload/...` 代理到 `DefaultStorage.current`。最干净,无外部下游。 + +5. **logger** — `logger/logger.go`:`type Manager struct{ cfg; logger/apiLog/dbLog *zap.Logger; fileWriters; mu }` + `DefaultLogger`。包级 `Init/Sync/Close/Info/.../APILog/DBLog` 代理。**特殊**:logger 是 `App.Init` 最先初始化的组件,`DefaultLogger` 初始化前包级函数须安全降级到 Nop(现状 `Close()` 已重置为 Nop,沿用)。下游 8 包的 `logger.Info(...)` 调用点无需改(仍走包级 facade)。 + +`App` struct(`app.go:41-62`)加字段:`redisMgr *database.RedisManager`、`cacheMgr *cache.CacheManager`、`jwtMgr *jwt.Manager`、`storageMgr *storage.Manager`、`loggerMgr *logger.Manager`。 + +### 组 3:App 生命周期 + +**#12 Lifecycle Hooks** — `app.go` +新增 `type Hook struct{ Name string; OnInit func(*App) error; OnStart func(*App) error; OnReady func(*App); OnStop func(*App) error }` + `WithHook(Hook) Option`。`App` 加 `hooks []Hook`。`Init()` 内组件初始化完成后按序调 `OnInit`;`StartServer` 监听前调 `OnStart`,端口就绪后调 `OnReady`;`Shutdown` 开头调 `OnStop`。各 hook 错误中断流程并返回。 + +**#22 App.Go + in-flight goroutine** — `app.go` +`App` 加 `wg sync.WaitGroup` + `ctx context.Context`(根 ctx)+ `cancel`。新增 `App.Go(fn func(ctx context.Context))`:`wg.Add(1); go func(){ defer wg.Done(); fn(ctx) }()`。`Shutdown` 在 `OnStop` 后、关 HTTP 前调 `cancel()` 并 `wg.Wait()`(带 `ShutdownTimeout` 超时)。 + +**#11 删 wire 包** — 删 `wire/wire.go`(整包)。清理 `app.go` 的 `WithWire/WithoutWire/enableWire` 及 `Init()` 中 `wire.InitServices()` 调用。`cache.Init()` 原由 wire 触发,改由 `WithRedis`/`WithCache` 触发(或 `App.Init` 显式调)。 + +**清理双轨** — `app.go:494-537`:删 `StartServerWithPort`、`GracefulShutdown`(与 `App.StartServer`/`App.Shutdown` 重复)。breaking,升级说明列出。 + +### 组 4:中间件与路由 + +**#24 RequestID 默认装入** — `app.go:~348` +`App.Init` 中间件链改为无条件 `a.router.Use(middleware.RequestID())`(在 Recovery 之前),让每个响应/panic 日志都带 request_id。核对 #23:`middleware/recover.go:20` 已 `GetRequestID(c)`,配合 #24 后 panic 日志 request_id 非空。移除 `gin.Recovery()` 双重保险(保留 `middleware.Recover()` 一个即可)。 + +**#19 请求级 Timeout 中间件** — 新增 `middleware/timeout.go` +`func Timeout(d time.Duration) gin.HandlerFunc`:`ctx, cancel := context.WithTimeout(c.Request.Context(), d); defer cancel(); c.Request = c.Request.WithContext(ctx); c.Next()`。可通过 `WithRequestTimeout(d)` Option 装入全局,下游 GORM/Redis 走 `c.Request.Context()` 级联取消。 + +**#18 Prometheus metrics** — 新依赖 `prometheus/client_golang` +新增 `middleware/metrics.go`:`middleware.Metrics()` 记录 HTTP latency / status code / in-flight(histogram + counter + gauge)。新增 `router/metrics.go`:`RegisterMetricsRoute(r, path...)` 默认 `/metrics` 挂 `promhttp.Handler()`。新增 `WithMetricsRoute(path...)` Option。`App.Init` 装入 `Metrics()` 中间件 + 路由。 + +**#17 livez/readyz** — `router/router.go` +新增 `RegisterLivenessRoute(r)` → `GET /livez` 永不依赖外部(进程存活即 200)。新增 `RegisterReadinessRoute(r, checks...)` → `GET /readyz` 复用 `HealthCheck`,失败 503。新增 `WithLivenessRoute()`/`WithReadinessRoute()` Option。`/health` 保留兼容。`livez` 不查依赖、`readyz` 查依赖,对 K8s probe 友好。 + +### 组 5:依赖健康自愈(#21) + +**#21 主库探活 + replica 健康剔除** — `database/manager.go` +- `database.Manager` 加 `healthy bool` + `consecutiveFailures int` + `healthMu`。`Pool.SetConnMaxIdleTime` 配置化(`DatabaseConfig` 加 `ConnMaxIdleTime`)。 +- 探活:`App.Init` 末尾用 `App.Go` 起后台 goroutine,每 30s `HealthCheck(ctx)`,连续 N 次失败标记 `healthy=false`;`readyz`/`/health` 读此标记返回 503。 +- `ReplicaPicker` 接口加健康度:replica ping 失败剔除轮询,恢复后重新纳入。`RoundRobinPicker`/`RandomPicker` 实现 health-aware 选取。 + +### 组 6:收尾与发版 + +- `app.go:27` `const Version = "1.1.0"`。 +- `CHANGELOG.md` 加 `[1.1.0]` 章节(Added/Changed/Fixed + **升级说明** breaking 列表)。README 更新日志 + 底部链接。 +- `examples/`(full/minimal)同步:full 例子的配置文件加 `server.read_timeout` 等新字段、`jwt.expire: 24h`,验证 5 组件 Manager 化后仍可跑。 +- 测试:`go test ./...` 全绿;为 Manager 化、Validate、response Mode、livez/readyz、metrics、timeout、App.Go 补单测。 +- `go mod tidy`。 + +## 关键文件清单 + +| 文件 | 涉及 issue | +|---|---| +| `app.go` | #10 App 字段、#12 hooks、#13 server、#19/#18/#17/#24 路由中间件、#21 探活、#22 App.Go、#11 删 wire、删双轨、Version | +| `config/config.go` | #13 ServerConfig、#14 JWTConfig+删TokenExpire、#16 Validate | +| `config/manager.go` | #16 Load 调 Validate、Duration decode hook | +| `response/response.go` | #15 Mode 开关 + status 映射 | +| `database/redis.go` | #10 redis Manager | +| `database/manager.go` | #21 探活 + replica 健康剔除 | +| `cache/cache.go` `cache/lock.go` | #10 cache Manager + 解耦 RedisClient | +| `jwt/jwt.go` | #10 jwt Manager + #14 Expire | +| `storage/storage.go` | #10 storage Manager | +| `logger/logger.go` | #10 logger Manager | +| `middleware/{requestid,recover}.go` | #24 装入 + #23 核对 | +| `middleware/timeout.go`(新) | #19 | +| `middleware/metrics.go`(新) | #18 | +| `router/router.go` `router/metrics.go`(新) | #17 livez/readyz + #18 /metrics | +| `wire/wire.go` | #11 删除 | +| `go.mod` | #18 prometheus 依赖 | +| `CHANGELOG.md` `README.md` | 发版 | + +## 验证 + +1. `go mod tidy && go build ./...` — 编译通过。 +2. `go test ./...` — 全绿,含新增单测。 +3. `go run ./example`(full 栈)— 启动无错,`/livez`→200、`/readyz`→200(依赖在时)、`/metrics`→prometheus 文本、`/health` 兼容;配置错误时 `Validate` 在启动期拦截。 +4. 手动验证 breaking:删 wire 后 `example` 不再 import wire;`AppConfig.TokenExpire` 删除后 grep 无残留;`response.SetMode(ModeREST)` 下 `Unauthorized` 返回 401。 +5. `App.Go` 起一个长任务 goroutine,发 SIGTERM,确认 `Shutdown` 等其退出(日志可见)。 +6. 主库探活:手动关 mysql,30s 后 `/readyz`→503、`/health`→503;恢复后自动转 200。 +7. 发版:commit + annotated tag `v1.1.0` → `git push xlgo-core main && git push xlgo-core v1.1.0`;release 内容写本地 `gitHub_release_v1.1.0.md`(.gitignore 忽略),用户网页创建。 diff --git a/docs/plans/Version_v1.0.2_report.md b/docs/plans/Version_v1.0.2_report.md new file mode 100644 index 0000000..68e2c94 --- /dev/null +++ b/docs/plans/Version_v1.0.2_report.md @@ -0,0 +1,519 @@ +# xlgo v1.0.2 体检报告 + +> 资深 Go 视角的代码与架构 Review。目标是把 xlgo 从"业务沉淀工具集"打磨成"通用 / 高可用 / 易上手"的开源 Web 框架。 +> +> 整体判断:v1.0.2 已经把"业务耦合 + 不可组合 + 框架内 Fatal"这三个最大的设计债还掉了,框架骨架是健康的。但仍有若干**真实 Bug**和**架构层面的债**,按下方优先级逐项核查即可。 + +--- + +## 一、必须立刻修的真实 Bug(带行号) + +这些不是设计取舍,是确凿的缺陷,先于一切改进。 + +### 1. `response.CodeSuccess` 与 `CodeInvalidParams` 撞码 ⚠️ + +```go +// response/error.go:13-16 +CodeSuccess = 1 // 成功 +CodeFail = 0 // 通用失败 +CodeInvalidParams = 1 // 参数错误 ← 跟 Success 同值! +``` + +只要任何业务调用 `response.FailWithError(c, response.ErrInvalidParams)`,前端拿到的 `code` 跟成功响应一模一样。这是**生产级 bug**。 + +**建议**:制定明确的码段策略—— + +- `0` = success(业内更通用),或者 `200`/`0` 二选一 +- `1` 留给"通用失败" +- 参数错误使用 `40001` 等业务码段 +- 同时为了避免后续重复,写一个 `init()` 自检: + +```go +func init() { + seen := map[int]string{} + for code, name := range allErrorCodes { + if old, ok := seen[code]; ok { + panic(fmt.Sprintf("duplicate error code %d: %s vs %s", code, old, name)) + } + seen[code] = name + } +} +``` + +### 2. CORS 中 `Allow-Credentials` 永远是 `true` ⚠️ + +```go +// middleware/cors.go:86-91 +if corsConfig != nil && corsConfig.AllowCredentials { + c.Header("Access-Control-Allow-Credentials", "true") +} else { + c.Header("Access-Control-Allow-Credentials", "true") // 默认允许 ← 错 +} +``` + +并且当 `Origin: *` 时还会被浏览器拒绝。这是 CORS 经典坑。**修复**: + +```go +if corsConfig.AllowCredentials && allowedOrigin != "*" { + c.Header("Access-Control-Allow-Credentials", "true") +} +``` + +### 3. 日志在开发模式下写两份到同一文件链 ⚠️ + +```go +// logger/logger.go:95-99 +core := zapcore.NewTee(apiCore, dbCore, consoleCore) +Logger = zap.New(core, ...) +``` + +`Logger` 是全局通用 logger,但 Tee 把 dbCore 也接进去——结果**所有 `logger.Info(...)` 都会同时写到 `api.log` 和 `database.log`**,再加一份控制台。`APILog()`/`DBLog()` 的"分流"形同虚设。 + +**修复**:通用 logger 只写 console + 一个 app.log;`APILog()`/`DBLog()` 各自独立 core,互不 Tee。 + +### 4. `DBResolver.BeforeQuery` 是死代码 ⚠️ + +```go +// database/mysql.go:386-408 +func (r *DBResolver) BeforeQuery(db *gorm.DB) { ... } +``` + +这个 hook 从未通过 `db.Callback().Query().Before(...)` 注册过。所以**读写分离实际上需要业务侧自己调用 `GetDBFromContext(ctx)`**,但 README/GUIDE 暗示它会自动路由。要么把 hook 真正注册上,要么把这段代码删掉、文档明确"显式 `UseReplica/UseMaster`"。 + +我倾向**删掉**——GORM 官方有 `dbresolver` plugin,实现得更完整(权重、policy)。引入它比自造轮子更稳。 + +### 5. 重试策略对所有错误一视同仁 + +```go +// database/mysql.go:213-240 +maxRetries := 5 +// 不论是 driver 错、密码错、端口错都重试 5 次,指数退避 +``` + +**密码错误**也会让进程在启动阶段死等 1 分钟才报错,体验很差。建议区分: + +- `*mysql.MySQLError` Code 1045(access denied)/ 1049(unknown db)等 → 直接返回,不重试 +- `net.OpError`、`io.EOF`、上下文未到等 → 重试 + +### 6. `generateJTI` 忽略 `rand.Read` 错误 + +```go +// jwt/jwt.go:40-44 +func generateJTI() string { + bytes := make([]byte, 16) + rand.Read(bytes) // ← 错误丢弃 + return base64.URLEncoding.EncodeToString(bytes) +} +``` + +`crypto/rand.Read` 在 Linux 早期启动或某些容器中**确实会失败**。失败时 JTI 会是全零,黑名单可能误判。改为返回 `(string, error)` 或在失败时 `panic` 都比静默吞错好。 + +### 7. `repository.QueryBuilder.Page` 的 Count 受残留 limit 影响 + +```go +// repository/repository.go:404-417 +countDB := qb.db.Session(&gorm.Session{}) // ← 复用了已 Limit/Offset 的 db +if err := countDB.WithContext(ctx).Count(&total).Error; err != nil { ... } +``` + +如果用户先 `.Limit(10)` 再 `.Page(...)`,`countDB` 会带 LIMIT,Count 是错的。需要 `Limit(-1).Offset(-1).Order("")`: + +```go +countDB := qb.db.Session(&gorm.Session{}).Limit(-1).Offset(-1).Order("") +``` + +### 8. `OSSStorage.Upload` 文件名冲突风险 + +```go +// storage/storage.go:205 +objectKey := fmt.Sprintf("%s/%d%s", filepath.Join(...), now.UnixNano(), ext) +``` + +并发上传 / 容器集群同纳秒会产生**完全相同的 key**,OSS 会覆盖。补一个随机后缀或 uuid: + +```go +objectKey := fmt.Sprintf("%s/%d-%s%s", dir, now.UnixNano(), randHex(8), ext) +``` + +### 9. `go.mod` indirect 里的可疑版本 + +``` +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 +``` + +这是 2026-04-01 的 pseudo-version,搭配 `golang.org/x/crypto v0.49.0`、`golang.org/x/net v0.52.0` 看起来正常,但需要确认这是 OTel 1.43 强制带来的传递依赖,还是历史 `go.sum` 没整理。建议跑一次 `go mod tidy && go mod verify` 之后人工 review。 + +--- + +## 二、架构层面的"债"(影响通用性与多实例化) + +v1.0.2 把 config 和 database 改成了 Manager + 全局 facade 的双轨,但还有几个核心组件**没跟上这套抽象**: + +### 10. Storage / Cache / Redis / JWT / Logger 仍是单例 + +| 组件 | 全局变量 | 多实例可能性 | +|---|---|---| +| `storage.storage` | 包级 var | 不能同时连 OSS + 本地 | +| `cache.globalCache` | 包级 var | 不能为不同业务设不同 prefix/TTL 默认值 | +| `database.RedisClient` | 包级 var | 不能多 Redis(缓存 + 队列 + 限流分库) | +| `jwt.tokenBlacklist` | 包级 var | 不能区分 user-token 和 refresh-token blacklist | +| `logger.Logger` | 包级 var | 不能区分多 app/多模块独立日志 | + +**建议**:照 `database.Manager` + `database.DefaultManager` 的模式,每个组件提供 `XxxManager` 类型 + 全局便捷 facade,App 持有自己的 Manager 实例。这样: + +- 单元测试可以注入 mock +- 多 App 共存(比如同进程跑 admin + api 两个 Engine) +- 微服务里组件解耦 + +优先级:**Redis Manager 最重要**(因为 JWT、Cache、RateLimiter、分布式锁都依赖它,目前全是访问 `database.RedisClient`,没法替换)。 + +### 11. `wire` 包名误导 + +```go +// wire/wire.go - 整个文件 32 行 +func InitServices() { ... } +``` + +它叫 `wire`,但跟 Google Wire 没关系,也不是 DI 容器。新用户会困惑。建议二选一: + +- **删掉**——其实现的事 App 通过 Option 已经做了 +- **真正引入 Wire 或 fx/uber**——给一个最小 DI 范式 + +### 12. `App.Init()` / `App.Run()` 缺少 Lifecycle Hooks + +现在的 App 内部是硬编码顺序:config → logger → mysql → redis → storage → wire → migrate → routes。如果用户想插入"Migrate 之前先初始化分布式锁,避免多副本同时迁移"或者"启动后注册到服务发现",没有钩子。 + +**建议**(v1.1.0 路线): + +```go +type Hook struct { + Name string + OnInit func(*App) error // Init 流程内 + OnStart func(*App) error // 监听端口前 + OnReady func(*App) // 端口就绪后 + OnStop func(*App) error // Shutdown 前 +} + +func WithHook(h Hook) Option +``` + +并提供两个内置示例:`hooks.RegisterEtcd(...)`、`hooks.RegisterDistributedMigrate(...)`。 + +### 13. Server 参数全部硬编码 + +```go +// app.go:400-406 +ReadTimeout: 15 * time.Second, +WriteTimeout: 30 * time.Second, +IdleTimeout: 60 * time.Second, +``` + +加上 `Shutdown` 30s 超时。这些都该进 `ServerConfig`: + +```yaml +server: + port: 8080 + read_timeout: 15s + write_timeout: 30s + idle_timeout: 60s + shutdown_timeout: 30s + max_header_bytes: 1048576 + tls: + enabled: false + cert_file: "" + key_file: "" + unix_socket: "" # 优先级高于 port +``` + +### 14. `JWTConfig.Expire` 与 `AppConfig.TokenExpire` 重复且单位不明 + +两个字段都是过期秒数,都没有 `time.Duration` 类型。Go 项目应优先用 `time.Duration` + viper 的 `string` 解析(`"24h"`、`"30m"`),**单位看就懂**: + +```go +type JWTConfig struct { + Secret string `mapstructure:"secret"` + Expire time.Duration `mapstructure:"expire"` // "24h" + RefreshExpire time.Duration `mapstructure:"refresh_expire"` // "168h" + Issuer string `mapstructure:"issuer"` + Algorithm string `mapstructure:"algorithm"` // HS256/RS256 +} +``` + +`AppConfig.TokenExpire` 直接删掉。 + +### 15. `response` 把 4xx/5xx 全压成 HTTP 200 + +```go +// response/response.go:32-39 +func Success(c *gin.Context, data any) { + c.JSON(http.StatusOK, ...) // ← 永远 200 +} +// Unauthorized / Fail / NotFound / ServerError 全部 200 +``` + +这是国内典型"业务码 in body"的玩法,**对接 APM、Prometheus、APISIX/网关、Sentry 都很难受**——它们靠 HTTP status 区分异常。建议: + +1. 默认仍保留业务码模式(兼容存量),但允许通过全局开关切到"REST 模式": + +```go +response.SetMode(response.ModeREST) // 或在 ServerConfig 中 +// 401 错误 → 返回 HTTP 401, body 带业务 code +``` + +2. 或者更优雅:`Fail` 带一个明示的 HTTP status: + +```go +response.Fail(c, response.ErrUnauthorized) // 自动 401 +response.Custom(c, http.StatusBadRequest, ErrInvalidParams, nil) +``` + +### 16. 配置缺少 Validate + +`config.Manager.Load` 解析完直接返回,对必填字段 / 取值范围都没校验。建议加 `Validate() error`: + +```go +func (c *Config) Validate() error { + if c.Server.Port <= 0 || c.Server.Port > 65535 { ... } + if c.JWT.Secret != "" && len(c.JWT.Secret) < 32 { ... } + // 启用 mysql 时强制要求关键字段 + return nil +} +``` + +并在 `Manager.Load` 内自动调用——配置错把启动时间从"运行时第一次请求"提前到"进程启动",是高可用的小细节。 + +--- + +## 三、高可用 / 生产就绪的缺口 + +### 17. 没有 Liveness / Readiness 区分 + +v1.0.2 的 `/health` 只有一个,对 K8s 不友好。K8s probe 期望: + +- `/livez`:进程是否活着(**永远不依赖外部**,只检查 goroutine、内存) +- `/readyz`:是否可以接流量(依赖 mysql/redis 通透) + +建议在保持 `/health` 兼容的同时加: + +```go +xlgo.WithLivenessRoute() // GET /livez +xlgo.WithReadinessRoute() // GET /readyz, 复用 healthChecks +``` + +### 18. 没有 Prometheus / Metrics 中间件 + +通用 Web 框架不带 metrics endpoint 是硬伤。建议: + +```go +import "github.com/prometheus/client_golang/prometheus/promhttp" + +func WithMetricsRoute(path ...string) Option // 默认 /metrics +``` + +并提供一个 `middleware.Metrics()`——HTTP latency、status code、in-flight 这些标配指标。 + +### 19. 没有请求级超时中间件 + +`http.Server.ReadTimeout` 是连接级。**业务级**超时需要 `middleware.Timeout(5*time.Second)`: + +```go +func Timeout(d time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), d) + defer cancel() + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} +``` + +下游 GORM/Redis 调用走 `c.Request.Context()` 才能真正级联取消。 + +### 20. RateLimiter 内存版无法集群共享 + +`middleware/ratelimit.go` 的 `RateLimiter` 是单进程内存版,多副本部署时限流器各管各的。Redis 版本(`RedisRateLimiter`)应该一并提供,并且: + +- 用 lua 脚本实现 token bucket 或滑动窗口(避免多次 round-trip) +- 默认每个限流器有 `Name`,方便 Prometheus 上报"被限流次数" + +### 21. 没有依赖健康自愈 + +主库宕机后 `database.Manager.master` 会一直握着断连。建议: + +- `Pool.SetConnMaxIdleTime` 配置化 +- 探活定时任务:每 30s ping 一次,连续 N 次失败标记"unhealthy",readiness 立即返回 503 +- Replica 健康剔除:`ReplicaPicker` 支持权重 + 健康度(v1.1.0 路线) + +### 22. Graceful shutdown 没等业务 in-flight goroutine + +现在 `Shutdown` 只关 HTTP server。如果业务在 handler 里 spawn 了后台 goroutine(异步发短信、写日志),它们会被进程退出强制砍掉。建议: + +- App 暴露 `App.Go(fn func(ctx context.Context))`,内部维护 `sync.WaitGroup` +- Shutdown 时 `wg.Wait()` 带超时 + +### 23. `gin.Recovery` + `middleware.Recover` 双重保险但没 trace_id + +panic 时只记录 stack,没有 request_id / trace_id 关联。建议 Recover 中间件改为: + +```go +func Recover() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + rid := c.GetString("request_id") + logger.Error("panic recovered", + zap.String("request_id", rid), + zap.Any("error", r), + zap.ByteString("stack", debug.Stack())) + response.ServerError(c, "服务器内部错误") + } + }() + c.Next() + } +} +``` + +### 24. RequestID 中间件没默认装入 + +`response.go` 依赖 `c.GetString("request_id")` 但默认中间件链里没有这一环。建议 `app.Init` 时无条件 `Use(middleware.RequestID())`,让每个响应都带 `request_id`,trace 才有意义。 + +--- + +## 四、易上手 / 开发体验 + +### 25. 模块路径与 import alias 不一致 + +`go.mod` 是 `github.com/EthanCodeCraft/xlgo-core`,CLAUDE.md 又提"本地导入用 xlgo"——新用户第一次 `go mod tidy` 多半会撞墙。建议: + +- 模块路径直接定为 `github.com/EthanCodeCraft/xlgo`(去掉 `-core`),**包名仍然 `xlgo`** +- README 第一段就给完整 import 语句,不要让用户猜 + +### 26. 代码里大量 "评分: ⭐⭐⭐⭐⭐" 注释 + +`response.go`、`storage.go`、`repository.go`、`config.go`、`cache.go`、`middleware/cors.go` ……到处都是: + +```go +// 评分: ⭐⭐⭐⭐⭐ +// 理由: 文件下载封装,自动设置响应头 +``` + +这些是 AI 生成留下的"自夸",**对外发布的库代码里出现这个会显得不专业**。建议批量删掉。如果想留设计理由,改成 `// Why: ...` 风格的简洁注释。 + +### 27. 大量 `Without*` Option 实际上不需要 + +```go +WithLogger / WithoutLogger +WithMySQL / WithoutMySQL +WithRedis / WithoutRedis +... +``` + +每对都是 v1.0.2 在"全开 vs 全关"摇摆产生的副产品。既然已经定调"`xlgo.New()` 是轻量",那 `WithoutLogger` 的用途就只剩"用了 `NewFullStack` 又想关掉一个"。**建议**: + +- `Without*` 全部标 `Deprecated` +- 文档统一推荐组合: + - `xlgo.New(...)` + 显式 `With*` + - `xlgo.NewFullStack(...)` 全开 +- 真要关单项,让 FullStack 接受函数式排除:`xlgo.NewFullStack(xlgo.Disable("redis"))` + +### 28. CLI 模板太单一 + +`xlgo new` 只生成一种模板。`Version_Update_Plan_v1.0.2.md` 第 884 行已经规划了: + +``` +xlgo new myproject --template minimal +xlgo new myproject --template api +xlgo new myproject --template fullstack +xlgo new myproject --template grpc # 建议补 +xlgo new myproject --template microservice +``` + +是时候做了。最小模板对降低"上手第一公里"的心理负担非常关键。 + +### 29. 缺一个 examples/ 目录 + +我看到 README 里有 `make run` → `go run ./example`,但仓库里**没有 example 目录**。新用户 clone 之后跑不起来。建议至少补两个: + +- `examples/minimal/` —— 50 行能跑 +- `examples/full/` —— mysql + redis + jwt + 一个 user CRUD + +### 30. CHANGELOG 与 Version_Update_Plan 应分离 + +`Version_Update_Plan_v1.0.2.md` 是规划文档,不应放仓库根。建议: + +``` +docs/ + ├── CHANGELOG.md # 追加格式,每个版本 Added/Changed/Fixed + ├── plans/ + │ └── v1.0.2.md # 历史规划归档 + ├── architecture.md + └── migration/v1.0.1-to-v1.0.2.md +``` + +--- + +## 五、值得期待的 v1.1+ 路线 + +按优先级排成一个推荐的迭代节奏: + +### v1.0.3(Bug Fix Release,1~2 周) + +**修真实 bug,不破坏 API** + +- ✅ #1 错误码冲突 + 自检 +- ✅ #2 CORS Allow-Credentials +- ✅ #3 Logger Tee bug +- ✅ #4 删掉死代码 DBResolver / 引入 gorm dbresolver +- ✅ #6 `generateJTI` 错误处理 +- ✅ #7 QueryBuilder.Page Count +- ✅ #8 OSS 文件名冲突 +- ✅ #9 go.mod tidy 复查 +- ✅ #26 删除"评分"注释 + +### v1.0.4(DX & Docs) + +- ✅ #25 模块路径修正 +- ✅ #28 CLI 多模板 +- ✅ #29 examples/ +- ✅ #30 文档结构调整 + +### v1.1.0(HA & Manager 化) + +- #10 Storage/Cache/Redis/JWT 全部 Manager 化 +- #12 Lifecycle Hooks +- #13 Server 参数全配置化 +- #14 `time.Duration` 配置 +- #16 Config Validate +- #17 livez / readyz +- #18 metrics 中间件 +- #19 请求级 Timeout +- #20 Redis 限流器 +- #21 主库探活 + replica 健康剔除 +- #22 等业务 goroutine +- #23 Recover 带 request_id +- #24 RequestID 默认装入 + +### v1.2.0(生态) + +- 内置 OpenAPI 3.x(替代 swaggo,后者已半弃维) +- gRPC Gateway 模板 +- 多租户模板(参考 #1 错误码 namespace) +- RBAC 扩展包 +- DDD / Clean Architecture 项目模板 + +--- + +## 六、整体判断 + +xlgo 的 v1.0.2 已经迈过了"内部工具集"到"框架"的这道坎,特别是**dialect 注册表**、**config.Manager + SetDefaultManager**、**database.Manager** 这三个设计是真正的框架式抽象,证明设计上是在往正确方向走。 + +但要打到"通用 / 高可用 / 易上手",**最值得马上做的两件事**是: + +1. **先把第一节那 9 个 Bug 修掉**——它们里任意一个被开源用户踩到,都会发 issue 质疑框架质量。 +2. **把 Storage/Cache/Redis/JWT 也 Manager 化**——这是把 v1.0.2 的好抽象"贯彻到底"。否则现在是**一半组件可注入,一半是单例**的撕裂状态,对中大型项目和单元测试都不友好。 + +建议从 v1.0.3 的 Bug Fix 开始动手,优先级: + +> #1 (错误码冲突) → #2 (CORS) → #3 (Logger Tee) → #4 (DBResolver 死代码) + +这几个改完都不破坏 API,可以一个 PR 一个 commit 走 review。 diff --git a/docs/plans/v2.0-review.md b/docs/plans/v2.0-review.md new file mode 100644 index 0000000..244d673 --- /dev/null +++ b/docs/plans/v2.0-review.md @@ -0,0 +1,485 @@ +# xlgo Web 框架评估报告(v2.0 优化后版本) + +## 一、项目概述 + +xlgo 是一个基于 Go + Gin 构建的轻量级 Web 开发框架,旨在提供后端开发的基础设施。本报告基于 v2.0 版本(经过全面优化和 zl 工具库移植后)进行评估。 + +--- + +## 二、本轮优化内容汇总 + +### 2.1 新增核心包 + +| 包名 | 来源 | 函数数 | 核心功能 | +|------|------|--------|----------| +| `utils/` | zl/utils | 111 | 随机数、字符串、时间、转换、文件、URL、验证、加密、HTTP客户端、UUID | +| `console/` | zl/utils/print_*.go | 22 | 彩色控制台输出(Debug/Info/Success/Warn/Error) | +| `compress/` | zl/service/compress | 7 | Gzip/Zip 压缩解压 | +| `cache/keybuilder.go` | zl/service/cache | 新增 | 键名前缀管理,多站点共用 Redis | +| `cache/lock.go` | zl/service/cache | 新增 | 分布式锁、计数器、TTL管理 | +| `middleware/requestid.go` | zl/middleware | 2 | 请求ID追踪 | +| `middleware/recover.go` | zl/middleware | 2 | Panic恢复中间件 | +| `handler/handler.go` | zl/response | 增强 | 类型安全参数获取(QueryInt/PathInt/FormInt等) | +| `response/response.go` | zl/response | 增强 | RequestID字段、文件下载、HTML响应、跳转 | +| `config/config.go` | 新增 | AppConfig | 应用配置(SiteName/Env/Version等) | + +### 2.2 配置增强 + +新增 `AppConfig` 配置块,支持站点别名: + +```yaml +app: + name: "用户管理系统" + site_name: "user_api" # 站点别名,用于缓存键前缀、日志标识 + version: "1.0.0" + env: "prod" + debug: false + base_url: "https://user.example.com" + token_expire: 86400 +``` + +### 2.3 CLI 工具重构 + +将原来单个 `main.go`(约700行)拆分为模块化结构: + +``` +cmd/xlgo/ +├── main.go # 入口和命令路由(~50行) +├── types.go # 类型定义 +├── utils.go # 工具函数 +├── commands.go # 命令实现 +└── templates.go # 所有模板定义 +``` + +--- + +## 三、项目规模对比 + +| 指标 | v1.1.0 | v2.0 | 提升 | +|------|--------|------|------| +| Go 源文件数 | 28 | 54 | +93% | +| 代码行数 | ~2,800 | ~8,400 | +200% | +| 包数量 | 16 | 23 | +44% | +| 函数数量 | ~150 | ~450 | +200% | +| 测试覆盖 | 0% | 8% (2/24包) | +8% | utils/console已有测试 | + +--- + +## 四、模块完整性评估(v2.0) + +### 4.1 核心模块评分对比 + +| 模块 | v1.1.0 | v2.0 | 提升 | 说明 | +|------|--------|------|------|------| +| 配置管理 | 8/10 | 9/10 | +1 | 新增 AppConfig、SiteName | +| 数据库 | 8/10 | 8/10 | - | 保持稳定 | +| Redis/缓存 | 9/10 | 10/10 | +1 | 分布式锁、键名前缀、计数器 | +| JWT 认证 | 8/10 | 8/10 | - | 保持稳定 | +| 日志系统 | 9/10 | 9/10 | - | 保持稳定 | +| 中间件 | 8/10 | 9/10 | +1 | RequestID、Recover、彩色输出 | +| 文件存储 | 9/10 | 9/10 | - | 保持稳定 | +| 工具函数 | 3/10 | 9/10 | +6 | 111个实用函数 | +| 验证器 | 9/10 | 9/10 | - | 保持稳定 | +| 错误处理 | 8/10 | 8/10 | - | 保持稳定 | +| 密码加密 | 9/10 | 9/10 | - | 保持稳定 | +| SSE 支持 | 8/10 | 8/10 | - | 保持稳定 | +| WebSocket | 8/10 | 8/10 | - | 保持稳定 | +| 定时任务 | 7/10 | 7/10 | - | 保持稳定 | +| 测试支持 | 8/10 | 8/10 | - | 保持稳定 | +| CLI 工具 | 7/10 | 8/10 | +1 | 模块化重构、模板分离 | +| 压缩解压 | 0/10 | 8/10 | +8 | Gzip/Zip完整实现 | +| 控制台输出 | 0/10 | 9/10 | +9 | 跨平台彩色输出 | + +### 4.2 新增 utils 包功能清单 + +#### 4.2.1 随机数生成 (random.go) +```go +RandString(16) // 随机字符串(字母+数字) +RandDigit(6) // 随机数字字符串 +RandInt(1, 100) // 随机整数 +RandInt64(0, 1000) // 随机int64 +``` + +#### 4.2.2 字符串处理 (strings.go) +```go +IsBlank(s) // 检查空白 +IsAnyBlank(strs...) // 批量检查 +DefaultIfBlank(s, def) // 默认值 +Substr(s, 0, 10) // 子字符串(支持中文) +StrLen(s) // Unicode长度 +EqualsIgnoreCase(a, b) // 不区分大小写 +Trim(s) // 去除空白 +``` + +#### 4.2.3 时间日期 (datetime.go) +```go +NowUnix() // 秒时间戳 +NowTimestamp() // 毫秒时间戳 +FromUnix(unix) // 时间戳转时间 +FormatDateTime(t) // 格式化 "2006-01-02 15:04:05" +StartOfDay(t) // 当天开始 +EndOfDay(t) // 当天结束 +StartOfMonth(t) // 当月开始 +EndOfMonth(t) // 当月结束 +``` + +#### 4.2.4 类型转换 (convert.go) +```go +ToInt(s) // 字符串转int +ToIntDefault(s, 0) // 带默认值 +ToInt64(s) // 转int64 +ToFloat64(s) // float64 +CalcPageCount(100, 10) // 计算总页数 +CalcOffset(2, 20) // 分页偏移 +``` + +#### 4.2.5 文件操作 (file.go) +```go +FileExists(path) // 检查文件存在 +DirExists(path) // 检查目录存在 +EnsureDir(path) // 确保目录存在 +ReadFile(path) // 读取文件 +WriteFile(path, data) // 写入文件 +CopyFile(dst, src) // 复制文件 +``` + +#### 4.2.6 URL处理 (url.go) +```go +ParseURL(rawURL) // 解析URL +builder.AddQuery("key", "value") // 链式添加参数 +URLEncode(s) // URL编码 +URLDecode(s) // URL解码 +``` + +#### 4.2.7 格式验证 (validator.go) +```go +IsPhone(phone) // 手机号验证 +IsEmail(email) // 邮箱验证 +IsIPv4(ip) // IPv4验证 +IsIDCard(id) // 身份证验证 +IsChinese(s) // 中文验证 +IsNumeric(s) // 数字验证 +IsAlphanumeric(s) // 字母数字验证 +``` + +#### 4.2.8 加密编码 (crypto.go) +```go +MD5(s) // MD5哈希 +SHA256(s) // SHA256哈希 +Base64Encode(data) // Base64编码 +Base64URLEncode(data) // URL安全编码 +``` + +#### 4.2.9 HTTP客户端 (http.go) +```go +client := NewHTTPClient() +client.SetTimeout(30*time.Second) +client.SetHeader("Authorization", "Bearer xxx") +client.Get(url, params) // GET请求 +client.PostJSON(url, data) // POST JSON +client.Upload(url, files, params) // 文件上传 +``` + +#### 4.2.10 UUID生成 (uuid.go) +```go +UUID() // UUID v4字符串 +UUIDShort() // 短UUID(无横线) +UUIDValid(s) // 验证UUID +``` + +### 4.3 新增 console 包功能 + +```go +console.Debug("调试信息") // 青色 +console.Info("普通信息") // 白色 +console.Success("成功信息") // 绿色 +console.Warn("警告信息") // 黄色 +console.Error("错误信息") // 红色 + +// 自定义配置 +c := console.New( + console.WithColor(true), + console.WithTime(true), + console.WithCaller(true, 2), +) +c.Debug("自定义控制台输出") +``` + +### 4.4 新增 compress 包功能 + +```go +// Gzip 数据压缩 +GzipCompress(data) +GzipDecompress(data) + +// Gzip 文件压缩 +GzipCompressFile(src, dst) +GzipDecompressFile(src, dst) + +// Zip 打包 +Zip(zipPath, paths) // 打包文件/目录 +Unzip(zipPath, dstDir) // 解压到目录 +``` + +### 4.5 缓存键名前缀管理 + +```go +// 自动从配置读取 site_name +cache.K("user:1") // -> "cache:user_api:user:1" +cache.KTemp("token") // -> "temp:user_api:token" +cache.KPerm("config") // -> "perm:user_api:config" +cache.KLock("order:123") // -> "lock:user_api:order:123" +cache.KCounter("visit") // -> "counter:user_api:visit" +cache.KSession("sid") // -> "session:user_api:sid" + +// 分布式锁 +cache.Lock(ctx, key, ttl) +cache.Unlock(ctx, key) +cache.TryLock(ctx, key, ttl, retry, maxRetry) +cache.WithLock(ctx, key, ttl, func(context.Context) error) // 自动管理锁 + +// 计数器 +cache.Incr(ctx, key) +cache.Decr(ctx, key) +``` + +### 4.6 中间件增强 + +```go +// RequestID - 请求追踪 +r.Use(middleware.RequestID()) +// 响应头自动添加 X-Request-ID + +// Recover - Panic恢复 +r.Use(middleware.Recover()) // 生产环境 +r.Use(middleware.RecoverWithDetail()) // 开发环境(返回详细信息) +``` + +### 4.7 Handler 参数获取增强 + +```go +// 类型安全的参数获取 +page := handler.QueryInt(c, "page", 1) +id := handler.PathInt64(c, "id", 0) +price := handler.QueryFloat64(c, "price", 0.0) +enabled := handler.QueryBool(c, "enabled", false) +count := handler.FormInt(c, "count", 0) +``` + +--- + +## 五、多站点共用 Redis 方案 + +### 5.1 问题背景 + +多个小项目共用一台 Redis 服务器时,缓存键名可能冲突: +- 项目A: `user:1` +- 项目B: `user:1` +- 项目C: `user:1` + +### 5.2 解决方案 + +通过 `site_name` 配置自动添加前缀: + +| 项目 | config.yaml | 实际键名 | +|------|-------------|----------| +| 用户API | `site_name: user_api` | `cache:user_api:user:1` | +| 订单API | `site_name: order_api` | `cache:order_api:user:1` | +| 支付API | `site_name: pay_api` | `cache:pay_api:user:1` | + +### 5.3 使用方式 + +```yaml +# config.yaml +app: + site_name: "my_project" +``` + +```go +// 无需额外代码,自动生效 +cache.K("user:1") // -> "cache:my_project:user:1" +``` + +--- + +## 六、性能评估(v2.0) + +### 6.1 性能优化点 + +| 优化项 | 说明 | +|--------|------| +| RandString/RandDigit | 使用 sync.Pool 复用 rand.Source | +| StringToBytes/BytesToString | 零拷贝转换 | +| HTTPClient | 链式配置,连接复用 | +| 缓存键名 | 预构建,避免重复拼接 | + +### 6.2 性能基准估算 + +| 场景 | 预估 QPS | 说明 | +|------|----------|------| +| 简单查询 API | 12,000-18,000 | 无 DB 查询 | +| 带 DB 查询 | 3,000-5,000 | 单表主键查询 | +| 缓存查询 | 10,000-15,000 | Redis 缓存命中 | +| SSE 流式响应 | 5,000-8,000 | 单连接 | +| WebSocket 连接 | 10,000+ | 连接数 | +| 文件压缩 | 50-100 MB/s | Gzip | +| HTTP请求 | 1,000-5,000 | 外部API调用 | + +--- + +## 七、扩展性评估(v2.0) + +| 方面 | v1.1.0 | v2.0 | 说明 | +|------|--------|------|------| +| 存储扩展 | 本地 + OSS | 本地 + OSS | 保持 | +| 缓存扩展 | 基础操作 | 分布式锁、计数器、前缀 | +3功能 | +| 验证扩展 | 自定义规则 | 手机/邮箱/IP等预定义 | +7规则 | +| 通信扩展 | HTTP + SSE + WS | 保持 | - | +| 工具扩展 | 无 | 111个函数 | +111 | +| 压缩扩展 | 无 | Gzip + Zip | +2 | +| 控制台扩展 | 无 | 彩色输出 | +1 | + +--- + +## 八、易用性评估(v2.0) + +### 8.1 开发效率提升 + +| 任务 | v1.1.0 | v2.0 | 节省 | +|------|--------|------|------| +| 创建新项目 | 5 分钟 | 5 分钟 | - | +| 添加 CRUD 模块 | 10 分钟 | 10 分钟 | - | +| 请求验证 | 5 分钟 | 5 分钟 | - | +| 参数类型转换 | 手动编写 | 调用 handler.QueryInt | 90% | +| 文件压缩 | 手动实现 | 调用 compress.Zip | 100% | +| 随机字符串 | 手动实现 | 调用 utils.RandString | 100% | +| 时间格式化 | 记忆布局 | 调用 utils.FormatDateTime | 80% | +| 分布式锁 | 手动实现 | 调用 cache.Lock | 100% | +| HTTP请求 | 手动封装 | 调用 NewHTTPClient().Get | 100% | +| 控制台调试 | fmt.Println | console.Debug(彩色) | 50% | + +### 8.2 代码简化示例 + +```go +// v1.1.0 - 手动实现 +func GetUser(c *gin.Context) { + pageStr := c.Query("page") + page := 1 + if pageStr != "" { + p, err := strconv.Atoi(pageStr) + if err == nil { + page = p + } + } + // ... +} + +// v2.0 - 一行搞定 +func GetUser(c *gin.Context) { + page := handler.QueryInt(c, "page", 1) + // ... +} +``` + +--- + +## 九、安全性评估(v2.0) + +| 检查项 | v1.1.0 | v2.0 | 说明 | +|--------|--------|------|------| +| SQL 注入 | ✅ GORM | ✅ GORM | 保持 | +| XSS | ⚠️ 应用层 | ⚠️ 应用层 | 保持 | +| CSRF | ❌ 无 | ✅ 完善 | +1 | 多种模式:Cookie/API/DoubleSubmit | +| 密码加密 | ✅ bcrypt | ✅ bcrypt | 保持 | +| 请求验证 | ✅ validator | ✅ validator | 保持 | +| 限流防护 | ✅ 完善 | ✅ 完善 | 保持 | +| JWT 安全 | ✅ 黑名单 | ✅ 黑名单 | 保持 | +| Panic恢复 | ❌ 无 | ✅ Recover中间件 | +1 | +| 请求追踪 | ❌ 无 | ✅ RequestID | +1 | +| 类型安全 | ⚠️ 手动 | ✅ 强类型函数 | +1 | + +--- + +## 十、综合评分(v2.0) + +| 维度 | v1.1.0 | v2.0 | 提升 | +|------|--------|------|------| +| 完整性 | 8.5/10 | 9.5/10 | +1.0 | +| 性能 | 8.5/10 | 9.0/10 | +0.5 | +| 扩展性 | 7.5/10 | 9.0/10 | +1.5 | +| 易用性 | 9.0/10 | 9.5/10 | +0.5 | +| 安全性 | 8.0/10 | 9.0/10 | +1.0 | +| **总分** | **8.35/10** | **9.2/10** | **+0.85** | + +--- + +## 十一、剩余改进建议 + +### 11.1 P3 - 下一步优化 + +| 功能 | 优先级 | 说明 | +|------|--------|------| +| Kafka 生产者 | 低 | 需重新设计连接池 | +| WebSocket 客户端 | 低 | 框架已有服务端实现 | +| 链路追踪 | 中 | OpenTelemetry集成 | +| 单元测试 | 高 | 进行中,覆盖所有包 | +| 性能基准 | 中 | 建立benchmark | + +### 11.2 测试建议 + +```bash +# 运行现有测试 +go test ./utils/... ./console/... -v + +# 建议添加 +go test ./cache/... ./handler/... ./middleware/... +``` + +--- + +## 十二、结论 + +经过本轮全面优化,xlgo 框架已从 **生产就绪** 提升至 **功能丰富** 水平。 + +### 12.1 核心优势 + +1. **工具库完善** - 111个实用函数,覆盖日常开发80%场景 +2. **多站点支持** - SiteName配置解决共用Redis冲突 +3. **类型安全** - QueryInt/PathInt等避免手动转换 +4. **开发调试** - 彩色控制台输出,一眼识别日志级别 +5. **分布式支持** - Redis分布式锁开箱即用 +6. **文件处理** - Gzip/Zip压缩解压 + +### 12.2 适用场景 + +- ✅ 中大型 API 服务 +- ✅ 用户管理系统 +- ✅ AI 应用后端 +- ✅ 实时通信应用 +- ✅ 多项目共用资源 +- ✅ 创业项目 MVP +- ✅ 学习 Go Web 开发 + +### 12.3 开发效率总结 + +| 场景 | 使用 xlgo v2.0 | 不使用框架 | 节省 | +|------|----------------|------------|------| +| 用户系统 | 4-5 人天 | 10-16 人天 | 60% | +| AI应用 | 4-5 人天 | 9-14 人天 | 55% | +| 通用API | 2-3 人天 | 5-8 人天 | 60% | + +--- + +## 十三、版本历史 + +| 版本 | 日期 | 主要变化 | +|------|------|----------| +| v1.0.0 | 2026-04-29 | 基础框架 | +| v1.1.0 | 2026-04-29 | P0/P1/P2修复(12项) | +| v2.0.0 | 2026-04-29 | zl工具库移植、模块重构 | + +--- + +*评估日期:2026-04-29* +*评估版本:v2.0.0* +*优化内容:新增5个包 + 增强5个包 + 配置扩展 + CLI重构 = 约120个函数* diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..4cf6dcf --- /dev/null +++ b/examples/README.md @@ -0,0 +1,50 @@ +# xlgo 示例 + +两个可运行示例,帮助快速上手 xlgo。 + +## minimal — 最小 HTTP 服务 + +不依赖 MySQL / Redis / Storage,纯 HTTP + 健康检查。第一次接触 xlgo 从这里开始。 + +```bash +go run ./examples/minimal +``` + +访问 http://localhost:8081/health(健康检查)与 http://localhost:8081/api/v1/(示例路由)。 + +## full — 完整业务 API + +包含 MySQL + Redis + JWT + 一个 user CRUD(登录发 token、认证路由、创建/查询用户)。 + +**运行前需准备**: +- MySQL(修改 `examples/full/config.yaml` 的 `database` 配置) +- Redis(修改 `examples/full/config.yaml` 的 `redis` 配置) + +```bash +go run ./examples/full +``` + +启动后自动迁移 user 表,并初始化示例用户 `alice/secret`(密码以 bcrypt 哈希保存)。接口: + +| 方法 | 路径 | 说明 | 认证 | +|---|---|---|---| +| POST | /api/v1/login | 登录,返回 token | 否 | +| GET | /api/v1/users/:id | 查询用户 | 是(Bearer token) | +| POST | /api/v1/users | 创建用户 | 是(Bearer token) | + +登录示例: +```bash +curl -X POST http://localhost:8082/api/v1/login \ + -H "Content-Type: application/json" \ + -d '{"username":"alice","password":"secret"}' +``` + +创建用户示例: +```bash +curl -X POST http://localhost:8082/api/v1/users \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"username":"bob","password":"s3cret"}' +``` + +> 示例代码为演示用途,已演示 bcrypt 密码哈希与登录校验;生产环境仍需配合 `validation` 包完善入参校验、密码强度策略与用户注册流程。 diff --git a/examples/full/config.yaml b/examples/full/config.yaml new file mode 100644 index 0000000..4b02232 --- /dev/null +++ b/examples/full/config.yaml @@ -0,0 +1,44 @@ +app: + name: "xlgo-full-example" + env: "dev" + debug: true + +server: + host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机 + port: 8082 + mode: development + read_timeout: 15s + write_timeout: 30s + idle_timeout: 60s + shutdown_timeout: 30s + response_mode: business # business(默认) 或 rest + +database: + driver: mysql + host: localhost + port: 3306 + user: root + password: your_password + name: xlgo_example + max_idle_conns: 10 + max_open_conns: 100 + +redis: + host: localhost + port: 6379 + password: "" + db: 0 + +jwt: + secret: change-me-to-a-long-random-secret-at-least-32-chars + expire: "24h" + refresh_expire: "168h" + issuer: xlgo + algorithm: HS256 + +log: + dir: ./logs + max_size: 100 + max_backups: 30 + max_age: 30 + compress: true diff --git a/examples/full/main.go b/examples/full/main.go new file mode 100644 index 0000000..a6c4fc2 --- /dev/null +++ b/examples/full/main.go @@ -0,0 +1,175 @@ +// Package main 是 xlgo 的完整示例:MySQL + Redis + JWT + 一个 user CRUD。 +// +// 运行前需准备: +// - MySQL(config.yaml 中 database 配置) +// - Redis(config.yaml 中 redis 配置) +// +// 启动后会自动迁移 user 表。访问: +// +// POST /api/v1/login {"username":"alice","password":"secret"} → 返回 token +// GET /api/v1/users/:id (需 Authorization: Bearer ) +// POST /api/v1/users (需 Authorization: Bearer ,创建用户) +// +// 示例会在启动时初始化 alice/secret,密码以 bcrypt 哈希保存。 +// +// 运行: +// +// go run ./examples/full +package main + +import ( + "context" + "errors" + "fmt" + "os" + + xlgo "github.com/EthanCodeCraft/xlgo-core" + "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/EthanCodeCraft/xlgo-core/jwt" + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/EthanCodeCraft/xlgo-core/repository" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/EthanCodeCraft/xlgo-core/router" + "github.com/EthanCodeCraft/xlgo-core/validation" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// User 示例模型 +type User struct { + gorm.Model + Username string `gorm:"uniqueIndex;size:64" json:"username"` + Password string `gorm:"size:128" json:"-"` // bcrypt 哈希,永不返回给客户端 + UserType string `gorm:"size:32" json:"user_type"` +} + +var userRepo *repository.BaseRepo[User] + +func main() { + app := xlgo.NewFullStack( + xlgo.WithConfigPath("./examples/full/config.yaml"), + xlgo.WithModels(&User{}), + xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()), + xlgo.WithModules(router.ModuleFunc(registerRoutes)), + xlgo.WithHook(xlgo.Hook{ + Name: "seed-example-user", + OnInit: func(*xlgo.App) error { + return ensureExampleUser(context.Background()) + }, + }), + ) + + if err := app.Run(); err != nil { + fmt.Printf("启动失败: %v\n", err) + os.Exit(1) + } +} + +func registerRoutes(r *gin.RouterGroup) { + // 延迟初始化 repo:此时 App.Init 已完成,database.GetDB() 可用 + userRepo = repository.NewBaseRepo[User](database.GetDB()) + + api := r.Group("/api/v1") + + // 公开路由:登录 + api.POST("/login", login) + + // 认证路由 + auth := api.Group("/", middleware.AuthRequired()) + auth.GET("/users/:id", getUser) + auth.POST("/users", createUser) +} + +func login(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, "参数错误") + return + } + + u, err := userRepo.FindOne(c.Request.Context(), "username = ?", req.Username) + if err != nil { + response.Fail(c, "用户名或密码错误") + return + } + if !validation.CheckPassword(u.Password, req.Password) { + response.Fail(c, "用户名或密码错误") + return + } + + token, err := jwt.GenerateToken(u.ID, u.Username, "user", u.UserType) + if err != nil { + response.ServerError(c, "生成 token 失败") + return + } + response.Success(c, gin.H{"token": token}) +} + +func getUser(c *gin.Context) { + id := c.Param("id") + // 示例简化:直接用 repo 查询,实际应转 uint + var uid uint + if _, err := fmt.Sscanf(id, "%d", &uid); err != nil { + response.Fail(c, "用户ID无效") + return + } + u, err := userRepo.FindByID(c.Request.Context(), uid) + if err != nil { + response.NotFound(c, "用户不存在") + return + } + response.Success(c, u) +} + +func createUser(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + UserType string `json:"user_type"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, "参数错误") + return + } + + hash, err := validation.HashPassword(req.Password) + if err != nil { + response.ServerError(c, "密码加密失败") + return + } + u := User{ + Username: req.Username, + Password: hash, + UserType: req.UserType, + } + if u.UserType == "" { + u.UserType = "user" + } + if err := userRepo.Create(c.Request.Context(), &u); err != nil { + response.Fail(c, "创建失败: "+err.Error()) + return + } + response.Success(c, u) +} + +func ensureExampleUser(ctx context.Context) error { + _, err := userRepo.FindOne(ctx, "username = ?", "alice") + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + hash, err := validation.HashPassword("secret") + if err != nil { + return err + } + return userRepo.Create(ctx, &User{ + Username: "alice", + Password: hash, + UserType: "user", + }) +} diff --git a/examples/full/main_test.go b/examples/full/main_test.go new file mode 100644 index 0000000..145eaee --- /dev/null +++ b/examples/full/main_test.go @@ -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) +} diff --git a/examples/minimal/config.yaml b/examples/minimal/config.yaml new file mode 100644 index 0000000..104edf1 --- /dev/null +++ b/examples/minimal/config.yaml @@ -0,0 +1,8 @@ +app: + name: "xlgo-minimal-example" + env: "dev" + debug: true + +server: + port: 8081 + mode: development diff --git a/examples/minimal/main.go b/examples/minimal/main.go new file mode 100644 index 0000000..31c81ff --- /dev/null +++ b/examples/minimal/main.go @@ -0,0 +1,42 @@ +// Package main 是 xlgo 的最小可运行示例。 +// +// 仅依赖一个 config.yaml,不初始化 MySQL / Redis / Storage, +// 适合第一次接触 xlgo、或纯 HTTP 场景。 +// +// 运行: +// +// go run ./examples/minimal +package main + +import ( + "fmt" + "os" + + xlgo "github.com/EthanCodeCraft/xlgo-core" + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/EthanCodeCraft/xlgo-core/router" + "github.com/gin-gonic/gin" +) + +func main() { + app := xlgo.New( + xlgo.WithConfigPath("./examples/minimal/config.yaml"), + xlgo.WithLogger(), + xlgo.WithHealthRoutes(), + xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()), + xlgo.WithModules(router.ModuleFunc(registerRoutes)), + ) + + if err := app.Run(); err != nil { + fmt.Printf("启动失败: %v\n", err) + os.Exit(1) + } +} + +func registerRoutes(r *gin.RouterGroup) { + api := r.Group("/api/v1") + api.GET("/", func(c *gin.Context) { + response.Success(c, gin.H{"message": "Hello xlgo!"}) + }) +} diff --git a/glm_note_report.md b/glm_note_report.md new file mode 100644 index 0000000..7478839 --- /dev/null +++ b/glm_note_report.md @@ -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 ) +// 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 ) +// POST /api/v1/users (需 Authorization: Bearer ,创建用户) +``` + +### 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 MaxPage { + p = MaxPage + } + if ps < 1 { + ps = 20 + } + if ps > 100 { + ps = 100 + } + + return p, ps +} + +// GetIDFromPath 从路径获取 ID +func GetIDFromPath(c *gin.Context, paramName string) (uint, bool) { + idStr := c.Param(paramName) + idStr = strings.Trim(idStr, "/") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + return 0, false + } + return uint(id), true +} + +// ===== 类型安全的参数获取函数 ===== + +// QueryInt 获取 Query 参数中的整数(带默认值) +func QueryInt(c *gin.Context, key string, def int) int { + return utils.ToIntDefault(c.Query(key), def) +} + +// QueryInt64 获取 Query 参数中的 int64(带默认值) +func QueryInt64(c *gin.Context, key string, def int64) int64 { + return utils.ToInt64Default(c.Query(key), def) +} + +// QueryFloat64 获取 Query 参数中的 float64(带默认值) +func QueryFloat64(c *gin.Context, key string, def float64) float64 { + return utils.ToFloat64Default(c.Query(key), def) +} + +// QueryBool 获取 Query 参数中的布尔值(带默认值) +func QueryBool(c *gin.Context, key string, def bool) bool { + val := c.Query(key) + if val == "" { + return def + } + return val == "true" || val == "1" || val == "yes" +} + +// PathInt 从路径参数获取整数(带默认值) +func PathInt(c *gin.Context, key string, def int) int { + val := strings.Trim(c.Param(key), "/") + return utils.ToIntDefault(val, def) +} + +// PathInt64 从路径参数获取 int64(带默认值) +func PathInt64(c *gin.Context, key string, def int64) int64 { + val := strings.Trim(c.Param(key), "/") + return utils.ToInt64Default(val, def) +} + +// PathUint64 从路径参数获取 uint64(带默认值) +func PathUint64(c *gin.Context, key string, def uint64) uint64 { + val := strings.Trim(c.Param(key), "/") + return utils.ToUint64Default(val, def) +} + +// FormInt 获取 POST 表单参数中的整数(带默认值) +func FormInt(c *gin.Context, key string, def int) int { + return utils.ToIntDefault(c.PostForm(key), def) +} + +// FormInt64 获取 POST 表单参数中的 int64(带默认值) +func FormInt64(c *gin.Context, key string, def int64) int64 { + return utils.ToInt64Default(c.PostForm(key), def) +} + +// FormUint64 获取 POST 表单参数中的 uint64(带默认值) +func FormUint64(c *gin.Context, key string, def uint64) uint64 { + return utils.ToUint64Default(c.PostForm(key), def) +} + +// FormFloat64 获取 POST 表单参数中的 float64(带默认值) +func FormFloat64(c *gin.Context, key string, def float64) float64 { + return utils.ToFloat64Default(c.PostForm(key), def) +} + +// FormBool 获取 POST 表单参数中的布尔值(带默认值) +func FormBool(c *gin.Context, key string, def bool) bool { + val := c.PostForm(key) + if val == "" { + return def + } + return val == "true" || val == "1" || val == "yes" || val == "on" +} + +// FormString 获取 POST 表单参数中的字符串(带默认值) +func FormString(c *gin.Context, key string, def string) string { + val := c.PostForm(key) + if val == "" { + return def + } + return val +} + +// ParseInt 解析整数 +func ParseInt(s string, defaultValue int) int { + return utils.ToIntDefault(s, defaultValue) +} + +// BadRequest 返回参数/请求错误响应。 +// +// 委托 response.FailWithCode(CodeFail),遵循当前响应模式(ModeBusiness 下 HTTP 200, +// 错误信息通过 body 中的 code 表达;ModeREST 下 CodeFail 属业务失败不映射 HTTP 错误,仍 200), +// 并写入 RequestID——与 response 模式系统一致,不再硬编 HTTP 状态码、不再丢失链路追踪。 +func BadRequest(c *gin.Context, msg string) { + response.FailWithCode(c, response.CodeFail, msg) +} + +// InternalError 返回服务器错误响应。 +// +// 委托 response.ServerError(CodeServerError),遵循当前响应模式(ModeBusiness 下 HTTP 200, +// ModeREST 下 HTTP 500),并写入 RequestID——与 response 模式系统一致, +// 不再硬编 HTTP 状态码、不再丢失链路追踪。 +func InternalError(c *gin.Context, msg string) { + response.ServerError(c, msg) +} diff --git a/handler/handler_test.go b/handler/handler_test.go new file mode 100644 index 0000000..af3b9e1 --- /dev/null +++ b/handler/handler_test.go @@ -0,0 +1,414 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/handler" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" +) + +func setupTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + return r +} + +func TestHealthCheck(t *testing.T) { + r := setupTestRouter() + r.GET("/health", handler.HealthCheck) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/health", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("HealthCheck status = %d, want 200", w.Code) + } +} + +// TestHealthCheckSchemaConverged_H8d:handler.HealthCheck 响应体应与 +// router.RegisterHealthRoute 收敛为同一 schema(200 + {"status":"ok"}), +// 不再走 response 业务信封 {code,msg,data}。 +func TestHealthCheckSchemaConverged_H8d(t *testing.T) { + r := setupTestRouter() + r.GET("/health", handler.HealthCheck) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/health", nil)) + + body := w.Body.String() + if !strings.Contains(body, `"status":"ok"`) { + t.Fatalf("HealthCheck body = %s, want {\"status\":\"ok\"}", body) + } + if strings.Contains(body, `"code"`) || strings.Contains(body, `"data"`) { + t.Fatalf("HealthCheck should not use response envelope, got %s", body) + } +} + +func TestQueryInt(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + page := handler.QueryInt(c, "page", 1) + c.JSON(200, gin.H{"page": page}) + }) + + tests := []struct { + query string + expected int + }{ + {"?page=10", 10}, + {"?page=abc", 1}, // 无效返回默认 + {"", 1}, // 无参数返回默认 + } + + for _, tt := range tests { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test"+tt.query, nil) + r.ServeHTTP(w, req) + + // 验证响应包含正确的值 + if w.Code != 200 { + t.Errorf("QueryInt status = %d", w.Code) + } + } +} + +func TestQueryInt64(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + id := handler.QueryInt64(c, "id", 0) + c.JSON(200, gin.H{"id": id}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test?id=1234567890123", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("QueryInt64 status = %d", w.Code) + } +} + +func TestQueryFloat64(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + price := handler.QueryFloat64(c, "price", 0.0) + c.JSON(200, gin.H{"price": price}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test?price=99.99", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("QueryFloat64 status = %d", w.Code) + } +} + +func TestQueryBool(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + enabled := handler.QueryBool(c, "enabled", false) + c.JSON(200, gin.H{"enabled": enabled}) + }) + + tests := []struct { + query string + expected bool + }{ + {"?enabled=true", true}, + {"?enabled=1", true}, + {"?enabled=yes", true}, + {"?enabled=false", false}, + {"?enabled=0", false}, + {"", false}, // 默认值 + } + + for _, tt := range tests { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test"+tt.query, nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("QueryBool status = %d", w.Code) + } + } +} + +func TestPathInt(t *testing.T) { + r := setupTestRouter() + r.GET("/user/:id", func(c *gin.Context) { + id := handler.PathInt(c, "id", 0) + c.JSON(200, gin.H{"id": id}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/user/123", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("PathInt status = %d", w.Code) + } +} + +func TestPathInt64(t *testing.T) { + r := setupTestRouter() + r.GET("/user/:id", func(c *gin.Context) { + id := handler.PathInt64(c, "id", 0) + c.JSON(200, gin.H{"id": id}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/user/1234567890123", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("PathInt64 status = %d", w.Code) + } +} + +func TestGetPage(t *testing.T) { + r := setupTestRouter() + r.GET("/list", func(c *gin.Context) { + page, pageSize := handler.GetPage(c) + c.JSON(200, gin.H{"page": page, "pageSize": pageSize}) + }) + + tests := []struct { + query string + expectedPage int + expectedSize int + }{ + {"?page=2&page_size=50", 2, 50}, + {"?page=0&page_size=0", 1, 20}, // 边界修正 + {"?page=-1&page_size=200", 1, 100}, // 超限修正 + {"", 1, 20}, // 默认值 + } + + for _, tt := range tests { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/list"+tt.query, nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("GetPage status = %d", w.Code) + } + } +} + +func TestGetIDFromPath(t *testing.T) { + r := setupTestRouter() + r.GET("/user/:id", func(c *gin.Context) { + id, ok := handler.GetIDFromPath(c, "id") + c.JSON(200, gin.H{"id": id, "ok": ok}) + }) + + // 有效 ID + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/user/123", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("GetIDFromPath status = %d", w.Code) + } + + // 无效 ID + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("GET", "/user/abc", nil) + r.ServeHTTP(w2, req2) + + if w2.Code != 200 { + t.Errorf("GetIDFromPath invalid status = %d", w2.Code) + } +} + +// withResponseMode 切换响应模式并在测试结束后恢复为默认 ModeBusiness。 +func withResponseMode(t *testing.T, m response.Mode) { + t.Helper() + response.SetMode(m) + t.Cleanup(func() { response.SetMode(response.ModeBusiness) }) +} + +// decodeBody 解析统一响应体。 +func decodeBody(t *testing.T, w *httptest.ResponseRecorder) response.Response { + t.Helper() + var body response.Response + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v (body=%q)", err, w.Body.String()) + } + return body +} + +func TestBadRequest(t *testing.T) { + // 默认 ModeBusiness:所有失败响应 HTTP 200,错误经 body code 表达。 + withResponseMode(t, response.ModeBusiness) + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + handler.BadRequest(c, "参数错误") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("BadRequest (ModeBusiness) status = %d, want 200", w.Code) + } + body := decodeBody(t, w) + if body.Code != response.CodeFail { + t.Errorf("BadRequest code = %d, want %d", body.Code, response.CodeFail) + } + if body.Msg != "参数错误" { + t.Errorf("BadRequest msg = %q, want %q", body.Msg, "参数错误") + } +} + +func TestInternalError(t *testing.T) { + // 默认 ModeBusiness:服务器错误也走 HTTP 200,错误经 body code 表达。 + withResponseMode(t, response.ModeBusiness) + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + handler.InternalError(c, "服务器错误") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("InternalError (ModeBusiness) status = %d, want 200", w.Code) + } + body := decodeBody(t, w) + if body.Code != response.CodeServerError { + t.Errorf("InternalError code = %d, want %d", body.Code, response.CodeServerError) + } +} + +// TestBadRequestRESTMode:ModeREST 下 CodeFail 属业务失败不映射 HTTP 错误,仍 200。 +// 修复前 BadRequest 硬编 400 绕过 Mode;修复后委托 response.FailWithCode 遵循 Mode。 +func TestBadRequestRESTMode(t *testing.T) { + withResponseMode(t, response.ModeREST) + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + handler.BadRequest(c, "参数错误") + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + if w.Code != http.StatusOK { + t.Errorf("BadRequest (ModeREST) status = %d, want 200 (CodeFail 不映射)", w.Code) + } + body := decodeBody(t, w) + if body.Code != response.CodeFail { + t.Errorf("BadRequest code = %d, want %d", body.Code, response.CodeFail) + } +} + +// TestInternalErrorRESTMode:ModeREST 下 CodeServerError 映射 HTTP 500。 +func TestInternalErrorRESTMode(t *testing.T) { + withResponseMode(t, response.ModeREST) + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + handler.InternalError(c, "服务器错误") + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + if w.Code != http.StatusInternalServerError { + t.Errorf("InternalError (ModeREST) status = %d, want 500", w.Code) + } + body := decodeBody(t, w) + if body.Code != response.CodeServerError { + t.Errorf("InternalError code = %d, want %d", body.Code, response.CodeServerError) + } +} + +// TestBadRequestWritesRequestID:修复前 BadRequest 直接 c.JSON 不写 RequestID(丢链路); +// 修复后委托 response 体系,经 writeResp 写入上下文中的 request_id。 +func TestBadRequestWritesRequestID(t *testing.T) { + r := setupTestRouter() + r.Use(func(c *gin.Context) { c.Set("request_id", "req-123"); c.Next() }) + r.GET("/test", func(c *gin.Context) { + handler.BadRequest(c, "参数错误") + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + body := decodeBody(t, w) + if body.RequestID != "req-123" { + t.Errorf("BadRequest request_id = %q, want %q (修复前为空)", body.RequestID, "req-123") + } +} + +// TestInternalErrorWritesRequestID:同上,覆盖 InternalError。 +func TestInternalErrorWritesRequestID(t *testing.T) { + r := setupTestRouter() + r.Use(func(c *gin.Context) { c.Set("request_id", "req-456"); c.Next() }) + r.GET("/test", func(c *gin.Context) { + handler.InternalError(c, "服务器错误") + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + body := decodeBody(t, w) + if body.RequestID != "req-456" { + t.Errorf("InternalError request_id = %q, want %q (修复前为空)", body.RequestID, "req-456") + } +} + +func TestBindJSON(t *testing.T) { + r := setupTestRouter() + r.POST("/test", func(c *gin.Context) { + var req struct { + Name string `json:"name"` + } + if err := handler.BindJSON(c, &req); err != nil { + handler.BadRequest(c, "绑定失败") + return + } + c.JSON(200, req) + }) + + // 有效 JSON + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"name":"test"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + 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) + } +} diff --git a/jwt/jwt.go b/jwt/jwt.go new file mode 100644 index 0000000..6cb0393 --- /dev/null +++ b/jwt/jwt.go @@ -0,0 +1,526 @@ +package jwt + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/EthanCodeCraft/xlgo-core/logger" + "github.com/golang-jwt/jwt/v5" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +// Claims JWT 声明 +type Claims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` // admin 或 staff + UserType string `json:"user_type"` // super_admin, admin, staff + JTI string `json:"jti"` // JWT ID(唯一标识,用于黑名单) + jwt.RegisteredClaims +} + +var ( + //ErrTokenExpired 令牌已过期 + ErrTokenExpired = errors.New("令牌已过期") + //ErrTokenInvalid 令牌无效 + ErrTokenInvalid = errors.New("令牌无效") + //ErrTokenMalformed 令牌格式错误 + ErrTokenMalformed = errors.New("令牌格式错误") + //ErrTokenNotValidYet 令牌尚未生效 + ErrTokenNotValidYet = errors.New("令牌尚未生效") + //ErrTokenRevoked 令牌已被撤销 + ErrTokenRevoked = errors.New("令牌已被撤销") + // ErrBlacklistUnavailable Redis 未初始化或不可用,黑名单功能失效(C9a 修复)。 + // Add 返回此错误使调用方(RefreshToken/InvalidateToken)感知黑名单不可用并 fail-closed, + // 避免无 Redis 时静默成功致撤销/刷新失效、新旧 token 双有效。 + // IsBlacklisted 在无 Redis 时仍返回 false(验证侧 fail-open 是无 Redis 部署的固有局限, + // 文档约束:安全敏感场景必须启用 Redis)。 + ErrBlacklistUnavailable = errors.New("token 黑名单不可用:Redis 未初始化") + // ErrEmptySecret JWT 密钥为空(P0 修复)。空 secret 意味着以零长度 HMAC 密钥签发/校验, + // 任何以 "" 签名的 token 都会通过——签发与校验一律 fail-closed 拒绝,杜绝该空密钥绕过。 + ErrEmptySecret = errors.New("jwt.secret 未配置:拒绝签发/校验(防空密钥导致任意 token 通过)") + // ErrUnsupportedAlgorithm 配置了不支持的签名算法(P0 修复)。 + // 本实现仅支持 HMAC 族(HS256/HS384/HS512);RS256 等非对称算法暂不支持, + // 不再静默回退 HS256——避免用户误以为在用非对称算法、实则 HMAC,并助长算法混淆攻击。 + ErrUnsupportedAlgorithm = errors.New("jwt: 不支持的签名算法(仅支持 HS256/HS384/HS512)") + // ErrInvalidExpiry 过期时间非法。签发非正过期时间的 token 会立即失效或产生不可预期会话语义。 + ErrInvalidExpiry = errors.New("jwt: 过期时间必须大于 0") + // ErrEmptyJTI 空 JTI 无法匹配任何正常 token,却会写入 jwt_bl: 这种永不命中的黑名单键。 + ErrEmptyJTI = errors.New("jwt: jti 不能为空") +) + +// validMethods 允许的签名算法名(HMAC 族)。ParseWithClaims 传 jwt.WithValidMethods 固定算法, +// 防算法混淆(alg confusion,P0):拒绝 alg=none 及非 HMAC 算法——否则若部署配置为非对称算法, +// 攻击者可用公钥作为 HMAC 密钥伪造 token 通过校验。 +var validMethods = []string{"HS256", "HS384", "HS512"} + +// secretKey 返回 HMAC 密钥字节;cfg 为 nil 或密钥为空时 fail-closed 返回 ErrEmptySecret(P0)。 +func secretKey(cfg *config.Config) ([]byte, error) { + if cfg == nil || cfg.JWT.Secret == "" { + return nil, ErrEmptySecret + } + return []byte(cfg.JWT.Secret), nil +} + +// hmacKeyfunc 构造校验签名方法为 HMAC 族并返回密钥的 jwt.Keyfunc(P0:防算法混淆 + 空密钥)。 +// 双重防护:① 断言 token.Method 为 *jwt.SigningMethodHMAC,拒绝非 HMAC(含 none/RS/ES); +// ② 经 secretKey 拒绝空密钥。配合 ParseWithClaims 的 jwt.WithValidMethods(validMethods) 使用。 +func hmacKeyfunc(cfg *config.Config) jwt.Keyfunc { + return func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("%w: 期望 HMAC,实际 alg=%v", ErrUnsupportedAlgorithm, token.Header["alg"]) + } + return secretKey(cfg) + } +} + +// generateJTI 生成唯一的 JWT ID +func generateJTI() (string, error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("生成 JTI 失败: %w", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +// TokenBlacklist Token 黑名单管理(使用 JTI 优化)。 +// client 为 nil 时回退到 database.GetRedis(),兼容存量未注入场景。 +type TokenBlacklist struct { + client *redis.Client +} + +// NewTokenBlacklist 创建黑名单实例,client 可为 nil(懒取全局 Redis)。 +func NewTokenBlacklist(client *redis.Client) *TokenBlacklist { + return &TokenBlacklist{client: client} +} + +func (tb *TokenBlacklist) redisClient() *redis.Client { + if tb != nil && tb.client != nil { + return tb.client + } + return database.GetRedis() +} + +// blacklistOpTimeout 黑名单 Redis 操作的上下文超时(M-A 修复)。 +// Redis 客户端已配 ReadTimeout/WriteTimeout=3s(redis.go D7),但鉴权热路径(ParseToken +// 每次调 IsBlacklisted)需更紧边界——显式 ctx 超时把鉴权阻塞上限收敛到 1s,避免 Redis +// 挂起时每个鉴权请求被长时间拖住。健康 Redis 下 Exists/SET 为亚毫秒级,1s 余量充足。 +// 注:ctx 超时只约束命令往返,不影响 Set 的服务端 TTL(ttl 可远大于 1s)。 +const blacklistOpTimeout = 1 * time.Second + +// BlacklistPolicy 控制解析 Token 时遇到黑名单查询错误的处理策略。 +// ParseToken 默认 BlacklistFailClosed(黑名单不可检查即拒绝); +// 需显式 fail-open(仅无 Redis 或低安全场景)用 ParseTokenWithBlacklistPolicy(..., BlacklistFailOpen)。 +type BlacklistPolicy int + +const ( + // BlacklistFailOpen 黑名单查询出错时放行 Token(仅用于无 Redis 或低安全场景,非默认)。 + BlacklistFailOpen BlacklistPolicy = iota + // BlacklistFailClosed 黑名单查询出错时拒绝 Token(默认;ParseToken 即用此策略)。 + BlacklistFailClosed +) + +// blacklistCtx 创建带超时的 context 用于黑名单 Redis 操作(M-A)。 +func blacklistCtx() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), blacklistOpTimeout) +} + +// Add 将 Token 的 JTI 加入黑名单 +// 参数: jti JWT ID,expiry Token 过期时间 +// +// 无 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 让调用方决策。 + return ErrBlacklistUnavailable + } + + ctx, cancel := blacklistCtx() + defer cancel() + ttl := time.Until(expiry) + if ttl <= 0 { + // Token 已过期,无需加入黑名单 + return nil + } + + // 使用 JTI 作为键名(约24字节),而非完整 Token(数百字节) + key := fmt.Sprintf("jwt_bl:%s", jti) + return client.Set(ctx, key, "1", ttl).Err() +} + +// IsBlacklisted 检查 JTI 是否在黑名单中。 +// 命中返回 (true, nil);未命中返回 (false, nil); +// Redis 未启用或不可达返回 (false, ErrBlacklistUnavailable),由调用方决定 fail-open/fail-closed。 +func (tb *TokenBlacklist) IsBlacklisted(jti string) (bool, error) { + client := tb.redisClient() + if client == nil { + return false, ErrBlacklistUnavailable + } + + ctx, cancel := blacklistCtx() + defer cancel() + key := fmt.Sprintf("jwt_bl:%s", jti) + n, err := client.Exists(ctx, key).Result() + if err != nil { + return false, err + } + return n > 0, nil +} + +// Manager JWT 管理器(#10)。持有独立的 TokenBlacklist, +// 支持多实例(如区分 user-token 与 refresh-token 黑名单)。 +type Manager struct { + mu sync.Mutex + blacklist *TokenBlacklist +} + +// defaultManager 是全局默认 JWT 管理器的真实存储,经 atomic 读写(C9c)。 +var defaultManager atomic.Pointer[Manager] + +func init() { + defaultManager.Store(NewJWTManager()) +} + +// currentManager 返回全局默认 JWT 管理器(atomic 读取,C9c)。 +// 正常情况下 init 后永不为 nil;防御性地在极罕见的 nil 情况下回退一个懒取 Redis 的实例。 +func currentManager() *Manager { + if m := defaultManager.Load(); m != nil { + return m + } + m := NewJWTManager() + defaultManager.Store(m) + return m +} + +// GetDefaultJWT 返回全局默认 JWT 管理器(并发安全,C9c/J1 修复)。 +// 替代已删除的 DefaultJWT 包级变量。 +func GetDefaultJWT() *Manager { + return currentManager() +} + +// currentBlacklist 返回全局默认 Manager 持有的黑名单(atomic 读取 Manager 后经 Blacklist(),C9c)。 +func currentBlacklist() *TokenBlacklist { + return currentManager().Blacklist() +} + +// NewJWTManager 创建 JWT 管理器实例(blacklist 懒取全局 Redis)。 +func NewJWTManager() *Manager { + return &Manager{blacklist: NewTokenBlacklist(nil)} +} + +// NewJWTManagerWithRedis 创建 JWT 管理器并注入指定 Redis 客户端(用于多 Redis/测试隔离)。 +func NewJWTManagerWithRedis(client *redis.Client) *Manager { + return &Manager{blacklist: NewTokenBlacklist(client)} +} + +// SetDefaultJWTManager 提升指定 Manager 为全局默认(atomic 置换,并发安全,J1 修复)。 +func SetDefaultJWTManager(m *Manager) { + if m == nil { + return + } + defaultManager.Store(m) +} + +// Blacklist 返回 Manager 持有的黑名单实例。 +func (m *Manager) Blacklist() *TokenBlacklist { + m.mu.Lock() + defer m.mu.Unlock() + return m.blacklist +} + +// GenerateToken 生成 JWT Token +func GenerateToken(userID uint, username, role, userType string) (string, error) { + cfg := config.Get() + var expiry time.Duration + if cfg != nil { + expiry = cfg.JWT.Expire + } + return generateTokenWithExpiry(cfg, userID, username, role, userType, expiry) +} + +func generateTokenWithExpiry(cfg *config.Config, userID uint, username, role, userType string, expiry time.Duration) (string, error) { + if expiry <= 0 { + return "", ErrInvalidExpiry + } + // P0:先校验密钥非空与算法受支持(fail-closed)。secretKey 亦守卫 cfg==nil, + // 通过后 cfg 保证非空,后续访问 cfg.JWT.* 安全。 + key, err := secretKey(cfg) + if err != nil { + return "", err + } + method, err := signingMethod(cfg.JWT.Algorithm) + if err != nil { + return "", err + } + + // 生成唯一的 JWT ID + jti, err := generateJTI() + 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(expiry)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: issuerOrDefault(cfg.JWT.Issuer), + ID: jti, // 同时设置到 RegisteredClaims.ID + }, + } + + token := jwt.NewWithClaims(method, claims) + return token.SignedString(key) +} + +// GenerateTokenWithCustomExpiry 生成带自定义过期时间的 Token +func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) { + cfg := config.Get() + if expireSeconds <= 0 { + return "", ErrInvalidExpiry + } + return generateTokenWithExpiry(cfg, userID, username, role, userType, time.Duration(expireSeconds)*time.Second) +} + +// issuerOrDefault 返回配置的 issuer,未配置时回退 "xlgo"。 +func issuerOrDefault(issuer string) string { + if issuer == "" { + return "xlgo" + } + return issuer +} + +// signingMethod 根据 algorithm 配置返回 HMAC 签名方法。 +// 支持 HS256(默认,空值等价)/HS384/HS512;其它值(含 RS256 等非对称算法,暂不支持) +// 返回 ErrUnsupportedAlgorithm,不再静默回退 HS256(P0:防"配 RS256 实得 HMAC"的算法混淆隐患)。 +func signingMethod(algorithm string) (jwt.SigningMethod, error) { + switch strings.ToUpper(strings.TrimSpace(algorithm)) { + case "", "HS256": + return jwt.SigningMethodHS256, nil + case "HS384": + return jwt.SigningMethodHS384, nil + case "HS512": + return jwt.SigningMethodHS512, nil + default: + return nil, fmt.Errorf("%w: %q", ErrUnsupportedAlgorithm, algorithm) + } +} + +func parseOptions(cfg *config.Config) []jwt.ParserOption { + issuer := "xlgo" + if cfg != nil { + issuer = issuerOrDefault(cfg.JWT.Issuer) + } + return []jwt.ParserOption{ + jwt.WithValidMethods(validMethods), + jwt.WithIssuer(issuer), + } +} + +func validateIssuer(cfg *config.Config, claims *Claims) error { + want := "xlgo" + if cfg != nil { + want = issuerOrDefault(cfg.JWT.Issuer) + } + if claims == nil || claims.Issuer != want { + return ErrTokenInvalid + } + return nil +} + +func mapParseTokenError(err error) error { + if errors.Is(err, jwt.ErrTokenExpired) { + return ErrTokenExpired + } + if errors.Is(err, jwt.ErrTokenMalformed) { + return ErrTokenMalformed + } + if errors.Is(err, jwt.ErrTokenNotValidYet) { + return ErrTokenNotValidYet + } + if errors.Is(err, ErrEmptySecret) { + return ErrEmptySecret + } + if errors.Is(err, ErrUnsupportedAlgorithm) { + return ErrUnsupportedAlgorithm + } + return fmt.Errorf("%w: %w", ErrTokenInvalid, err) +} + +func checkTokenBlacklist(claims *Claims, policy BlacklistPolicy) error { + if claims == nil || claims.JTI == "" { + return nil + } + revoked, err := currentBlacklist().IsBlacklisted(claims.JTI) + if err != nil { + if policy == BlacklistFailClosed { + return err + } + logger.Warn("JWT 黑名单检查失败,fail-open 策略放行 token", zap.String("jti", claims.JTI), zap.Error(err)) + return nil + } + if revoked { + return ErrTokenRevoked + } + return nil +} + +// ParseToken 解析 JWT Token。默认 fail-closed:黑名单后端不可检查时返回 +// ErrBlacklistUnavailable,拒绝该 Token。需要显式 fail-open(仅无 Redis 或低安全场景) +// 请用 ParseTokenWithBlacklistPolicy(token, BlacklistFailOpen)。 +func ParseToken(tokenString string) (*Claims, error) { + return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailClosed) +} + +// ParseTokenWithBlacklistPolicy 使用显式黑名单查询策略解析 JWT Token。 +func ParseTokenWithBlacklistPolicy(tokenString string, policy BlacklistPolicy) (*Claims, error) { + cfg := config.Get() + + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), parseOptions(cfg)...) + + if err != nil { + return nil, mapParseTokenError(err) + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + // 使用 JTI 检查黑名单(更高效) + if err := checkTokenBlacklist(claims, policy); err != nil { + return nil, err + } + return claims, nil + } + + return nil, ErrTokenInvalid +} + +// InvalidateToken 使 Token 失效(加入黑名单) +func InvalidateToken(tokenString string) error { + cfg := config.Get() + + opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation()) + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...) + + if err != nil { + // Token 签名/格式/issuer 无效,无需加入黑名单。 + return nil + } + + if claims, ok := token.Claims.(*Claims); ok { + 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) + } + } + + return nil +} + +// 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) +} + +// RefreshToken 刷新 Token +// +// 安全约束(C9b 修复):将旧 Token 加入黑名单的 Add 错误必须向上传播——若 Add 失败 +// (Redis 抖动或未启用)仍签发新 token,会导致旧 token 未拉黑、新旧 token 双有效, +// 形成会话固定窗口。故 Add 失败时不签发新 token(fail-closed)。 +func RefreshToken(tokenString string) (string, error) { + claims, err := ParseToken(tokenString) + if err != nil { + return "", err + } + + // 将旧 Token 加入黑名单;失败则不签发新 token(C9b:禁止吞 Add 错误)。 + if strings.TrimSpace(claims.JTI) == "" { + return "", ErrEmptyJTI + } + if claims.ExpiresAt != nil { + if err := currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time); err != nil { + return "", fmt.Errorf("刷新令牌失败:旧令牌撤销失败: %w", err) + } + } + + 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(不验证签名) +// 用于需要在验证前获取 JTI 的场景 +func GetJTI(tokenString string) (string, error) { + token, _, err := jwt.NewParser().ParseUnverified(tokenString, &Claims{}) + if err != nil { + return "", err + } + + if claims, ok := token.Claims.(*Claims); ok { + return claims.JTI, nil + } + + return "", ErrTokenInvalid +} + +// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)。 +// 返回 (是否撤销, 错误);黑名单后端不可用时返回 (false, ErrBlacklistUnavailable)。 +func IsTokenRevoked(jti string) (bool, error) { + return currentBlacklist().IsBlacklisted(jti) +} + +// GetClaimsFromToken 获取 Token 的 Claims(不验证过期) +// 用于获取已过期 Token 的信息 +func GetClaimsFromToken(tokenString string) (*Claims, error) { + cfg := config.Get() + + 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 +} diff --git a/jwt/jwt_c9c_internal_test.go b/jwt/jwt_c9c_internal_test.go new file mode 100644 index 0000000..e53e812 --- /dev/null +++ b/jwt/jwt_c9c_internal_test.go @@ -0,0 +1,142 @@ +package jwt + +import ( + "sync" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +// c9cSetupConfig 注入测试用 JWT 配置(内部测试无法访问 jwt_test 的 setupTestConfig)。 +func c9cSetupConfig(t *testing.T) { + t.Helper() + config.Set(&config.Config{ + JWT: config.JWTConfig{ + Secret: "test-secret-key-1234567890123456789012", // ≥32 字节 + Expire: time.Hour, + }, + }) +} + +// TestC9cConcurrentSetDefaultAndRead 验证 SetDefaultJWTManager(写)与包级函数 +// (读 currentBlacklist → currentManager → defaultManager.Load)并发无数据竞争(C9c)。 +// 修复前 tokenBlacklist 为裸包级指针,SetDefaultJWTManager 裸写、ParseToken/IsTokenRevoked/ +// InvalidateTokenByID 裸读,-race 必采到指针读写竞争。 +func TestC9cConcurrentSetDefaultAndRead(t *testing.T) { + c9cSetupConfig(t) + t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) }) // 还原无 Redis 基线 + + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + + // 预生成一个合法 token 供读者 ParseToken(JTI 随机,不会被下方 InvalidateTokenByID 的固定 jti 命中) + token, err := GenerateToken(1, "u", "admin", "super_admin") + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // 写者:在"注入 Redis"与"无 Redis"Manager 间原子置换(模拟启动期/测试期 SetDefaultJWTManager) + wg.Add(1) + go func() { + defer wg.Done() + withRedis := NewJWTManagerWithRedis(client) + withoutRedis := NewJWTManager() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + if i%2 == 0 { + SetDefaultJWTManager(withRedis) + } else { + SetDefaultJWTManager(withoutRedis) + } + } + }() + + // 读者:并发调用经 currentBlacklist() 的包级函数 + currentManager + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _, _ = ParseToken(token) + _, _ = IsTokenRevoked("some-jti") + _ = InvalidateTokenByID("some-jti", time.Now().Add(time.Hour)) + _ = currentManager() + } + }() + } + + // 让竞争窗口运行一段时间,确保 -race 能采到(若有未加锁读) + time.Sleep(50 * time.Millisecond) + close(stop) + wg.Wait() +} + +// TestC9cCurrentManagerReflectsSwap 验证 SetDefaultJWTManager 原子置换后, +// 包级函数经 currentManager 读到的是新 Manager 的 blacklist(C9c 一致性)。 +func TestC9cCurrentManagerReflectsSwap(t *testing.T) { + c9cSetupConfig(t) + t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) }) + + // 基线:无 Redis,InvalidateTokenByID 返 ErrBlacklistUnavailable + if err := InvalidateTokenByID("jti-1", time.Now().Add(time.Hour)); !errorIsBlacklistUnavailable(err) { + t.Fatalf("baseline without Redis: want ErrBlacklistUnavailable, got %v", err) + } + + // 置换为注入 Redis 的 Manager + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + SetDefaultJWTManager(NewJWTManagerWithRedis(client)) + + // 置换后包级函数应读到新 blacklist(Redis 可用),InvalidateTokenByID 成功 + if err := InvalidateTokenByID("jti-2", time.Now().Add(time.Hour)); err != nil { + t.Fatalf("after swap to Redis Manager: InvalidateTokenByID err = %v, want nil", err) + } + + // currentManager 应等于刚 Store 的 Manager + m := currentManager() + if m == nil { + t.Fatal("currentManager should not be nil") + } +} + +// TestC9cDefaultJWTAliasConsistent 验证 GetDefaultJWT 与 defaultManager 真实存储一致。 +func TestC9cDefaultJWTAliasConsistent(t *testing.T) { + t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) }) + + // init 后 GetDefaultJWT 与 currentManager 应指向同一实例 + if GetDefaultJWT() != currentManager() { + t.Fatal("GetDefaultJWT should equal currentManager after init") + } + + // SetDefaultJWTManager 后同步 + m := NewJWTManagerWithRedis(nil) + SetDefaultJWTManager(m) + if GetDefaultJWT() != m { + t.Fatal("GetDefaultJWT should sync after SetDefaultJWTManager") + } + if currentManager() != m { + t.Fatal("currentManager should reflect SetDefaultJWTManager") + } +} + +// errorIsBlacklistUnavailable 避免内部测试依赖 errors.Is 包装判断。 +func errorIsBlacklistUnavailable(err error) bool { + return err != nil && err.Error() == ErrBlacklistUnavailable.Error() +} diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go new file mode 100644 index 0000000..5832f8f --- /dev/null +++ b/jwt/jwt_test.go @@ -0,0 +1,578 @@ +package jwt_test + +import ( + "errors" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/jwt" + "github.com/alicebob/miniredis/v2" + gojwt "github.com/golang-jwt/jwt/v5" + "github.com/redis/go-redis/v9" +) + +func setupTestConfig() { + // 设置测试配置 + cfg := &config.Config{ + JWT: config.JWTConfig{ + Secret: "test-secret-key-1234567890123456789012", // ≥32 字节 + Expire: time.Hour, // 1小时 + }, + } + config.Set(cfg) +} + +func TestGenerateToken(t *testing.T) { + setupTestConfig() + + token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin") + if err != nil { + t.Fatalf("GenerateToken error: %v", err) + } + + if token == "" { + t.Error("GenerateToken should return non-empty token") + } + + // Token 应包含三部分(用 . 分隔) + parts := splitToken(token) + if len(parts) != 3 { + t.Errorf("Token should have 3 parts, got %d", len(parts)) + } +} + +func TestParseToken(t *testing.T) { + setupTestConfig() + setupMiniRedis(t) + + // 先生成 token + token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin") + + // 解析 token + claims, err := jwt.ParseToken(token) + if err != nil { + t.Fatalf("ParseToken error: %v", err) + } + + if claims.UserID != 1 { + t.Errorf("UserID = %d, want 1", claims.UserID) + } + if claims.Username != "testuser" { + t.Errorf("Username = %s, want testuser", claims.Username) + } + if claims.Role != "admin" { + t.Errorf("Role = %s, want admin", claims.Role) + } + if claims.UserType != "super_admin" { + t.Errorf("UserType = %s, want super_admin", claims.UserType) + } +} + +func TestParseTokenInvalid(t *testing.T) { + setupTestConfig() + + // 无效 token + _, err := jwt.ParseToken("invalid-token") + if err == nil { + t.Error("ParseToken should fail with invalid token") + } + + // 空 token + _, err = jwt.ParseToken("") + if err == nil { + t.Error("ParseToken should fail with empty token") + } +} + +func TestParseTokenWrongSecret(t *testing.T) { + setupTestConfig() + + // 用不同 secret 生成的 token + token, _ := jwt.GenerateToken(1, "test", "admin", "admin") + + // 修改 secret + cfg := &config.Config{ + JWT: config.JWTConfig{ + Secret: "different-secret-key-12345678901234567890", + Expire: 3600, + }, + } + if err := config.Set(cfg); err != nil { + t.Fatalf("Set wrong secret config: %v", err) + } + + // 应该解析失败 + _, err := jwt.ParseToken(token) + if err == nil { + t.Error("ParseToken should fail with wrong secret") + } +} + +func TestRefreshToken(t *testing.T) { + setupTestConfig() + + // 生成 token + token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin") + + // 无 Redis 时 RefreshToken 必须 fail-closed(C9b 修复:旧 token 撤销失败不签发新 token) + _, err := jwt.RefreshToken(token) + if !errors.Is(err, jwt.ErrBlacklistUnavailable) { + t.Errorf("RefreshToken without Redis should fail with ErrBlacklistUnavailable, got %v", err) + } +} + +// setupMiniRedis 启动 miniredis 并注入到 jwt 包级 tokenBlacklist(经 SetDefaultJWTManager)。 +// 测试结束 cleanup 还原为无 Redis 的默认 Manager,避免污染后续测试(-shuffle 下尤其关键)。 +func setupMiniRedis(t *testing.T) *miniredis.Miniredis { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { + _ = client.Close() + // 还原包级 tokenBlacklist 为无 Redis 默认 Manager,避免残留指向已关闭 miniredis 的 client。 + jwt.SetDefaultJWTManager(jwt.NewJWTManager()) + }) + // 用注入 Redis 的 Manager 替换默认,使包级 tokenBlacklist 指向 miniredis。 + jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(client)) + return mr +} + +func TestClaimsStructure(t *testing.T) { + claims := jwt.Claims{ + UserID: 1, + Username: "test", + Role: "admin", + UserType: "super_admin", + } + + if claims.UserID != 1 { + t.Error("Claims UserID failed") + } + if claims.Username != "test" { + t.Error("Claims Username failed") + } + if claims.Role != "admin" { + t.Error("Claims Username failed") + } + if claims.UserType != "super_admin" { + t.Error("Claims Username failed") + } +} + +func TestErrorDefinitions(t *testing.T) { + if jwt.ErrTokenExpired == nil { + t.Error("ErrTokenExpired should be defined") + } + if jwt.ErrTokenInvalid == nil { + t.Error("ErrTokenInvalid should be defined") + } + if jwt.ErrTokenMalformed == nil { + t.Error("ErrTokenMalformed should be defined") + } + if jwt.ErrTokenNotValidYet == nil { + t.Error("ErrTokenNotValidYet should be defined") + } +} + +func TestTokenBlacklist(t *testing.T) { + tb := jwt.TokenBlacklist{} + + // 无 Redis 时,Add 应返回 ErrBlacklistUnavailable(C9a 修复:fail-closed) + err := tb.Add("test-token", time.Now().Add(time.Hour)) + if !errors.Is(err, jwt.ErrBlacklistUnavailable) { + t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err) + } + + // 无 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) + } +} + +func TestInvalidateToken(t *testing.T) { + setupTestConfig() + + token, _ := jwt.GenerateToken(1, "test", "admin", "admin") + + // 无 Redis 时应返回 ErrBlacklistUnavailable(C9a 修复:fail-closed,不再静默成功) + err := jwt.InvalidateToken(token) + if !errors.Is(err, jwt.ErrBlacklistUnavailable) { + t.Errorf("InvalidateToken without Redis should return ErrBlacklistUnavailable, got %v", err) + } +} + +func splitToken(token string) []string { + count := 0 + for _, c := range token { + if c == '.' { + count++ + } + } + if count != 2 { + return []string{} + } + + start := 0 + result := make([]string, 0, 3) + for i, c := range token { + if c == '.' { + result = append(result, token[start:i]) + start = i + 1 + } + } + result = append(result, token[start:]) + return result +} + +// ===== C9b 回归:刷新令牌撤销闭环 ===== + +// 回归 C9b:RefreshToken 成功后,旧 token 必须被拉黑(ParseToken 返 ErrTokenRevoked), +// 新 token 可用。旧实现丢弃 Add 错误仍签发新 token,旧 token 仍有效(双有效)。 +func TestRefreshTokenRevokesOldToken(t *testing.T) { + setupTestConfig() + setupMiniRedis(t) + + token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin") + + // 刷新 + newToken, err := jwt.RefreshToken(token) + if err != nil { + t.Fatalf("RefreshToken: %v", err) + } + if newToken == "" { + t.Fatal("RefreshToken should return non-empty token") + } + + // 新 token 可解析 + newClaims, err := jwt.ParseToken(newToken) + if err != nil { + t.Fatalf("ParseToken new token: %v", err) + } + if newClaims.Username != "testuser" { + t.Error("new token claims should match original") + } + + // 旧 token 必须已被拉黑(C9b 核心) + _, err = jwt.ParseToken(token) + if !errors.Is(err, jwt.ErrTokenRevoked) { + t.Errorf("old token after refresh err = %v, want ErrTokenRevoked (C9b: old must be blacklisted)", err) + } +} + +// 回归 C9b:Redis 抖动(Add 失败)时 RefreshToken 必须 fail-closed,不签发新 token。 +// 旧实现丢弃 Add 错误仍签发新 token → 旧 token 未拉黑、新旧双有效。 +func TestRefreshTokenFailsOnRedisError(t *testing.T) { + setupTestConfig() + mr := setupMiniRedis(t) + + token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin") + + // 模拟 Redis 抖动:关闭 miniredis,使 Add 的 Set 失败。 + mr.Close() + + _, err := jwt.RefreshToken(token) + if err == nil { + t.Fatal("RefreshToken should fail when Redis unavailable (C9b: must not issue new token)") + } + // 不应是 ErrBlacklistUnavailable(那是无 Redis 路径),而是 Add 的 Set 错误包装。 + // 关键:未签发新 token,旧 token 未被拉黑(因 Add 失败)。 +} + +// 回归 C9a/b:InvalidateToken 闭环——登出后 token 被拉黑、ParseToken 返 ErrTokenRevoked。 +func TestInvalidateTokenRevokesToken(t *testing.T) { + setupTestConfig() + setupMiniRedis(t) + + token, _ := jwt.GenerateToken(1, "test", "admin", "admin") + + // 登出前可解析 + if _, err := jwt.ParseToken(token); err != nil { + t.Fatalf("ParseToken before invalidate: %v", err) + } + + // 登出(拉黑) + if err := jwt.InvalidateToken(token); err != nil { + t.Fatalf("InvalidateToken: %v", err) + } + + // 登出后必须被拉黑 + _, err := jwt.ParseToken(token) + if !errors.Is(err, jwt.ErrTokenRevoked) { + t.Errorf("ParseToken after invalidate err = %v, want ErrTokenRevoked", err) + } +} + +// 回归 C9a:无 Redis 时 InvalidateTokenByID 返 ErrBlacklistUnavailable(fail-closed)。 +func TestInvalidateTokenByIDNoRedis(t *testing.T) { + setupTestConfig() + // 不 setupMiniRedis → tokenBlacklist 指向无 Redis 的 Manager + jwt.SetDefaultJWTManager(jwt.NewJWTManager()) + t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) }) // 还原基线 + + err := jwt.InvalidateTokenByID("some-jti", time.Now().Add(time.Hour)) + if !errors.Is(err, jwt.ErrBlacklistUnavailable) { + t.Errorf("InvalidateTokenByID without Redis err = %v, want ErrBlacklistUnavailable", err) + } +} + +// ===== P0 回归:算法混淆 / 空密钥 / 不支持算法 ===== + +// 回归 P0:alg=none 的 token 必须被拒(jwt.WithValidMethods 固定 HMAC 族)。 +// 这是算法混淆攻击的一种典型形态——攻击者去掉签名并将 alg 置为 none。 +func TestParseTokenRejectsAlgNone(t *testing.T) { + setupTestConfig() + + claims := jwt.Claims{UserID: 1, Username: "attacker", Role: "admin", UserType: "super_admin"} + // 构造 alg=none 的未签名 token。 + noneToken := gojwt.NewWithClaims(gojwt.SigningMethodNone, claims) + signed, err := noneToken.SignedString(gojwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("构造 none token 失败: %v", err) + } + + if _, err := jwt.ParseToken(signed); err == nil { + t.Error("ParseToken 必须拒绝 alg=none 的 token(算法混淆防护)") + } +} + +// 回归 P0:空密钥时 GenerateToken/ParseToken 必须 fail-closed 返 ErrEmptySecret, +// 杜绝以零长度 HMAC 密钥签发/校验导致任意 token 通过。 +func TestEmptySecretFailsClosed(t *testing.T) { + config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}}) + t.Cleanup(setupTestConfig) // 还原基线,避免污染其它测试 + + _, err := jwt.GenerateToken(1, "u", "admin", "admin") + if !errors.Is(err, jwt.ErrEmptySecret) { + t.Errorf("空密钥 GenerateToken err = %v, want ErrEmptySecret", err) + } + + // 校验侧:任意 token 在空密钥下都不得通过。 + if _, err := jwt.ParseToken("a.b.c"); err == nil { + t.Error("空密钥 ParseToken 不得通过任何 token") + } +} + +// 回归 P0:配置不支持的算法(如 RS256)时 GenerateToken 必须返 ErrUnsupportedAlgorithm, +// 不再静默回退 HS256。 +func TestUnsupportedAlgorithmFailsClosed(t *testing.T) { + config.Set(&config.Config{JWT: config.JWTConfig{ + Secret: "test-secret-key-1234567890123456789012", + Algorithm: "RS256", + Expire: time.Hour, + }}) + t.Cleanup(setupTestConfig) + + _, err := jwt.GenerateToken(1, "u", "admin", "admin") + if !errors.Is(err, jwt.ErrUnsupportedAlgorithm) { + t.Errorf("RS256 GenerateToken err = %v, want ErrUnsupportedAlgorithm", err) + } +} + +// 回归 P0:显式支持的 HS384 仍可正常签发与校验(确认算法固定未误伤合法 HMAC 族)。 +func TestSupportedAlgorithmHS384(t *testing.T) { + config.Set(&config.Config{JWT: config.JWTConfig{ + Secret: "test-secret-key-1234567890123456789012", + Algorithm: "HS384", + Expire: time.Hour, + }}) + t.Cleanup(setupTestConfig) + setupMiniRedis(t) + + token, err := jwt.GenerateToken(1, "u", "admin", "admin") + if err != nil { + t.Fatalf("HS384 GenerateToken: %v", err) + } + if _, err := jwt.ParseToken(token); err != nil { + t.Errorf("HS384 ParseToken: %v", err) + } +} + +func TestParseTokenRejectsWrongIssuer(t *testing.T) { + config.Set(&config.Config{JWT: config.JWTConfig{ + Secret: "test-secret-key-1234567890123456789012", + Expire: time.Hour, + Issuer: "trusted-issuer", + }}) + t.Cleanup(setupTestConfig) + + claims := jwt.Claims{ + UserID: 1, + Username: "attacker", + Role: "admin", + UserType: "super_admin", + RegisteredClaims: gojwt.RegisteredClaims{ + ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)), + IssuedAt: gojwt.NewNumericDate(time.Now()), + NotBefore: gojwt.NewNumericDate(time.Now()), + Issuer: "evil-issuer", + }, + } + token := signTokenForTest(t, claims) + + if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrTokenInvalid) { + t.Errorf("ParseToken wrong issuer err = %v, want ErrTokenInvalid", err) + } + if _, err := jwt.GetClaimsFromToken(token); !errors.Is(err, jwt.ErrTokenInvalid) { + t.Errorf("GetClaimsFromToken wrong issuer err = %v, want ErrTokenInvalid", err) + } +} + +func TestRefreshTokenUsesRefreshExpire(t *testing.T) { + config.Set(&config.Config{JWT: config.JWTConfig{ + Secret: "test-secret-key-1234567890123456789012", + Expire: time.Hour, + RefreshExpire: 4 * time.Hour, + }}) + t.Cleanup(setupTestConfig) + setupMiniRedis(t) + + token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin") + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + newToken, err := jwt.RefreshToken(token) + if err != nil { + t.Fatalf("RefreshToken: %v", err) + } + claims, err := jwt.ParseToken(newToken) + if err != nil { + t.Fatalf("ParseToken refreshed token: %v", err) + } + ttl := time.Until(claims.ExpiresAt.Time) + if ttl < 3*time.Hour || ttl > 4*time.Hour+time.Minute { + t.Errorf("refreshed token ttl = %v, want about 4h", ttl) + } +} + +func TestGenerateTokenWithCustomExpiryRejectsNonPositive(t *testing.T) { + setupTestConfig() + + for _, seconds := range []int{0, -1} { + if _, err := jwt.GenerateTokenWithCustomExpiry(1, "u", "admin", "admin", seconds); !errors.Is(err, jwt.ErrInvalidExpiry) { + t.Errorf("GenerateTokenWithCustomExpiry(%d) err = %v, want ErrInvalidExpiry", seconds, err) + } + } +} + +func TestInvalidateTokenByIDRejectsEmptyJTI(t *testing.T) { + setupTestConfig() + + if err := jwt.InvalidateTokenByID("", time.Now().Add(time.Hour)); !errors.Is(err, jwt.ErrEmptyJTI) { + t.Errorf("InvalidateTokenByID empty err = %v, want ErrEmptyJTI", err) + } +} + +func TestRefreshTokenRejectsEmptyJTI(t *testing.T) { + setupTestConfig() + setupMiniRedis(t) + + token := signTokenForTest(t, jwt.Claims{ + UserID: 1, + Username: "legacy", + Role: "admin", + UserType: "admin", + RegisteredClaims: gojwt.RegisteredClaims{ + ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)), + IssuedAt: gojwt.NewNumericDate(time.Now()), + NotBefore: gojwt.NewNumericDate(time.Now()), + Issuer: "xlgo", + }, + }) + + if _, err := jwt.RefreshToken(token); !errors.Is(err, jwt.ErrEmptyJTI) { + t.Errorf("RefreshToken empty JTI err = %v, want ErrEmptyJTI", err) + } +} + +func TestInvalidateTokenRejectsEmptyJTI(t *testing.T) { + setupTestConfig() + setupMiniRedis(t) + + token := signTokenForTest(t, jwt.Claims{ + UserID: 1, + Username: "legacy", + Role: "admin", + UserType: "admin", + RegisteredClaims: gojwt.RegisteredClaims{ + ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)), + IssuedAt: gojwt.NewNumericDate(time.Now()), + NotBefore: gojwt.NewNumericDate(time.Now()), + Issuer: "xlgo", + }, + }) + + if err := jwt.InvalidateToken(token); !errors.Is(err, jwt.ErrEmptyJTI) { + t.Errorf("InvalidateToken empty JTI err = %v, want ErrEmptyJTI", err) + } +} + +func TestParseTokenEmptySecretReturnsSentinel(t *testing.T) { + setupTestConfig() + token, err := jwt.GenerateToken(1, "u", "admin", "admin") + if err != nil { + t.Fatalf("GenerateToken before empty secret: %v", err) + } + + config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}}) + t.Cleanup(setupTestConfig) + + if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrEmptySecret) { + t.Fatalf("ParseToken empty secret err = %v, want ErrEmptySecret", err) + } +} + +func TestParseTokenBlacklistPolicy(t *testing.T) { + setupTestConfig() + jwt.SetDefaultJWTManager(jwt.NewJWTManager()) + t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) }) + + token, err := jwt.GenerateToken(1, "test", "admin", "admin") + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) { + t.Fatalf("ParseToken default fail-closed should reject without Redis, err = %v, want ErrBlacklistUnavailable", err) + } + if _, err := jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen); err != nil { + t.Fatalf("ParseTokenWithBlacklistPolicy fail-open should pass without Redis: %v", err) + } +} + +func TestInvalidateTokenAllowsFutureNotBeforeToken(t *testing.T) { + setupTestConfig() + mr := setupMiniRedis(t) + + claims := jwt.Claims{ + UserID: 1, + Username: "external", + Role: "admin", + UserType: "admin", + JTI: "future-jti", + RegisteredClaims: gojwt.RegisteredClaims{ + ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)), + IssuedAt: gojwt.NewNumericDate(time.Now()), + NotBefore: gojwt.NewNumericDate(time.Now().Add(10 * time.Minute)), + Issuer: "xlgo", + ID: "future-jti", + }, + } + token := signTokenForTest(t, claims) + + if err := jwt.InvalidateToken(token); err != nil { + t.Fatalf("InvalidateToken future nbf: %v", err) + } + if !mr.Exists("jwt_bl:future-jti") { + t.Fatal("future nbf token JTI should be blacklisted") + } +} + +func signTokenForTest(t *testing.T, claims jwt.Claims) string { + t.Helper() + token, err := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims).SignedString([]byte("test-secret-key-1234567890123456789012")) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return token +} diff --git a/logger/field.go b/logger/field.go new file mode 100644 index 0000000..11446a9 --- /dev/null +++ b/logger/field.go @@ -0,0 +1,30 @@ +package logger + +import ( + "time" + + "go.uber.org/zap" +) + +// Field Zap 字段别名,用于简化日志字段定义 +var Field = struct { + String func(key string, value string) zap.Field + Int func(key string, value int) zap.Field + Int64 func(key string, value int64) zap.Field + Bool func(key string, value bool) zap.Field + Uint func(key string, value uint) zap.Field + Float64 func(key string, value float64) zap.Field + Duration func(key string, value time.Duration) zap.Field + Error func(err error) zap.Field +}{ + String: zap.String, + Int: zap.Int, + Int64: zap.Int64, + Bool: zap.Bool, + Uint: zap.Uint, + Float64: zap.Float64, + Duration: zap.Duration, + Error: func(err error) zap.Field { + return zap.Error(err) + }, +} diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..054427c --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,537 @@ +package logger + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + + "github.com/EthanCodeCraft/xlgo-core/config" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +var ( + // 内部 atomic 存储——四个 logger 的真实源(H7 修复)。 + // 所有包级函数(Info/Debug/.../APILog/DBLog/Sync)经 atomic Load 读取, + // 使请求 goroutine 不与 Init/Close 重新装配全局变量竞争。 + // 原实现用 m.mu(实例锁)保护包级全局变量,锁与被保护对象作用域错配, + // 读侧无锁裸读 → 热重载 re-Init/Close 与请求日志存在数据竞争。 + loggerPtr atomic.Pointer[zap.Logger] + sugarPtr atomic.Pointer[zap.SugaredLogger] + apiLogPtr atomic.Pointer[zap.Logger] + dbLogPtr atomic.Pointer[zap.Logger] + + // defaultLoggerPtr 是包级 facade 使用的默认 manager 原子快照。 + // 保留导出的 DefaultLogger *LogManager 作为兼容变量;SetDefaultLogManager + // 只替换这个内部指针,避免 facade 裸读/裸写导出变量。 + defaultLoggerPtr atomic.Pointer[LogManager] + globalLogGeneration uint64 + + // globalMu 串行化包级 logger/writer 的发布与关闭。 + // 不能仅依赖 LogManager.mu:多个 LogManager 实例并发 Init/Close 时, + // 实例锁无法保护 Logger/fileWriters 这类包级状态。 + globalMu sync.Mutex + + // Logger 全局通用日志实例(兼容别名)。Init 之前为 Nop,调用安全。 + // + // Deprecated: 此导出变量由 Init/Close 在 globalMu 下同步维护,但直接读它在 re-Init/Close + // 期间非并发安全(L-E)。请改用包级函数 Info/Debug/Warn/Error/...(经 atomic 读取, + // 并发安全)。保留仅为向后兼容,未来大版本可能移除。 + Logger = zap.NewNop() + + // fileWriters 持有所有 lumberjack 实例引用, + // Close() 调用时显式释放文件句柄。 + // 必要性:lumberjack 不依赖 GC 关闭文件,进程长跑或测试场景下 + // 不显式关闭会持有句柄导致 Windows 上无法删除日志目录。 + // 仅在 globalMu 下访问(Init/Close),无读侧竞争。 + fileWriters []*lumberjack.Logger +) + +func init() { + // 初始化 atomic 存储为 Nop,保证包级函数在任何时刻 Load 均非 nil。 + nop := zap.NewNop() + loggerPtr.Store(nop) + sugarPtr.Store(nop.Sugar()) + apiLogPtr.Store(nop) + dbLogPtr.Store(nop) + defaultLoggerPtr.Store(DefaultLogger) +} + +// currentLogger 返回通用 logger 的 atomic 快照(永不 nil)。 +func currentLogger() *zap.Logger { + if l := loggerPtr.Load(); l != nil { + return l + } + return zap.NewNop() // 防御:init 后不可达 +} + +// currentSugar 返回 sugared logger 的 atomic 快照(永不 nil)。 +func currentSugar() *zap.SugaredLogger { + if s := sugarPtr.Load(); s != nil { + return s + } + return zap.NewNop().Sugar() // 防御:init 后不可达 +} + +// currentAPILog 返回 API logger 的 atomic 快照(永不 nil)。 +func currentAPILog() *zap.Logger { + if l := apiLogPtr.Load(); l != nil { + return l + } + return zap.NewNop() +} + +// currentDBLog 返回 DB logger 的 atomic 快照(永不 nil)。 +func currentDBLog() *zap.Logger { + if l := dbLogPtr.Load(); l != nil { + return l + } + return zap.NewNop() +} + +// LogManager 日志管理器(#10)。照 database.Manager 模式: +// 实例化 + DefaultLogger 全局默认 + 包级 facade 代理。 +// 包级 Logger/sugar/apiLog/dbLog 由 Init 同步维护,下游 8 个包零改动。 +type LogManager struct { + mu sync.Mutex + cfg *config.Config + // level 是所有 core 共享的 AtomicLevel,支持运行期 SetLevel 热切换(M19)。 + // Init 前为 nil,SetLevel 守卫之。 + 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 { + m.mu.Lock() + defer m.mu.Unlock() + if m.level == (zap.AtomicLevel{}) { + return false + } + m.level.SetLevel(l) + return true +} + +// GetLevel 返回当前日志级别(未 Init 返回 InfoLevel)。 +func (m *LogManager) GetLevel() zapcore.Level { + m.mu.Lock() + defer m.mu.Unlock() + if m.level == (zap.AtomicLevel{}) { + return zapcore.InfoLevel + } + return m.level.Level() +} + +// SetLevel 包级 facade:运行期热切换默认日志级别(M19)。未 Init 时无操作返回 false。 +func SetLevel(l zapcore.Level) bool { return GetDefaultLogManager().SetLevel(l) } + +// GetLevel 包级 facade:返回默认日志当前级别。 +func GetLevel() zapcore.Level { return GetDefaultLogManager().GetLevel() } + +// SetDefaultLogManager 提升指定 LogManager 为全局默认(atomic.Store,并发安全)。 +func SetDefaultLogManager(m *LogManager) { + if m != nil { + defaultLoggerPtr.Store(m) + } +} + +// SnapshotDefault 返回默认 logger 全局状态快照。 +// 快照仅用于 App 初始化回滚;调用方成功提交后必须 CloseDefaultSnapshot 释放旧 writers。 +func SnapshotDefault() *DefaultSnapshot { + globalMu.Lock() + defer globalMu.Unlock() + return &DefaultSnapshot{ + manager: GetDefaultLogManager(), + logger: currentLogger(), + sugar: currentSugar(), + apiLog: currentAPILog(), + dbLog: currentDBLog(), + writers: append([]*lumberjack.Logger(nil), fileWriters...), + generation: globalLogGeneration, + } +} + +// RestoreDefaultSnapshot 恢复 SnapshotDefault 捕获的默认 logger 状态,并关闭当前新 writers。 +func RestoreDefaultSnapshot(s *DefaultSnapshot) error { + if s == nil { + return nil + } + globalMu.Lock() + newLogger := currentLogger() + newAPILog := currentAPILog() + newDBLog := currentDBLog() + newWriters := fileWriters + loggerPtr.Store(s.logger) + sugarPtr.Store(s.sugar) + apiLogPtr.Store(s.apiLog) + dbLogPtr.Store(s.dbLog) + Logger = s.logger + fileWriters = append([]*lumberjack.Logger(nil), s.writers...) + globalLogGeneration = s.generation + globalMu.Unlock() + if s.manager != nil { + SetDefaultLogManager(s.manager) + } + return errors.Join(syncLoggers(newLogger, newAPILog, newDBLog), closeFileWriters(newWriters)) +} + +// CloseDefaultSnapshot 关闭 SnapshotDefault 捕获的旧 writers;用于新 logger 已提交成功后释放旧资源。 +func CloseDefaultSnapshot(s *DefaultSnapshot) error { + if s == nil { + return nil + } + return errors.Join(syncLoggers(s.logger, s.apiLog, s.dbLog), closeFileWriters(s.writers)) +} + +// Init 初始化日志。 +// +// 三个 logger 的分流策略: +// - Logger(通用):写 console + logs/app.log +// - APILog() :写 console + logs/api.log +// - DBLog() :写 console + logs/database.log +// +// 关键修复(v1.0.3):旧实现把 apiCore 和 dbCore 都 Tee 进通用 Logger, +// 导致每条 logger.Info(...) 都会同时落到 api.log + database.log + console +// 三份,磁盘占用翻倍且分流形同虚设。新实现通用 Logger 只走独立的 app.log。 +func (m *LogManager) Init(cfg *config.Config) error { + return m.init(cfg, true) +} + +// InitPreservingPrevious 初始化并发布 logger,但暂不关闭被替换的旧 writers。 +// App.Init 使用该方法配合 SnapshotDefault,在后续 hook 失败时可恢复旧 logger。 +func (m *LogManager) InitPreservingPrevious(cfg *config.Config) error { + return m.init(cfg, false) +} + +func (m *LogManager) init(cfg *config.Config, closePrevious bool) error { + if cfg == nil { + return errors.New("logger: 配置为空") + } + if err := validateLogConfig(cfg.Log); err != nil { + return err + } + + m.mu.Lock() + defer m.mu.Unlock() + + // 确保日志目录存在(0750:owner+group 可访问,与 storage 目录权限一致) + if err := os.MkdirAll(cfg.Log.Dir, 0o750); err != nil { + return err + } + + // 日志编码器配置 + encoderConfig := zapcore.EncoderConfig{ + TimeKey: "time", + LevelKey: "level", + NameKey: "logger", + CallerKey: "caller", + FunctionKey: zapcore.OmitKey, + MessageKey: "msg", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.LowercaseLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.SecondsDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } + + // 根据运行模式设置日志级别(M19:用 AtomicLevel 支持运行期 SetLevel 热切换) + level := zap.NewAtomicLevelAt(zapcore.DebugLevel) + if cfg.IsProduction() { + level = zap.NewAtomicLevelAt(zapcore.InfoLevel) + } + + jsonEncoder := zapcore.NewJSONEncoder(encoderConfig) + consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig) + consoleCore := zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), level) + + // 通用日志独立文件,避免与 api/db 日志重复写入 + appWriter := newRotatingWriter(cfg.Log, "app.log") + apiWriter := newRotatingWriter(cfg.Log, "api.log") + dbWriter := newRotatingWriter(cfg.Log, "database.log") + + appCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(appWriter), level) + apiCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(apiWriter), level) + dbCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(dbWriter), level) + + // 三个 logger 各写自己的文件 + console,互不 Tee + newLogger := zap.New( + zapcore.NewTee(appCore, consoleCore), + zap.AddCaller(), zap.AddCallerSkip(1), + ) + newAPILog := zap.New( + zapcore.NewTee(apiCore, consoleCore), + zap.AddCaller(), zap.AddCallerSkip(1), + ) + newDBLog := zap.New( + zapcore.NewTee(dbCore, consoleCore), + zap.AddCaller(), zap.AddCallerSkip(1), + ) + + // 全部构造成功后再原子替换全局变量,避免半初始化状态。 + // 先发布新 logger,再关闭旧 writer,避免请求 goroutine 在替换窗口内 + // 通过包级函数拿到仍指向已关闭 writer 的旧 logger。 + // H7:四个 logger 经 atomic.Pointer Store,读侧(请求 goroutine)无锁原子 load; + // fileWriters 仅在 globalMu 下访问。Logger 兼容别名同步维护。 + globalMu.Lock() + oldWriters := fileWriters + globalLogGeneration++ + m.generation = globalLogGeneration + loggerPtr.Store(newLogger) + sugarPtr.Store(newLogger.Sugar()) + apiLogPtr.Store(newAPILog) + dbLogPtr.Store(newDBLog) + Logger = newLogger + fileWriters = []*lumberjack.Logger{appWriter, apiWriter, dbWriter} + globalMu.Unlock() + + m.level = level + m.cfg = cfg + + if closePrevious { + return closeFileWriters(oldWriters) + } + return nil +} + +// Init 包级 facade:初始化日志(代理到 DefaultLogger)。 +func Init(cfg *config.Config) error { + return GetDefaultLogManager().Init(cfg) +} + +func validateLogConfig(cfg config.LogConfig) error { + if strings.TrimSpace(cfg.Dir) == "" { + return errors.New("logger: 日志目录为空") + } + if cfg.MaxSize < 0 { + return errors.New("logger: MaxSize 不能为负数") + } + if cfg.MaxBackups < 0 { + return errors.New("logger: MaxBackups 不能为负数") + } + if cfg.MaxAge < 0 { + return errors.New("logger: MaxAge 不能为负数") + } + return nil +} + +// newRotatingWriter 创建带 lumberjack 滚动归档的 writer +func newRotatingWriter(cfg config.LogConfig, filename string) *lumberjack.Logger { + return &lumberjack.Logger{ + Filename: filepath.Join(cfg.Dir, filename), + MaxSize: cfg.MaxSize, + MaxBackups: cfg.MaxBackups, + MaxAge: cfg.MaxAge, + Compress: cfg.Compress, + } +} + +// closeFileWriters 关闭传入的 lumberjack writer,并聚合关闭错误。 +func closeFileWriters(writers []*lumberjack.Logger) error { + var errs []error + for _, w := range writers { + if w != nil { + if err := w.Close(); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + +func syncLoggers(loggers ...*zap.Logger) error { + var errs []error + for _, l := range loggers { + if l == nil { + continue + } + if err := l.Sync(); err != nil && !isHarmlessSyncError(err) { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// Sync 同步全部 logger 缓冲到底层 writer。 +// +// 注意:在 Windows / 部分 *nix 平台上对 stdout/stderr 调用 Sync 会返回 +// "invalid argument" / "inappropriate ioctl for device",属于 zap 已知行为, +// 这里把这类错误识别并忽略,只返回真实的写入失败。 +func (m *LogManager) Sync() error { + return syncLoggers(currentLogger(), currentAPILog(), currentDBLog()) +} + +// Sync 包级 facade:同步全部 logger 缓冲(代理到 DefaultLogger)。 +func Sync() error { + return GetDefaultLogManager().Sync() +} + +// Close 关闭日志文件句柄,重置全局 logger 为 Nop。 +// 通常由 App.Shutdown 在 Sync 之后调用;测试场景需要清理临时目录时也应调用。 +// +// 调用后再次写日志不会 panic(fall back to nop logger),但不会写入文件。 +// 如需重新启用,请再次调用 Init。 +func (m *LogManager) Close() error { + m.mu.Lock() + globalMu.Lock() + if m.generation == 0 || m.generation != globalLogGeneration { + m.level = zap.AtomicLevel{} + m.cfg = nil + m.generation = 0 + globalMu.Unlock() + m.mu.Unlock() + return nil + } + oldLogger := currentLogger() + oldAPILog := currentAPILog() + oldDBLog := currentDBLog() + oldWriters := fileWriters + + // H7:重置为 Nop 经 atomic Store,与读侧一致;Logger 兼容别名同步。 + nop := zap.NewNop() + loggerPtr.Store(nop) + sugarPtr.Store(nop.Sugar()) + apiLogPtr.Store(nop) + dbLogPtr.Store(nop) + Logger = nop + fileWriters = nil + globalMu.Unlock() + + m.level = zap.AtomicLevel{} + m.cfg = nil + m.generation = 0 + m.mu.Unlock() + + return errors.Join( + syncLoggers(oldLogger, oldAPILog, oldDBLog), + closeFileWriters(oldWriters), + ) +} + +// Close 包级 facade:关闭日志文件句柄,重置为 Nop(代理到 DefaultLogger)。 +func Close() error { + return GetDefaultLogManager().Close() +} + +// isHarmlessSyncError 识别 stdout/stderr Sync 在不同平台返回的预期错误。 +// 这些错误来自 console core,对真实 writer 无影响,可安全忽略。 +func isHarmlessSyncError(err error) bool { + if err == nil { + return true + } + msg := err.Error() + for _, sub := range []string{ + "invalid argument", // Linux stdout + "inappropriate ioctl for device", // macOS stdout + "bad file descriptor", // Windows stdout + } { + if strings.Contains(msg, sub) { + return true + } + } + return false +} + +// Debug 调试日志 +func Debug(msg string, fields ...zap.Field) { + currentLogger().Debug(msg, fields...) +} + +// Info 信息日志 +func Info(msg string, fields ...zap.Field) { + currentLogger().Info(msg, fields...) +} + +// Warn 警告日志 +func Warn(msg string, fields ...zap.Field) { + currentLogger().Warn(msg, fields...) +} + +// Error 错误日志 +func Error(msg string, fields ...zap.Field) { + currentLogger().Error(msg, fields...) +} + +// Fatal 致命错误日志(仅供应用层使用,框架内部禁止调用) +func Fatal(msg string, fields ...zap.Field) { + currentLogger().Fatal(msg, fields...) +} + +// Debugf 格式化调试日志 +func Debugf(template string, args ...any) { + currentSugar().Debugf(template, args...) +} + +// Infof 格式化信息日志 +func Infof(template string, args ...any) { + currentSugar().Infof(template, args...) +} + +// Warnf 格式化警告日志 +func Warnf(template string, args ...any) { + currentSugar().Warnf(template, args...) +} + +// Errorf 格式化错误日志 +func Errorf(template string, args ...any) { + currentSugar().Errorf(template, args...) +} + +// Fatalf 格式化致命错误日志(仅供应用层使用,框架内部禁止调用) +func Fatalf(template string, args ...any) { + currentSugar().Fatalf(template, args...) +} + +// APILog 返回 API 专用日志器(写 logs/api.log + console) +func APILog() *zap.Logger { + return currentAPILog() +} + +// DBLog 返回数据库专用日志器(写 logs/database.log + console) +func DBLog() *zap.Logger { + return currentDBLog() +} diff --git a/logger/logger_h7_internal_test.go b/logger/logger_h7_internal_test.go new file mode 100644 index 0000000..2f99c03 --- /dev/null +++ b/logger/logger_h7_internal_test.go @@ -0,0 +1,482 @@ +package logger + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// tmpCfg 构造一个指向临时目录的日志配置,用于 Init。 +func tmpCfg(dir string) *config.Config { + return &config.Config{ + Server: config.ServerConfig{Mode: "production"}, + Log: config.LogConfig{ + Dir: dir, + MaxSize: 1, + MaxBackups: 1, + MaxAge: 1, + Compress: false, + }, + } +} + +// TestH7ConcurrentInitCloseAndRead 验证请求 goroutine 的包级日志读路径 +// 与 Init/Close 重新装配全局 logger 无数据竞争(-race)。 +// +// H7 根因:原实现 Info/Error/APILog/DBLog/Sync 裸读包级 Logger/sugar/apiLog/dbLog, +// 而 Init/Close 在 m.mu 下写这些变量——实例锁保护全局变量、读侧无锁 → re-Init/Close +// 与请求日志竞争。修复后读路径经 atomic.Pointer Load。 +// +// 红/绿验证:将 currentLogger/currentSugar/currentAPILog/currentDBLog 临时改为 +// 裸读 Logger/sugar/apiLog/dbLog(或等价地恢复包级裸读),-race 必复现 DATA RACE; +// 恢复 atomic 后绿。 +func TestH7ConcurrentInitCloseAndRead(t *testing.T) { + dir := t.TempDir() + cfg := tmpCfg(dir) + + // 确保默认 LogManager 起点干净。 + _ = GetDefaultLogManager().Close() + t.Cleanup(func() { _ = GetDefaultLogManager().Close() }) + + stop := make(chan struct{}) + var wg sync.WaitGroup + + // 写者:循环 Init/Close(重新装配全局 logger)。 + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _ = GetDefaultLogManager().Init(cfg) + _ = GetDefaultLogManager().Close() + } + }() + + // 读者:并发调用读路径(包级函数 + atomic 快照读取)。 + // 仅读取 logger 指针——这正是被竞态的访问;不调用 .Info/.Errorf 等写方法, + // 避免对已被 re-Init 关闭的旧 writer 触发 lumberjack 重新打开同一文件, + // 导致测试 TempDir 清理时句柄仍占用(框架不支持 re-Init 与在途写入并发, + // 该约束非 H7 范围)。 + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + // 这些调用内部经 atomic Load 读取包级 logger 指针。 + _ = currentLogger() + _ = currentSugar() + _ = currentAPILog() + _ = currentDBLog() + _ = APILog() + _ = DBLog() + } + }() + } + + // 运行窗口足以让 race detector 采到任何竞争。 + time.Sleep(120 * time.Millisecond) + close(stop) + wg.Wait() + + // 收尾到 Nop,避免后续测试持有临时目录句柄。 + _ = GetDefaultLogManager().Close() +} + +// TestH7CurrentLoggerReflectsInitAndClose 验证 atomic 快照随 Init/Close 正确切换: +// Init 后 currentLogger 写入文件;Close 后回到 Nop(不写文件)。 +func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) { + dir := t.TempDir() + cfg := tmpCfg(dir) + + _ = GetDefaultLogManager().Close() + t.Cleanup(func() { _ = GetDefaultLogManager().Close() }) + + // Init 前:Nop,调用安全且不写文件。 + before := currentLogger() + if before == nil { + t.Fatal("currentLogger nil before Init") + } + + if err := GetDefaultLogManager().Init(cfg); err != nil { + t.Fatalf("Init: %v", err) + } + + // Init 后:currentLogger 与 Logger 兼容别名一致,且为非 Nop 实例。 + after := currentLogger() + if after == nil { + t.Fatal("currentLogger nil after Init") + } + if after != Logger { + t.Error("after Init, currentLogger() != Logger (compat alias out of sync)") + } + + // 写一条日志并 flush,验证落到文件(说明拿到的是真实 logger,非 Nop)。 + const mark = "H7_INIT_REFLECT_xyz" + after.Info(mark) + _ = GetDefaultLogManager().Sync() + + data, err := readFile(t, filepath.Join(dir, "app.log")) + if err != nil { + t.Fatalf("read app.log: %v", err) + } + if !strings.Contains(data, mark) { + t.Errorf("app.log missing mark %q after Init (got Nop?)", mark) + } + + // Close 后:currentLogger 回到 Nop(与 Init 前实例不同,但同为 Nop 行为)。 + if err := GetDefaultLogManager().Close(); err != nil { + t.Fatalf("Close: %v", err) + } + closed := currentLogger() + if closed == nil { + t.Fatal("currentLogger nil after Close") + } + // Nop logger 写不报错也不落盘;验证 Close 后再写不新增 mark。 + closed.Info("H7_SHOULD_NOT_APPEAR_xyz") + _ = GetDefaultLogManager().Sync() + data2, _ := readFile(t, filepath.Join(dir, "app.log")) + if strings.Contains(data2, "H7_SHOULD_NOT_APPEAR_xyz") { + t.Error("wrote to file after Close (expected Nop)") + } +} + +// TestH7AtomicPointersNonNil 验证 init 后四个 atomic 快照永不 nil, +// 且 APILog/DBLog 返回与内部 atomic 一致的实例。 +func TestH7AtomicPointersNonNil(t *testing.T) { + if loggerPtr.Load() == nil { + t.Error("loggerPtr nil after init") + } + if sugarPtr.Load() == nil { + t.Error("sugarPtr nil after init") + } + if apiLogPtr.Load() == nil { + t.Error("apiLogPtr nil after init") + } + if dbLogPtr.Load() == nil { + t.Error("dbLogPtr nil after init") + } + if APILog() != apiLogPtr.Load() { + t.Error("APILog() != apiLogPtr") + } + if DBLog() != dbLogPtr.Load() { + t.Error("DBLog() != dbLogPtr") + } + if currentLogger() != loggerPtr.Load() { + t.Error("currentLogger() != loggerPtr") + } +} + +// TestH7DefaultLoggerCompatibility 锁定 M3 的兼容边界: +// DefaultLogger 仍保持 *LogManager 类型,旧代码可继续直接调用其方法; +// 包级 facade 的默认 manager 替换则必须走 SetDefaultLogManager 的 atomic 快照。 +func TestH7DefaultLoggerCompatibility(t *testing.T) { + var _ *LogManager = DefaultLogger + + old := GetDefaultLogManager() + t.Cleanup(func() { SetDefaultLogManager(old) }) + + next := NewLogManager() + SetDefaultLogManager(next) + if got := GetDefaultLogManager(); got != next { + t.Fatalf("GetDefaultLogManager() = %p, want %p", got, next) + } +} + +// TestM3StaleManagerCloseDoesNotCloseCurrentLogger 回归:旧 manager 的 Close +// 不得关闭另一个 manager 后续发布的全局 logger/writer。 +func TestM3StaleManagerCloseDoesNotCloseCurrentLogger(t *testing.T) { + dir1 := t.TempDir() + dir2 := t.TempDir() + m1 := NewLogManager() + m2 := NewLogManager() + t.Cleanup(func() { + _ = m1.Close() + _ = m2.Close() + SetDefaultLogManager(NewLogManager()) + }) + + if err := m1.Init(tmpCfg(dir1)); err != nil { + t.Fatalf("m1.Init: %v", err) + } + if err := m2.Init(tmpCfg(dir2)); err != nil { + t.Fatalf("m2.Init: %v", err) + } + if err := m1.Close(); err != nil { + t.Fatalf("stale m1.Close: %v", err) + } + + const mark = "M3_CURRENT_LOGGER_SURVIVES_STALE_CLOSE" + currentLogger().Info(mark) + _ = syncLoggers(currentLogger()) + data, err := readFile(t, filepath.Join(dir2, "app.log")) + if err != nil { + t.Fatalf("read current app.log: %v", err) + } + if !strings.Contains(data, mark) { + t.Fatal("stale manager Close closed the current logger") + } +} + +// TestH7DurationFieldFix 验证 H7b:Field.Duration 签名改为 +// func(key string, value time.Duration) zap.Field,且 key 不再被丢弃。 +// +// 旧实现 func(key string, value interface{}) 在 case zap.Field 分支 return v +// 丢弃 key,签名与实现矛盾。修复后直接委托 zap.Duration(key, value)。 +func TestH7DurationFieldFix(t *testing.T) { + f := Field.Duration("elapsed", 5*time.Second) + if f.Key != "elapsed" { + t.Errorf("Duration key lost: got %q, want %q (H7b regression)", f.Key, "elapsed") + } + // zap.Duration 用 zapcore.DurationType 编码,Integer 字段承载纳秒。 + if f.Integer != int64(5*time.Second) { + t.Errorf("Duration value mismatch: got %d, want %d", f.Integer, int64(5*time.Second)) + } + + // 零值与其他字段不受影响。 + f2 := Field.Duration("d0", 0) + if f2.Key != "d0" { + t.Errorf("Duration zero key lost: %q", f2.Key) + } +} + +// TestH7DurationFieldSignature 编译期锁定 Field.Duration 的签名为 +// func(string, time.Duration) zap.Field(H7b 修复的核心:类型安全签名)。 +// +// 旧签名为 func(string, interface{}) zap.Field,其 case zap.Field 分支 return v +// 丢弃传入的 key。若回退旧签名,下方赋值将因参数类型不匹配(interface{} ≠ time.Duration) +// 编译失败——即"修复前红、修复后绿"由编译器强制保证。 +func TestH7DurationFieldSignature(t *testing.T) { + var _ func(string, time.Duration) zap.Field = Field.Duration +} + +// readFile 读取文件内容,文件不存在时返回空串(不报错)。 +func readFile(t *testing.T, path string) (string, error) { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return string(b), nil +} + +// TestSetLevelHotSwap_M19:Init 后可运行期热切换日志级别(M19)。 +func TestSetLevelHotSwap_M19(t *testing.T) { + dir := t.TempDir() + cfg := tmpCfg(dir) + _ = GetDefaultLogManager().Close() + t.Cleanup(func() { _ = GetDefaultLogManager().Close() }) + + if err := GetDefaultLogManager().Init(cfg); err != nil { + t.Fatalf("Init: %v", err) + } + + // Init 后(tmpCfg 为 production 模式,默认 InfoLevel)可热切换到 ErrorLevel。 + before := GetDefaultLogManager().GetLevel() + if before != zapcore.InfoLevel { + t.Errorf("default level = %v, want InfoLevel (production)", before) + } + if ok := GetDefaultLogManager().SetLevel(zapcore.ErrorLevel); !ok { + t.Fatal("SetLevel after Init should return true") + } + if got := GetDefaultLogManager().GetLevel(); got != zapcore.ErrorLevel { + t.Errorf("after SetLevel, level = %v, want ErrorLevel", got) + } + + // 包级 facade 同步。 + SetLevel(zapcore.WarnLevel) + if got := GetLevel(); got != zapcore.WarnLevel { + t.Errorf("package GetLevel = %v, want WarnLevel", got) + } +} + +// TestM3ConcurrentInitSetLevelAndClose 验证 Init/SetLevel/GetLevel/Close +// 共享同一个 manager 临界区,不再出现 m.level 锁外写与锁内读写竞争(-race)。 +func TestM3ConcurrentInitSetLevelAndClose(t *testing.T) { + dir := t.TempDir() + cfg := tmpCfg(dir) + m := NewLogManager() + SetDefaultLogManager(m) + t.Cleanup(func() { + _ = m.Close() + SetDefaultLogManager(NewLogManager()) + }) + + stop := make(chan struct{}) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _ = m.Init(cfg) + _ = m.Close() + } + }() + + for i := 0; i < 4; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + levels := []zapcore.Level{ + zapcore.DebugLevel, + zapcore.InfoLevel, + zapcore.WarnLevel, + zapcore.ErrorLevel, + } + for { + select { + case <-stop: + return + default: + } + _ = m.SetLevel(levels[i%len(levels)]) + _ = m.GetLevel() + } + }(i) + } + + time.Sleep(120 * time.Millisecond) + close(stop) + wg.Wait() +} + +// TestM3ConcurrentSetDefaultAndFacades 验证默认 LogManager 经 atomic.Pointer +// 读写,SetDefaultLogManager 与包级 facade 并发时无裸全局指针竞态(-race)。 +func TestM3ConcurrentSetDefaultAndFacades(t *testing.T) { + t.Cleanup(func() { + _ = Close() + SetDefaultLogManager(NewLogManager()) + }) + + stop := make(chan struct{}) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + SetDefaultLogManager(NewLogManager()) + } + }() + + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _ = GetLevel() + _ = SetLevel(zapcore.WarnLevel) + _ = Sync() + _ = APILog() + _ = DBLog() + } + }() + } + + time.Sleep(120 * time.Millisecond) + close(stop) + wg.Wait() +} + +// TestM3ConcurrentManagersInitClose 验证不同 LogManager 实例并发 Init/Close 时, +// 包级 Logger/fileWriters 的发布与关闭由 globalMu 串行化,不再依赖实例锁保护包级状态。 +func TestM3ConcurrentManagersInitClose(t *testing.T) { + dir := t.TempDir() + cfg := tmpCfg(dir) + + stop := make(chan struct{}) + var wg sync.WaitGroup + managers := make([]*LogManager, 3) + + for i := 0; i < 3; i++ { + managers[i] = NewLogManager() + wg.Add(1) + go func(m *LogManager) { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + SetDefaultLogManager(m) + _ = m.Init(cfg) + _ = m.Close() + } + }(managers[i]) + } + + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _ = currentLogger() + _ = currentSugar() + _ = APILog() + _ = DBLog() + } + }() + + time.Sleep(120 * time.Millisecond) + close(stop) + wg.Wait() + for _, m := range managers { + _ = m.Close() + } + _ = Close() + SetDefaultLogManager(NewLogManager()) +} + +// TestM3InitRejectsInvalidLogConfig 验证 logger.Init 对明显非法日志配置失败, +// 避免空目录意外把 app.log 写到进程工作目录。 +func TestM3InitRejectsInvalidLogConfig(t *testing.T) { + if err := NewLogManager().Init(&config.Config{}); err == nil { + t.Fatal("Init with empty log dir should fail") + } + + cfg := tmpCfg(t.TempDir()) + cfg.Log.MaxAge = -1 + if err := NewLogManager().Init(cfg); err == nil { + t.Fatal("Init with negative MaxAge should fail") + } +} diff --git a/logger/logger_test.go b/logger/logger_test.go new file mode 100644 index 0000000..ff5cf71 --- /dev/null +++ b/logger/logger_test.go @@ -0,0 +1,181 @@ +package logger_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/logger" +) + +// 注意:logger 需要先初始化才能调用 Info/Debug/Warn/Error +// 这里只测试 Field 函数,不测试日志输出 + +func TestAPILog(t *testing.T) { + _ = logger.APILog() + // 未初始化时为 nop logger,调用安全 +} + +func TestDBLog(t *testing.T) { + _ = logger.DBLog() + // 未初始化时为 nop logger,调用安全 +} + +func TestFieldString(t *testing.T) { + field := logger.Field.String("key", "value") + if field.Key != "key" { + t.Error("Field.String key failed") + } +} + +func TestFieldInt(t *testing.T) { + field := logger.Field.Int("count", 123) + if field.Key != "count" { + t.Error("Field.Int key failed") + } +} + +func TestFieldInt64(t *testing.T) { + field := logger.Field.Int64("id", 1234567890) + if field.Key != "id" { + t.Error("Field.Int64 key failed") + } +} + +func TestFieldBool(t *testing.T) { + field := logger.Field.Bool("enabled", true) + if field.Key != "enabled" { + t.Error("Field.Bool key failed") + } +} + +func TestFieldUint(t *testing.T) { + field := logger.Field.Uint("uint_val", 100) + if field.Key != "uint_val" { + t.Error("Field.Uint key failed") + } +} + +func TestFieldFloat64(t *testing.T) { + field := logger.Field.Float64("price", 99.99) + if field.Key != "price" { + t.Error("Field.Float64 key failed") + } +} + +func TestFieldError(t *testing.T) { + _ = logger.Field.Error(nil) + // zap.Field 是结构体,仅验证函数调用正常 +} + +// initWithTempDir 使用临时目录初始化 logger,返回日志目录与读取文件内容的辅助函数。 +// 验证修复 #3 之后,三个 logger 各自只写自己的文件,互不串扰。 +// +// 测试退出时通过 t.Cleanup 调用 logger.Close 释放文件句柄, +// 避免 Windows 上 t.TempDir 因句柄占用而清理失败。 +func initWithTempDir(t *testing.T) (dir string, read func(name string) string) { + t.Helper() + dir = t.TempDir() + cfg := &config.Config{ + Server: config.ServerConfig{Mode: "production"}, // 走 InfoLevel,避免 Debug 噪音 + Log: config.LogConfig{ + Dir: dir, + MaxSize: 10, + MaxBackups: 1, + MaxAge: 1, + Compress: false, + }, + } + if err := logger.Init(cfg); err != nil { + t.Fatalf("logger.Init failed: %v", err) + } + t.Cleanup(func() { _ = logger.Close() }) + + read = func(name string) string { + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "" + } + t.Fatalf("read %s: %v", path, err) + } + return string(data) + } + return dir, read +} + +// TestLoggerNoCrossWriting 验证 Logger / APILog / DBLog 不会重复写入彼此的日志文件。 +// +// 这是 v1.0.3 修复的核心:旧实现 Logger = Tee(apiCore, dbCore, consoleCore), +// 导致 logger.Info(...) 同时写到 api.log + database.log + console 三份。 +func TestLoggerNoCrossWriting(t *testing.T) { + _, read := initWithTempDir(t) + + // 三个标记字符串足够独特,分别由三个 logger 写入 + const ( + appMark = "MARKER_APP_LOG_ONLY_xyz" + apiMark = "MARKER_API_LOG_ONLY_xyz" + dbMark = "MARKER_DB_LOG_ONLY_xyz" + ) + + logger.Info(appMark) + logger.APILog().Info(apiMark) + logger.DBLog().Info(dbMark) + + if err := logger.Sync(); err != nil { + t.Fatalf("Sync: %v", err) + } + + app := read("app.log") + api := read("api.log") + db := read("database.log") + + // 各自的日志文件必须包含自己的标记 + if !strings.Contains(app, appMark) { + t.Errorf("app.log missing %q, got: %s", appMark, app) + } + if !strings.Contains(api, apiMark) { + t.Errorf("api.log missing %q, got: %s", apiMark, api) + } + if !strings.Contains(db, dbMark) { + t.Errorf("database.log missing %q, got: %s", dbMark, db) + } + + // 关键:禁止串写 + if strings.Contains(api, appMark) { + t.Errorf("api.log should NOT contain %q (cross-writing bug regressed)", appMark) + } + if strings.Contains(db, appMark) { + t.Errorf("database.log should NOT contain %q (cross-writing bug regressed)", appMark) + } + if strings.Contains(app, apiMark) { + t.Errorf("app.log should NOT contain %q", apiMark) + } + if strings.Contains(db, apiMark) { + t.Errorf("database.log should NOT contain %q", apiMark) + } + if strings.Contains(app, dbMark) { + t.Errorf("app.log should NOT contain %q", dbMark) + } + if strings.Contains(api, dbMark) { + t.Errorf("api.log should NOT contain %q", dbMark) + } +} + +// TestLoggerInitNilConfig 验证 Init 对 nil 配置返回错误,不再 panic。 +func TestLoggerInitNilConfig(t *testing.T) { + if err := logger.Init(nil); err == nil { + t.Error("Init(nil) should return error, got nil") + } +} + +// TestLoggerSyncBeforeInit 验证未初始化时 Sync 不 panic(globals 仍为 Nop)。 +func TestLoggerSyncBeforeInit(t *testing.T) { + // Nop logger 的 Sync 永远返回 nil + if err := logger.Sync(); err != nil { + t.Errorf("Sync on nop logger should be nil, got %v", err) + } +} \ No newline at end of file diff --git a/logs/app.log b/logs/app.log new file mode 100644 index 0000000..f51f7ba --- /dev/null +++ b/logs/app.log @@ -0,0 +1,3 @@ +{"level":"warn","time":"2026-07-02T23:25:17.643+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 1/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."} +{"level":"warn","time":"2026-07-02T23:25:18.648+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 2/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."} +{"level":"warn","time":"2026-07-02T23:25:20.651+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 3/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..5e3d41e --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,258 @@ +package middleware + +import ( + "strings" + + "github.com/EthanCodeCraft/xlgo-core/jwt" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" +) + +const ( + // ContextKeyUserID 用户ID上下文键 + ContextKeyUserID = "user_id" + // ContextKeyUsername 用户名上下文键 + ContextKeyUsername = "username" + // ContextKeyRole 角色上下文键 + ContextKeyRole = "role" + // ContextKeyUserType 用户类型上下文键 + ContextKeyUserType = "user_type" + + // DefaultUserTypeSuperAdmin 默认超级管理员用户类型 + DefaultUserTypeSuperAdmin = "super_admin" + // DefaultUserTypeAdmin 默认管理员用户类型 + DefaultUserTypeAdmin = "admin" + // DefaultUserTypeStaff 默认员工用户类型 + DefaultUserTypeStaff = "staff" +) + +// AuthUser 当前认证用户 +type AuthUser struct { + UserID uint + Username string + Role string + UserType string +} + +// AuthChecker 自定义权限检查函数 +type AuthChecker func(user AuthUser, c *gin.Context) bool + +// AuthRequired JWT 认证中间件 +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + response.Unauthorized(c, "请先登录") + c.Abort() + return + } + + // 解析 Bearer Token + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + response.Unauthorized(c, "认证格式错误") + c.Abort() + return + } + + tokenString := parts[1] + claims, err := jwt.ParseToken(tokenString) + if err != nil { + response.Unauthorized(c, "登录已过期,请重新登录") + c.Abort() + return + } + + // 将用户信息存入上下文 + c.Set(ContextKeyUserID, claims.UserID) + c.Set(ContextKeyUsername, claims.Username) + c.Set(ContextKeyRole, claims.Role) + c.Set(ContextKeyUserType, claims.UserType) + + c.Next() + } +} + +// RequireAuth 自定义权限中间件 +func RequireAuth(checker AuthChecker, messages ...string) gin.HandlerFunc { + return func(c *gin.Context) { + user, ok := GetAuthUser(c) + if !ok { + abortAuthContext(c) + return + } + + if checker == nil || !checker(user, c) { + abortForbidden(c, messages...) + return + } + + c.Next() + } +} + +// RequireUserTypes 用户类型权限中间件 +func RequireUserTypes(userTypes ...string) gin.HandlerFunc { + allowed := make(map[string]struct{}, len(userTypes)) + for _, userType := range userTypes { + allowed[userType] = struct{}{} + } + + return RequireAuth(func(user AuthUser, c *gin.Context) bool { + _, ok := allowed[user.UserType] + return ok + }) +} + +// RequireRoles 角色权限中间件 +func RequireRoles(roles ...string) gin.HandlerFunc { + allowed := make(map[string]struct{}, len(roles)) + for _, role := range roles { + allowed[role] = struct{}{} + } + + return RequireAuth(func(user AuthUser, c *gin.Context) bool { + _, ok := allowed[user.Role] + return ok + }) +} + +// AdminRequired 管理员权限中间件 +func AdminRequired() gin.HandlerFunc { + return RequireUserTypes(DefaultUserTypeSuperAdmin, DefaultUserTypeAdmin) +} + +// SuperAdminRequired 超级管理员权限中间件 +func SuperAdminRequired() gin.HandlerFunc { + return RequireUserTypes(DefaultUserTypeSuperAdmin) +} + +// StaffRequired 员工权限中间件 +func StaffRequired() gin.HandlerFunc { + return RequireUserTypes(DefaultUserTypeStaff) +} + +// AnyUserRequired 任意用户权限中间件(默认允许 admin/super_admin/staff) +func AnyUserRequired() gin.HandlerFunc { + return RequireUserTypes(DefaultUserTypeSuperAdmin, DefaultUserTypeAdmin, DefaultUserTypeStaff) +} + +func abortAuthContext(c *gin.Context) { + if _, exists := c.Get(ContextKeyUserID); !exists { + response.Unauthorized(c, "请先登录") + } else { + response.Unauthorized(c, "用户信息异常") + } + c.Abort() +} + +func abortForbidden(c *gin.Context, messages ...string) { + if len(messages) > 0 && messages[0] != "" { + response.Fail(c, messages[0]) + } else { + response.FailWithError(c, response.ErrForbidden) + } + c.Abort() +} + +// GetAuthUser 从上下文获取认证用户 +func GetAuthUser(c *gin.Context) (AuthUser, bool) { + userID, exists := c.Get(ContextKeyUserID) + if !exists { + return AuthUser{}, false + } + id, ok := userID.(uint) + if !ok { + return AuthUser{}, false + } + + username, exists := c.Get(ContextKeyUsername) + if !exists { + return AuthUser{}, false + } + name, ok := username.(string) + if !ok { + return AuthUser{}, false + } + + role, exists := c.Get(ContextKeyRole) + if !exists { + return AuthUser{}, false + } + r, ok := role.(string) + if !ok { + return AuthUser{}, false + } + + userType, exists := c.Get(ContextKeyUserType) + if !exists { + return AuthUser{}, false + } + ut, ok := userType.(string) + if !ok { + return AuthUser{}, false + } + + return AuthUser{ + UserID: id, + Username: name, + Role: r, + UserType: ut, + }, true +} + +// GetUserID 从上下文获取用户ID +func GetUserID(c *gin.Context) uint { + userID, exists := c.Get(ContextKeyUserID) + if !exists { + return 0 + } + // 安全的类型断言 + id, ok := userID.(uint) + if !ok { + return 0 + } + return id +} + +// GetUsername 从上下文获取用户名 +func GetUsername(c *gin.Context) string { + username, exists := c.Get(ContextKeyUsername) + if !exists { + return "" + } + // 安全的类型断言 + name, ok := username.(string) + if !ok { + return "" + } + return name +} + +// GetRole 从上下文获取角色 +func GetRole(c *gin.Context) string { + role, exists := c.Get(ContextKeyRole) + if !exists { + return "" + } + // 安全的类型断言 + r, ok := role.(string) + if !ok { + return "" + } + return r +} + +// GetUserType 从上下文获取用户类型 +func GetUserType(c *gin.Context) string { + userType, exists := c.Get(ContextKeyUserType) + if !exists { + return "" + } + // 安全的类型断言 + ut, ok := userType.(string) + if !ok { + return "" + } + return ut +} diff --git a/middleware/auth_test.go b/middleware/auth_test.go new file mode 100644 index 0000000..4b4ec59 --- /dev/null +++ b/middleware/auth_test.go @@ -0,0 +1,198 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/jwt" + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/gin-gonic/gin" +) + +func performAuthMiddlewareRequest(m gin.HandlerFunc, setup func(*gin.Context)) *httptest.ResponseRecorder { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + if setup != nil { + setup(c) + } + }) + r.Use(m) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + return w +} + +func setAuthUser(userType, role string) func(*gin.Context) { + return func(c *gin.Context) { + c.Set(middleware.ContextKeyUserID, uint(1)) + c.Set(middleware.ContextKeyUsername, "tester") + c.Set(middleware.ContextKeyRole, role) + c.Set(middleware.ContextKeyUserType, userType) + } +} + +func TestAuthRequiredAcceptsCaseInsensitiveBearer_M8(t *testing.T) { + setupMiddlewareMiniRedis(t) + if err := config.Set(&config.Config{ + JWT: config.JWTConfig{ + Secret: "test-secret-key-1234567890123456789012", + Expire: time.Hour, + }, + }); err != nil { + t.Fatalf("set config: %v", err) + } + token, err := jwt.GenerateToken(1, "tester", "owner", "tenant_admin") + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(middleware.AuthRequired()) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "bearer "+token) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d, body %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"ok":true`) { + t.Fatalf("expected request to pass, got body %s", w.Body.String()) + } +} + +func TestRequireUserTypes(t *testing.T) { + w := performAuthMiddlewareRequest(middleware.RequireUserTypes("tenant_admin"), setAuthUser("tenant_admin", "owner")) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), `"ok":true`) { + t.Fatalf("expected request to pass, got body %s", w.Body.String()) + } +} + +func TestRequireUserTypesRejectsOtherTypes(t *testing.T) { + w := performAuthMiddlewareRequest(middleware.RequireUserTypes("tenant_admin"), setAuthUser("staff", "owner")) + if w.Code != http.StatusOK { + t.Fatalf("expected business error over status 200, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), `"code":403`) { + t.Fatalf("expected forbidden response, got body %s", w.Body.String()) + } +} + +func TestRequireRoles(t *testing.T) { + w := performAuthMiddlewareRequest(middleware.RequireRoles("owner"), setAuthUser("tenant_admin", "owner")) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), `"ok":true`) { + t.Fatalf("expected request to pass, got body %s", w.Body.String()) + } +} + +func TestRequireAuthCustomChecker(t *testing.T) { + checker := func(user middleware.AuthUser, c *gin.Context) bool { + return user.UserID == 1 && user.UserType == "merchant" && user.Role == "owner" + } + + w := performAuthMiddlewareRequest(middleware.RequireAuth(checker), setAuthUser("merchant", "owner")) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), `"ok":true`) { + t.Fatalf("expected request to pass, got body %s", w.Body.String()) + } + + w = performAuthMiddlewareRequest(middleware.RequireAuth(checker, "需要商户所有者权限"), setAuthUser("merchant", "staff")) + if !strings.Contains(w.Body.String(), "需要商户所有者权限") { + t.Fatalf("expected custom forbidden message, got body %s", w.Body.String()) + } +} + +func TestDefaultRoleShortcuts(t *testing.T) { + tests := []struct { + name string + mw gin.HandlerFunc + userType string + wantPass bool + }{ + {"admin allows super admin", middleware.AdminRequired(), middleware.DefaultUserTypeSuperAdmin, true}, + {"admin allows admin", middleware.AdminRequired(), middleware.DefaultUserTypeAdmin, true}, + {"admin rejects staff", middleware.AdminRequired(), middleware.DefaultUserTypeStaff, false}, + {"super admin allows super admin", middleware.SuperAdminRequired(), middleware.DefaultUserTypeSuperAdmin, true}, + {"super admin rejects admin", middleware.SuperAdminRequired(), middleware.DefaultUserTypeAdmin, false}, + {"staff allows staff", middleware.StaffRequired(), middleware.DefaultUserTypeStaff, true}, + {"any allows staff", middleware.AnyUserRequired(), middleware.DefaultUserTypeStaff, true}, + {"any rejects external", middleware.AnyUserRequired(), "external", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := performAuthMiddlewareRequest(tt.mw, setAuthUser(tt.userType, "role")) + body := w.Body.String() + if tt.wantPass && !strings.Contains(body, `"ok":true`) { + t.Fatalf("expected pass, got body %s", body) + } + if !tt.wantPass && !strings.Contains(body, `"code":403`) { + t.Fatalf("expected forbidden, got body %s", body) + } + }) + } +} + +func TestRequireAuthRejectsMissingContext(t *testing.T) { + w := performAuthMiddlewareRequest(middleware.RequireUserTypes("admin"), nil) + if !strings.Contains(w.Body.String(), `"code":401`) || !strings.Contains(w.Body.String(), "请先登录") { + t.Fatalf("expected unauthorized login response, got body %s", w.Body.String()) + } +} + +func TestRequireAuthRejectsMalformedContext(t *testing.T) { + w := performAuthMiddlewareRequest(middleware.RequireUserTypes("admin"), func(c *gin.Context) { + c.Set(middleware.ContextKeyUserID, uint(1)) + c.Set(middleware.ContextKeyUsername, "tester") + c.Set(middleware.ContextKeyRole, "owner") + c.Set(middleware.ContextKeyUserType, 123) + }) + if !strings.Contains(w.Body.String(), `"code":401`) || !strings.Contains(w.Body.String(), "用户信息异常") { + t.Fatalf("expected malformed user response, got body %s", w.Body.String()) + } +} + +func TestGetAuthUser(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + setAuthUser("tenant_admin", "owner")(c) + + user, ok := middleware.GetAuthUser(c) + if !ok { + t.Fatal("expected auth user") + } + if user.UserID != 1 || user.Username != "tester" || user.UserType != "tenant_admin" || user.Role != "owner" { + t.Fatalf("unexpected auth user: %+v", user) + } + if middleware.GetRole(c) != "owner" { + t.Fatalf("expected role owner, got %q", middleware.GetRole(c)) + } + + c.Set(middleware.ContextKeyUserID, "bad") + if _, ok := middleware.GetAuthUser(c); ok { + t.Fatal("expected malformed auth user to fail") + } +} diff --git a/middleware/cors.go b/middleware/cors.go new file mode 100644 index 0000000..43df38f --- /dev/null +++ b/middleware/cors.go @@ -0,0 +1,217 @@ +package middleware + +import ( + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/gin-gonic/gin" +) + +// CORS 跨域中间件 +// 支持从配置文件读取 CORS 配置;遵循 W3C CORS 规范: +// 当 AllowCredentials=true 时,Access-Control-Allow-Origin 必须回显为具体 Origin, +// 不能使用 "*"(浏览器会拒绝携带凭证的响应)。 +func CORS() gin.HandlerFunc { + return CORSWithConfig(nil) +} + +// CORSWithConfig 使用自定义配置的 CORS 中间件 +func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + + // 获取配置 + cfg := config.Get() + var corsConfig *config.CORSConfig + if corsCfg != nil { + corsConfig = corsCfg + } else if cfg != nil { + corsConfig = &cfg.CORS + } + + // 是否允许携带凭证(影响 Origin 回显策略) + allowCredentials := corsConfig != nil && corsConfig.AllowCredentials + + // 获取允许的域名列表 + allowedOrigins := getAllowedOrigins(cfg, corsConfig) + + // 匹配 Origin + // 注意:当 allowCredentials=true 且匹配到通配符时, + // 必须回显具体 Origin 而非 "*",否则浏览器会拒绝响应。 + allowedOrigin := "" + matchedWildcard := false + if origin != "" { + for _, ao := range allowedOrigins { + if ao == "*" { + matchedWildcard = true + break + } + if matchOrigin(origin, ao) { + allowedOrigin = origin + break + } + } + } + + // 处理通配符 + 兜底策略 + if allowedOrigin == "" { + if matchedWildcard { + if allowCredentials && origin != "" { + // AllowCredentials=true 时 spec 禁止 "*",回显具体 Origin + allowedOrigin = origin + } else { + allowedOrigin = "*" + } + } else if cfg != nil && cfg.IsDevelopment() && origin != "" && isLocalhostOrigin(origin) { + // 开发环境兜底:仅对 localhost 来源回显具体 Origin(C7b 修复)。 + // 旧实现无条件回显任意 Origin,若同时 AllowCredentials=true 则构成凭据型反射。 + allowedOrigin = origin + } + } + + // 设置 CORS 响应头 + // 仅在 origin 匹配时发送 CORS 头(C7 收尾:未匹配 origin 不发 Allow-Methods/Headers 等, + // 收敛信息泄露——避免向未授权 origin 暴露 API 允许的方法/头清单)。 + if allowedOrigin != "" { + c.Header("Access-Control-Allow-Origin", allowedOrigin) + // Origin 不是 "*" 时,下游缓存(CDN / 网关)必须按 Origin 区分缓存 + if allowedOrigin != "*" { + c.Header("Vary", "Origin") + } + + methods := corsConfig.GetAllowedMethods() + headers := corsConfig.GetAllowedHeaders() + exposedHeaders := corsConfig.GetExposedHeaders() + maxAge := corsConfig.GetMaxAge() + + c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", ")) + c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", ")) + c.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", ")) + c.Header("Access-Control-Max-Age", strconv.Itoa(maxAge)) + } + + // 仅在显式启用且 Origin 不是 "*" 时才发 Allow-Credentials + // (CORS 规范:Allow-Origin: * 时禁止携带凭证) + if allowCredentials && allowedOrigin != "" && allowedOrigin != "*" { + c.Header("Access-Control-Allow-Credentials", "true") + } + + // 处理预检请求 + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} + +// matchOrigin 判断 origin 是否匹配允许的 Origin 模式 ao。 +// +// 支持三种模式(C7a 修复): +// 1. 精确匹配:ao == origin(scheme+host+port 全等)。 +// 2. 通配子域:ao 形如 "*.example.com",匹配 example.com 的任意子域(含 a.example.com、 +// a.b.example.com),但**不匹配 apex example.com 自身**,也**不匹配 notexample.com、 +// evil-example.com 等后缀相同但非真实子域的域名**。旧实现用 strings.HasSuffix(origin, domain) +// 未锚定 host 边界,导致上述绕过。 +// 3. 通配 apex+子域:ao 形如 "*.example.com" 经本函数仅匹配子域;若需同时允许 apex, +// 配置中需显式列出 "https://example.com"。 +// +// 解析 origin 的 host 做真实子域边界判断(而非字符串后缀),杜绝 notexample.com 类绕过。 +func matchOrigin(origin, ao string) bool { + if origin == "" || ao == "" { + return false + } + // 精确匹配。 + if origin == ao { + return true + } + // 通配子域 *.domain。 + if strings.HasPrefix(ao, "*.") { + wildcardDomain := strings.ToLower(strings.TrimSuffix(ao[2:], ".")) // 如 "example.com" + u, err := url.Parse(origin) + if err != nil || u.Host == "" { + return false + } + // 去端口、去尾点(FQDN 尾点表示 a.example.com. 应等同 a.example.com)。 + host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) + // host 必须是 *.domain 的真实子域:host == "x." + wildcardDomain, + // 且 x 非空、不含点(即直接子域)或为多级子域(a.b.example.com)。 + // 关键:host 必须以 "." + wildcardDomain 结尾(锚定边界),杜绝 notexample.com。 + if !strings.HasSuffix(host, "."+wildcardDomain) { + return false + } + // 排除 host == wildcardDomain 自身(apex 不由通配匹配,需显式配置)。 + if host == wildcardDomain { + return false + } + return true + } + return false +} + +// isLocalhostOrigin 判断 origin 是否为 localhost 来源(开发态兜底用,C7b 修复)。 +// 仅允许 localhost / 127.0.0.1 / ::1 的任意端口,杜绝开发态回显任意 Origin。 +func isLocalhostOrigin(origin string) bool { + u, err := url.Parse(origin) + if err != nil || u.Host == "" { + return false + } + host := strings.TrimSuffix(u.Hostname(), ".") + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +// getAllowedOrigins 获取允许的域名列表 +// 优先使用配置文件,生产环境必须显式配置,开发环境提供 localhost 兜底。 +func getAllowedOrigins(cfg *config.Config, corsConfig *config.CORSConfig) []string { + // 优先使用 CORS 配置 + if corsConfig != nil && len(corsConfig.AllowedOrigins) > 0 { + return corsConfig.AllowedOrigins + } + + // 生产环境:必须配置具体的域名 + if cfg != nil && cfg.IsProduction() { + // 返回空列表,生产环境必须配置 + return []string{} + } + + // 开发环境允许 localhost + return []string{ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:8080", + "http://localhost:4200", + "http://127.0.0.1:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:8080", + "http://127.0.0.1:4200", + } +} + +// CORSWithOrigins 使用指定域名列表的 CORS 中间件 +func CORSWithOrigins(origins []string) gin.HandlerFunc { + return CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: origins, + }) +} + +// CORSWithWildcard 允许所有来源的 CORS 中间件(仅用于开发环境) +// 注意:生产环境不建议使用 +func CORSWithWildcard() gin.HandlerFunc { + return CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"*"}, + }) +} + +// CORSForAPI 适用于 API 的 CORS 中间件 +func CORSForAPI() gin.HandlerFunc { + return CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, + AllowedHeaders: []string{"Content-Type", "Authorization", "X-Requested-With"}, + AllowCredentials: false, // API 模式不允许凭证 + }) +} \ No newline at end of file diff --git a/middleware/cors_internal_test.go b/middleware/cors_internal_test.go new file mode 100644 index 0000000..7a56fbd --- /dev/null +++ b/middleware/cors_internal_test.go @@ -0,0 +1,94 @@ +package middleware + +import "testing" + +// 回归 C7a:通配符子域匹配必须锚定真实子域边界,拒绝后缀相同但非子域的域名。 +// 旧实现 strings.HasSuffix(origin, domain) 接受 notexample.com / evil-example.com。 +func TestMatchOriginWildcardBoundary(t *testing.T) { + cases := []struct { + name string + origin string + ao string + want bool + }{ + // 精确匹配 + {"exact", "https://example.com", "https://example.com", true}, + {"exact with port", "https://example.com:8443", "https://example.com:8443", true}, + {"exact mismatch", "https://example.com", "https://other.com", false}, + + // 通配子域 *.example.com + {"subdomain ok", "https://a.example.com", "*.example.com", true}, + {"deep subdomain ok", "https://a.b.example.com", "*.example.com", true}, + {"subdomain with port ok", "https://a.example.com:3000", "*.example.com", true}, + {"apex not matched by wildcard", "https://example.com", "*.example.com", false}, + // C7a 核心:后缀相同但非真实子域必须拒绝 + {"notexample.com rejected", "https://notexample.com", "*.example.com", false}, + {"evil-example.com rejected", "https://evil-example.com", "*.example.com", false}, + {"villainexample.com rejected", "https://villainexample.com", "*.example.com", false}, + // 端口/协议不同的 origin host 仍按 host 判断 + {"http subdomain ok", "http://a.example.com", "*.example.com", true}, + + // 通配仅匹配 host,scheme 不同的精确配置不被通配覆盖 + {"different scheme exact not wildcard", "http://example.com", "https://example.com", false}, + + // 边界 + {"empty origin", "", "*.example.com", false}, + {"empty ao", "https://a.example.com", "", false}, + {"malformed origin", "://:bad", "*.example.com", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := matchOrigin(c.origin, c.ao) + if got != c.want { + t.Errorf("matchOrigin(%q, %q) = %v, want %v", c.origin, c.ao, got, c.want) + } + }) + } +} + +// 回归 C7a:通配大小写不敏感(host 归一化为小写比较)。 +func TestMatchOriginCaseInsensitive(t *testing.T) { + if !matchOrigin("https://A.Example.COM", "*.example.com") { + t.Error("matchOrigin should be case-insensitive on host") + } +} + +// 回归 C7a:trailing dot FQDN(a.example.com.)应等同 a.example.com 被接受(功能正确性)。 +func TestMatchOriginTrailingDot(t *testing.T) { + if !matchOrigin("https://a.example.com.", "*.example.com") { + t.Error("trailing-dot subdomain should match wildcard") + } + if !matchOrigin("https://a.example.com", "*.example.com.") { + t.Error("trailing-dot wildcard should match plain subdomain") + } +} + +// 回归 C7b:isLocalhostOrigin 仅允许 localhost/127.0.0.1/::1,拒绝任意其他来源。 +func TestIsLocalhostOrigin(t *testing.T) { + allowed := []string{ + "http://localhost:3000", + "http://localhost", + "http://127.0.0.1:8080", + "http://127.0.0.1", + "http://[::1]:4000", + "http://[::1]", + } + for _, o := range allowed { + if !isLocalhostOrigin(o) { + t.Errorf("isLocalhostOrigin(%q) = false, want true", o) + } + } + denied := []string{ + "https://evil.com", + "https://localhost.evil.com", // 后缀含 localhost 但非 localhost host + "https://notlocalhost.com", + "https://example.com", + "", + "://bad", + } + for _, o := range denied { + if isLocalhostOrigin(o) { + t.Errorf("isLocalhostOrigin(%q) = true, want false", o) + } + } +} diff --git a/middleware/csrf.go b/middleware/csrf.go new file mode 100644 index 0000000..c14841f --- /dev/null +++ b/middleware/csrf.go @@ -0,0 +1,465 @@ +package middleware + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "net/http" + "strings" + "sync" + "time" + + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" +) + +const ( + // CSRFTokenLength CSRF Token 长度 + CSRFTokenLength = 32 + // CSRFHeaderName CSRF Header 名称 + CSRFHeaderName = "X-CSRF-Token" + // CSRFCookieName CSRF Cookie 名称 + CSRFCookieName = "csrf_token" + // CSRFFormField 表单字段名称 + CSRFFormField = "_csrf" + // CSRFMaxBodyBytes JSON body 中读取 CSRF token 的最大字节数,防止 pre-auth OOM。 + CSRFMaxBodyBytes = 1 << 20 // 1 MiB +) + +// CSRFConfig CSRF 配置 +type CSRFConfig struct { + // TokenLength Token 长度 + TokenLength int + // HeaderName Header 名称 + HeaderName string + // CookieName Cookie 名称 + CookieName string + // FormField 表单字段名 + FormField string + // Secure Cookie 是否启用 Secure + Secure bool + // HTTPOnly Cookie 是否启用 HttpOnly + HTTPOnly bool + // SameSite Cookie SameSite 属性 + SameSite http.SameSite + // Domain Cookie 域名 + Domain string + // Path Cookie 路径 + 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 跳过检查函数 + SkipFunc func(c *gin.Context) bool +} + +// DefaultCSRFConfig 默认 CSRF 配置 +var DefaultCSRFConfig = CSRFConfig{ + 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 + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +// defaultCSRFError 默认错误处理 +func defaultCSRFError(c *gin.Context) { + response.Fail(c, "CSRF Token 无效,请刷新页面重试") + c.Abort() +} + +func csrfBodyTooLarge(c *gin.Context) { + response.Custom(c, http.StatusRequestEntityTooLarge, response.CodeFileTooLarge, "请求体过大", nil) + c.Abort() +} + +func normalizeCSRFConfig(cfg CSRFConfig) CSRFConfig { + if cfg.TokenLength <= 0 { + cfg.TokenLength = CSRFTokenLength + } + if cfg.HeaderName == "" { + cfg.HeaderName = CSRFHeaderName + } + 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) { + // 检查是否跳过 + if cfg.SkipFunc != nil && cfg.SkipFunc(c) { + c.Next() + return + } + + // 获取或生成 Token + cookieToken, err := c.Cookie(cfg.CookieName) + if err != nil || cookieToken == "" { + // 生成新 Token + token, err := generateCSRFToken(cfg.TokenLength) + if err != nil { + cfg.ErrorFunc(c) + return + } + cookieToken = token + + // 设置 Cookie + c.SetSameSite(cfg.SameSite) + c.SetCookie( + cfg.CookieName, + token, + cfg.MaxAge, + cfg.Path, + cfg.Domain, + cfg.Secure, + cfg.HTTPOnly, + ) + } + + // 将 Token 存入上下文,供前端使用 + c.Set("csrf_token", cookieToken) + + // 安全方法(GET, HEAD, OPTIONS, TRACE)不需要验证 + if isSafeMethod(c.Request.Method) { + c.Next() + return + } + + // 验证 Token + clientToken := "" + + // 优先从 Header 获取 + clientToken = c.GetHeader(cfg.HeaderName) + + // 其次从表单获取 + if clientToken == "" { + clientToken = c.PostForm(cfg.FormField) + } + + // 最后从 JSON body 获取。 + // P1 #9:用 ShouldBindBodyWith(binding.JSON) 而非 ShouldBindJSON——后者会读干 + // c.Request.Body,导致下游 handler 再 ShouldBindJSON 拿到空 body(EOF)。前者把原始 + // body 缓存进 gin context,下游可重复读取。 + if clientToken == "" { + var body map[string]any + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, cfg.MaxBodyBytes) + if err := c.ShouldBindBodyWith(&body, binding.JSON); err == nil { + if token, ok := body[cfg.FormField].(string); ok { + clientToken = token + } + } else { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + csrfBodyTooLarge(c) + return + } + } + } + + // 验证 Token 是否匹配(H-7: 恒定时间比较防时序侧信道) + if len(clientToken) == 0 || subtle.ConstantTimeCompare([]byte(clientToken), []byte(cookieToken)) != 1 { + cfg.ErrorFunc(c) + return + } + + c.Next() + } +} + +// isSafeMethod 判断是否为安全方法 +func isSafeMethod(method string) bool { + switch strings.ToUpper(method) { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: + return true + default: + return false + } +} + +// GetCSRFToken 从上下文获取 CSRF Token +func GetCSRFToken(c *gin.Context) string { + token, exists := c.Get("csrf_token") + if !exists { + return "" + } + // H-7: comma-ok 防裸断言 panic。csrf_token 虽由本包以 string 写入, + // 但 gin context 是共享 map,下游误写其他类型即 panic。 + s, ok := token.(string) + if !ok { + return "" + } + return s +} + +// CSRFToken 返回当前请求上下文中的 CSRF Token;若上下文无 Token 则现场生成一个返回。 +// 适用于 Cookie/双重提交模式(与 CSRF/DoubleSubmitCookie 中间件配合), +// 不与 CSRFForAPI 配套--API 模式的可校验 Token 请用 GenerateAPIToken 颁发。 +func CSRFToken(c *gin.Context) { + token := GetCSRFToken(c) + if token == "" { + var err error + token, err = generateCSRFToken(CSRFTokenLength) + if err != nil { + response.ServerError(c, "生成 Token 失败") + return + } + } + + response.Success(c, gin.H{ + "csrf_token": token, + }) +} + +// CSRFWithSkip 跳过指定路径的 CSRF 检查 +func CSRFWithSkip(skipPaths []string) gin.HandlerFunc { + cfg := DefaultCSRFConfig + cfg.SkipFunc = func(c *gin.Context) bool { + path := c.Request.URL.Path + for _, p := range skipPaths { + if pathMatchesSkip(path, p) { + return true + } + } + return false + } + return CSRF(cfg) +} + +// CSRFForAPI 适用于 API 的 CSRF 中间件(不使用 Cookie) +// 客户端需要先调用 GenerateAPIToken 获取 Token,随后在每个非安全方法请求的 +// X-CSRF-Token 头中携带。Token 单次消费(验证通过即删除)且受 TTL 约束, +// 防止重放与内存无限增长。 +// +// 注意:存储为进程内内存,仅适用于单实例部署。多实例请自行用 Redis +// SETEX + GETDEL 实现等价语义。 +func CSRFForAPI() gin.HandlerFunc { + return func(c *gin.Context) { + // 安全方法不需要验证 + if isSafeMethod(c.Request.Method) { + c.Next() + return + } + + // 从 Header 获取 Token + clientToken := c.GetHeader(CSRFHeaderName) + if clientToken == "" { + response.Fail(c, "缺少 CSRF Token") + c.Abort() + return + } + + // 单次消费 + TTL:校验通过即删除,防止同一 token 被重放。 + // 写锁内完成“查—删—过期清理”以保证原子性。 + apiTokensMu.Lock() + issuedAt, ok := apiTokens[clientToken] + if ok { + delete(apiTokens, clientToken) + } + // 懒清理:map 较大时顺带淘汰过期项,避免内存无限增长。 + if len(apiTokens) > 256 { + now := time.Now() + for t, at := range apiTokens { + if now.Sub(at) > apiTokenTTL { + delete(apiTokens, t) + } + } + } + apiTokensMu.Unlock() + + if !ok || time.Since(issuedAt) > apiTokenTTL { + response.Fail(c, "CSRF Token 无效") + c.Abort() + return + } + + c.Next() + } +} + +// GenerateAPIToken 生成 API CSRF Token(用于 API 模式) +// 颁发的 Token 写入进程内存储,供 CSRFForAPI 校验。 +func GenerateAPIToken(c *gin.Context) { + token, err := generateCSRFToken(CSRFTokenLength) + if err != nil { + response.ServerError(c, "生成 Token 失败") + return + } + + apiTokensMu.Lock() + cleanupExpiredAPITokensLocked(time.Now()) + apiTokens[token] = time.Now() + apiTokensMu.Unlock() + + response.Success(c, gin.H{ + "csrf_token": token, + }) +} + +// API 模式 CSRF Token 存储:token -> 颁发时间。 +// 受 apiTokensMu 保护,单次消费 + TTL(见 CSRFForAPI)。 +var ( + apiTokens = make(map[string]time.Time) + apiTokensMu sync.RWMutex +) + +// apiTokenTTL API 模式 CSRF Token 有效期 +const apiTokenTTL = 30 * time.Minute + +func cleanupExpiredAPITokensLocked(now time.Time) { + for t, at := range apiTokens { + if now.Sub(at) > apiTokenTTL { + delete(apiTokens, t) + } + } +} + +// CSRFExempt 标记路由不需要 CSRF 保护 +// 使用方法:在路由组上使用此中间件 +func CSRFExempt() gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("csrf_exempt", true) + c.Next() + } +} + +// CSRFWithExempt 支持 exempt 标记的 CSRF 中间件 +func CSRFWithExempt(config ...CSRFConfig) gin.HandlerFunc { + cfg := DefaultCSRFConfig + if len(config) > 0 { + cfg = config[0] + } + cfg = normalizeCSRFConfig(cfg) + + originalSkipFunc := cfg.SkipFunc + cfg.SkipFunc = func(c *gin.Context) bool { + // 检查是否标记为 exempt(P1 #9:comma-ok 防裸断言 panic——gin context 为共享 map, + // 下游若误以非 bool 写入 csrf_exempt,exempt.(bool) 会 panic)。 + if exempt, exists := c.Get("csrf_exempt"); exists { + if b, ok := exempt.(bool); ok && b { + return true + } + } + // 调用原始 SkipFunc + if originalSkipFunc != nil { + return originalSkipFunc(c) + } + return false + } + + return CSRF(cfg) +} + +// DoubleSubmitCookie 双重提交 Cookie 模式(无需服务器存储) +// 适用于无状态 API +func DoubleSubmitCookie() gin.HandlerFunc { + return func(c *gin.Context) { + // 安全方法不需要验证 + if isSafeMethod(c.Request.Method) { + // 设置 Cookie(如果不存在) + cookieToken, err := c.Cookie(CSRFCookieName) + if err != nil || cookieToken == "" { + 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 { + c.Set("csrf_token", cookieToken) + } + c.Next() + return + } + + // 获取 Cookie 中的 Token + cookieToken, err := c.Cookie(CSRFCookieName) + if err != nil { + response.Fail(c, "CSRF Token 缺失") + c.Abort() + return + } + + // 获取 Header 中的 Token + headerToken := c.GetHeader(CSRFHeaderName) + if headerToken == "" { + response.Fail(c, "缺少 CSRF Token") + c.Abort() + return + } + + // 验证 Cookie 和 Header 中的 Token 是否一致(H-7: 恒定时间比较防时序侧信道) + if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) != 1 { + response.Fail(c, "CSRF Token 不匹配") + c.Abort() + return + } + + c.Next() + } +} diff --git a/middleware/csrf_internal_test.go b/middleware/csrf_internal_test.go new file mode 100644 index 0000000..38b81b5 --- /dev/null +++ b/middleware/csrf_internal_test.go @@ -0,0 +1,234 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" +) + +// 回归 C6b:TTL 过期分支。直接向包级存储注入一个“已过期”的 token, +// 断言 CSRFForAPI 拒绝它(即使该 token 从未被消费过)。 +// 这条分支(time.Since(issuedAt) > apiTokenTTL)无法靠单次消费用例覆盖, +// 必须单独构造过期时间戳。 +func TestCSRFForAPITTLExpiry(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CSRFForAPI()) + r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + + expiredToken := "expired-test-token" + apiTokensMu.Lock() + // 注入一个早于 TTL 的颁发时间,模拟 token 已过期。 + apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second)) + apiTokensMu.Unlock() + defer func() { + apiTokensMu.Lock() + delete(apiTokens, expiredToken) + apiTokensMu.Unlock() + }() + + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/action", nil) + req.Header.Set("X-CSRF-Token", expiredToken) + r.ServeHTTP(w, req) + + var resp response.Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response %q: %v", w.Body.String(), err) + } + if resp.Code == response.CodeSuccess { + t.Errorf("expired token must be rejected, got code=%d (success)", resp.Code) + } +} + +// 回归 P1 #9:CSRF 从 JSON body 取 token 后,下游 handler 仍能读到完整请求体。 +// 修复前用 ShouldBindJSON 读干 body,下游再绑定拿到 EOF;改用 ShouldBindBodyWith 缓存 body。 +func TestCSRFJSONBodyPreservedForDownstream(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CSRF()) + + var gotData string + r.POST("/action", func(c *gin.Context) { + // 下游再次绑定 body,应能读到完整内容(而非被 CSRF 中间件读空)。 + var payload struct { + Data string `json:"data"` + } + if err := c.ShouldBindBodyWith(&payload, binding.JSON); err != nil { + c.JSON(500, gin.H{"err": err.Error()}) + return + } + gotData = payload.Data + c.JSON(200, gin.H{"status": "ok"}) + }) + + // token 仅放在 JSON body 里(不放 header/form),触发中间件读 body 的分支。 + const tok = "csrf-token-abc-1234567890" + body := `{"_csrf":"` + tok + `","data":"hello-downstream"}` + req := httptest.NewRequest("POST", "/action", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + // cookie 携带同一 token,使 CSRF 校验通过。 + req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: tok}) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d, body=%s", w.Code, w.Body.String()) + } + if gotData != "hello-downstream" { + t.Errorf("downstream read data=%q, want 'hello-downstream' (P1 #9: body must survive CSRF)", gotData) + } +} + +func TestCSRFJSONBodyTooLargeRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CSRF(CSRFConfig{MaxBodyBytes: 16})) + r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + + body := `{"_csrf":"token","data":"` + strings.Repeat("x", 64) + `"}` + req := httptest.NewRequest(http.MethodPost, "/action", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: "token"}) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413; body=%s", w.Code, w.Body.String()) + } +} + +func TestCSRFNegativeTokenLengthFallsBack(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CSRF(CSRFConfig{TokenLength: -1})) + r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"token": GetCSRFToken(c)}) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if len(w.Result().Cookies()) == 0 { + t.Fatal("expected csrf cookie to be set") + } +} + +func TestCSRFCookieSameSiteLax(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CSRF()) + r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil)) + for _, c := range w.Result().Cookies() { + if c.Name == CSRFCookieName { + if c.SameSite != http.SameSiteLaxMode { + t.Fatalf("SameSite = %v, want Lax", c.SameSite) + } + return + } + } + t.Fatal("csrf cookie not set") +} + +func TestCSRFPartialConfigKeepsCookieDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CSRF(CSRFConfig{TokenLength: -1})) + r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil)) + for _, c := range w.Result().Cookies() { + if c.Name == CSRFCookieName { + if c.SameSite != http.SameSiteLaxMode { + t.Fatalf("SameSite = %v, want Lax for partial config", c.SameSite) + } + if c.MaxAge != DefaultCSRFConfig.MaxAge { + t.Fatalf("MaxAge = %d, want %d for partial config", c.MaxAge, DefaultCSRFConfig.MaxAge) + } + return + } + } + t.Fatal("csrf cookie not set") +} + +func TestDoubleSubmitCookieSameSiteLax(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(DoubleSubmitCookie()) + r.GET("/get", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/get", nil)) + for _, c := range w.Result().Cookies() { + if c.Name == CSRFCookieName { + if c.SameSite != http.SameSiteLaxMode { + t.Fatalf("SameSite = %v, want Lax", c.SameSite) + } + return + } + } + t.Fatal("csrf cookie not set") +} + +func TestCSRFWithSkipAnchorsPathBoundary(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CSRFWithSkip([]string{"/api"})) + r.POST("/api", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + r.POST("/api/users", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + r.POST("/apix", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + for _, path := range []string{"/api", "/api/users"} { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil)) + if w.Code != http.StatusNoContent { + t.Fatalf("%s status = %d, want skipped 204", path, w.Code) + } + } + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/apix", nil)) + if w.Code == http.StatusNoContent { + t.Fatal("/apix was skipped by /api prefix; want CSRF rejection") + } +} + +func TestGenerateAPITokenCleansExpiredTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + expiredToken := "expired-before-issue" + apiTokensMu.Lock() + apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second)) + apiTokensMu.Unlock() + defer func() { + apiTokensMu.Lock() + delete(apiTokens, expiredToken) + apiTokensMu.Unlock() + }() + + r := gin.New() + r.GET("/csrf-token", GenerateAPIToken) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/csrf-token", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + + apiTokensMu.RLock() + _, exists := apiTokens[expiredToken] + apiTokensMu.RUnlock() + if exists { + t.Fatal("GenerateAPIToken did not clean expired token") + } +} diff --git a/middleware/logger.go b/middleware/logger.go new file mode 100644 index 0000000..fb72430 --- /dev/null +++ b/middleware/logger.go @@ -0,0 +1,308 @@ +package middleware + +import ( + "bytes" + "io" + "net/url" + "regexp" + "strings" + "time" + + "github.com/EthanCodeCraft/xlgo-core/logger" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// LoggerConfig 日志中间件配置 +type LoggerConfig struct { + // LogRequestBody 是否记录请求体 + LogRequestBody bool + // LogResponseBody 是否记录响应体 + LogResponseBody bool + // MaxBodyLength 最大记录的请求/响应体长度(字节) + MaxBodyLength int + // SkipPaths 不记录日志的路径 + SkipPaths []string + // SkipPathPrefixes 不记录日志的路径前缀 + SkipPathPrefixes []string + // SlowRequestThreshold 慢请求阈值(超过此时间记录警告) + SlowRequestThreshold time.Duration +} + +// DefaultLoggerConfig 默认日志配置 +var DefaultLoggerConfig = LoggerConfig{ + LogRequestBody: false, // 默认不记录请求体(敏感信息风险) + LogResponseBody: false, // 默认不记录响应体 + MaxBodyLength: 1024, // 最大 1KB + SkipPaths: []string{"/health", "/swagger"}, + SkipPathPrefixes: []string{"/public", "/static"}, + SlowRequestThreshold: 500 * time.Millisecond, +} + +// Logger 日志中间件(使用默认配置) +func Logger() gin.HandlerFunc { + return LoggerWithConfig(DefaultLoggerConfig) +} + +// LoggerWithConfig 使用自定义配置的日志中间件 +func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc { + // 统一封顶:MaxBodyLength<=0 时回退默认值,确保请求/响应 body 捕获均有上限(防 OOM) + if cfg.MaxBodyLength <= 0 { + cfg.MaxBodyLength = DefaultLoggerConfig.MaxBodyLength + } + return func(c *gin.Context) { + // 检查是否跳过此路径 + path := c.Request.URL.Path + if shouldSkipPath(path, cfg) { + c.Next() + return + } + + start := time.Now() + + // 记录请求体(可选) + var requestBody []byte + if cfg.LogRequestBody && c.Request.Body != nil { + requestBody = readBodyBounded(c, cfg.MaxBodyLength) + } + + // 记录响应体(可选) + var responseBody []byte + if cfg.LogResponseBody { + // 使用 ResponseWriter 包装器捕获响应体(缓冲区封顶,防止大响应 OOM) + blw := &bodyLogWriter{body: bytes.NewBufferString(""), maxLen: cfg.MaxBodyLength, ResponseWriter: c.Writer} + c.Writer = blw + c.Next() + responseBody = blw.body.Bytes() + } else { + c.Next() + } + + latency := time.Since(start) + + // 构建日志字段 + fields := []zap.Field{ + zap.Int("status", c.Writer.Status()), + zap.String("method", c.Request.Method), + zap.String("path", path), + zap.String("query", filterSensitiveQuery(c.Request.URL.RawQuery)), + zap.String("ip", c.ClientIP()), + zap.Duration("latency", latency), + zap.String("user-agent", c.Request.UserAgent()), + zap.Int("body_size", c.Writer.Size()), + zap.String("request_id", GetRequestID(c)), + } + + // 添加请求体 + if cfg.LogRequestBody && len(requestBody) > 0 { + // 过滤敏感字段 + safeBody := filterSensitiveFields(requestBody) + fields = append(fields, zap.String("request_body", safeBody)) + } + + // 添加响应体 + if cfg.LogResponseBody && len(responseBody) > 0 { + fields = append(fields, zap.String("response_body", string(responseBody))) + } + + // 添加用户信息(如果已登录) + userID := GetUserID(c) + if userID > 0 { + fields = append(fields, + zap.Uint("user_id", userID), + zap.String("username", GetUsername(c)), + zap.String("user_type", GetUserType(c)), + ) + } + + // 根据状态码和延迟选择日志级别 + status := c.Writer.Status() + + if latency > cfg.SlowRequestThreshold { + // 慢请求警告 + fields = append(fields, zap.Bool("slow_request", true)) + logger.APILog().Warn("慢请求", fields...) + } else if status >= 500 { + logger.APILog().Error("请求错误", fields...) + } else if status >= 400 { + logger.APILog().Warn("客户端请求错误", fields...) + } else { + logger.APILog().Info("API 请求", fields...) + } + } +} + +// readBodyBounded 读取请求体用于日志记录,封顶 maxLen 字节以防 OOM。 +// +// 仅向内存读入最多 maxLen+1 字节(+1 用于检测截断),其余部分不读入内存; +// 通过 io.MultiReader 把「已读前缀 + 原始 body 剩余」复原为 c.Request.Body, +// 因此下游处理器仍能拿到完整请求体。返回的日志副本截断为 maxLen。 +func readBodyBounded(c *gin.Context, maxLen int) []byte { + if maxLen <= 0 { + maxLen = DefaultLoggerConfig.MaxBodyLength + } + // io.ReadAll 永远返回非 nil 切片(即使出错也带已读部分)。LimitReader 封顶至 maxLen+1 字节。 + read, _ := io.ReadAll(io.LimitReader(c.Request.Body, int64(maxLen)+1)) + // 复原完整请求体供后续处理:已读前缀 + 原始 body 剩余部分(出错时保留已读字节,下游可重试读取剩余) + c.Request.Body = io.NopCloser(io.MultiReader(bytes.NewReader(read), c.Request.Body)) + // 日志副本截断到 maxLen + if len(read) > maxLen { + return read[:maxLen] + } + return read +} + +// bodyLogWriter 响应体记录包装器 +type bodyLogWriter struct { + gin.ResponseWriter + body *bytes.Buffer + maxLen int // 缓冲区上限(字节),<=0 表示不限制 +} + +// appendBounded 向缓冲区追加字节,但不超过 maxLen 上限,防止大响应 OOM。 +func (w *bodyLogWriter) appendBounded(b []byte) { + if w.maxLen <= 0 { + w.body.Write(b) + return + } + remaining := w.maxLen - w.body.Len() + if remaining <= 0 { + return + } + if len(b) > remaining { + b = b[:remaining] + } + w.body.Write(b) +} + +// Write 捕获响应体(缓冲区封顶,完整响应仍写入下游 ResponseWriter) +func (w *bodyLogWriter) Write(b []byte) (int, error) { + w.appendBounded(b) + return w.ResponseWriter.Write(b) +} + +// WriteString 捕获字符串响应 +func (w *bodyLogWriter) WriteString(s string) (int, error) { + w.appendBounded([]byte(s)) + return w.ResponseWriter.WriteString(s) +} + +// shouldSkipPath 检查是否跳过路径 +func shouldSkipPath(path string, cfg LoggerConfig) bool { + // 检查完整路径 + for _, p := range cfg.SkipPaths { + if path == p { + return true + } + } + + // 检查路径前缀 + for _, prefix := range cfg.SkipPathPrefixes { + if pathPrefixMatch(path, prefix) { + return true + } + } + + 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)。 +var sensitiveFieldsRE = regexp.MustCompile( + `"(password|passwd|pwd|token|access_token|refresh_token|secret|api_key|apikey|credit_card|card_number)"\s*:\s*"((?:[^"\\]|\\.)*)"`, +) + +// filterSensitiveFields M1 修复后实现:将敏感字段的值替换为 [FILTERED],完整移除原始值。 +// 对非 JSON 输入原样返回。 +func filterSensitiveFields(body []byte) string { + return sensitiveFieldsRE.ReplaceAllString(string(body), `"$1":"[FILTERED]"`) +} + +// sensitiveQueryKeys 需要在访问日志中脱敏的 query 参数名(小写匹配,P1 #14)。 +// 与 sensitiveFieldsRE 覆盖的字段保持一致。 +var sensitiveQueryKeys = map[string]struct{}{ + "password": {}, "passwd": {}, "pwd": {}, "token": {}, + "access_token": {}, "refresh_token": {}, "secret": {}, + "api_key": {}, "apikey": {}, "credit_card": {}, "card_number": {}, +} + +// filterSensitiveQuery 脱敏 query 字符串中的敏感参数值(P1 #14): +// 如 ?access_token=xxx 会被记为 access_token=%5BFILTERED%5D,防止令牌等随访问日志落盘。 +// 无敏感键时原样返回(不重排);解析失败时整体标记以免原文泄露。 +func filterSensitiveQuery(rawQuery string) string { + if rawQuery == "" { + return "" + } + values, err := url.ParseQuery(rawQuery) + if err != nil { + return "[redacted: unparsable query]" + } + changed := false + for k := range values { + if _, ok := sensitiveQueryKeys[strings.ToLower(k)]; ok { + values[k] = []string{"[FILTERED]"} + changed = true + } + } + if !changed { + return rawQuery + } + return values.Encode() +} + +// LoggerForAPI API 专用日志中间件(更详细) +func LoggerForAPI() gin.HandlerFunc { + cfg := DefaultLoggerConfig + cfg.LogRequestBody = true + cfg.LogResponseBody = false // 响应体通常较大 + return LoggerWithConfig(cfg) +} + +// LoggerForDebug 调试专用日志中间件(最详细) +func LoggerForDebug() gin.HandlerFunc { + cfg := LoggerConfig{ + LogRequestBody: true, + LogResponseBody: true, + MaxBodyLength: 4096, // 4KB + SkipPaths: []string{"/health"}, + SkipPathPrefixes: []string{}, + SlowRequestThreshold: 200 * time.Millisecond, + } + return LoggerWithConfig(cfg) +} + +// LoggerMinimal 最简日志中间件(只记录基本信息) +func LoggerMinimal() gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Request.URL.Path + + // 跳过健康检查和静态资源 + if path == "/health" || pathPrefixMatch(path, "/public") || pathPrefixMatch(path, "/static") { + c.Next() + return + } + + start := time.Now() + c.Next() + + logger.APILog().Info("请求", + zap.Int("status", c.Writer.Status()), + zap.String("method", c.Request.Method), + zap.String("path", path), + zap.String("ip", c.ClientIP()), + zap.Duration("latency", time.Since(start)), + ) + } +} diff --git a/middleware/logger_internal_test.go b/middleware/logger_internal_test.go new file mode 100644 index 0000000..1d60eb1 --- /dev/null +++ b/middleware/logger_internal_test.go @@ -0,0 +1,275 @@ +package middleware + +import ( + "bytes" + "io" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +// newCtxWithBody 构造一个带请求体的 gin.Context,仅用于 logger 内部测试。 +func newCtxWithBody(body []byte) *gin.Context { + req := httptest.NewRequest("POST", "/", bytes.NewReader(body)) + c := &gin.Context{} + c.Request = req + return c +} + +// TestReadBodyBounded_TruncatesLogCopy 复现 H3:日志副本必须封顶到 maxLen, +// 而不是把整个 body 读入内存后再截断(原 io.ReadAll 无上限 → OOM)。 +func TestReadBodyBounded_TruncatesLogCopy(t *testing.T) { + cases := []struct { + name string + bodyLen int + maxLen int + }{ + {"smaller_than_limit", 100, 1024}, + {"equal_to_limit", 1024, 1024}, + {"larger_than_limit", 100_000, 1024}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := bytes.Repeat([]byte("a"), tc.bodyLen) + c := newCtxWithBody(body) + + got := readBodyBounded(c, tc.maxLen) + + want := tc.bodyLen + if want > tc.maxLen { + want = tc.maxLen + } + if len(got) != want { + t.Fatalf("log copy len = %d, want %d (maxLen=%d, bodyLen=%d)", len(got), want, tc.maxLen, tc.bodyLen) + } + }) + } +} + +// TestReadBodyBounded_RestoresFullBody 是 H3 修复的闭环断言: +// 即使日志副本被截断,下游处理器仍必须能读到完整请求体(io.MultiReader 复原)。 +func TestReadBodyBounded_RestoresFullBody(t *testing.T) { + const maxLen = 64 + body := []byte(strings.Repeat("ABCDEFGH", 1000)) // 8000 字节,远超 maxLen + c := newCtxWithBody(body) + + logCopy := readBodyBounded(c, maxLen) + + // 日志副本封顶 + if len(logCopy) != maxLen { + t.Fatalf("log copy len = %d, want %d", len(logCopy), maxLen) + } + + // 下游必须拿到完整原始 body + restored, err := io.ReadAll(c.Request.Body) + if err != nil { + t.Fatalf("read restored body: %v", err) + } + if !bytes.Equal(restored, body) { + t.Fatalf("downstream body corrupted: got len=%d, want len=%d", len(restored), len(body)) + } +} + +// TestReadBodyBounded_PreservesSmallBodyExactly 小于上限时日志副本与原始一致。 +func TestReadBodyBounded_PreservesSmallBodyExactly(t *testing.T) { + body := []byte(`{"user":"alice","password":"secret"}`) + c := newCtxWithBody(body) + + got := readBodyBounded(c, 1024) + if !bytes.Equal(got, body) { + t.Fatalf("log copy = %q, want %q", got, body) + } + + restored, _ := io.ReadAll(c.Request.Body) + if !bytes.Equal(restored, body) { + t.Fatalf("downstream body = %q, want %q", restored, body) + } +} + +// TestBodyLogWriter_Bounded 复现 H3 响应侧:响应体捕获缓冲区必须封顶, +// 完整响应仍写入下游 ResponseWriter。 +func TestBodyLogWriter_Bounded(t *testing.T) { + gin.SetMode(gin.TestMode) + const maxLen = 32 + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + w := &bodyLogWriter{ + ResponseWriter: c.Writer, + body: new(bytes.Buffer), + maxLen: maxLen, + } + + large := bytes.Repeat([]byte("Z"), 10_000) + n, err := w.Write(large) + if err != nil { + t.Fatalf("write: %v", err) + } + if n != len(large) { + t.Fatalf("Write returned %d, want %d (下游必须收到完整响应)", n, len(large)) + } + if w.body.Len() > maxLen { + t.Fatalf("captured buffer len = %d, must be <= %d (OOM 防护失效)", w.body.Len(), maxLen) + } + if w.body.Len() != maxLen { + t.Fatalf("captured buffer len = %d, want exactly %d", w.body.Len(), maxLen) + } + // 下游 ResponseWriter 收到完整内容 + if rec.Body.Len() != len(large) { + t.Fatalf("downstream response len = %d, want %d", rec.Body.Len(), len(large)) + } +} + +// TestBodyLogWriter_NoLimit maxLen<=0 时不限制(向后兼容)。 +func TestBodyLogWriter_NoLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + w := &bodyLogWriter{ + ResponseWriter: c.Writer, + body: new(bytes.Buffer), + maxLen: 0, + } + data := []byte("hello world") + if _, err := w.Write(data); err != nil { + t.Fatalf("write: %v", err) + } + if !bytes.Equal(w.body.Bytes(), data) { + t.Fatalf("captured = %q, want %q", w.body.Bytes(), data) + } +} + +// TestBodyLogWriter_MultiWriteAccumulation 多次小写累积不超过 maxLen, +// 下游仍收到完整拼接结果。 +func TestBodyLogWriter_MultiWriteAccumulation(t *testing.T) { + gin.SetMode(gin.TestMode) + const maxLen = 32 + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + w := &bodyLogWriter{ + ResponseWriter: c.Writer, + body: new(bytes.Buffer), + maxLen: maxLen, + } + chunks := [][]byte{[]byte("0123456789"), []byte("abcdefghij"), []byte("ABCDEFGHIJ"), []byte("zzzzzzzzzz")} + var downstream bytes.Buffer + for _, ch := range chunks { + n, err := w.Write(ch) + if err != nil || n != len(ch) { + t.Fatalf("Write chunk %q: n=%d err=%v", ch, n, err) + } + downstream.Write(ch) + } + if w.body.Len() > maxLen { + t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen) + } + if w.body.Len() != maxLen { + t.Fatalf("captured len = %d, want exactly %d", w.body.Len(), maxLen) + } + if !bytes.Equal(rec.Body.Bytes(), downstream.Bytes()) { + t.Fatalf("downstream = %q, want %q", rec.Body.Bytes(), downstream.Bytes()) + } +} + +// TestBodyLogWriter_WriteStringBounded WriteString 路径同样封顶。 +func TestBodyLogWriter_WriteStringBounded(t *testing.T) { + gin.SetMode(gin.TestMode) + const maxLen = 16 + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + w := &bodyLogWriter{ + ResponseWriter: c.Writer, + body: new(bytes.Buffer), + maxLen: maxLen, + } + large := strings.Repeat("S", 500) + n, err := w.WriteString(large) + if err != nil { + t.Fatalf("WriteString: %v", err) + } + if n != len(large) { + t.Fatalf("WriteString returned %d, want %d", n, len(large)) + } + if w.body.Len() > maxLen { + t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen) + } + if rec.Body.Len() != len(large) { + t.Fatalf("downstream len = %d, want %d", rec.Body.Len(), len(large)) + } +} + +// TestFilterSensitiveQuery P1 #14:query 中的敏感参数值必须脱敏,非敏感原样保留。 +func TestFilterSensitiveQuery(t *testing.T) { + cases := []struct { + name string + in string + contains string // 期望结果包含 + absent string // 期望结果不含(敏感原值) + }{ + {"empty", "", "", ""}, + {"no sensitive", "page=1&size=20", "page=1", ""}, + {"access_token redacted", "access_token=supersecret123&page=1", "FILTERED", "supersecret123"}, + {"password redacted", "user=alice&password=hunter2", "FILTERED", "hunter2"}, + {"case insensitive key", "Token=abc.def.ghi", "FILTERED", "abc.def.ghi"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := filterSensitiveQuery(tc.in) + if tc.contains != "" && !strings.Contains(got, tc.contains) { + t.Errorf("filterSensitiveQuery(%q) = %q, want contains %q", tc.in, got, tc.contains) + } + if tc.absent != "" && strings.Contains(got, tc.absent) { + t.Errorf("filterSensitiveQuery(%q) = %q, must NOT contain sensitive value %q", tc.in, got, tc.absent) + } + }) + } +} + +// TestLoggerWithConfig_NormalizesMaxBodyLength MaxBodyLength<=0 时归一化为默认值, +// 确保响应侧捕获缓冲区仍有上限(H3 复审 MEDIUM:消除请求/响应侧 maxLen<=0 不对称)。 +func TestLoggerWithConfig_NormalizesMaxBodyLength(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + cfg := LoggerConfig{ + LogRequestBody: true, + LogResponseBody: true, + MaxBodyLength: 0, // 手滑置 0,应被归一化为默认 1024 + SkipPaths: []string{}, + SkipPathPrefixes: []string{}, + SlowRequestThreshold: 500 * time.Millisecond, + } + r.Use(LoggerWithConfig(cfg)) + r.POST("/", func(c *gin.Context) { + body, _ := io.ReadAll(c.Request.Body) + c.Data(200, "text/plain", body) + }) + + // 请求体 5000 字节(> 默认 1024),下游应仍得完整 body + reqBody := bytes.Repeat([]byte("a"), 5000) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/", bytes.NewReader(reqBody)) + r.ServeHTTP(w, req) + + if w.Body.Len() != len(reqBody) { + t.Fatalf("downstream body len = %d, want %d (请求体必须完整复原)", w.Body.Len(), len(reqBody)) + } + if !bytes.Equal(w.Body.Bytes(), reqBody) { + t.Fatalf("downstream body corrupted") + } +} + +func TestLoggerSkipPathPrefixesAnchorsBoundary_M8(t *testing.T) { + cfg := LoggerConfig{SkipPathPrefixes: []string{"/api"}} + if !shouldSkipPath("/api", cfg) { + t.Fatal("/api should be skipped") + } + if !shouldSkipPath("/api/users", cfg) { + t.Fatal("/api/users should be skipped") + } + if shouldSkipPath("/api2/users", cfg) { + t.Fatal("/api2/users should not be skipped by /api prefix") + } +} diff --git a/middleware/metrics.go b/middleware/metrics.go new file mode 100644 index 0000000..a54045f --- /dev/null +++ b/middleware/metrics.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// #18 Prometheus 指标。三个标配: +// - http_requests_total: 请求计数(按 method/route/status 维度) +// - http_request_duration_seconds: 请求耗时直方图(按 method/route 维度) +// - http_requests_in_flight: 当前在飞请求数 + +var ( + httpRequestsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "HTTP 请求总数。", + }, + []string{"method", "route", "status"}, + ) + + httpRequestDuration = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "HTTP 请求耗时(秒)。", + Buckets: prometheus.DefBuckets, + }, + []string{"method", "route"}, + ) + + httpRequestsInFlight = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "http_requests_in_flight", + Help: "正在处理的 HTTP 请求数。", + }, + ) +) + +// Metrics Prometheus 指标中间件(#18)。记录请求计数、耗时、在飞数。 +// route 标签用 c.FullPath()(路由模板,如 /api/v1/users/:id),避免高基数。 +func Metrics() gin.HandlerFunc { + return func(c *gin.Context) { + httpRequestsInFlight.Inc() + defer httpRequestsInFlight.Dec() // M2 修复:defer 保证 panic 时也递减,不依赖 Recover 中间件顺序 + start := time.Now() + + c.Next() + + elapsed := time.Since(start).Seconds() + status := strconv.Itoa(c.Writer.Status()) + route := c.FullPath() + if route == "" { + route = "not_found" + } + method := c.Request.Method + + httpRequestsTotal.WithLabelValues(method, route, status).Inc() + httpRequestDuration.WithLabelValues(method, route).Observe(elapsed) + } +} diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go new file mode 100644 index 0000000..e127585 --- /dev/null +++ b/middleware/middleware_test.go @@ -0,0 +1,1548 @@ +package middleware_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/EthanCodeCraft/xlgo-core/logger" + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +// respCode 解析统一响应体中的业务 code 字段。 +func respCode(t *testing.T, w *httptest.ResponseRecorder) int { + t.Helper() + var r response.Response + if err := json.Unmarshal(w.Body.Bytes(), &r); err != nil { + t.Fatalf("unmarshal response %q: %v", w.Body.String(), err) + } + return r.Code +} + +func setupTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + return r +} + +// ===== RequestID Tests ===== + +func TestRequestID(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.RequestID()) + r.GET("/test", func(c *gin.Context) { + id := middleware.GetRequestID(c) + c.JSON(200, gin.H{"request_id": id}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + // 验证响应头有 X-Request-ID + headerID := w.Header().Get("X-Request-ID") + if headerID == "" { + t.Error("X-Request-ID header should be set") + } + + // 验证响应体中的 request_id + if w.Code != 200 { + t.Errorf("RequestID status = %d", w.Code) + } +} + +func TestRequestIDWithExisting(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.RequestID()) + r.GET("/test", func(c *gin.Context) { + id := middleware.GetRequestID(c) + c.JSON(200, gin.H{"request_id": id}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("X-Request-ID", "custom-id-123") + r.ServeHTTP(w, req) + + // 验证使用了传入的 ID + headerID := w.Header().Get("X-Request-ID") + if headerID != "custom-id-123" { + t.Errorf("X-Request-ID = %s, want custom-id-123", headerID) + } +} + +func TestGetRequestIDEmpty(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + id := middleware.GetRequestID(c) + c.JSON(200, gin.H{"request_id": id}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + // 没有 RequestID 中间件时,返回空 + if w.Code != 200 { + t.Errorf("status = %d", w.Code) + } +} + +// TestRequestIDSanitizesClientHeader_M15:含换行/超长的客户端 X-Request-ID 应被忽略并重新生成, +// 防头注入与日志伪造。合法 ASCII ID 仍沿用。 +func TestRequestIDSanitizesClientHeader_M15(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.RequestID()) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"request_id": middleware.GetRequestID(c)}) + }) + + // 含 CRLF 的非法 ID 应被忽略、重新生成(非空、无换行)。 + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("X-Request-ID", "evil\r\nX-Forged: 1") + r.ServeHTTP(w, req) + got := w.Header().Get("X-Request-ID") + if strings.Contains(got, "\n") || strings.Contains(got, "\r") || got == "" { + t.Errorf("CRLF-injected request id not sanitized, got %q", got) + } + + // 超长 ID 应被忽略、重新生成。 + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("GET", "/test", nil) + req2.Header.Set("X-Request-ID", strings.Repeat("a", 200)) + r.ServeHTTP(w2, req2) + if len(w2.Header().Get("X-Request-ID")) > 128 { + t.Errorf("overlong request id not regenerated, got len %d", len(w2.Header().Get("X-Request-ID"))) + } + + // 合法 ASCII ID 仍沿用客户端值。 + w3 := httptest.NewRecorder() + req3 := httptest.NewRequest("GET", "/test", nil) + req3.Header.Set("X-Request-ID", "trace-abc-123") + r.ServeHTTP(w3, req3) + if w3.Header().Get("X-Request-ID") != "trace-abc-123" { + t.Errorf("legit request id should be preserved, got %q", w3.Header().Get("X-Request-ID")) + } +} + +// ===== Recover Tests ===== + +func TestRecover(t *testing.T) { + // 需要初始化 logger,否则 Recover 会 panic + // 这里跳过完整测试,仅验证中间件可以正常注册 + r := setupTestRouter() + r.Use(middleware.Recover()) + r.GET("/normal", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/normal", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Normal status = %d, want 200", w.Code) + } +} + +func TestRecoverNoPanic(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.Recover()) + r.GET("/normal", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/normal", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Normal status = %d, want 200", w.Code) + } +} + +// ensureNopLogger 把全局 logger 重置为 Nop,避免 Recover 内 logger.Error +// 在 logger 未初始化时 nil deref 二次 panic 干扰断言。 +func ensureNopLogger() { + _ = logger.Close() // Close 后 Logger/apiLog/dbLog 均为 zap.NewNop(),写日志安全。 +} + +// 回归 C8:默认 ModeBusiness 下,真实触发 panic 必须返回 HTTP 500(而非 200)。 +// 修复前:FailWithCode 经 writeResp 在 ModeBusiness 下写 200 并 flush, +// 随后 AbortWithStatus(500) 因 w.Written()==true 沦为 no-op,客户端收 200 + body code:500。 +func TestRecoverPanicReturns500(t *testing.T) { + ensureNopLogger() + response.SetMode(response.ModeBusiness) // 默认模式,复现 bug 的模式 + defer response.SetMode(response.ModeBusiness) + + r := setupTestRouter() + r.Use(middleware.RequestID(), middleware.Recover()) + r.GET("/panic", func(c *gin.Context) { panic("boom") }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/panic", nil) + r.ServeHTTP(w, req) + + if w.Code != 500 { + t.Errorf("panic status = %d, want 500 (C8: ModeBusiness must not swallow 500 into 200)", w.Code) + } + if got := respCode(t, w); got != response.CodeServerError { + t.Errorf("panic body code = %d, want %d", got, response.CodeServerError) + } + // Custom 保留 RequestID,便于链路追踪。 + var r2 response.Response + if err := json.Unmarshal(w.Body.Bytes(), &r2); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if r2.RequestID == "" { + t.Error("panic response must carry request_id for tracing") + } +} + +// 回归 C8:RecoverWithDetail 同病同治——真实 panic 必须返回 500。 +func TestRecoverWithDetailPanicReturns500(t *testing.T) { + ensureNopLogger() + response.SetMode(response.ModeBusiness) + defer response.SetMode(response.ModeBusiness) + + r := setupTestRouter() + r.Use(middleware.RequestID(), middleware.RecoverWithDetail()) + r.GET("/panic", func(c *gin.Context) { panic("boom") }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/panic", nil) + r.ServeHTTP(w, req) + + if w.Code != 500 { + t.Errorf("RecoverWithDetail panic status = %d, want 500", w.Code) + } + if got := respCode(t, w); got != response.CodeServerError { + t.Errorf("RecoverWithDetail body code = %d, want %d", got, response.CodeServerError) + } +} + +// C8 跨模式一致性:ModeREST 下 panic 同样必须 500(修复前 REST 模式本就 500, +// 此用例锁定两模式行为一致,防止后续回归)。 +func TestRecoverPanicRESTMode500(t *testing.T) { + ensureNopLogger() + response.SetMode(response.ModeREST) + defer response.SetMode(response.ModeBusiness) + + r := setupTestRouter() + r.Use(middleware.RequestID(), middleware.Recover()) + r.GET("/panic", func(c *gin.Context) { panic("boom") }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/panic", nil) + r.ServeHTTP(w, req) + + if w.Code != 500 { + t.Errorf("REST mode panic status = %d, want 500", w.Code) + } +} + +// ===== CSRF Tests ===== + +func TestCSRF(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CSRF()) + r.GET("/form", func(c *gin.Context) { + token := middleware.GetCSRFToken(c) + c.JSON(200, gin.H{"csrf_token": token}) + }) + r.POST("/submit", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // GET 请求应该成功,并设置 cookie + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/form", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("CSRF GET status = %d", w.Code) + } + + // 获取 cookie 中的 token + cookies := w.Result().Cookies() + var csrfToken string + for _, c := range cookies { + if c.Name == "csrf_token" { + csrfToken = c.Value + break + } + } + + // POST 无 token 应该失败 + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/submit", nil) + r.ServeHTTP(w2, req2) + + if w2.Code != 200 { + // 成功捕获错误,返回 200 但 code != 1 + } + + // POST 带 token 应该成功 + w3 := httptest.NewRecorder() + req3 := httptest.NewRequest("POST", "/submit", nil) + req3.Header.Set("X-CSRF-Token", csrfToken) + r.ServeHTTP(w3, req3) + + // 注意:由于 cookie 需要从上一个请求传递,这里可能需要手动设置 +} + +func TestGetCSRFToken(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CSRF()) + r.GET("/token", func(c *gin.Context) { + token := middleware.GetCSRFToken(c) + c.JSON(200, gin.H{"token": token}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/token", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("GetCSRFToken status = %d", w.Code) + } +} + +func TestDoubleSubmitCookie(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.DoubleSubmitCookie()) + r.GET("/get", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + r.POST("/post", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // GET 应该成功 + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/get", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("DoubleSubmit GET status = %d", w.Code) + } + + // POST 无 token 应该失败 + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/post", nil) + r.ServeHTTP(w2, req2) + + // 应返回错误 + if w2.Code != 200 && w2.Code != 400 { + t.Errorf("DoubleSubmit POST without token status = %d", w2.Code) + } +} + +// ===== C6 回归:API 模式 CSRF(map 遮蔽修复 + 单次消费 + TTL) ===== + +// apiCSRFToken 从 GenerateAPIToken 响应体中提取颁发的 token。 +func apiCSRFToken(t *testing.T, w *httptest.ResponseRecorder) string { + t.Helper() + var r response.Response + if err := json.Unmarshal(w.Body.Bytes(), &r); err != nil { + t.Fatalf("unmarshal response %q: %v", w.Body.String(), err) + } + data, ok := r.Data.(map[string]any) + if !ok { + t.Fatalf("response data not an object: %v", r.Data) + } + tok, ok := data["csrf_token"].(string) + if !ok || tok == "" { + t.Fatalf("missing csrf_token in response: %v", r.Data) + } + return tok +} + +func setupAPICSRFRouter() *gin.Engine { + r := setupTestRouter() + r.Use(middleware.CSRFForAPI()) + r.GET("/csrf-token", middleware.GenerateAPIToken) + r.POST("/action", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + return r +} + +// 回归 C6a:颁发 → 校验闭环。修复前颁发的 token 永不在校验 map 里, +// 所有非安全方法请求被判“CSRF Token 无效”拒绝,API CSRF 模式整体不可用。 +func TestCSRFForAPIIssueValidateCycle(t *testing.T) { + r := setupAPICSRFRouter() + + // 颁发 token + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/csrf-token", nil) + r.ServeHTTP(w, req) + if respCode(t, w) != response.CodeSuccess { + t.Fatalf("issue token code = %d, want success", respCode(t, w)) + } + token := apiCSRFToken(t, w) + + // 携带 token 的 POST 必须通过(修复前这里恒失败) + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/action", nil) + req2.Header.Set("X-CSRF-Token", token) + r.ServeHTTP(w2, req2) + if respCode(t, w2) != response.CodeSuccess { + t.Errorf("POST with valid token code = %d, want success (issue→validate cycle broken)", respCode(t, w2)) + } +} + +// 回归 C6b:单次消费——同一 token 第二次使用必须被拒绝,防止重放。 +func TestCSRFForAPISingleUseConsumption(t *testing.T) { + r := setupAPICSRFRouter() + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/csrf-token", nil)) + token := apiCSRFToken(t, w) + + // 首次使用:通过 + w1 := httptest.NewRecorder() + req1 := httptest.NewRequest("POST", "/action", nil) + req1.Header.Set("X-CSRF-Token", token) + r.ServeHTTP(w1, req1) + if respCode(t, w1) != response.CodeSuccess { + t.Fatalf("first use code = %d, want success", respCode(t, w1)) + } + + // 重放:必须拒绝 + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/action", nil) + req2.Header.Set("X-CSRF-Token", token) + r.ServeHTTP(w2, req2) + if respCode(t, w2) == response.CodeSuccess { + t.Error("replayed token must be rejected (single-use consumption broken)") + } +} + +// 回归 C6:缺失 / 伪造 token 必须被拒绝。 +func TestCSRFForAPIInvalidAndMissing(t *testing.T) { + r := setupAPICSRFRouter() + + // 无 token + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("POST", "/action", nil)) + if respCode(t, w) == response.CodeSuccess { + t.Error("POST without token must be rejected") + } + + // 伪造 token + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/action", nil) + req2.Header.Set("X-CSRF-Token", "not-a-real-token") + r.ServeHTTP(w2, req2) + if respCode(t, w2) == response.CodeSuccess { + t.Error("POST with forged token must be rejected") + } +} + +// 回归 C6:安全方法不校验,直接放行。 +func TestCSRFForAPISafeMethodPasses(t *testing.T) { + r := setupAPICSRFRouter() + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/csrf-token", nil)) + if w.Code != 200 { + t.Errorf("safe method status = %d, want 200", w.Code) + } +} + +// 回归 C6c:DoubleSubmitCookie 的 cookie 必须 HttpOnly=false, +// 否则前端 JS 读不到 cookie、无法回填 X-CSRF-Token 头,双重提交对真实前端不可用。 +func TestDoubleSubmitCookieHttpOnlyFalse(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.DoubleSubmitCookie()) + r.GET("/get", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/get", nil)) + + for _, c := range w.Result().Cookies() { + if c.Name == "csrf_token" { + if c.HttpOnly { + t.Errorf("DoubleSubmit cookie HttpOnly = true, want false (JS must read it to refill header)") + } + return + } + } + t.Fatal("csrf_token cookie not set on GET") +} + +// 回归 C6c:前端回填闭环——GET 下发 cookie,POST 携带匹配的 X-CSRF-Token 通过,不匹配拒绝。 +func TestDoubleSubmitCookieFrontendRefill(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.DoubleSubmitCookie()) + r.GET("/get", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + r.POST("/post", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + + // GET 下发 cookie + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/get", nil)) + var cookieToken string + for _, c := range w.Result().Cookies() { + if c.Name == "csrf_token" { + cookieToken = c.Value + } + } + if cookieToken == "" { + t.Fatal("no csrf_token cookie issued") + } + + // 携带匹配 token 的 cookie + header:通过 + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/post", nil) + req2.Header.Set("Cookie", "csrf_token="+cookieToken) + req2.Header.Set("X-CSRF-Token", cookieToken) + r.ServeHTTP(w2, req2) + if w2.Code != 200 { + t.Errorf("POST with matching token status = %d, want 200", w2.Code) + } + + // 不匹配:拒绝 + w3 := httptest.NewRecorder() + req3 := httptest.NewRequest("POST", "/post", nil) + req3.Header.Set("Cookie", "csrf_token="+cookieToken) + req3.Header.Set("X-CSRF-Token", "mismatched") + r.ServeHTTP(w3, req3) + if w3.Code == 200 && respCode(t, w3) == response.CodeSuccess { + t.Error("POST with mismatched token must be rejected") + } +} + +// ===== CORS Tests ===== + +func TestCORS(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORS()) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // 使用 localhost origin(开发环境默认允许) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "http://localhost:3000") + r.ServeHTTP(w, req) + + // 验证请求成功 + if w.Code != 200 { + t.Errorf("CORS status = %d", w.Code) + } + + // 验证其他 CORS 头始终设置 + allowMethods := w.Header().Get("Access-Control-Allow-Methods") + if allowMethods == "" { + t.Error("Access-Control-Allow-Methods should be set") + } +} + +func TestCORSOptions(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORS()) + r.POST("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // OPTIONS 预检请求 + w := httptest.NewRecorder() + req := httptest.NewRequest("OPTIONS", "/test", nil) + r.ServeHTTP(w, req) + + // OPTIONS 应返回 204 + if w.Code != 204 { + t.Errorf("CORS OPTIONS status = %d, want 204", w.Code) + } +} + +// CORS 规范要求:AllowCredentials=false 时不应发送 Access-Control-Allow-Credentials 头。 +// 历史 bug:旧实现在 if/else 两个分支都设了 "true",相当于永远允许凭证。 +func TestCORSAllowCredentialsDefault(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"https://example.com"}, + AllowCredentials: false, + })) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://example.com") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Errorf("Access-Control-Allow-Credentials = %q, want empty when AllowCredentials=false", got) + } +} + +// AllowCredentials=true 且 Origin 在白名单内:发送凭证头并回显具体 Origin。 +func TestCORSAllowCredentialsExplicitOrigin(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"https://example.com"}, + AllowCredentials: true, + })) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://example.com") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\"", got) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" { + t.Errorf("Access-Control-Allow-Origin = %q, want \"https://example.com\"", got) + } + if got := w.Header().Get("Vary"); got != "Origin" { + t.Errorf("Vary = %q, want \"Origin\"", got) + } +} + +// CORS 规范:AllowCredentials=true 时禁止使用 "*",必须回显具体 Origin。 +func TestCORSWildcardWithCredentials(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowCredentials: true, + })) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://anywhere.example") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got == "*" { + t.Errorf("Access-Control-Allow-Origin must NOT be \"*\" when credentials are allowed, got %q", got) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://anywhere.example" { + t.Errorf("Access-Control-Allow-Origin = %q, want echoed origin", got) + } + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\"", got) + } +} + +// AllowedOrigins=["*"] 且 AllowCredentials=false:保持 "*" 通配符语义。 +func TestCORSWildcardWithoutCredentials(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowCredentials: false, + })) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://anywhere.example") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want \"*\"", got) + } + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Errorf("Access-Control-Allow-Credentials = %q, want empty (AllowCredentials=false)", got) + } +} + +// Origin 不在白名单时不应回显,避免反射型 CORS 漏洞。 +func TestCORSOriginNotAllowed(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"https://allowed.example"}, + AllowCredentials: true, + })) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://evil.example") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Access-Control-Allow-Origin = %q, want empty for non-whitelisted origin", got) + } + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Errorf("Access-Control-Allow-Credentials = %q, want empty for non-whitelisted origin", got) + } +} + +// ===== C7 回归:CORS 通配后缀绕过 + 开发态任意 Origin 回显 ===== + +// 回归 C7a:通配符 *.example.com 必须拒绝 notexample.com(后缀相同但非真实子域)。 +// 旧实现 strings.HasSuffix(origin, "example.com") 接受此类绕过。 +func TestCORSWildcardSuffixBypassRejected(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"*.example.com"}, + AllowCredentials: true, + })) + r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + + for _, evil := range []string{"https://notexample.com", "https://evil-example.com"} { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", evil) + r.ServeHTTP(w, req) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("evil origin %q got Allow-Origin %q, want empty (suffix bypass)", evil, got) + } + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Errorf("evil origin %q got Allow-Credentials %q, want empty", evil, got) + } + } + + // 真实子域仍应通过。 + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://app.example.com") + r.ServeHTTP(w, req) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { + t.Errorf("real subdomain got Allow-Origin %q, want echoed", got) + } +} + +// 回归 C7a:通配符不匹配 apex 自身(example.com 不由 *.example.com 覆盖,需显式配置)。 +func TestCORSWildcardDoesNotMatchApex(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"*.example.com"}, + })) + r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://example.com") + r.ServeHTTP(w, req) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("apex origin matched by wildcard got %q, want empty", got) + } +} + +// 回归 C7b:开发态兜底仅对 localhost 回显,不回显任意 Origin(防凭据型反射)。 +// 旧实现 cfg.IsDevelopment() && origin != "" 无条件回显任意 Origin。 +func TestCORSDevModeRejectsArbitraryOrigin(t *testing.T) { + // 注入开发态全局配置(无 CORS 白名单 → 走开发态兜底分支)。 + old := config.Get() + config.Set(&config.Config{ + App: config.AppConfig{Env: "development"}, + Server: config.ServerConfig{Mode: "development"}, + }) + t.Cleanup(func() { + if old != nil { + config.Set(old) + } else { + config.Set(&config.Config{}) + } + }) + + r := setupTestRouter() + r.Use(middleware.CORS()) + r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + + // 非 localhost 的任意 Origin 不应被回显。 + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://evil.com") + r.ServeHTTP(w, req) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("arbitrary origin echoed in dev mode: %q, want empty", got) + } + + // localhost 仍应被回显(开发态正常用法)。 + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("GET", "/test", nil) + req2.Header.Set("Origin", "http://localhost:3000") + r.ServeHTTP(w2, req2) + if got := w2.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" { + t.Errorf("localhost origin in dev mode got %q, want echoed", got) + } +} + +// 回归 C7 收尾:未匹配 origin 不发 Allow-Methods/Headers(收敛信息泄露)。 +// 旧实现无论 origin 是否匹配都无条件发送,向未授权 origin 暴露 API 允许的方法/头清单。 +func TestCORSUnmatchedOriginNoMethodHeaders(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"https://example.com"}, + })) + r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + + // 未匹配 origin:不应发任何 CORS 头(含 Allow-Methods/Headers)。 + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://evil.com") + r.ServeHTTP(w, req) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("unmatched origin got Allow-Origin %q, want empty", got) + } + if got := w.Header().Get("Access-Control-Allow-Methods"); got != "" { + t.Errorf("unmatched origin got Allow-Methods %q, want empty (info leak)", got) + } + if got := w.Header().Get("Access-Control-Allow-Headers"); got != "" { + t.Errorf("unmatched origin got Allow-Headers %q, want empty (info leak)", got) + } + if got := w.Header().Get("Access-Control-Expose-Headers"); got != "" { + t.Errorf("unmatched origin got Expose-Headers %q, want empty", got) + } + if got := w.Header().Get("Access-Control-Max-Age"); got != "" { + t.Errorf("unmatched origin got Max-Age %q, want empty", got) + } + + // 匹配 origin:正常发 Allow-Methods/Headers。 + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("GET", "/test", nil) + req2.Header.Set("Origin", "https://example.com") + r.ServeHTTP(w2, req2) + if got := w2.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" { + t.Errorf("matched origin got Allow-Origin %q, want echoed", got) + } + if got := w2.Header().Get("Access-Control-Allow-Methods"); got == "" { + t.Error("matched origin should have Allow-Methods set") + } + if got := w2.Header().Get("Access-Control-Allow-Headers"); got == "" { + t.Error("matched origin should have Allow-Headers set") + } +} + +// ===== RateLimit Tests ===== + +func TestRateLimit(t *testing.T) { + r := setupTestRouter() + // 使用自定义限流器 + limiter := middleware.NewRateLimiter(10, time.Minute) // 每分钟10次 + defer limiter.Stop() + r.Use(middleware.RateLimit(limiter)) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // 正常请求应该成功 + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + // 单次请求应该成功 + if w.Code != 200 { + t.Errorf("RateLimit status = %d, want 200", w.Code) + } +} + +func TestRateLimiterAllow(t *testing.T) { + limiter := middleware.NewRateLimiter(3, time.Minute) // 每分钟3次 + defer limiter.Stop() + + // 前3次允许 + for i := 0; i < 3; i++ { + if !limiter.Allow("192.168.1.1") { + t.Errorf("Request %d should be allowed", i+1) + } + } + + // 第4次拒绝 + if limiter.Allow("192.168.1.1") { + t.Error("Request 4 should be denied") + } + + // 不同 IP 应该独立计数 + if !limiter.Allow("192.168.1.2") { + t.Error("Different IP should be allowed") + } +} + +func TestCustomRateLimit(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CustomRateLimit(5, time.Minute)) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("CustomRateLimit status = %d", w.Code) + } +} + +// ===== H4b 回归:CustomRateLimit goroutine 泄漏 ===== + +// goroutineCount 返回当前 goroutine 数(经短暂 GC + 让出以稳定读数)。 +func goroutineCount() int { + runtime.GC() + // 给 cleanup goroutine 退出的时间窗口一点余量。 + for i := 0; i < 20; i++ { + n := runtime.NumGoroutine() + _ = n + runtime.Gosched() + } + return runtime.NumGoroutine() +} + +// 回归 H4b:CustomRateLimit 创建的限流器登记入表, +// StopRateLimiters 停止其 cleanup goroutine,无泄漏。 +// 修复前 CustomRateLimit 创建的 limiter 无句柄,StopRateLimiters 不感知 → cleanup goroutine 永久泄漏。 +func TestCustomRateLimitNoGoroutineLeak(t *testing.T) { + // 先清空全局状态(其他测试可能残留)。 + middleware.StopRateLimiters() + + before := goroutineCount() + + // 创建多个自定义限流器(每个启动一个 cleanup goroutine)。 + const n = 5 + r := setupTestRouter() + for i := 0; i < n; i++ { + r.Use(middleware.CustomRateLimit(100, time.Minute)) + } + r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + + // 触发一次请求使中间件生效(limiter 已在构造时创建)。 + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + if w.Code != 200 { + t.Fatalf("status = %d, want 200", w.Code) + } + + created := goroutineCount() + // 创建 n 个限流器后 goroutine 数应明显增加(至少 n 个 cleanup goroutine)。 + if created < before+n { + t.Errorf("after creating %d custom limiters: goroutines = %d, before = %d, want >= before+%d", n, created, before, n) + } + + // StopRateLimiters 须停止登记的自定义限流器 → cleanup goroutine 退出。 + middleware.StopRateLimiters() + + // 等待 cleanup goroutine 退出(Stop 内部 wg.Wait 已保证,但 NumGoroutine 读数有调度延迟)。 + deadline := time.Now().Add(2 * time.Second) + var after int + for time.Now().Before(deadline) { + after = goroutineCount() + if after <= before+2 { // 允许少量调度噪声 + break + } + time.Sleep(10 * time.Millisecond) + } + + if after > before+2 { + t.Errorf("after StopRateLimiters: goroutines = %d, before = %d, expected custom cleanup goroutines released (H4b leak)", after, before) + } +} + +// 回归 H4b:InitRateLimiters 重新初始化时也停止旧的自定义限流器(防 re-init 泄漏)。 +func TestCustomRateLimitReinitStopsOldCustoms(t *testing.T) { + middleware.StopRateLimiters() + + // 基线:InitRateLimiters 只建 3 个命名限流器(3 个 cleanup goroutine),无自定义。 + middleware.InitRateLimiters() + baseline := goroutineCount() + + // 创建 2 个自定义限流器(修复后登记入表)。 + _ = middleware.CustomRateLimit(100, time.Minute) + _ = middleware.CustomRateLimit(100, time.Minute) + + // InitRateLimiters 重建命名限流器时也应停止已登记的自定义限流器。 + // 修复后:自定义 2 个被停止,仅剩 3 个命名 cleanup goroutine → goroutine 数 ≈ baseline。 + // 修复前(不登记):2 个自定义 cleanup goroutine 泄漏 → goroutine 数 ≈ baseline+2。 + middleware.InitRateLimiters() + + deadline := time.Now().Add(2 * time.Second) + var after int + for time.Now().Before(deadline) { + after = goroutineCount() + if after <= baseline+1 { // 允许少量调度噪声 + break + } + time.Sleep(10 * time.Millisecond) + } + + if after > baseline+1 { + t.Errorf("after InitRateLimiters: goroutines = %d, baseline = %d, expected old custom limiters stopped (H4b re-init leak)", after, baseline) + } + + middleware.StopRateLimiters() +} + +// ===== H4a 回归:限流窗口语义 ===== + +// fakeClock 可控时钟,供 RateLimiter 测试注入 nowFunc(避免真实 Sleep flaky)。 +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func newFakeClock() *fakeClock { + return &fakeClock{now: time.Now()} +} + +func (fc *fakeClock) Now() time.Time { + fc.mu.Lock() + defer fc.mu.Unlock() + return fc.now +} + +func (fc *fakeClock) Advance(d time.Duration) { + fc.mu.Lock() + defer fc.mu.Unlock() + fc.now = fc.now.Add(d) +} + +// 回归 H4a:稳态客户端跨窗口持续低于 rate,不被误限。 +// rate=10/100ms。每窗口 8 次(< rate),分散在窗口内。 +// 旧实现放行每次更新 windowStart,致重置分支永不成立、count 跨窗口累加, +// 第 2 个窗口内 count 累加超过 10 被误限——尽管每窗口请求数(8)低于 rate。 +// 修复后 windowStart 仅窗口起点设置,跨窗口重置、稳态低于 rate 永不被限。 +func TestRateLimiterSteadyClientNotBlocked(t *testing.T) { + clock := newFakeClock() + limiter := middleware.NewRateLimiter(10, 100*time.Millisecond) + limiter.SetNowFunc(clock.Now) + defer limiter.Stop() + + // 3 个窗口,每窗口 8 次(< rate=10),每次间隔 12ms(8×12=96ms < 100ms 在窗口内)。 + // 窗口间靠最后一次 Advance 推进到 >100ms 触发跨窗口。 + for w := 0; w < 3; w++ { + for i := 0; i < 8; i++ { + if !limiter.Allow("1.2.3.4") { + t.Fatalf("window %d request %d should be allowed (steady below rate, H4a: count must reset per window)", w+1, i+1) + } + clock.Advance(12 * time.Millisecond) // 窗口内推进 + } + // 8×12=96ms 已过,再推进 8ms 到 104ms > 100ms,进入下一窗口。 + clock.Advance(8 * time.Millisecond) + } +} + +// 回归 H4a:窗口过后 count 重置,可再次放行(固定窗口语义)。 +// 旧实现 lastSeen 每次放行更新,窗口过期分支对持续客户端永不触发。 +func TestRateLimiterWindowReset(t *testing.T) { + clock := newFakeClock() + limiter := middleware.NewRateLimiter(3, time.Minute) + limiter.SetNowFunc(clock.Now) + defer limiter.Stop() + + for i := 0; i < 3; i++ { + if !limiter.Allow("1.2.3.4") { + t.Fatalf("request %d should be allowed", i+1) + } + } + if limiter.Allow("1.2.3.4") { + t.Error("request 4 should be denied (rate exceeded)") + } + clock.Advance(time.Minute + time.Second) + if !limiter.Allow("1.2.3.4") { + t.Error("request after window should be allowed (window reset)") + } +} + +// 回归 H4a:超限被拦(基本限流仍生效,修复不是放宽到无限)。 +func TestRateLimiterBlocksOverRate(t *testing.T) { + limiter := middleware.NewRateLimiter(3, time.Minute) + defer limiter.Stop() + + for i := 0; i < 3; i++ { + if !limiter.Allow("1.2.3.4") { + t.Fatalf("request %d should be allowed", i+1) + } + } + if limiter.Allow("1.2.3.4") { + t.Error("request 4 should be denied") + } +} + +// 回归 H4a:窗口内突发(瞬时集中)达 rate 后拒绝——固定窗口允许突发但封顶 rate。 +func TestRateLimiterBurstCappedAtRate(t *testing.T) { + clock := newFakeClock() + limiter := middleware.NewRateLimiter(5, time.Minute) + limiter.SetNowFunc(clock.Now) + defer limiter.Stop() + + for i := 0; i < 5; i++ { + if !limiter.Allow("1.2.3.4") { + t.Errorf("burst request %d should be allowed", i+1) + } + } + if limiter.Allow("1.2.3.4") { + t.Error("request 6 in same window should be denied") + } +} + +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() + + r := setupTestRouter() + r.Use(middleware.LoginRateLimit()) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("LoginRateLimit status = %d", w.Code) + } +} + +func TestAPIRateLimit(t *testing.T) { + middleware.InitRateLimiters() + defer middleware.StopRateLimiters() + + r := setupTestRouter() + r.Use(middleware.APIRateLimit()) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("APIRateLimit status = %d", w.Code) + } +} + +func TestUploadRateLimit(t *testing.T) { + middleware.InitRateLimiters() + defer middleware.StopRateLimiters() + + r := setupTestRouter() + r.Use(middleware.UploadRateLimit()) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("UploadRateLimit status = %d", w.Code) + } +} + +// ===== RedisRateLimiter Tests ===== + +func TestRedisRateLimiter(t *testing.T) { + limiter := middleware.NewRedisRateLimiter("test_limit", 10, time.Minute) + + // Without Redis, should always allow + ctx := context.Background() + allowed, err := limiter.Allow(ctx, "192.168.1.1") + if err != nil { + t.Errorf("RedisRateLimiter error: %v", err) + } + if !allowed { + t.Error("RedisRateLimiter should allow without Redis") + } +} + +func TestRedisRateLimitMiddleware(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.RedisRateLimit("test_api", 100)) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + // Without Redis, should pass + if w.Code != 200 { + t.Errorf("RedisRateLimit status = %d", w.Code) + } +} + +func TestLoginRedisRateLimit(t *testing.T) { + // H4c: LoginRedisRateLimit 改 fail-closed——无 Redis 时拒绝(503), + // 防爆破场景下 Redis 故障不能静默放行(原 fail-open 致限流失效)。 + // 测试环境无 Redis,故预期 503。 + prev := database.SetTestRedisClient(nil) + defer func() { database.SetTestRedisClient(prev) }() + + r := setupTestRouter() + r.Use(middleware.LoginRedisRateLimit()) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("LoginRedisRateLimit (no Redis, fail-closed) status = %d, want 503", w.Code) + } +} + +func TestAPIRedisRateLimit(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.APIRedisRateLimit()) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("APIRedisRateLimit status = %d", w.Code) + } +} + +func TestCustomRedisRateLimit(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("CustomRedisRateLimit status = %d", w.Code) + } +} + +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) + + ctx := context.Background() + count, err := limiter.GetCount(ctx, "192.168.1.1") + if err != nil { + t.Errorf("GetCount error: %v", err) + } + // Without Redis, count should be 0 + if count != 0 { + t.Errorf("GetCount = %d, want 0 without Redis", count) + } +} + +func TestRedisRateLimiterReset(t *testing.T) { + limiter := middleware.NewRedisRateLimiter("test_reset", 10, time.Minute) + + ctx := context.Background() + err := limiter.Reset(ctx, "192.168.1.1") + if err != nil { + t.Errorf("Reset error: %v", err) + } +} + +// ===== H4c 回归:RedisRateLimiter fail-open/fail-closed + 裸断言 ===== + +// setupMiddlewareMiniRedis 启动 miniredis 并注入 database 内部 redisClient,返回 mr 与清理。 +func setupMiddlewareMiniRedis(t *testing.T) *miniredis.Miniredis { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + old := database.SetTestRedisClient(client) + t.Cleanup(func() { database.SetTestRedisClient(old) }) + return mr +} + +// 回归 H4c-1a:无 Redis 时 fail-open 放行(兼容旧行为)。 +func TestRedisRateLimiterFailOpenNoRedis(t *testing.T) { + prev := database.SetTestRedisClient(nil) + defer func() { database.SetTestRedisClient(prev) }() + + limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute) + allowed, err := limiter.Allow(context.Background(), "1.2.3.4") + if err != nil { + t.Errorf("fail-open no-redis err = %v, want nil", err) + } + if !allowed { + t.Error("fail-open no-redis should allow (兼容旧行为)") + } +} + +// 回归 H4c-1b:无 Redis 时 fail-closed 拒绝 + 返 ErrRedisRateLimiterUnavailable。 +// 修复前无此选项;fail-closed 是 H4c 新增的安全语义。 +func TestRedisRateLimiterFailClosedNoRedis(t *testing.T) { + prev := database.SetTestRedisClient(nil) + defer func() { database.SetTestRedisClient(prev) }() + + 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") + } + if !errors.Is(err, middleware.ErrRedisRateLimiterUnavailable) { + t.Errorf("fail-closed no-redis err = %v, want ErrRedisRateLimiterUnavailable", err) + } +} + +// 回归 H4c-1c:Redis 故障(关闭 miniredis)时 fail-open 放行、fail-closed 拒绝。 +// 这是 H4c 核心——登录防爆破场景下 fail-closed 防限流静默失效。 +func TestRedisRateLimiterFailClosedOnRedisError(t *testing.T) { + mr := setupMiddlewareMiniRedis(t) + + // fail-open:正常时放行。 + openLimiter := middleware.NewRedisRateLimiter("test_open", 10, time.Minute) + allowed, err := openLimiter.Allow(context.Background(), "1.2.3.4") + if err != nil { + t.Fatalf("fail-open normal err = %v", err) + } + if !allowed { + t.Error("fail-open normal should allow") + } + + // fail-closed:正常时放行。 + 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) + } + if !allowed { + t.Error("fail-closed normal should allow") + } + + // 关闭 miniredis 模拟 Redis 故障。 + mr.Close() + + // fail-open:故障时放行(兼容旧行为)。 + allowed, _ = openLimiter.Allow(context.Background(), "1.2.3.4") + if !allowed { + t.Error("fail-open on redis error should allow (兼容旧行为)") + } + + // fail-closed:故障时拒绝(H4c 安全语义)。 + allowed, err = closedLimiter.Allow(context.Background(), "1.2.3.4") + if allowed { + t.Error("fail-closed on redis error should deny (H4c: 防限流静默失效)") + } + if err == nil { + t.Error("fail-closed on redis error should return non-nil err") + } +} + +// 回归 H4c-1d:fail-closed 中间件在 Redis 故障时返 503(区别于真实超限的 429)。 +func TestRedisRateLimitFailClosedMiddlewareReturns503(t *testing.T) { + prev := database.SetTestRedisClient(nil) + defer func() { database.SetTestRedisClient(prev) }() + + r := setupTestRouter() + 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() + r.ServeHTTP(w, httptest.NewRequest("POST", "/login", nil)) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("fail-closed middleware (no redis) status = %d, want 503", w.Code) + } + var body response.Response + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Code != response.CodeServiceUnavailable { + t.Errorf("fail-closed middleware code = %d, want %d", body.Code, response.CodeServiceUnavailable) + } +} + +// 回归 H4c-1e:fail-open 中间件在 Redis 故障时放行(兼容旧行为)。 +func TestRedisRateLimitFailOpenMiddlewareAllowsOnError(t *testing.T) { + prev := database.SetTestRedisClient(nil) + defer func() { database.SetTestRedisClient(prev) }() + + r := setupTestRouter() + r.Use(middleware.RedisRateLimit("api_limit", 100)) + r.GET("/api", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/api", nil)) + + if w.Code != 200 { + t.Errorf("fail-open middleware (no redis) status = %d, want 200", w.Code) + } +} + +// 回归 H4c-2:真实 Redis 闭环——超限被拦(429),未超限放行。 +// 用 miniredis 验证滑动窗口 Lua 脚本与 comma-ok 断言路径在真实 Redis 下正常。 +func TestRedisRateLimiterRealRedisLimitCycle(t *testing.T) { + setupMiddlewareMiniRedis(t) + + limiter := middleware.NewRedisRateLimiter("cycle_limit", 3, time.Minute) + ctx := context.Background() + + // 前 3 次放行。 + for i := 0; i < 3; i++ { + allowed, err := limiter.Allow(ctx, "1.2.3.4") + if err != nil { + t.Fatalf("request %d err = %v", i, err) + } + if !allowed { + t.Errorf("request %d should be allowed", i) + } + } + // 第 4 次拒绝(超限)。 + allowed, err := limiter.Allow(ctx, "1.2.3.4") + if err != nil { + t.Fatalf("over-limit err = %v", err) + } + if allowed { + t.Error("request 4 should be denied (over limit)") + } +} + +// 回归 H4c-2:comma-ok 断言防护——Redis 返回非 int64 时不 panic 而返错。 +// miniredis 的 slidingWindowLua 恒返 int64,无法经 Allow 触发断言失败路径; +// 此处直接用返回字符串的 Lua 脚本验证 result.(int64) 的 comma-ok 行为, +// 并对照证明裸断言会 panic(H4c-2 缺陷根因)。 +func TestRedisRateLimiterCommaOkAssertion(t *testing.T) { + setupMiddlewareMiniRedis(t) + ctx := context.Background() + + // miniredis 执行返回字符串的脚本——模拟 Redis 返回非 int64 结果。 + res, err := database.GetRedis().Eval(ctx, `return 'not-an-int'`, nil).Result() + if err != nil { + t.Fatalf("eval err = %v", err) + } + + // 对照:裸断言在非 int64 时 panic。 + var barePanicked bool + func() { + defer func() { + if r := recover(); r != nil { + barePanicked = true + } + }() + _ = res.(int64) // 旧实现行:裸断言 + }() + if !barePanicked { + t.Error("bare assertion res.(int64) should panic on non-int64 (H4c-2 缺陷根因)") + } + + // 修复:comma-ok 不 panic,返 ok=false。 + if _, ok := res.(int64); ok { + t.Error("comma-ok should return ok=false for non-int64 result") + } +} + +// 回归 H4c-2:SetFailClosed 切换策略——fail-open 限流器切 fail-closed 后无 Redis 拒绝。 +func TestRedisRateLimiterSetFailClosed(t *testing.T) { + prev := database.SetTestRedisClient(nil) + defer func() { database.SetTestRedisClient(prev) }() + + limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute) + // 初始 fail-open:放行。 + if allowed, _ := limiter.Allow(context.Background(), "1.2.3.4"); !allowed { + t.Error("initial fail-open should allow") + } + // 切换 fail-closed:拒绝。 + limiter.SetFailClosed(true) + if allowed, err := limiter.Allow(context.Background(), "1.2.3.4"); allowed { + t.Error("after SetFailClosed(true) should deny") + } else if !errors.Is(err, middleware.ErrRedisRateLimiterUnavailable) { + t.Errorf("after SetFailClosed err = %v, want ErrRedisRateLimiterUnavailable", err) + } +} diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go new file mode 100644 index 0000000..76963f4 --- /dev/null +++ b/middleware/ratelimit.go @@ -0,0 +1,654 @@ +package middleware + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +// RateLimiter 速率限制器(内存版,单实例使用) +type RateLimiter struct { + visitors map[string]*visitor + mu sync.RWMutex + rate int // 每分钟允许的请求数 + window time.Duration // 时间窗口 + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + nowFunc func() time.Time // 时间源(默认 time.Now,测试可注入可控时钟) +} + +type visitor struct { + // windowStart 当前固定窗口的起点。仅在新窗口开始时设置,放行时不变更(H4a 修复)。 + // 旧实现每次放行都更新 lastSeen,导致 time.Since(lastSeen) > window 重置分支对持续 + // 客户端永不成立、count 单调累加,稳态客户端(低于 rate)被误限流。 + windowStart time.Time + count int +} + +// 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), + rate: rate, + window: window, + ctx: ctx, + cancel: cancel, + nowFunc: time.Now, + } + + limiter.wg.Add(1) + go limiter.cleanupVisitors() + + 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 { + return rl.nowFunc() + } + return time.Now() +} + +// SetNowFunc 注入时间源(默认 time.Now),供测试用可控时钟验证窗口语义。 +// 生产代码通常无需调用。 +func (rl *RateLimiter) SetNowFunc(f func() time.Time) { + rl.mu.Lock() + rl.nowFunc = f + rl.mu.Unlock() +} + +// Allow 检查是否允许请求。 +// +// 固定窗口语义(H4a 修复):windowStart 是当前窗口起点,仅在窗口过期重置时变更; +// 放行时不再更新 windowStart。窗口内 count 达到 rate 即拒绝,窗口过期则重置为新窗口。 +// 旧实现每次放行更新 lastSeen,致重置分支对持续客户端永不成立、稳态客户端被误限流。 +// +// 注意:固定窗口算法允许窗口边界突发(两窗口交界处瞬时可达 2×rate)。 +// 如需平滑限流(无突发),请用 Redis 版 RedisRateLimiter(滑动窗口)。 +func (rl *RateLimiter) Allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := rl.now() + v, exists := rl.visitors[ip] + if !exists { + rl.visitors[ip] = &visitor{ + windowStart: now, + count: 1, + } + return true + } + + // 窗口过期:开新窗口,count 重置为 1。 + if now.Sub(v.windowStart) > rl.window { + v.windowStart = now + v.count = 1 + return true + } + + // 当前窗口内已达上限:拒绝(不更新 windowStart)。 + if v.count >= rl.rate { + return false + } + + // 放行:count++,windowStart 不变(固定窗口语义)。 + v.count++ + return true +} + +// cleanupVisitors 清理过期的访问者记录 +func (rl *RateLimiter) cleanupVisitors() { + defer rl.wg.Done() + + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + + for { + select { + case <-rl.ctx.Done(): + return + case <-ticker.C: + rl.mu.Lock() + for ip, v := range rl.visitors { + // 窗口起点超 window 未活跃即淘汰(H4a:windowStart 是窗口起点,不再被放行更新)。 + if rl.now().Sub(v.windowStart) > rl.window { + delete(rl.visitors, ip) + } + } + rl.mu.Unlock() + } + } +} + +// Stop 停止限流器(释放资源) +func (rl *RateLimiter) Stop() { + rl.cancel() + rl.wg.Wait() +} + +// ===== Redis 分布式限流器 ===== + +// H4c: Redis 限流器故障相关错误。 +var ( + // ErrRedisRateLimiterUnavailable Redis 未启用(database.GetRedis() == nil)。 + // fail-closed 限流器在此情况下拒绝请求;fail-open 限流器放行。 + ErrRedisRateLimiterUnavailable = errors.New("redis rate limiter: redis client unavailable") + // ErrRedisRateLimiterUnexpectedResult Redis 返回非预期的结果类型(非 int64)。 + // fail-closed 限流器拒绝;fail-open 限流器放行。旧实现裸断言会 panic。 + ErrRedisRateLimiterUnexpectedResult = errors.New("redis rate limiter: unexpected result type") +) + +// RedisRateLimiter Redis 分布式限流器 +type RedisRateLimiter struct { + keyPrefix string // 键名前缀 + rate int // 每分钟允许的请求数 + window time.Duration // 时间窗口 + 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 脚本 +// 返回: 当前窗口内的请求数 +const slidingWindowLua = ` +local key = KEYS[1] +local now = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local rate = tonumber(ARGV[3]) + +-- 移除窗口外的旧记录 +redis.call('ZREMRANGEBYSCORE', key, 0, now - window) + +-- 获取当前窗口内的请求数 +local count = redis.call('ZCARD', key) + +if count < rate then + -- 添加当前请求 + redis.call('ZADD', key, now, now .. '-' .. math.random()) + redis.call('PEXPIRE', key, window) + return 0 +else + return count +end +` + +// 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,兼容默认) + } + for _, opt := range opts { + if opt != nil { + opt(rl) + } + } + return rl +} + +// SetFailClosed 设置 Redis 故障时的策略:true=拒绝(安全型),false=放行(兼容默认)。 +// 供已创建的限流器切换策略。并发安全(atomic.Store),可与并发 Allow 调用共存。 +func (rl *RedisRateLimiter) SetFailClosed(failClosed bool) { + rl.failClosed.Store(failClosed) +} + +// redisClient 返回注入的 redis client,未注入则回退 database.GetRedis()(jwt 模型)。 +// M-C 教训:取一次复用,避免 nil 检查与 Eval 各调一次 GetRedis() 之间的 CloseRedis 竞态。 +func (rl *RedisRateLimiter) redisClient() *redis.Client { + if rl != nil && rl.client != nil { + return rl.client + } + return database.GetRedis() +} + +// Allow 检查是否允许请求。 +// +// H4c 修复: +// - result.(int64) 改 comma-ok,断言失败返 ErrRedisRateLimiterUnexpectedResult 而非 panic。 +// - Redis 错误/断言失败时按 failClosed 策略决定:fail-closed 返 (false, err) 拒绝, +// fail-open 返 (true, err) 放行(兼容旧行为)。中间件层据此 allowed 值决定放行/拒绝, +// 不再无条件 fail-open--登录防爆破等安全场景用 fail-closed 限流器即可在 Redis 故障时拒绝。 +// +// Redis 未启用(redisClient() == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable), +// fail-open 返 (true, nil)(兼容旧行为)。安全场景必须确保 Redis 已启用。 +// +// Phase 5:redisClient() 注入优先、全局兜底,App 经 WithRedisClient 注入 per-App client。 +func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) { + // M-C 修复:取一次复用。原实现 nil 检查与 Eval 各调一次 GetRedis(), + // 两次之间若 CloseRedis,第二次返回 nil -> nil.Eval panic。 + rdb := rl.redisClient() + if rdb == nil { + if rl.failClosed.Load() { + return false, ErrRedisRateLimiterUnavailable + } + return true, nil + } + + key := rl.keyPrefix + ":" + identifier + now := float64(time.Now().UnixMilli()) + windowMs := float64(rl.window.Milliseconds()) + + result, err := rdb.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result() + if err != nil { + if rl.failClosed.Load() { + return false, err + } + return true, err // 出错时允许请求,避免影响业务(兼容旧行为) + } + + // H4c: comma-ok 断言,避免 Redis 返回非 int64 时 panic。 + count, ok := result.(int64) + if !ok { + if rl.failClosed.Load() { + return false, ErrRedisRateLimiterUnexpectedResult + } + return true, ErrRedisRateLimiterUnexpectedResult + } + return count == 0, nil +} + +// GetCount 获取当前窗口内的请求数 +func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (int64, error) { + // M-C 修复(Phase 5:改 redisClient):取一次复用(原三次调用存在 nil-deref 窗口)。 + rdb := rl.redisClient() + if rdb == nil { + return 0, nil + } + + key := rl.keyPrefix + ":" + identifier + now := time.Now().UnixMilli() + windowStart := now - rl.window.Milliseconds() + + // 移除旧记录并获取当前计数 + rdb.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart)) + return rdb.ZCard(ctx, key).Result() +} + +// Reset 重置限流计数 +func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error { + // M-C 修复(Phase 5:改 redisClient):取一次复用(原两次调用存在 nil-deref 窗口)。 + rdb := rl.redisClient() + if rdb == nil { + return nil + } + + key := rl.keyPrefix + ":" + identifier + 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 丢弃)。 + +// RateLimitRegistry 持一组内存限流器,支持 per-App 隔离。 +type RateLimitRegistry struct { + mu sync.Mutex + loginLimiter *RateLimiter + apiLimiter *RateLimiter + uploadLimiter *RateLimiter + customLimiters []*RateLimiter +} + +// NewRateLimitRegistry 创建限流器注册表实例。 +func NewRateLimitRegistry() *RateLimitRegistry { + return &RateLimitRegistry{} +} + +func (r *RateLimitRegistry) loginLimiterInstance() *RateLimiter { + r.mu.Lock() + defer r.mu.Unlock() + if r.loginLimiter == nil { + r.loginLimiter = NewRateLimiter(10, time.Minute) + } + return r.loginLimiter +} + +func (r *RateLimitRegistry) apiLimiterInstance() *RateLimiter { + r.mu.Lock() + defer r.mu.Unlock() + if r.apiLimiter == nil { + r.apiLimiter = NewRateLimiter(100, time.Minute) + } + return r.apiLimiter +} + +func (r *RateLimitRegistry) uploadLimiterInstance() *RateLimiter { + r.mu.Lock() + defer r.mu.Unlock() + if r.uploadLimiter == nil { + r.uploadLimiter = NewRateLimiter(20, time.Minute) + } + return r.uploadLimiter +} + +// registerCustom 登记自定义限流器供 Stop 统一停止(H4b)。 +func (r *RateLimitRegistry) registerCustom(l *RateLimiter) { + r.mu.Lock() + r.customLimiters = append(r.customLimiters, l) + r.mu.Unlock() +} + +// Init 预初始化标准限流器(可选;不调则首次请求时懒创建)。先停旧的同名限流器释放 goroutine。 +func (r *RateLimitRegistry) Init() { + r.mu.Lock() + defer r.mu.Unlock() + if r.loginLimiter != nil { + r.loginLimiter.Stop() + } + if r.apiLimiter != nil { + r.apiLimiter.Stop() + } + if r.uploadLimiter != nil { + r.uploadLimiter.Stop() + } + r.loginLimiter = NewRateLimiter(10, time.Minute) + r.apiLimiter = NewRateLimiter(100, time.Minute) + r.uploadLimiter = NewRateLimiter(20, time.Minute) +} + +// Stop 停止注册表中所有限流器(释放 cleanup goroutine)。幂等。 +func (r *RateLimitRegistry) Stop() { + r.mu.Lock() + defer r.mu.Unlock() + if r.loginLimiter != nil { + r.loginLimiter.Stop() + r.loginLimiter = nil + } + if r.apiLimiter != nil { + r.apiLimiter.Stop() + r.apiLimiter = nil + } + if r.uploadLimiter != nil { + r.uploadLimiter.Stop() + r.uploadLimiter = nil + } + for _, l := range r.customLimiters { + l.Stop() + } + r.customLimiters = nil +} + +// --- App-bound 中间件(捕获此 Registry,不查全局默认,多 App per-App 隔离) --- +// +// 与包级 LoginRateLimit() 等的区别:包级在请求时查 GetDefaultRateLimitRegistry()(多 App 下 +// 仅最后 Init 的 App 是全局默认,故包级 facade 不提供 per-App 计数隔离);本组方法捕获 r, +// 始终用此 Registry 的 limiter。多 App 场景用 app.RateLimitRegistry().LoginRateLimit() 装配路由。 + +// LoginRateLimit 返回绑定到此 Registry 的登录限流中间件。 +func (r *RateLimitRegistry) LoginRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + rateLimitAllow(c, r.loginLimiterInstance()) + } +} + +// APIRateLimit 返回绑定到此 Registry 的普通 API 限流中间件。 +func (r *RateLimitRegistry) APIRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + rateLimitAllow(c, r.apiLimiterInstance()) + } +} + +// UploadRateLimit 返回绑定到此 Registry 的上传限流中间件。 +func (r *RateLimitRegistry) UploadRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + rateLimitAllow(c, r.uploadLimiterInstance()) + } +} + +// CustomRateLimit 返回绑定到此 Registry 的自定义限流中间件。limiter 在首次请求时创建并 +// 登记到此 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) { + rateLimitAllow(c, limiter) + } +} + +// LoginRateLimit 登录接口速率限制。限流器在首次请求时从当前默认 Registry 懒创建, +// 故 App.Init swap 后用的是 App 的 Registry(避免 pre-Init 注册到 init Registry 被丢弃)。 +func LoginRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + rateLimitAllow(c, GetDefaultRateLimitRegistry().loginLimiterInstance()) + } +} + +// APIRateLimit 普通 API 速率限制。 +func APIRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + rateLimitAllow(c, GetDefaultRateLimitRegistry().apiLimiterInstance()) + } +} + +// UploadRateLimit 上传接口速率限制。 +func UploadRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + rateLimitAllow(c, GetDefaultRateLimitRegistry().uploadLimiterInstance()) + } +} + +// CustomRateLimit 自定义速率限制(内存版)。限流器在首次请求时创建并登记到当前默认 +// Registry(供 Stop 统一停止,避免 cleanup goroutine 泄漏,H4b)。 +func 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) + 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 分布式限流中间件 ===== + +// redisLimitDecision 处理 RedisRateLimiter.Allow 的结果,按 allowed 值决定放行/拒绝。 +// +// H4c: 不再无条件 fail-open。Allow 已按 limiter 的 failClosed 策略把 Redis 故障翻成 +// allowed 值--fail-closed 时 allowed=false(拒绝),fail-open 时 allowed=true(放行)。 +// 故 err 与 allowed 的组合语义: +// - err==nil, allowed==true -> 放行 +// - err==nil, allowed==false -> 真超限,返 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。 + response.Custom(c, http.StatusServiceUnavailable, response.CodeServiceUnavailable, + "限流服务暂时不可用", nil) + c.Abort() + return + } + if !allowed { + response.RateLimit(c) + c.Abort() + return + } + c.Next() +} + +// RedisRateLimit Redis 分布式限流中间件。 +// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景(登录防爆破等) +// 传 WithFailClosed(true),Redis 故障时拒绝以防限流失效。 +func RedisRateLimit(keyPrefix string, rate int, opts ...RedisRateLimiterOption) gin.HandlerFunc { + limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...) + + return func(c *gin.Context) { + identifier := c.ClientIP() + + // 可选:使用用户ID作为标识(登录后) + // userID := GetUserID(c) + // if userID > 0 { + // identifier = fmt.Sprintf("user:%d", userID) + // } + + allowed, err := limiter.Allow(c.Request.Context(), identifier) + redisLimitDecision(c, allowed, err) + } +} + +// 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 := "" + if identifierFunc != nil { + identifier = identifierFunc(c) + } + if identifier == "" { + identifier = c.ClientIP() + } + + allowed, err := limiter.Allow(c.Request.Context(), identifier) + redisLimitDecision(c, allowed, err) + } +} + +// LoginRedisRateLimit 登录接口 Redis 分布式限流(fail-closed)。 +// +// H4c: 登录防爆破场景必须 fail-closed--Redis 故障时若 fail-open 则限流失效、 +// 攻击者可借 Redis 抖动窗口无限爆破。改为 fail-closed:Redis 故障时返 503 拒绝。 +func LoginRedisRateLimit() gin.HandlerFunc { + return RedisRateLimit("login_limit", 10, WithFailClosed(true)) +} + +// APIRedisRateLimit API Redis 分布式限流(fail-open,避免影响业务)。 +func APIRedisRateLimit() gin.HandlerFunc { + return RedisRateLimit("api_limit", 100) +} + +// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-closed)。 +// 上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效。 +func UploadRedisRateLimit() gin.HandlerFunc { + return RedisRateLimit("upload_limit", 20, WithFailClosed(true)) +} + +// CustomRedisRateLimit 自定义 Redis 分布式限流。 +// 默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed。 +func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) gin.HandlerFunc { + limiter := NewRedisRateLimiter(keyPrefix, rate, window, opts...) + + return func(c *gin.Context) { + identifier := c.ClientIP() + allowed, err := limiter.Allow(c.Request.Context(), identifier) + redisLimitDecision(c, allowed, err) + } +} diff --git a/middleware/ratelimit_phase5_internal_test.go b/middleware/ratelimit_phase5_internal_test.go new file mode 100644 index 0000000..081ff45 --- /dev/null +++ b/middleware/ratelimit_phase5_internal_test.go @@ -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)") + }} diff --git a/middleware/recover.go b/middleware/recover.go new file mode 100644 index 0000000..ea1f722 --- /dev/null +++ b/middleware/recover.go @@ -0,0 +1,88 @@ +package middleware + +import ( + "fmt" + "net/http" + "runtime/debug" + + "github.com/EthanCodeCraft/xlgo-core/logger" + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// Recover panic恢复中间件,捕获panic并返回统一错误响应 +func Recover() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + // 获取请求ID + requestID := GetRequestID(c) + + // 记录错误日志 + logger.Error("panic 已恢复", + zap.String("request_id", requestID), + zap.String("error", fmt.Sprintf("%v", err)), + zap.String("path", c.Request.URL.Path), + zap.String("method", c.Request.Method), + zap.String("stack", string(debug.Stack())), + ) + + // 返回错误响应。 + // 必须用 response.Custom 显式写 HTTP 500:FailWithCode 经 writeResp→httpStatusFor, + // 在默认 ModeBusiness 下对 CodeServerError 返回 200,c.JSON(200,...) 会先 flush + // 锁定状态,随后 AbortWithStatus(500) 因 w.Written()==true 沦为 no-op, + // 导致客户端收到 HTTP 200 + body code:500,网关/APM 按 status 看不到 panic。 + // Custom 不受 Mode 影响,直接写 500 且保留 RequestID。 + // + // M-B 修复:若 panic 发生在响应已开始写出之后(流式/部分写),c.Writer.Written()==true, + // gin 拒绝再次写响应,response.Custom 沦为 no-op,客户端会收到截断响应而非 500。 + // 此时不再尝试写 500(无效),仅 Abort + 记录,避免无意义的二次写入。 + if !c.Writer.Written() { + response.Custom(c, http.StatusInternalServerError, response.CodeServerError, "服务器内部错误", nil) + } else { + logger.Warn("panic 发生在响应已开始写入后,无法写入 500,客户端将收到截断响应", + zap.String("request_id", requestID), + zap.String("path", c.Request.URL.Path), + zap.Int("bytes_written", c.Writer.Size()), + ) + } + c.Abort() + } + }() + c.Next() + } +} + +// RecoverWithDetail panic恢复中间件(详细版本,返回更多信息) +// 注意: 生产环境不应使用,会暴露敏感信息 +func RecoverWithDetail() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + requestID := GetRequestID(c) + + logger.Error("panic 已恢复", + zap.String("request_id", requestID), + zap.String("error", fmt.Sprintf("%v", err)), + zap.String("path", c.Request.URL.Path), + zap.String("method", c.Request.Method), + zap.String("stack", string(debug.Stack())), + ) + + // 开发环境返回详细错误。同样用 Custom 显式写 500(见 Recover 注释)。 + // M-B 修复:响应已写出时不重复写 500(同 Recover)。 + if !c.Writer.Written() { + response.Custom(c, http.StatusInternalServerError, response.CodeServerError, fmt.Sprintf("服务器内部错误: %v", err), nil) + } else { + logger.Warn("panic 发生在响应已开始写入后,无法写入 500,客户端将收到截断响应", + zap.String("request_id", requestID), + zap.String("path", c.Request.URL.Path), + ) + } + c.Abort() + } + }() + c.Next() + } +} \ No newline at end of file diff --git a/middleware/requestid.go b/middleware/requestid.go new file mode 100644 index 0000000..8323936 --- /dev/null +++ b/middleware/requestid.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "github.com/EthanCodeCraft/xlgo-core/utils" + "github.com/gin-gonic/gin" +) + +// 请求 ID 校验约束(M15 修复:不无条件信任客户端 X-Request-ID,防头注入/日志伪造)。 +const ( + // requestIDMaxLen 客户端传入请求 ID 的最大长度。超长视为非法并重新生成,避免日志膨胀/注入。 + requestIDMaxLen = 128 +) + +// sanitizeRequestID 校验客户端传入的 X-Request-ID:仅允许可见 ASCII(0x20-0x7e)且 +// 不含 CR/LF(防 HTTP 头注入与日志换行伪造),长度不超过 requestIDMaxLen。 +// 非法或为空时返回 "",由调用方重新生成。 +func sanitizeRequestID(id string) string { + if len(id) == 0 || len(id) > requestIDMaxLen { + return "" + } + for i := 0; i < len(id); i++ { + c := id[i] + if c < 0x20 || c > 0x7e { + return "" + } + } + return id +} + +// RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪。 +// +// 客户端可经 X-Request-ID 头传入 ID 用于链路串联,但会做校验(M15 修复): +// 仅接受可见 ASCII、无换行、长度 ≤128 的值,否则忽略并重新生成——防头注入与日志伪造。 +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := sanitizeRequestID(c.GetHeader("X-Request-ID")) + if requestID == "" { + requestID = utils.UUID() + } + c.Set("request_id", requestID) + c.Header("X-Request-ID", requestID) + c.Next() + } +} + +// GetRequestID 从上下文获取请求ID +func GetRequestID(c *gin.Context) string { + return c.GetString("request_id") +} \ No newline at end of file diff --git a/middleware/timeout.go b/middleware/timeout.go new file mode 100644 index 0000000..7807a2f --- /dev/null +++ b/middleware/timeout.go @@ -0,0 +1,37 @@ +package middleware + +import ( + "context" + "time" + + "github.com/gin-gonic/gin" +) + +// Timeout 请求级超时中间件(#19)。 +// +// 与 http.Server.ReadTimeout(连接级)不同,这是业务级超时: +// 为每个请求的 context 设置 deadline,下游 GORM / Redis / HTTP 调用 +// 走 c.Request.Context() 即可级联取消,避免单个慢请求拖垮协程。 +// +// ⚠️ 软超时语义(M14 文档化):本中间件仅注入带 deadline 的 ctx,不会主动中断 handler。 +// 生效前提是下游实际消费 ctx——GORM/Redis/HTTP 客户端等会响应 ctx 取消;但若 handler +// 是纯 CPU 循环、阻塞于不查 ctx 的调用、或不读 c.Request.Context(),则 deadline 到期 +// 也不会停。需要硬中断的场景请配合 http.Server.WriteTimeout 或在 handler 内显式 select ctx.Done。 +// +// 用法: +// +// r.Use(middleware.Timeout(5 * time.Second)) +// +// d <= 0 时不启用超时(直接 Next)。 +func Timeout(d time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + if d <= 0 { + c.Next() + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), d) + defer cancel() + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} diff --git a/middleware/timeout_test.go b/middleware/timeout_test.go new file mode 100644 index 0000000..a309c4c --- /dev/null +++ b/middleware/timeout_test.go @@ -0,0 +1,72 @@ +package middleware_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/gin-gonic/gin" +) + +func TestTimeoutAllowsFastHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(middleware.Timeout(time.Second)) + r.GET("/ok", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/ok", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestTimeoutCancelsSlowHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(middleware.Timeout(50 * time.Millisecond)) + r.GET("/slow", func(c *gin.Context) { + select { + case <-c.Request.Context().Done(): + // 超时取消生效 + return + case <-time.After(time.Second): + t.Error("handler should have been cancelled by timeout") + } + }) + + w := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + r.ServeHTTP(w, httptest.NewRequest("GET", "/slow", nil)) + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("request did not return in time") + } +} + +func TestTimeoutZeroDisables(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(middleware.Timeout(0)) // 不启用 + ctxCancelled := false + r.GET("/x", func(c *gin.Context) { + ctxCancelled = c.Request.Context() == context.Background() + _ = ctxCancelled + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/x", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} diff --git a/model/base.go b/model/base.go new file mode 100644 index 0000000..8f67b06 --- /dev/null +++ b/model/base.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// BaseModel 基础模型。CreatedAt/UpdatedAt 不显式指定 type,由 GORM 按驱动选默认 +// 时间列类型(MySQL 通常为 datetime(6),保留亚秒精度)。 +type BaseModel struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// BaseModelWithTime 显式指定时间列为 datetime 类型。 +// +// 命名注意(N1):本类型与 BaseModel 的唯一区别是 CreatedAt/UpdatedAt 带 `gorm:"type:datetime"`, +// 二者都有时间戳——名字里的 "WithTime" 易误导。datetime 类型在部分 MySQL 版本下精度为秒 +// (丢毫秒),需毫秒精度请用 BaseModel 或显式 `type:datetime(3)`。保留此类型仅为向后兼容。 +type BaseModelWithTime struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `gorm:"type:datetime" json:"created_at"` + UpdatedAt time.Time `gorm:"type:datetime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/repository/page_internal_test.go b/repository/page_internal_test.go new file mode 100644 index 0000000..622edf8 --- /dev/null +++ b/repository/page_internal_test.go @@ -0,0 +1,72 @@ +package repository + +import ( + "strings" + "testing" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +type pageCountModel struct { + gorm.Model + Name string +} + +// newDryRunDB 用 mysql 驱动 + DryRun 模式构造一个不实际连接数据库的 GORM 实例, +// 仅用于校验生成的 SQL。SkipInitializeWithVersion 避免初始化时执行 SELECT VERSION()。 +func newDryRunDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(mysql.New(mysql.Config{ + DSN: "root:root@tcp(127.0.0.1:3306)/test?parseTime=true", + SkipInitializeWithVersion: true, + }), &gorm.Config{ + DryRun: true, + DisableAutomaticPing: true, + Logger: gormlogger.Default.LogMode(gormlogger.Silent), + SkipDefaultTransaction: true, + }) + if err != nil { + t.Fatalf("open dry-run db: %v", err) + } + return db +} + +// TestQueryBuilderPageCountStripsLimit 验证 Page 的 Count 不受残留 Limit/Offset 影响。 +// 修复前:count SQL 会被包成子查询并带上 LIMIT,导致统计行数被截断。 +// 修复后:count SQL 不应包含 LIMIT。 +func TestQueryBuilderPageCountStripsLimit(t *testing.T) { + db := newDryRunDB(t) + repo := NewBaseRepo[pageCountModel](db) + + qb := repo.NewQueryBuilder(). + Where("name = ?", "foo"). + Limit(10). + Offset(5) + + // 复刻 Page 内部的统计路径(已修复) + countDB := qb.db.Session(&gorm.Session{}).Limit(-1).Offset(-1) + var total int64 + result := countDB.Session(&gorm.Session{}).Count(&total) + sql := result.Statement.SQL.String() + + if strings.Contains(strings.ToUpper(sql), "LIMIT") { + t.Errorf("count SQL should not contain LIMIT (residual limit would truncate total), got: %s", sql) + } +} + +// TestQueryBuilderPageFindKeepsLimit 验证查询路径仍然带分页 Limit(回归保护)。 +func TestQueryBuilderPageFindKeepsLimit(t *testing.T) { + db := newDryRunDB(t) + repo := NewBaseRepo[pageCountModel](db) + + qb := repo.NewQueryBuilder().Where("name = ?", "foo") + var models []pageCountModel + result := qb.db.Session(&gorm.Session{}).Limit(10).Offset(5).Find(&models) + sql := strings.ToUpper(result.Statement.SQL.String()) + + if !strings.Contains(sql, "LIMIT") { + t.Errorf("find SQL should contain LIMIT for pagination, got: %s", sql) + } +} diff --git a/repository/repository.go b/repository/repository.go new file mode 100644 index 0000000..9c7ce6e --- /dev/null +++ b/repository/repository.go @@ -0,0 +1,718 @@ +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 查询 + FindByID(ctx context.Context, id uint) (*T, error) + // Create 创建记录 + Create(ctx context.Context, model *T) error + // Update 更新记录 + Update(ctx context.Context, model *T) error + // Delete 删除记录(T 含软删除字段时为软删除,否则为硬删除;详见 BaseRepo.Delete) + Delete(ctx context.Context, id uint) error + // FindByIDs 批量查询 + FindByIDs(ctx context.Context, ids []uint) ([]T, error) +} + +// BaseRepo 基础仓库实现。 +// +// 连接路由契约(H6c 修复): +// - 读操作经 readConn(ctx) 路由:优先 join 外层事务,否则走 database.GetDBFromContext +// (默认从库,支持 UseMaster/UseReplica 读写分离),DefaultManager 未初始化时回退 r.db。 +// - 写操作经 writeConn(ctx) 路由:优先 join 外层事务,否则走主库 database.GetWriteDB(), +// 回退 r.db。写操作不路由到从库(从库只读)。 +// - 事务内(WithTransaction 创建的 txRepo)所有方法 join 同一事务。 +// +// 下游典型用法 NewBaseRepo[T](database.GetDB()) 仍兼容:GetDB 返回主库,DefaultManager +// 初始化后读操作自动路由到从库;未初始化(如单测注入 sqlite)时回退到 r.db。 +type BaseRepo[T any] struct { + db *gorm.DB + // tx 在事务内(WithTransaction)非 nil,使 txRepo 的方法 join 该事务而非另开连接/路由。 + tx *gorm.DB +} + +// NewBaseRepo 创建基础仓库 +func NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T] { + return &BaseRepo[T]{db: db} +} + +func normalizeContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} + +func (r *BaseRepo[T]) requireDB() *gorm.DB { + if r.db == nil { + panic("repository.BaseRepo: db is nil. Use NewBaseRepo with a valid *gorm.DB") + } + return r.db +} + +// readConn 返回读连接:先校验 repo 构造时注入的 r.db 非 nil,再按 +// 外层 ctx 事务 > 本 repo 事务 > 读写分离路由 > r.db 回退。 +// RP1 修复:r.db 为 nil 时 panic 并给出明确消息,即使 ctx 携带外层事务也要求 repo 构造合法。 +func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB { + ctx = normalizeContext(ctx) + base := r.requireDB() + if tx := database.TxFromContext(ctx); tx != nil { + return tx.WithContext(ctx) + } + if r.tx != nil { + return r.tx.WithContext(ctx) + } + if gdb := database.GetDBFromContext(ctx); gdb != nil { + return gdb.WithContext(ctx) + } + return base.WithContext(ctx) +} + +// writeConn 返回写连接:先校验 repo 构造时注入的 r.db 非 nil,再按 +// 外层 ctx 事务 > 本 repo 事务 > 主库 > r.db 回退。 +// 写操作始终走主库,不路由到只读从库。 +// RP1 修复:r.db 为 nil 时 panic 并给出明确消息,即使 ctx 携带外层事务也要求 repo 构造合法。 +func (r *BaseRepo[T]) writeConn(ctx context.Context) *gorm.DB { + ctx = normalizeContext(ctx) + base := r.requireDB() + if tx := database.TxFromContext(ctx); tx != nil { + return tx.WithContext(ctx) + } + if r.tx != nil { + return r.tx.WithContext(ctx) + } + if mdb := database.GetWriteDB(); mdb != nil { + return mdb.WithContext(ctx) + } + return base.WithContext(ctx) +} + +// FindByID 根据 ID 查询 +func (r *BaseRepo[T]) FindByID(ctx context.Context, id uint) (*T, error) { + var model T + err := r.readConn(ctx).First(&model, id).Error + if err != nil { + return nil, err + } + return &model, nil +} + +// Create 创建记录 +func (r *BaseRepo[T]) Create(ctx context.Context, model *T) error { + return r.writeConn(ctx).Create(model).Error +} + +// Update 更新记录(全列覆写,基于 Save)。 +// +// 注意(H6a):Save 会写入所有字段(包括零值),无法区分"未设置"与"清零", +// 且可能用零值覆盖并发更新。需要局部更新(仅更新非零字段或指定字段)请用 UpdateFields。 +func (r *BaseRepo[T]) Update(ctx context.Context, model *T) error { + return r.writeConn(ctx).Save(model).Error +} + +// UpdateFields 局部更新(H6a):基于 gorm.Updates,仅更新非零字段(struct)或指定字段(map)。 +// +// - 传 struct:仅更新非零字段(零值被忽略,避免覆盖)。 +// - 传 map[string]any:更新指定字段(可显式置零)。 +// +// 示例: +// +// repo.UpdateFields(ctx, &User{Name: "new"}) // struct:仅更新非零字段 name +// repo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map:显式置零,带条件 +func (r *BaseRepo[T]) UpdateFields(ctx context.Context, model any, conds ...any) error { + db := r.writeConn(ctx).Model(new(T)) + if len(conds) > 0 { + db = db.Where(conds[0], conds[1:]...) + } + return db.Updates(model).Error +} + +// Delete 删除记录。 +// +// 行为契约(H6b):若 T 内嵌 gorm.DeletedAt(或 gorm.Model),为软删除; +// 否则为硬删除(泛型类型约束无法在编译期强制)。需硬删用 HardDelete,需恢复用 Restore。 +func (r *BaseRepo[T]) Delete(ctx context.Context, id uint) error { + return r.writeConn(ctx).Delete(new(T), id).Error +} + +// HardDelete 硬删除记录(物理删除) +func (r *BaseRepo[T]) HardDelete(ctx context.Context, id uint) error { + return r.writeConn(ctx).Unscoped().Delete(new(T), id).Error +} + +// FindByIDs 批量查询 +func (r *BaseRepo[T]) FindByIDs(ctx context.Context, ids []uint) ([]T, error) { + if len(ids) == 0 { + return []T{}, nil + } + var models []T + err := r.readConn(ctx).Where("id IN ?", ids).Find(&models).Error + return models, err +} + +// 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 +} + +// Count 统计数量 +func (r *BaseRepo[T]) Count(ctx context.Context) (int64, error) { + var count int64 + err := r.readConn(ctx).Model(new(T)).Count(&count).Error + return count, err +} + +// CountWhere 条件统计 +func (r *BaseRepo[T]) CountWhere(ctx context.Context, query string, args ...any) (int64, error) { + var count int64 + err := r.readConn(ctx).Model(new(T)).Where(query, args...).Count(&count).Error + return count, err +} + +// GetDB 获取数据库实例。 +// 事务内(txRepo)返回当前事务的 tx;否则返回构造时注入的 db。 +// 注意:此方法无 ctx 参数,不参与读写分离路由;需要路由请用具体方法(FindByID/FindPage 等)。 +func (r *BaseRepo[T]) GetDB() *gorm.DB { + if r.tx != nil { + return r.tx + } + return r.db +} + +// ===== 扩展查询功能 ===== + +// FindOne 条件查询单条记录 +func (r *BaseRepo[T]) FindOne(ctx context.Context, query string, args ...any) (*T, error) { + var model T + err := r.readConn(ctx).Where(query, args...).First(&model).Error + if err != nil { + return nil, err + } + return &model, nil +} + +// FindWhere 条件查询多条记录 +func (r *BaseRepo[T]) FindWhere(ctx context.Context, query string, args ...any) ([]T, error) { + var models []T + err := r.readConn(ctx).Where(query, args...).Find(&models).Error + return models, err +} + +// FindWhereOrdered 条件查询并排序。 +// +// H-15 修复(breaking):签名由 (ctx, query, args []any, order) 改为 +// (ctx, order, query string, args ...any),与 FindWhere 等同类方法统一为变长 args。 +// Go 语法要求变长参数必须为最后一个,故 order 前置于 query。 +func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, order, query string, args ...any) ([]T, error) { + if err := validateOrderClause(order); err != nil { + return nil, err + } + var models []T + err := r.readConn(ctx).Where(query, args...).Order(order).Find(&models).Error + return models, err +} + +// FindOrdered 查询并排序 +func (r *BaseRepo[T]) FindOrdered(ctx context.Context, order string, limit int) ([]T, error) { + if err := validateOrderClause(order); err != nil { + return nil, err + } + var models []T + query := r.readConn(ctx).Order(order) + if limit > 0 { + query = query.Limit(limit) + } + err := query.Find(&models).Error + return models, err +} + +// 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 +} + +// ===== 分页查询 ===== + +// PageResult 分页结果 +type PageResult[T any] struct { + Items []T `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + 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 + } + return offset +} + +// FindPage 分页查询。 +// +// count 与 list 包进单事务(H6d),保证 total 与 items 在同一快照下一致, +// 避免高并发下两条独立语句间数据变动致 total/items 不一致。 +// +// 若 ctx 已携带外层事务(database.WithTx),readConn 返回该 tx,此处 .Transaction +// 在其上开 savepoint(gorm 行为),count+list 仍同快照一致;无外层事务则开独立读事务。 +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 { + return e + } + return tx.Offset(offset).Limit(pageSize).Find(&models).Error + }) + if err != nil { + return nil, err + } + return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil +} + +// 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 { + return e + } + return tx.Order(order).Offset(offset).Limit(pageSize).Find(&models).Error + }) + if err != nil { + return nil, err + } + return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil +} + +// FindPageWhere 条件分页查询(count+list 单事务,H6d) +func (r *BaseRepo[T]) FindPageWhere(ctx context.Context, page, pageSize int, query string, args ...any) (*PageResult[T], error) { + var models []T + var total int64 + page, pageSize = normalizePage(page, pageSize) + offset := pageOffset(page, pageSize) + err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error { + if e := tx.Model(new(T)).Where(query, args...).Count(&total).Error; e != nil { + return e + } + return tx.Where(query, args...).Offset(offset).Limit(pageSize).Find(&models).Error + }) + if err != nil { + return nil, err + } + return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil +} + +// FindPageWhereOrdered 条件分页查询并排序(count+list 单事务,H6d)。 +// +// H-15 修复(breaking):签名由 (ctx, page, pageSize, query, args []any, order) 改为 +// (ctx, page, pageSize, order, query string, args ...any),与 FindPageWhere 统一为变长 args。 +// Go 语法要求变长参数必须为最后一个,故 order 前置于 query。 +func (r *BaseRepo[T]) FindPageWhereOrdered(ctx context.Context, page, pageSize int, order, query string, args ...any) (*PageResult[T], error) { + if err := validateOrderClause(order); err != nil { + return nil, err + } + var models []T + var total int64 + page, pageSize = normalizePage(page, pageSize) + offset := pageOffset(page, pageSize) + err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error { + if e := tx.Model(new(T)).Where(query, args...).Count(&total).Error; e != nil { + return e + } + return tx.Where(query, args...).Order(order).Offset(offset).Limit(pageSize).Find(&models).Error + }) + if err != nil { + return nil, err + } + return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil +} + +// ===== 批量操作 ===== + +// CreateBatch 批量创建 +func (r *BaseRepo[T]) CreateBatch(ctx context.Context, models []T) error { + return r.writeConn(ctx).Create(models).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 +} + +// ===== 存在性检查 ===== + +// Exists 检查是否存在 +func (r *BaseRepo[T]) Exists(ctx context.Context, id uint) (bool, error) { + var count int64 + err := r.readConn(ctx).Model(new(T)).Where("id = ?", id).Count(&count).Error + return count > 0, err +} + +// ExistsWhere 条件检查是否存在 +func (r *BaseRepo[T]) ExistsWhere(ctx context.Context, query string, args ...any) (bool, error) { + var count int64 + err := r.readConn(ctx).Model(new(T)).Where(query, args...).Limit(1).Count(&count).Error + return count > 0, err +} + +// ===== 软删除操作 ===== + +// SoftDeleteColumn 返回软删除列名(RP2 修复:可被子类覆盖以适配自定义列名)。 +// 默认 "deleted_at",与 GORM gorm.DeletedAt 的默认列名一致。 +func (r *BaseRepo[T]) SoftDeleteColumn() string { + return "deleted_at" +} + +// Restore 恢复软删除记录 +func (r *BaseRepo[T]) Restore(ctx context.Context, id uint) error { + 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 +} + +// FindAllWithDeleted 查询所有记录(包括软删除) +func (r *BaseRepo[T]) FindAllWithDeleted(ctx context.Context) ([]T, error) { + var models []T + err := r.readConn(ctx).Unscoped().Find(&models).Error + return models, err +} + +// ===== 事务支持 ===== + +// WithTransaction 在事务中执行操作。 +// +// fn 收到的 txRepo 处于事务内,其所有方法(FindByID/Create/Update/...)自动 join 该事务, +// 不再路由到主从库(H6c)。事务在主库上开启。 +// +// 跨 repo / 跨层 join:若 fn 内需调用其它 repo 或 database 层方法参与同一事务, +// 用 database.WithTx(ctx, tx) 把事务注入 ctx 后传递: +// +// return repo.WithTransaction(ctx, func(txRepo *BaseRepo[T]) error { +// if err := txRepo.Create(ctx, a); err != nil { return err } +// // 另一个 repo 也加入同一事务: +// ctx2 := database.WithTx(ctx, txRepo.GetDB()) +// return otherRepo.Update(ctx2, b) +// }) +// +// 注:fn 内捕获的 ctx 不会自动携带 tx;仅 txRepo 的方法 join 事务。 +func (r *BaseRepo[T]) WithTransaction(ctx context.Context, fn func(txRepo *BaseRepo[T]) error) error { + return r.writeConn(ctx).Transaction(func(tx *gorm.DB) error { + txRepo := &BaseRepo[T]{db: r.db, tx: tx} + return fn(txRepo) + }) +} + +// ===== QueryBuilder 链式查询 ===== +// +// QueryBuilder 为单次使用、非并发安全(H6e):链式方法 Where/Or/Order/Limit/Offset 会 +// mutate qb.db,禁止跨 goroutine 共用同一实例或多次终结调用复用累积条件。 +// 终结方法(Find/First/Count/Page)基于 Session 克隆,不污染 qb.db;Count 额外剥离 +// Limit/Offset 避免残留分页条件截断统计。 +// +// 路由:QueryBuilder 查询经构造时注入的 db(通常主库),不参与读写分离路由; +// 需读写分离请用具体方法(FindPage/FindWhere 等)。 + +// QueryBuilder 链式查询构建器 +type QueryBuilder[T any] struct { + db *gorm.DB + limit int + err error +} + +// NewQueryBuilder 创建查询构建器 +func (r *BaseRepo[T]) NewQueryBuilder() *QueryBuilder[T] { + return &QueryBuilder[T]{db: r.requireDB().Model(new(T))} +} + +// Where 添加条件 +func (qb *QueryBuilder[T]) Where(query string, args ...any) *QueryBuilder[T] { + qb.db = qb.db.Where(query, args...) + return qb +} + +// Or 添加 OR 条件 +func (qb *QueryBuilder[T]) Or(query string, args ...any) *QueryBuilder[T] { + qb.db = qb.db.Or(query, args...) + return qb +} + +// 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 +} + +// Offset 设置偏移量 +func (qb *QueryBuilder[T]) Offset(offset int) *QueryBuilder[T] { + qb.db = qb.db.Offset(offset) + return qb +} + +// clone 返回 qb.db 的 Session 克隆,确保终结方法不污染 qb.db(H6e)。 +func (qb *QueryBuilder[T]) clone() *gorm.DB { + return qb.db.Session(&gorm.Session{}) +} + +// Find 执行查询 +func (qb *QueryBuilder[T]) Find(ctx context.Context) ([]T, error) { + if qb.err != nil { + return nil, qb.err + } + ctx = normalizeContext(ctx) + var models []T + err := qb.clone().WithContext(ctx).Find(&models).Error + return models, err +} + +// First 执行查询并返回第一条 +func (qb *QueryBuilder[T]) First(ctx context.Context) (*T, error) { + if qb.err != nil { + return nil, qb.err + } + ctx = normalizeContext(ctx) + var model T + err := qb.clone().WithContext(ctx).First(&model).Error + if err != nil { + return nil, err + } + return &model, nil +} + +// 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 +} + +// Page 执行分页查询。 +// count 与 list 基于同一 Session 克隆,count 剥离 Limit/Offset(H6e)。 +// 注意:与 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) + if err := countDB.WithContext(ctx).Count(&total).Error; err != nil { + return nil, err + } + + offset := pageOffset(page, pageSize) + if err := qb.clone().WithContext(ctx).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil { + return nil, err + } + + return &PageResult[T]{ + Items: models, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +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 +} diff --git a/repository/repository_h6_internal_test.go b/repository/repository_h6_internal_test.go new file mode 100644 index 0000000..1f4c982 --- /dev/null +++ b/repository/repository_h6_internal_test.go @@ -0,0 +1,491 @@ +package repository + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +// h6User 测试模型,内嵌 gorm.Model 支持软删除。 +type h6User struct { + gorm.Model + Name string + Age int +} + +func init() { + // 注册 SQLite 方言,供路由测试通过 database 包级 facade(InitDB 等,代理到 DefaultManager)初始化主从库使用。 + database.RegisterDialect(database.DialectSpec{ + Name: "sqlite", + Aliases: []string{"sqlite3"}, + Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) }, + }) +} + +// newH6SqliteDB 构造一个独立 sqlite 文件的 GORM 实例(不走 DefaultManager), +// 用于 fallback 路径(DefaultManager 未初始化)的行为闭环测试。 +func newH6SqliteDB(t *testing.T) *gorm.DB { + t.Helper() + dir := t.TempDir() + db, err := gorm.Open(sqlite.Open(filepath.Join(dir, "test.db")), &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&h6User{}); err != nil { + t.Fatalf("migrate: %v", err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + return db +} + +// namesFrom 提取 h6User 切片的 Name 集合,便于路由断言。 +func namesFrom(users []h6User) map[string]struct{} { + out := make(map[string]struct{}, len(users)) + for i := range users { + out[users[i].Name] = struct{}{} + } + return out +} + +// ===== H6c fallback 路径(DefaultManager 未初始化,readConn/writeConn 回退 r.db)===== + +// TestH6CrudRoundTrip 验证 CRUD 闭环经 readConn/writeConn 回退 r.db 正常工作。 +func TestH6CrudRoundTrip(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + + u := &h6User{Name: "alice", Age: 30} + if err := repo.Create(ctx, u); err != nil { + t.Fatalf("Create: %v", err) + } + if u.ID == 0 { + t.Fatal("ID not populated after Create") + } + + got, err := repo.FindByID(ctx, u.ID) + if err != nil { + t.Fatalf("FindByID: %v", err) + } + if got.Name != "alice" || got.Age != 30 { + t.Fatalf("unexpected user: %+v", got) + } + + got.Age = 31 + if err := repo.Update(ctx, got); err != nil { + t.Fatalf("Update: %v", err) + } + got2, _ := repo.FindByID(ctx, u.ID) + if got2.Age != 31 { + t.Fatalf("Update not persisted, age=%d", got2.Age) + } + + if err := repo.Delete(ctx, u.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := repo.FindByID(ctx, u.ID); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("after Delete want ErrRecordNotFound, got %v", err) + } +} + +// ===== H6a UpdateFields 局部更新 ===== + +// TestH6UpdateFieldsStructNonZeroOnly 传 struct 仅更新非零字段(零值 Age=0 不覆盖)。 +func TestH6UpdateFieldsStructNonZeroOnly(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + + u := &h6User{Name: "bob", Age: 30} + _ = repo.Create(ctx, u) + + // Name="carol"(非零),Age=0(零值,不应写入) + if err := repo.UpdateFields(ctx, &h6User{Name: "carol"}, "id = ?", u.ID); err != nil { + t.Fatalf("UpdateFields: %v", err) + } + got, _ := repo.FindByID(ctx, u.ID) + if got.Name != "carol" { + t.Fatalf("Name should be carol, got %q", got.Name) + } + if got.Age != 30 { + t.Fatalf("Age should remain 30 (struct zero-value not written), got %d", got.Age) + } +} + +// TestH6UpdateFieldsMapExplicitZero 传 map 可显式置零。 +func TestH6UpdateFieldsMapExplicitZero(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + + u := &h6User{Name: "dave", Age: 30} + _ = repo.Create(ctx, u) + + if err := repo.UpdateFields(ctx, map[string]any{"age": 0}, "id = ?", u.ID); err != nil { + t.Fatalf("UpdateFields map: %v", err) + } + got, _ := repo.FindByID(ctx, u.ID) + if got.Age != 0 { + t.Fatalf("Age should be 0 (map explicit zero), got %d", got.Age) + } +} + +// ===== H6c 事务 join:r.tx 字段(WithTransaction)===== + +// TestH6WithTransactionRollback fn 返回错误 → 回滚,已写记录不持久化。 +func TestH6WithTransactionRollback(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + + err := repo.WithTransaction(ctx, func(txRepo *BaseRepo[h6User]) error { + if e := txRepo.Create(ctx, &h6User{Name: "rollback"}); e != nil { + return e + } + return errors.New("boom") + }) + if err == nil { + t.Fatal("want error from WithTransaction") + } + + all, _ := repo.FindAll(ctx) + if _, ok := namesFrom(all)["rollback"]; ok { + t.Fatal("rollback row should not persist after failed tx") + } +} + +// TestH6WithTransactionCommit fn 成功 → 提交,记录持久化。 +func TestH6WithTransactionCommit(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + + err := repo.WithTransaction(ctx, func(txRepo *BaseRepo[h6User]) error { + return txRepo.Create(ctx, &h6User{Name: "commit"}) + }) + if err != nil { + t.Fatalf("WithTransaction: %v", err) + } + + all, _ := repo.FindAll(ctx) + if _, ok := namesFrom(all)["commit"]; !ok { + t.Fatal("commit row should persist after successful tx") + } +} + +// TestH6WithTransactionTxRepoJoinsTx fn 内 txRepo 的写与读都参与同一事务。 +// 关键:txRepo.Create 写入后,txRepo.FindOne 在事务内可见;若 txRepo 不 join tx +// (走 r.db 回退),FindOne 仍可见但写已 autocommit,回滚测试(上一用例)会失败。 +func TestH6WithTransactionTxRepoJoinsTx(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + + var seenInTx bool + err := repo.WithTransaction(ctx, func(txRepo *BaseRepo[h6User]) error { + if e := txRepo.Create(ctx, &h6User{Name: "in-tx"}); e != nil { + return e + } + got, e := txRepo.FindOne(ctx, "name = ?", "in-tx") + if e != nil { + return e + } + seenInTx = got != nil && got.Name == "in-tx" + return nil + }) + if err != nil { + t.Fatalf("WithTransaction: %v", err) + } + if !seenInTx { + t.Fatal("txRepo.FindOne should see row created within the same tx") + } +} + +// ===== H6c 事务 join:ctx 携带 tx(database.WithTx 跨层 join)===== + +// TestH6WithTxCtxJoinsOuterTx 外层 gorm 事务通过 database.WithTx 注入 ctx, +// repo 方法必须 join 该事务;外层回滚则 repo 写入随之回滚。 +// 红绿:修复前 repo 走 r.db(fallback)autocommit,回滚后行仍存在(红); +// 修复后 repo 取 ctx 的 tx,随外层回滚(绿)。 +func TestH6WithTxCtxJoinsOuterTx(t *testing.T) { + db := newH6SqliteDB(t) + repo := NewBaseRepo[h6User](db) + ctx := context.Background() + + err := db.Transaction(func(tx *gorm.DB) error { + ctx2 := database.WithTx(ctx, tx) + if e := repo.Create(ctx2, &h6User{Name: "outer-tx"}); e != nil { + return e + } + return errors.New("force rollback") + }) + if err == nil { + t.Fatal("want error from outer tx") + } + + all, _ := repo.FindAll(ctx) + if _, ok := namesFrom(all)["outer-tx"]; ok { + t.Fatal("outer-tx row should be rolled back when repo joins outer tx") + } +} + +// TestH6WithTxCtxCommitsWhenOuterCommits 外层提交则 repo 写入持久化(正向闭环)。 +func TestH6WithTxCtxCommitsWhenOuterCommits(t *testing.T) { + db := newH6SqliteDB(t) + repo := NewBaseRepo[h6User](db) + ctx := context.Background() + + err := db.Transaction(func(tx *gorm.DB) error { + ctx2 := database.WithTx(ctx, tx) + return repo.Create(ctx2, &h6User{Name: "outer-commit"}) + }) + if err != nil { + t.Fatalf("outer tx: %v", err) + } + + all, _ := repo.FindAll(ctx) + if _, ok := namesFrom(all)["outer-commit"]; !ok { + t.Fatal("outer-commit row should persist when outer tx commits") + } +} + +// ===== H6d 分页 count+list 单事务 + 基本正确性 ===== + +// TestH6FindPage 验证分页 total 与 items 正确。 +func TestH6FindPage(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + for i := 0; i < 5; i++ { + _ = repo.Create(ctx, &h6User{Name: "u"}) + } + + p1, err := repo.FindPage(ctx, 1, 2) + if err != nil { + t.Fatalf("FindPage: %v", err) + } + if p1.Total != 5 || len(p1.Items) != 2 { + t.Fatalf("page1: total=%d items=%d, want 5/2", p1.Total, len(p1.Items)) + } + + p3, _ := repo.FindPage(ctx, 3, 2) + if len(p3.Items) != 1 { + t.Fatalf("page3 items=%d, want 1", len(p3.Items)) + } +} + +// TestH6FindPageWhere 验证条件分页。 +func TestH6FindPageWhere(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + for i := 0; i < 3; i++ { + _ = repo.Create(ctx, &h6User{Name: "match"}) + } + _ = repo.Create(ctx, &h6User{Name: "other"}) + + p, err := repo.FindPageWhere(ctx, 1, 10, "name = ?", "match") + if err != nil { + t.Fatalf("FindPageWhere: %v", err) + } + if p.Total != 3 || len(p.Items) != 3 { + t.Fatalf("want total=3 items=3, got %d/%d", p.Total, len(p.Items)) + } +} + +// ===== H6b 软删除契约 ===== + +// TestH6DeleteSoftDeletesWithGormModel T 内嵌 gorm.Model 时 Delete 为软删除: +// FindByID 不可见、FindDeleted 可见、Restore 恢复。 +func TestH6DeleteSoftDeletesWithGormModel(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + + u := &h6User{Name: "soft"} + _ = repo.Create(ctx, u) + + if err := repo.Delete(ctx, u.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := repo.FindByID(ctx, u.ID); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("soft-deleted should be invisible to FindByID, got %v", err) + } + deleted, _ := repo.FindDeleted(ctx) + if _, ok := namesFrom(deleted)["soft"]; !ok { + t.Fatal("FindDeleted should see soft-deleted row") + } + if err := repo.Restore(ctx, u.ID); err != nil { + t.Fatalf("Restore: %v", err) + } + if _, err := repo.FindByID(ctx, u.ID); err != nil { + t.Fatalf("after Restore FindByID should succeed, got %v", err) + } +} + +// ===== H6e QueryBuilder 终结方法克隆 + Count 剥离 Limit/Offset ===== + +// TestH6QueryBuilderCountStripsLimit Count 不受残留 Limit/Offset 截断。 +// 红绿:修复前 Count 沿用 qb.db(带 Limit(2)),返回 2(红);修复后剥离返回 5(绿)。 +func TestH6QueryBuilderCountStripsLimit(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + for i := 0; i < 5; i++ { + _ = repo.Create(ctx, &h6User{Name: "q"}) + } + + count, err := repo.NewQueryBuilder().Limit(2).Offset(1).Count(ctx) + if err != nil { + t.Fatalf("Count: %v", err) + } + if count != 5 { + t.Fatalf("Count should be 5 (stripped Limit/Offset), got %d", count) + } +} + +// TestH6QueryBuilderFindKeepsLimit Find 仍受 Limit 约束(回归保护)。 +func TestH6QueryBuilderFindKeepsLimit(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + for i := 0; i < 5; i++ { + _ = repo.Create(ctx, &h6User{Name: "q"}) + } + + got, err := repo.NewQueryBuilder().Limit(2).Find(ctx) + if err != nil { + t.Fatalf("Find: %v", err) + } + if len(got) != 2 { + t.Fatalf("Find with Limit(2) should return 2, got %d", len(got)) + } +} + +// TestH6QueryBuilderPageNoAccumulation 连续 Page 调用不因克隆失效而累积污染。 +func TestH6QueryBuilderPageNoAccumulation(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + for i := 0; i < 4; i++ { + _ = repo.Create(ctx, &h6User{Name: "q"}) + } + + qb := repo.NewQueryBuilder().Where("name = ?", "q") + p1, err := qb.Page(ctx, 1, 2) + if err != nil { + t.Fatalf("Page1: %v", err) + } + p2, err := qb.Page(ctx, 2, 2) + if err != nil { + t.Fatalf("Page2: %v", err) + } + if p1.Total != 4 || len(p1.Items) != 2 { + t.Fatalf("page1: total=%d items=%d, want 4/2", p1.Total, len(p1.Items)) + } + if p2.Total != 4 || len(p2.Items) != 2 { + t.Fatalf("page2: total=%d items=%d, want 4/2", p2.Total, len(p2.Items)) + } +} + +// ===== H6c 路由:DefaultManager 主从读写分离(headline)===== + +// setupH6Manager 初始化 DefaultManager 为 sqlite 主+从(独立文件), +// 并在两侧建表。返回 cleanup 还原全局状态(CloseAll + 置 nil-master)。 +func setupH6Manager(t *testing.T) (masterFile, replicaFile string) { + t.Helper() + dir := t.TempDir() + masterFile = filepath.Join(dir, "master.db") + replicaFile = filepath.Join(dir, "replica.db") + + cfg := &config.Config{} + cfg.Database.Driver = "sqlite" + cfg.Database.CustomDSN = masterFile + + if err := database.InitDBWithReplicas(context.Background(), cfg, []string{replicaFile}); err != nil { + t.Fatalf("InitDBWithReplicas: %v", err) + } + if err := database.GetWriteDB().AutoMigrate(&h6User{}); err != nil { + t.Fatalf("migrate master: %v", err) + } + if err := database.GetReadDB().AutoMigrate(&h6User{}); err != nil { + t.Fatalf("migrate replica: %v", err) + } + t.Cleanup(func() { + _ = database.CloseAll() + }) + return masterFile, replicaFile +} + +// TestH6RoutingReadWriteSplit 验证读操作路由到从库、写操作路由到主库(H6c 核心)。 +// 红绿:修复前 BaseRepo 全部走 r.db(构造捕获的主库),默认读会看到主库标记(红); +// 修复后默认读路由到从库,仅 UseMaster 读主库,写永远落主库(绿)。 +func TestH6RoutingReadWriteSplit(t *testing.T) { + setupH6Manager(t) + ctx := context.Background() + + // 主从分别写入不同标记 + _ = database.GetWriteDB().Create(&h6User{Name: "MASTER"}) + _ = database.GetReadDB().Create(&h6User{Name: "REPLICA"}) + + // 下游典型用法:r.db = 主库(database.GetDB()) + repo := NewBaseRepo[h6User](database.GetDB()) + + // 默认 ctx(无 UseMaster/UseReplica)→ GetDBFromContext → 从库 → 仅 REPLICA + all, err := repo.FindAll(ctx) + if err != nil { + t.Fatalf("FindAll default: %v", err) + } + names := namesFrom(all) + if _, ok := names["REPLICA"]; !ok { + t.Errorf("default read should route to replica and see REPLICA, got %v", names) + } + if _, ok := names["MASTER"]; ok { + t.Errorf("default read should NOT see master-only MASTER, got %v", names) + } + + // UseMaster → 主库 → MASTER + allM, _ := repo.FindAll(database.UseMaster(ctx)) + namesM := namesFrom(allM) + if _, ok := namesM["MASTER"]; !ok { + t.Errorf("UseMaster read should see MASTER, got %v", namesM) + } + + // UseReplica → 从库 → REPLICA + allR, _ := repo.FindAll(database.UseReplica(ctx)) + namesR := namesFrom(allR) + if _, ok := namesR["REPLICA"]; !ok { + t.Errorf("UseReplica read should see REPLICA, got %v", namesR) + } + + // 写操作 → 主库:Create 后 UseMaster 可见,默认(从库)不可见 + if err := repo.Create(ctx, &h6User{Name: "WRITE"}); err != nil { + t.Fatalf("Create: %v", err) + } + afterWriteM, _ := repo.FindAll(database.UseMaster(ctx)) + if _, ok := namesFrom(afterWriteM)["WRITE"]; !ok { + t.Errorf("write should land on master (visible via UseMaster), got %v", namesFrom(afterWriteM)) + } + afterWriteR, _ := repo.FindAll(ctx) // 默认从库 + if _, ok := namesFrom(afterWriteR)["WRITE"]; ok { + t.Errorf("write should NOT appear on replica, got %v", namesFrom(afterWriteR)) + } +} + +// TestH6RoutingWriteConnNeverHitsReplica 写操作即便 UseReplica(ctx) 也走主库。 +func TestH6RoutingWriteConnNeverHitsReplica(t *testing.T) { + setupH6Manager(t) + ctx := context.Background() + + repo := NewBaseRepo[h6User](database.GetDB()) + + // 即便 ctx 标记 UseReplica,写也必须落主库 + if err := repo.Create(database.UseReplica(ctx), &h6User{Name: "W2"}); err != nil { + t.Fatalf("Create under UseReplica: %v", err) + } + onMaster, _ := repo.FindAll(database.UseMaster(ctx)) + if _, ok := namesFrom(onMaster)["W2"]; !ok { + t.Errorf("write under UseReplica must still land on master, got %v", namesFrom(onMaster)) + } +} diff --git a/repository/repository_m5_internal_test.go b/repository/repository_m5_internal_test.go new file mode 100644 index 0000000..0213c33 --- /dev/null +++ b/repository/repository_m5_internal_test.go @@ -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) + } +} diff --git a/repository/repository_test.go b/repository/repository_test.go new file mode 100644 index 0000000..5862721 --- /dev/null +++ b/repository/repository_test.go @@ -0,0 +1,26 @@ +package repository_test + +import ( + "testing" + + "github.com/EthanCodeCraft/xlgo-core/repository" + "gorm.io/gorm" +) + +// N2:原 repository_test.go 全为空注释壳,CRUD 零覆盖。 +// 此处用编译期断言锁定 BaseRepo 实现 BaseRepository 接口契约—— +// 接口一旦与实现漂移,编译即失败(比运行期空壳更有价值)。 + +type testModel struct { + gorm.Model + Name string +} + +// 编译期保证 *BaseRepo[T] 满足 BaseRepository[T] 接口(N2 接口契约守卫)。 +var _ repository.BaseRepository[testModel] = (*repository.BaseRepo[testModel])(nil) + +func TestBaseRepoSatisfiesInterface(t *testing.T) { + // 运行期占位:编译期 var _ 已是主断言,此处仅保证测试函数不被 lint 清理。 + var r repository.BaseRepository[testModel] = repository.NewBaseRepo[testModel](nil) + _ = r +} diff --git a/response/error.go b/response/error.go new file mode 100644 index 0000000..7bde2ca --- /dev/null +++ b/response/error.go @@ -0,0 +1,233 @@ +package response + +import ( + "fmt" + + "github.com/gin-gonic/gin" +) + +// 业务错误码定义 +// 格式:模块(2位) + 功能(2位) + 错误类型(2位) +// +// 约定: +// - CodeSuccess = 0:成功 +// - CodeFail = 1:通用失败 +// - 其余 framework 内置错误码使用 HTTP 风格(401/403/404/429/500/503)或 5 位业务码段 +// - 参数错误等业务化错误码请由业务项目自行定义,框架不再内置 CodeInvalidParams +const ( + // 通用错误 00xxxx + CodeSuccess = 0 // 成功 + CodeFail = 1 // 通用失败 + CodeUnauthorized = 401 // 未授权 + CodeForbidden = 403 // 无权限 + CodeNotFound = 404 // 资源不存在 + CodeRateLimit = 429 // 请求过于频繁 + CodeServerError = 500 // 服务器错误 + CodeServiceUnavailable = 503 // 服务不可用 + + // 用户模块错误 01xxxx + CodeUserNotFound = 10001 // 用户不存在 + CodeUserAlreadyExists = 10002 // 用户已存在 + CodeUserDisabled = 10003 // 用户已禁用 + CodePasswordWrong = 10004 // 密码错误 + CodePasswordWeak = 10005 // 密码强度不足 + CodePhoneInvalid = 10006 // 手机号无效 + CodeEmailInvalid = 10007 // 邮箱无效 + CodeLoginFailed = 10008 // 登录失败 + CodeTokenExpired = 10009 // Token 已过期 + CodeTokenInvalid = 10010 // Token 无效 + + // 文件模块错误 02xxxx + CodeFileNotFound = 20001 // 文件不存在 + CodeFileTooLarge = 20002 // 文件过大 + CodeFileTypeInvalid = 20003 // 文件类型不支持 + CodeFileUploadFailed = 20004 // 文件上传失败 + + // 数据模块错误 03xxxx + CodeDataNotFound = 30001 // 数据不存在 + CodeDataAlreadyExists = 30002 // 数据已存在 + CodeDataInvalid = 30003 // 数据无效 + CodeDataConflict = 30004 // 数据冲突 + + // 业务模块错误 04xxxx + CodeOperationFailed = 40001 // 操作失败 + CodeOperationTimeout = 40002 // 操作超时 + CodeBusinessRuleError = 40003 // 业务规则错误 +) + +// Error 业务错误 +type Error struct { + Code int // 错误码 + Message string // 错误消息 + Detail string // 详细信息(可选) +} + +// NewError 创建业务错误 +func NewError(code int, message string) *Error { + return &Error{ + Code: code, + Message: message, + } +} + +// NewErrorWithDetail 创建带详细信息的业务错误 +func NewErrorWithDetail(code int, message, detail string) *Error { + return &Error{ + Code: code, + Message: message, + Detail: detail, + } +} + +// Error 实现 error 接口 +func (e *Error) Error() string { + if e.Detail != "" { + return fmt.Sprintf("[%d] %s: %s", e.Code, e.Message, e.Detail) + } + return fmt.Sprintf("[%d] %s", e.Code, e.Message) +} + +// WithDetail 添加详细信息。 +// +// H-10 修复:返回新 *Error 拷贝,不 mutate 接收者。预定义 Err*(如 ErrNotFound)是 +// 包级共享指针,原实现 e.Detail = detail 会污染全局且并发调用存在数据竞争。 +// +// nil 接收者:返回 &Error{Detail: detail}(Code=0/Message=""),不 panic;调用方应避免 +// 在 nil 上调用本方法以拿到无 Code/Message 的半成品错误。 +func (e *Error) WithDetail(detail string) *Error { + if e == nil { + return &Error{Detail: detail} + } + return &Error{ + Code: e.Code, + Message: e.Message, + Detail: detail, + } +} + +// ToResponse 转换为响应结构。 +// 把 Detail 放入 data.detail(若非空),保留链路细节(M7:原实现丢 Detail)。 +// 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, + Msg: e.Message, + Data: gin.H{"detail": e.Detail}, + } + } + return Response{ + Code: e.Code, + Msg: e.Message, + } +} + +// 预定义错误 +var ( + ErrUnauthorized = NewError(CodeUnauthorized, "请先登录") + ErrForbidden = NewError(CodeForbidden, "无权限访问") + ErrNotFound = NewError(CodeNotFound, "资源不存在") + ErrRateLimit = NewError(CodeRateLimit, "请求过于频繁") + ErrServerError = NewError(CodeServerError, "服务器错误") + ErrServiceUnavailable = NewError(CodeServiceUnavailable, "服务暂时不可用") + + // 用户相关 + ErrUserNotFound = NewError(CodeUserNotFound, "用户不存在") + ErrUserAlreadyExists = NewError(CodeUserAlreadyExists, "用户已存在") + ErrUserDisabled = NewError(CodeUserDisabled, "用户已禁用") + ErrPasswordWrong = NewError(CodePasswordWrong, "密码错误") + ErrPasswordWeak = NewError(CodePasswordWeak, "密码强度不足") + ErrPhoneInvalid = NewError(CodePhoneInvalid, "手机号无效") + ErrEmailInvalid = NewError(CodeEmailInvalid, "邮箱无效") + ErrLoginFailed = NewError(CodeLoginFailed, "登录失败") + ErrTokenExpired = NewError(CodeTokenExpired, "登录已过期") + ErrTokenInvalid = NewError(CodeTokenInvalid, "Token 无效") + + // 文件相关 + ErrFileNotFound = NewError(CodeFileNotFound, "文件不存在") + ErrFileTooLarge = NewError(CodeFileTooLarge, "文件过大") + ErrFileTypeInvalid = NewError(CodeFileTypeInvalid, "文件类型不支持") + ErrFileUploadFailed = NewError(CodeFileUploadFailed, "文件上传失败") + + // 数据相关 + ErrDataNotFound = NewError(CodeDataNotFound, "数据不存在") + ErrDataAlreadyExists = NewError(CodeDataAlreadyExists, "数据已存在") + ErrDataInvalid = NewError(CodeDataInvalid, "数据无效") + ErrDataConflict = NewError(CodeDataConflict, "数据冲突") + + // 业务相关 + ErrOperationFailed = NewError(CodeOperationFailed, "操作失败") + ErrOperationTimeout = NewError(CodeOperationTimeout, "操作超时") + ErrBusinessRule = NewError(CodeBusinessRuleError, "业务规则错误") +) + +// _errorCodeUniquenessGuard 在编译期保证所有内置 Code* 不重复。 +// Go spec: 同一个常量 key 在 map 字面量中出现两次会触发 +// "duplicate key in map literal" 编译错误,从而把"错误码不能撞" +// 这件事写进类型系统,比 init() 里 panic 检查更早、更安全。 +// +// 维护规则:新增 Code* 常量时,**必须**把它登记到这里。 +// 注意:变量名故意以下划线开头并赋给 _,避免 unused 报错且不会进入 runtime。 +var _ = map[int]string{ + CodeSuccess: "CodeSuccess", + CodeFail: "CodeFail", + CodeUnauthorized: "CodeUnauthorized", + CodeForbidden: "CodeForbidden", + CodeNotFound: "CodeNotFound", + CodeRateLimit: "CodeRateLimit", + CodeServerError: "CodeServerError", + CodeServiceUnavailable: "CodeServiceUnavailable", + + CodeUserNotFound: "CodeUserNotFound", + CodeUserAlreadyExists: "CodeUserAlreadyExists", + CodeUserDisabled: "CodeUserDisabled", + CodePasswordWrong: "CodePasswordWrong", + CodePasswordWeak: "CodePasswordWeak", + CodePhoneInvalid: "CodePhoneInvalid", + CodeEmailInvalid: "CodeEmailInvalid", + CodeLoginFailed: "CodeLoginFailed", + CodeTokenExpired: "CodeTokenExpired", + CodeTokenInvalid: "CodeTokenInvalid", + + CodeFileNotFound: "CodeFileNotFound", + CodeFileTooLarge: "CodeFileTooLarge", + CodeFileTypeInvalid: "CodeFileTypeInvalid", + CodeFileUploadFailed: "CodeFileUploadFailed", + + CodeDataNotFound: "CodeDataNotFound", + CodeDataAlreadyExists: "CodeDataAlreadyExists", + CodeDataInvalid: "CodeDataInvalid", + CodeDataConflict: "CodeDataConflict", + + CodeOperationFailed: "CodeOperationFailed", + CodeOperationTimeout: "CodeOperationTimeout", + CodeBusinessRuleError: "CodeBusinessRuleError", +} + +// FailWithError 使用预定义错误响应(M7 修复:通过 ToResponse 保留 Detail)。 +func FailWithError(c *gin.Context, err *Error) { + resp := err.ToResponse() + writeResp(c, resp.Code, resp.Msg, resp.Data) +} + +// 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 + } + writeResp(c, err.Code, err.Message, nil) +} diff --git a/response/error_test.go b/response/error_test.go new file mode 100644 index 0000000..f4f4194 --- /dev/null +++ b/response/error_test.go @@ -0,0 +1,67 @@ +package response_test + +import ( + "sync" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/response" +) + +// TestWithDetailDoesNotMutateOriginal H-10 回归:WithDetail 必须返回新拷贝, +// 不得 mutate 共享的预定义 Err*。修复前 WithDetail 会写 e.Detail 污染全局。 +func TestWithDetailDoesNotMutateOriginal(t *testing.T) { + if response.ErrNotFound.Detail != "" { + t.Fatalf("预置条件:ErrNotFound.Detail 应为空,实际 %q", response.ErrNotFound.Detail) + } + + detailed := response.ErrNotFound.WithDetail("用户 123 不存在") + + // 原共享对象不得被污染 + if response.ErrNotFound.Detail != "" { + t.Fatalf("WithDetail 污染了共享 ErrNotFound,Detail=%q", response.ErrNotFound.Detail) + } + // 新拷贝携带 detail + if detailed.Detail != "用户 123 不存在" { + t.Fatalf("新 Error 应携带 detail,实际 %q", detailed.Detail) + } + if detailed.Code != response.ErrNotFound.Code || detailed.Message != response.ErrNotFound.Message { + t.Fatalf("新 Error 应继承 Code/Message,实际 Code=%d Message=%q", detailed.Code, detailed.Message) + } +} + +// TestWithDetailConcurrentOnSharedError H-10 回归:并发在共享 Err* 上调 WithDetail +// 不应触发数据竞争(修复前 mutate 共享对象,-race 必采)。 +func TestWithDetailConcurrentOnSharedError(t *testing.T) { + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + e := response.ErrNotFound.WithDetail("concurrent") + if e.Detail != "concurrent" { + t.Errorf("Detail 应为 concurrent,实际 %q", e.Detail) + } + }(i) + } + wg.Wait() +} + +// TestExposeDetailGating P1 #15:SetExposeDetail(false) 时 ToResponse 不得输出 Detail; +// true 时输出。默认(未设置)为 true,保持存量行为。 +func TestExposeDetailGating(t *testing.T) { + t.Cleanup(func() { response.SetExposeDetail(true) }) // 还原默认 + + err := response.NewErrorWithDetail(response.CodeServerError, "服务器错误", "pq: relation \"users\" does not exist") + + // 暴露开启(默认/开发):Detail 出现在 Data 中 + response.SetExposeDetail(true) + if resp := err.ToResponse(); resp.Data == nil { + t.Error("SetExposeDetail(true): ToResponse 应包含 detail") + } + + // 暴露关闭(生产):Detail 不得出现,防内部错误泄露 + response.SetExposeDetail(false) + if resp := err.ToResponse(); resp.Data != nil { + t.Errorf("SetExposeDetail(false): ToResponse 不得输出 detail,实际 Data=%v", resp.Data) + } +} diff --git a/response/mode.go b/response/mode.go new file mode 100644 index 0000000..961af03 --- /dev/null +++ b/response/mode.go @@ -0,0 +1,110 @@ +package response + +import ( + "net/http" + "sync/atomic" + + "github.com/gin-gonic/gin" +) + +// Mode 响应模式 +type Mode int32 + +const ( + // ModeBusiness 业务码模式(默认):所有响应 HTTP 200,错误信息通过 body 中的 code 表达。 + // 兼容存量"业务码 in body"玩法。 + ModeBusiness Mode = iota + // ModeREST REST 模式:失败响应按业务码映射对应的 HTTP status(401/404/500...), + // body 仍带业务码。便于 APM / Prometheus / 网关 / Sentry 按 status 区分异常。 + ModeREST +) + +// currentMode 当前响应模式,默认 ModeBusiness。原子读写,可在运行时切换。 +var currentMode atomic.Int32 + +// SetMode 设置全局响应模式。 +func SetMode(m Mode) { currentMode.Store(int32(m)) } + +// GetMode 返回当前响应模式。 +func GetMode() Mode { return Mode(currentMode.Load()) } + +// exposeDetail 控制是否把 Error.Detail 写入发给客户端的响应体(P1 #15)。 +// 默认 true(保持存量行为:直接使用 response 包的项目不受影响)。 +// App 在 Init 时按环境设置:生产环境置为 false,避免调用方误把内部错误(SQL 报错、 +// 堆栈上下文等)塞进 Detail 而泄露给客户端;开发环境保持 true 便于排查。 +// 1 表示暴露,0 表示隐藏。 +var exposeDetail atomic.Int32 + +func init() { + exposeDetail.Store(1) // 默认暴露,保持存量行为 +} + +// SetExposeDetail 设置是否向客户端暴露 Error.Detail(P1 #15)。 +// 生产环境建议置为 false,防止内部错误细节泄露。并发安全。 +func SetExposeDetail(expose bool) { + if expose { + exposeDetail.Store(1) + } else { + exposeDetail.Store(0) + } +} + +// detailExposed 返回当前是否暴露 Detail。 +func detailExposed() bool { return exposeDetail.Load() == 1 } + +// statusForCode 按业务码推断 HTTP status(用于 ModeREST)。 +// 已知框架错误码显式映射;用户/文件/数据模块的业务错误码(1xxxx~3xxxx) +// 属业务语义而非 HTTP 错误,保持 200;操作失败类(4xxxx)映射 400。 +func statusForCode(code int) int { + switch code { + case CodeSuccess: + return http.StatusOK + case CodeUnauthorized, CodeTokenExpired, CodeTokenInvalid: + return http.StatusUnauthorized + case CodeForbidden: + return http.StatusForbidden + case CodeNotFound, CodeUserNotFound, CodeFileNotFound, CodeDataNotFound: + return http.StatusNotFound + case CodeDataConflict, CodeDataAlreadyExists: + return http.StatusConflict + case CodeRateLimit: + return http.StatusTooManyRequests + case CodeServerError: + return http.StatusInternalServerError + case CodeServiceUnavailable: + return http.StatusServiceUnavailable + } + if code >= 40000 && code < 50000 { + return http.StatusBadRequest + } + return http.StatusOK +} + +// httpStatusFor 返回当前模式下失败响应对应的 HTTP status。 +func httpStatusFor(code int) int { + if GetMode() == ModeREST { + return statusForCode(code) + } + return http.StatusOK +} + +// writeResp 统一写入响应,按当前 Mode 决定 HTTP status。 +func writeResp(c *gin.Context, code int, msg string, data any) { + c.JSON(httpStatusFor(code), Response{ + Code: code, + Msg: msg, + Data: data, + RequestID: getRequestID(c), + }) +} + +// Custom 显式指定 HTTP status 与业务码的响应,不受 Mode 影响。 +// 适用于需要精确控制 HTTP status 的场景(如 REST 模式下的特殊端点)。 +func Custom(c *gin.Context, httpStatus, code int, msg string, data any) { + c.JSON(httpStatus, Response{ + Code: code, + Msg: msg, + Data: data, + RequestID: getRequestID(c), + }) +} diff --git a/response/mode_test.go b/response/mode_test.go new file mode 100644 index 0000000..207c6b0 --- /dev/null +++ b/response/mode_test.go @@ -0,0 +1,106 @@ +package response_test + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" +) + +// withMode 切换响应模式并在测试结束后恢复为默认 ModeBusiness。 +func withMode(t *testing.T, m response.Mode) { + t.Helper() + response.SetMode(m) + t.Cleanup(func() { response.SetMode(response.ModeBusiness) }) +} + +func TestSetGetMode(t *testing.T) { + withMode(t, response.ModeREST) + if response.GetMode() != response.ModeREST { + t.Errorf("GetMode = %v, want ModeREST", response.GetMode()) + } +} + +func TestModeBusinessReturns200(t *testing.T) { + withMode(t, response.ModeBusiness) + r := setupTestRouter() + r.GET("/u", func(c *gin.Context) { response.Unauthorized(c, "no") }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/u", nil)) + + if w.Code != 200 { + t.Errorf("ModeBusiness Unauthorized status = %d, want 200", w.Code) + } +} + +func TestModeRESTMapsStatus(t *testing.T) { + withMode(t, response.ModeREST) + cases := []struct { + path string + fn func(c *gin.Context) + wantCode int + }{ + {"/unauth", func(c *gin.Context) { response.Unauthorized(c, "no") }, 401}, + {"/notfound", func(c *gin.Context) { response.NotFound(c, "no") }, 404}, + {"/server", func(c *gin.Context) { response.ServerError(c, "err") }, 500}, + {"/ratelimit", func(c *gin.Context) { response.RateLimit(c) }, 429}, + {"/fail", func(c *gin.Context) { response.Fail(c, "bad") }, 200}, // CodeFail 不映射 + } + for _, tc := range cases { + r := setupTestRouter() + r.GET(tc.path, tc.fn) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil)) + if w.Code != tc.wantCode { + t.Errorf("%s status = %d, want %d", tc.path, w.Code, tc.wantCode) + } + // body 仍带业务码 + var body response.Response + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Errorf("%s body unmarshal: %v", tc.path, err) + } + if body.Code == 0 && tc.path != "/fail" { + // 非 Fail 路径 code 应非 0(Fail 的 CodeFail=1 也非 0,跳过) + } + } +} + +func TestCustomIgnoresMode(t *testing.T) { + withMode(t, response.ModeBusiness) // 即使 business 模式,Custom 也用指定 status + r := setupTestRouter() + r.GET("/c", func(c *gin.Context) { response.Custom(c, 418, 999, "teapot", nil) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/c", nil)) + if w.Code != 418 { + t.Errorf("Custom status = %d, want 418", w.Code) + } +} + +func TestFailWithErrorREST(t *testing.T) { + withMode(t, response.ModeREST) + r := setupTestRouter() + r.GET("/e", func(c *gin.Context) { response.FailWithError(c, response.ErrUnauthorized) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/e", nil)) + if w.Code != 401 { + t.Errorf("FailWithError(ErrUnauthorized) status = %d, want 401", w.Code) + } +} + +// TestDataAlreadyExistsMapsConflict_B7:CodeDataAlreadyExists 应映射 409(与 CodeDataConflict 一致)。 +func TestDataAlreadyExistsMapsConflict_B7(t *testing.T) { + withMode(t, response.ModeREST) + r := setupTestRouter() + r.GET("/dup", func(c *gin.Context) { response.FailWithError(c, response.ErrDataAlreadyExists) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/dup", nil)) + if w.Code != 409 { + t.Errorf("ErrDataAlreadyExists status = %d, want 409", w.Code) + } +} diff --git a/response/response.go b/response/response.go new file mode 100644 index 0000000..e114860 --- /dev/null +++ b/response/response.go @@ -0,0 +1,170 @@ +package response + +import ( + "bytes" + "io" + "net/http" + "net/url" + + "github.com/gin-gonic/gin" +) + +// Response 统一响应结构 +// +// M-38/M-39 修复(breaking):Data/RequestID 去掉 omitempty,让 data 与 request_id +// 字段在所有响应中恒存在(失败时 data=null、未装 RequestID 中间件时 request_id="")。 +// 原实现用 omitempty 导致失败响应无 data 字段、空切片被 omit、未装中间件时无 request_id, +// 破坏"统一响应结构"契约,下游严格按 schema 解析会出错。 +type Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data any `json:"data"` + RequestID string `json:"request_id"` // 请求追踪ID +} + +// PageData 分页数据结构 +type PageData struct { + Items any `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// getRequestID 从上下文获取请求ID +func getRequestID(c *gin.Context) string { + return c.GetString("request_id") +} + +// Success 成功响应 +func Success(c *gin.Context, data any) { + c.JSON(http.StatusOK, Response{ + Code: CodeSuccess, + Msg: "操作成功", + Data: data, + RequestID: getRequestID(c), + }) +} + +// SuccessWithMsg 成功响应(自定义消息) +func SuccessWithMsg(c *gin.Context, msg string, data any) { + c.JSON(http.StatusOK, Response{ + Code: CodeSuccess, + Msg: msg, + Data: data, + RequestID: getRequestID(c), + }) +} + +// Fail 失败响应 +func Fail(c *gin.Context, msg string) { + writeResp(c, CodeFail, msg, nil) +} + +// FailWithCode 失败响应(自定义错误码) +func FailWithCode(c *gin.Context, code int, msg string) { + writeResp(c, code, msg, nil) +} + +// Unauthorized 未授权响应 +func Unauthorized(c *gin.Context, msg string) { + writeResp(c, CodeUnauthorized, msg, nil) +} + +// NotFound 资源不存在响应 +func NotFound(c *gin.Context, msg string) { + writeResp(c, CodeNotFound, msg, nil) +} + +// ServerError 服务器错误响应 +func ServerError(c *gin.Context, msg string) { + writeResp(c, CodeServerError, msg, nil) +} + +// RateLimit 请求过于频繁响应 +func RateLimit(c *gin.Context) { + writeResp(c, CodeRateLimit, "请求过于频繁,请稍后再试", nil) +} + +// Page 分页响应 +func Page(c *gin.Context, items any, total int64, page, pageSize int) { + Success(c, PageData{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + }) +} + +// contentDisposition 生成 RFC 5987 兼容的 Content-Disposition 值(M6 修复)。 +// 同时给出 ASCII filename(向后兼容旧客户端)与 filename*(UTF-8 百分号编码,支持中文等非 ASCII), +// 避免直接拼接导致中文文件名乱码。 +func contentDisposition(filename string) string { + ascii := asciiFallbackName(filename) + enc := url.PathEscape(filename) + // PathEscape 把空格编码为 %20(符合 RFC 5987),无需额外处理。 + return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + enc +} + +// asciiFallbackName 生成仅含 ASCII 的回退文件名:非 ASCII 字符替换为下划线, +// 空文件名回退为 "download"。 +func asciiFallbackName(filename string) string { + if filename == "" { + return "download" + } + b := make([]byte, 0, len(filename)) + for i := 0; i < len(filename); i++ { + c := filename[i] + if c >= 0x20 && c < 0x7f && c != '"' && c != '\\' { + b = append(b, c) + } else { + b = append(b, '_') + } + } + if len(b) == 0 { + return "download" + } + return string(b) +} + +// Download 文件下载响应 +// Compatibility note: for large files or object-storage streams, prefer +// DownloadReader so the whole object is not buffered in []byte first. +func Download(c *gin.Context, filename string, data []byte) { + 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) { + 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) +} + +// Redirect 页面跳转 +func Redirect(c *gin.Context, code int, url string) { + c.Redirect(code, url) +} diff --git a/response/response_test.go b/response/response_test.go new file mode 100644 index 0000000..c979204 --- /dev/null +++ b/response/response_test.go @@ -0,0 +1,399 @@ +package response_test + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" +) + +func setupTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + return r +} + +func TestSuccess(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.Success(c, gin.H{"message": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Success status = %d", w.Code) + } + + // 验证响应体包含 code=0 + body := w.Body.String() + if !contains(body, `"code":0`) { + t.Errorf("Success body should contain code:0, got %s", body) + } +} + +func TestSuccessWithMsg(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.SuccessWithMsg(c, "操作成功", gin.H{"id": 1}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("SuccessWithMsg status = %d", w.Code) + } +} + +func TestFail(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.Fail(c, "参数错误") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Fail status = %d", w.Code) + } + + body := w.Body.String() + if !contains(body, `"code":1`) { + t.Errorf("Fail body should contain code:1, got %s", body) + } +} + +func TestFailWithCode(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.FailWithCode(c, response.CodeUnauthorized, "未授权") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("FailWithCode status = %d", w.Code) + } +} + +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) { + response.Unauthorized(c, "请先登录") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Unauthorized status = %d", w.Code) + } +} + +func TestNotFound(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.NotFound(c, "资源不存在") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("NotFound status = %d", w.Code) + } +} + +func TestServerError(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.ServerError(c, "服务器错误") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("ServerError status = %d", w.Code) + } +} + +func TestRateLimit(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.RateLimit(c) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("RateLimit status = %d", w.Code) + } + + body := w.Body.String() + if !contains(body, `"code":`) { + t.Errorf("RateLimit body should contain code, got %s", body) + } +} + +func TestPage(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + items := []string{"a", "b", "c"} + response.Page(c, items, 100, 1, 20) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Page status = %d", w.Code) + } + + body := w.Body.String() + if !contains(body, `"total":100`) { + t.Errorf("Page body should contain total:100, got %s", body) + } +} + +func TestDownload(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + data := []byte("test file content") + response.Download(c, "test.txt", data) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Download status = %d", w.Code) + } + + // 验证响应头 + contentType := w.Header().Get("Content-Type") + if contentType != "application/octet-stream" { + t.Errorf("Download Content-Type = %s, want application/octet-stream", contentType) + } + + disposition := w.Header().Get("Content-Disposition") + if !contains(disposition, "test.txt") { + t.Errorf("Download Content-Disposition should contain filename, got %s", disposition) + } +} + +// TestDownloadChineseFilename_M6:中文文件名经 RFC 5987 编码(filename*),不乱码。 +func TestDownloadChineseFilename_M6(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.Download(c, "报告.txt", []byte("x")) + }) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + disposition := w.Header().Get("Content-Disposition") + // 应含 RFC 5987 编码的 filename*(百分号编码),且无原始 UTF-8 字节直接拼入 filename= 段 + if !contains(disposition, "filename*=UTF-8''") { + t.Fatalf("Content-Disposition should contain filename*=UTF-8''..., got %s", disposition) + } + if !contains(disposition, "%E6%8A%A5%E5%91%8A") { // "报告" 的百分号编码 + t.Fatalf("Content-Disposition should contain percent-encoded 报告, got %s", disposition) + } + // ASCII 回退名不应为空 + if !contains(disposition, `filename="`) { + t.Fatalf("Content-Disposition should contain ASCII fallback filename, got %s", disposition) + } +} + +func TestDownloadWithContentType(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + data := []byte("test file content") + response.DownloadWithContentType(c, "test.pdf", "application/pdf", data) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("DownloadWithContentType status = %d", w.Code) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/pdf" { + t.Errorf("DownloadWithContentType = %s, want application/pdf", contentType) + } +} + +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) { + response.HTML(c, "Hello") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("HTML status = %d", w.Code) + } + + contentType := w.Header().Get("Content-Type") + if !contains(contentType, "text/html") { + t.Errorf("HTML Content-Type should be text/html, got %s", contentType) + } +} + +func TestRedirect(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.Redirect(c, 302, "/new-location") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + // 重定向返回 302 + if w.Code != 302 { + t.Errorf("Redirect status = %d, want 302", w.Code) + } + + location := w.Header().Get("Location") + if location != "/new-location" { + t.Errorf("Redirect Location = %s, want /new-location", location) + } +} + +func TestErrorCodes(t *testing.T) { + // 测试错误码定义 + if response.CodeSuccess != 0 { + t.Errorf("CodeSuccess = %d, want 0", response.CodeSuccess) + } + if response.CodeFail != 1 { + t.Errorf("CodeFail = %d, want 1", response.CodeFail) + } + if response.CodeUnauthorized == 0 { + t.Error("CodeUnauthorized should not be 0") + } + if response.CodeNotFound == 0 { + t.Error("CodeNotFound should not be 0") + } + if response.CodeServerError == 0 { + t.Error("CodeServerError should not be 0") + } + if response.CodeRateLimit == 0 { + t.Error("CodeRateLimit should not be 0") + } +} + +func TestResponseStructure(t *testing.T) { + // 测试 Response 结构体 + resp := response.Response{ + Code: 0, + Msg: "成功", + Data: gin.H{"id": 1}, + RequestID: "test-123", + } + + if resp.Code != 0 { + t.Error("Response Code failed") + } + if resp.Msg != "成功" { + t.Error("Response Msg failed") + } +} + +func TestPageDataStructure(t *testing.T) { + // 测试 PageData 结构体 + data := response.PageData{ + Items: []string{"a", "b"}, + Total: 100, + Page: 1, + PageSize: 20, + } + + if data.Total != 100 { + t.Error("PageData Total failed") + } +} + +func contains(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/router/health_test.go b/router/health_test.go new file mode 100644 index 0000000..aa5adde --- /dev/null +++ b/router/health_test.go @@ -0,0 +1,157 @@ +package router_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/router" +) + +func TestRegisterHealthRoute(t *testing.T) { + r := setupTestRouter() + router.RegisterHealthRoute(r) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), `"status":"ok"`) { + t.Fatalf("expected ok health response, got %s", w.Body.String()) + } +} + +func TestRegisterHealthRouteWithChecks(t *testing.T) { + r := setupTestRouter() + router.RegisterHealthRoute(r, + router.HealthCheck{Name: "mysql", Check: func(context.Context) error { return nil }}, + router.HealthCheck{Name: "redis", Disabled: true}, + ) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, `"mysql":"ok"`) || !strings.Contains(body, `"redis":"disabled"`) { + t.Fatalf("expected check statuses, got %s", body) + } +} + +func TestRegisterHealthRouteWithFailingCheck(t *testing.T) { + r := setupTestRouter() + router.RegisterHealthRoute(r, router.HealthCheck{Name: "mysql", Check: func(context.Context) error { return errors.New("down") }}) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected status 503, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), `"status":"error"`) { + t.Fatalf("expected error health response, got %s", w.Body.String()) + } +} + +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) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected health status 200, got %d", w.Code) + } + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil) + r.ServeHTTP(w, req) + if w.Code == http.StatusNotFound { + t.Fatal("expected swagger route to be registered") + } +} diff --git a/router/livez_readyz_test.go b/router/livez_readyz_test.go new file mode 100644 index 0000000..c8c4c45 --- /dev/null +++ b/router/livez_readyz_test.go @@ -0,0 +1,55 @@ +package router_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/router" +) + +func TestRegisterLivenessRoute(t *testing.T) { + r := setupTestRouter() + router.RegisterLivenessRoute(r) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/livez", nil)) + if w.Code != http.StatusOK { + t.Fatalf("livez status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), `"status":"ok"`) { + t.Fatalf("livez body = %s", w.Body.String()) + } +} + +func TestRegisterReadinessRouteHealthy(t *testing.T) { + r := setupTestRouter() + router.RegisterReadinessRoute(r, + router.HealthCheck{Name: "mysql", Check: func(context.Context) error { return nil }}, + ) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if w.Code != http.StatusOK { + t.Fatalf("readyz status = %d, want 200", w.Code) + } +} + +func TestRegisterReadinessRouteUnhealthy(t *testing.T) { + r := setupTestRouter() + router.RegisterReadinessRoute(r, + router.HealthCheck{Name: "redis", Check: func(context.Context) error { return errors.New("down") }}, + ) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("readyz status = %d, want 503", w.Code) + } + if !strings.Contains(w.Body.String(), `"redis":"error"`) { + t.Fatalf("readyz body = %s", w.Body.String()) + } +} diff --git a/router/metrics.go b/router/metrics.go new file mode 100644 index 0000000..f80b99c --- /dev/null +++ b/router/metrics.go @@ -0,0 +1,26 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// RegisterMetricsRoute 注册 Prometheus 指标暴露端点(#18,H8c 修正)。 +// +// 默认路径 /metrics。传入 path 可自定义。本函数仅注册暴露端点,不再用 r.Use +// 挂采集中间件——采集中间件改由 Registry.SetMetricsMiddleware 在 Apply 内作为 +// 首个全局中间件装入,使所有经注册中心注册的业务路由都被采集,且不依赖本函数 +// 相对其他路由注册的调用顺序。/metrics 端点自身与 /health 等基础路由不经采集中间件。 +// +// 用法: +// +// router.RegisterMetricsRoute(r) // /metrics +// router.RegisterMetricsRoute(r, "/metrics") // 等价 +func RegisterMetricsRoute(r *gin.Engine, path ...string) { + p := "/metrics" + if len(path) > 0 && path[0] != "" { + p = path[0] + } + // 幂等注册(H8d 收尾):重复调用跳过,避免重复路由 panic。 + registerGETOnce(r, p, gin.WrapH(promhttp.Handler())) +} diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..4601c4b --- /dev/null +++ b/router/router.go @@ -0,0 +1,780 @@ +package router + +import ( + "context" + "errors" + "fmt" + "net/http" + "runtime/debug" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/EthanCodeCraft/xlgo-core/logger" + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +var routerWarnf atomic.Value // stores func(string, ...any) + +func warnf(format string, args ...any) { + if fn, ok := routerWarnf.Load().(func(string, ...any)); ok && fn != nil { + fn(format, args...) + return + } + logger.Warnf(format, args...) +} + +func isDuplicateRoutePanic(msg, path string) bool { + if strings.Contains(msg, "already registered") { + return true + } + if !strings.Contains(msg, "conflicts with existing wildcard") { + return false + } + return strings.Contains(msg, fmt.Sprintf("new path '%s'", path)) && + strings.Contains(msg, fmt.Sprintf("existing prefix '%s'", path)) +} + +// HealthCheck 健康检查项 +type HealthCheck struct { + Name string + Check func(context.Context) error + Disabled bool + Timeout time.Duration +} + +// DefaultHealthCheckTimeout bounds each dependency check so /health and /readyz +// do not hang behind a stuck database/client call. +const DefaultHealthCheckTimeout = 2 * time.Second + +type healthCheckRunner struct { + check HealthCheck + running chan struct{} +} + +func newHealthCheckRunners(checks []HealthCheck) []healthCheckRunner { + runners := make([]healthCheckRunner, len(checks)) + for i, check := range checks { + runners[i] = healthCheckRunner{ + check: check, + running: make(chan struct{}, 1), + } + } + return runners +} + +func healthCheckTimeout(check HealthCheck) time.Duration { + if check.Timeout > 0 { + return check.Timeout + } + return DefaultHealthCheckTimeout +} + +func healthCheckStatusFromContext(err error) string { + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + return "error" +} + +func (r healthCheckRunner) run(ctx context.Context) (string, error) { + check := r.check + checkCtx, cancel := context.WithTimeout(ctx, healthCheckTimeout(check)) + defer cancel() + + select { + case r.running <- struct{}{}: + case <-checkCtx.Done(): + return healthCheckStatusFromContext(checkCtx.Err()), checkCtx.Err() + } + + errCh := make(chan error, 1) + go func() { + defer func() { + <-r.running + }() + defer func() { + if rec := recover(); rec != nil { + errCh <- fmt.Errorf("health check %q panic recovered: %v\n%s", check.Name, rec, debug.Stack()) + } + }() + errCh <- check.Check(checkCtx) + }() + + select { + case err := <-errCh: + if err == nil { + return "ok", nil + } + return healthCheckStatusFromContext(err), err + case <-checkCtx.Done(): + return healthCheckStatusFromContext(checkCtx.Err()), checkCtx.Err() + } +} + +// registerGETOnce 幂等注册 GET 路由(H8d 收尾:消除 defaultModule 与 Register* 系列 +// 并存时 Gin 重复路由 panic 的 footgun)。若 (GET, path) 已存在则静默跳过——首次注册胜出。 +// +// 实现分两条路径: +// - *gin.Engine:经 Routes() 精确预检(method+path),命中即跳过;未命中则直接注册, +// 不吞 panic——真正不同的路由冲突仍按 gin 原语义 panic,不被掩盖。 +// - *gin.RouterGroup(gin 未暴露其 engine,无法预检):用 recover 兜底,仅吞 gin 的 +// 重复路由 panic("already registered" / "conflicts with existing wildcard", +// 两者覆盖 gin 对重复注册的两类 panic),其余 panic 原样抛出。defaultModule 的 +// /swagger/*any 与 /health 重复注册即走此路径被吞。最坏情况(gin 改动文本)退化为 +// 当前行为(panic),不引入新风险。 +// +// 注册期单线程调用,无并发问题。 +func registerGETOnce(r gin.IRoutes, path string, h gin.HandlerFunc) { + if r == nil { + warnf("router: skip GET %q because routes is nil", path) + return + } + if h == nil { + warnf("router: skip GET %q because handler is nil", path) + return + } + switch route := r.(type) { + case *gin.Engine: + eng := route + if eng == nil { + warnf("router: skip GET %q because engine is nil", path) + return + } + for _, ri := range eng.Routes() { + if ri.Method == http.MethodGet && ri.Path == path { + warnf("router: duplicate GET %q skipped", path) + return + } + } + eng.GET(path, h) + return + case *gin.RouterGroup: + if route == nil { + warnf("router: skip GET %q because router group is nil", path) + return + } + } + defer func() { + if rec := recover(); rec != nil { + msg := fmt.Sprint(rec) + if isDuplicateRoutePanic(msg, path) { + warnf("router: duplicate GET %q skipped: %v", path, rec) + return // 重复路由,静默跳过 + } + panic(rec) // 非重复路由 panic,原样抛出 + } + }() + r.GET(path, h) +} + +// runHealthChecks 执行所有检查项,返回总体状态、HTTP code 与逐项结果。 +// 无检查项时视为健康(用于 /livez 与无依赖场景)。 +func runHealthChecks(ctx context.Context, 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(runners)) + for _, runner := range runners { + check := runner.check + if check.Name == "" { + continue + } + if check.Disabled || check.Check == nil { + result[check.Name] = "disabled" + continue + } + checkStatus, err := runner.run(ctx) + result[check.Name] = checkStatus + if err != nil { + status = "error" + code = http.StatusServiceUnavailable + continue + } + } + return status, code, result +} + +// healthHandler 返回统一的 /health 风格响应(H8d 收敛)。 +// 无检查项时视为健康(200 + {"status":"ok"});有检查项时任一失败返回 503 +// 并附逐项结果。RegisterHealthRoute / RegisterReadinessRoute / defaultModule +// 均委托此实现,避免三个 /health 行为与响应体不一致。 +func healthHandler(checks []HealthCheck) gin.HandlerFunc { + runners := newHealthCheckRunners(checks) + return func(c *gin.Context) { + status, code, result := runHealthChecks(c.Request.Context(), runners) + if result == nil { + c.JSON(http.StatusOK, gin.H{"status": status}) + return + } + c.JSON(code, gin.H{"status": status, "checks": result}) + } +} + +// RegisterHealthRoute 注册健康检查路由(兼容端点,等价于 readiness)。 +// 幂等:若 /health 已注册则跳过(首次注册胜出),避免与 defaultModule 等并存时重复路由 panic。 +func RegisterHealthRoute(r *gin.Engine, checks ...HealthCheck) { + registerGETOnce(r, "/health", healthHandler(checks)) +} + +// RegisterLivenessRoute 注册存活性探针(#17)。 +// GET /livez 永不依赖外部,仅表示进程存活,始终返回 200。 +// 供 K8s livenessProbe 使用:失败由进程崩溃体现,而非端点返回 503。 +// 幂等:重复注册跳过。 +func RegisterLivenessRoute(r *gin.Engine) { + registerGETOnce(r, "/livez", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) +} + +// RegisterReadinessRoute 注册就绪性探针(#17)。 +// GET /readyz 复用 HealthCheck 检查依赖(mysql/redis...),任一失败返回 503。 +// 供 K8s readinessProbe 使用:未就绪时不接流量。 +// 幂等:重复注册跳过。 +func RegisterReadinessRoute(r *gin.Engine, checks ...HealthCheck) { + registerGETOnce(r, "/readyz", healthHandler(checks)) +} + +// RegisterSwaggerRoutes 注册 Swagger 文档路由。幂等:重复注册跳过。 +func RegisterSwaggerRoutes(r *gin.Engine) { + registerGETOnce(r, "/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) +} + +// RegisterDefaultRoutes 注册框架默认路由(健康检查、Swagger) +// 用户可以选择使用或不使用这些默认路由 +func RegisterDefaultRoutes(r *gin.Engine, checks ...HealthCheck) { + RegisterSwaggerRoutes(r) + RegisterHealthRoute(r, checks...) +} + +// DefaultModule 默认路由模块(可用于 WithModules) +var DefaultModule = &defaultModule{} + +type defaultModule struct{} + +func (m *defaultModule) Name() string { return "default" } +func (m *defaultModule) Register(r *gin.RouterGroup) { + // 作为模块注册时,路由在根路径。经 registerGETOnce 幂等注册(H8d 收尾): + // 若用户已通过 RegisterSwaggerRoutes / RegisterHealthRoute 注册过同名路由, + // 此处静默跳过,避免 Gin 重复路由 panic。首次注册胜出。 + registerGETOnce(r, "/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + registerGETOnce(r, "/health", healthHandler(nil)) +} + +// Module 路由模块接口 +// 用户实现此接口来注册业务路由 +type Module interface { + // Name 模块名称(用于日志和调试) + Name() string + // Register 注册路由到指定组 + Register(r *gin.RouterGroup) +} + +// ModuleFunc 函数式模块(简化单文件模块注册) +type ModuleFunc func(r *gin.RouterGroup) + +// Register 实现 Module 接口 +func (f ModuleFunc) Register(r *gin.RouterGroup) { + f(r) +} + +// Name 实现 Module 接口(函数式模块默认名称) +func (f ModuleFunc) Name() string { + return "func-module" +} + +// VersionedAPI 版本化 API 配置 +type VersionedAPI struct { + Version string // 版本标识,如 "v1", "v2" + BasePath string // 基础路径,如 "/api/v1" + Modules []Module // 该版本的模块列表 + Middlewares []gin.HandlerFunc // 该版本的公共中间件 +} + +// MiddlewareGroup 中间件分组 +type MiddlewareGroup struct { + Name string + Middlewares []gin.HandlerFunc +} + +// Registry 路由注册中心 +type Registry struct { + mu sync.Mutex + engine *gin.Engine + modules []Module + versions map[string]*VersionedAPI + middlewareGroups map[string]*MiddlewareGroup + globalMiddlewares []gin.HandlerFunc + + // metricsMiddleware 在 Apply 时作为首个全局中间件装入(H8c), + // 使所有经注册中心注册的路由都被采集,不依赖 RegisterMetricsRoute 的调用顺序。 + // 为 nil 时不装入。/metrics 端点本身与 /health 等基础路由直接挂在 engine 上, + // 不经此中间件,故不被自采集。 + metricsMiddleware gin.HandlerFunc + + // applyOnce 保证 Apply 仅执行一次(H8b + P1 #13)。 + // 原实现用裸 bool 无同步,并发 Apply 会竞态并可能重复 engine.Use/重复注册致 gin panic; + // 改用 sync.Once,二次/并发 Apply 均安全幂等。 + applyOnce sync.Once + applied atomic.Bool +} + +// NewRegistry 创建路由注册中心 +func NewRegistry(engine *gin.Engine) *Registry { + return &Registry{ + engine: engine, + modules: make([]Module, 0), + versions: make(map[string]*VersionedAPI), + 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 { + 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}) +} + +// namedModule 命名模块包装(内部类型) +type namedModule struct { + name string + fn func(r *gin.RouterGroup) +} + +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 append([]gin.HandlerFunc(nil), group.Middlewares...) + } + return nil +} + +// SetMetricsMiddleware 设置指标采集中间件(H8c)。Apply 时它会作为首个全局中间件 +// 装入 engine,使所有经注册中心注册的路由都被采集,不再依赖注册顺序。 +// 传入 nil 清除。须在 Apply 之前调用。 +func (r *Registry) SetMetricsMiddleware(mw gin.HandlerFunc) { + unlock, ok := r.lockForMutation("SetMetricsMiddleware") + if !ok { + return + } + defer unlock() + r.metricsMiddleware = mw +} + +type versionApplySnapshot struct { + isNil bool + version string + basePath string + middlewares []gin.HandlerFunc + modules []Module +} + +// Apply 应用所有路由注册。 +// +// 幂等(H8b):二次调用直接返回,避免重复 engine.Use 与 Gin 重复路由 panic。 +// 装入顺序:metrics 中间件(若有)→ 用户全局中间件 → 模块/版本路由。 +// metrics 置于首位保证全量业务路由被采集,且不依赖 RegisterMetricsRoute 调用顺序。 +func (r *Registry) Apply() { + 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() { + r.mu.Lock() + r.applied.Store(true) + metricsMiddleware := r.metricsMiddleware + globalMiddlewares := append([]gin.HandlerFunc(nil), r.globalMiddlewares...) + modules := append([]Module(nil), r.modules...) + versions := make([]versionApplySnapshot, 0, len(r.versions)) + versionKeys := make([]string, 0, len(r.versions)) + for k := range r.versions { + versionKeys = append(versionKeys, k) + } + sort.Strings(versionKeys) + for _, k := range versionKeys { + v := r.versions[k] + if v == nil { + versions = append(versions, versionApplySnapshot{isNil: true, version: k}) + continue + } + versions = append(versions, versionApplySnapshot{ + version: v.Version, + basePath: v.BasePath, + middlewares: append([]gin.HandlerFunc(nil), v.Middlewares...), + modules: append([]Module(nil), v.Modules...), + }) + } + r.mu.Unlock() + + // 指标采集中间件首个装入,统计所有经注册中心注册的业务路由 + if metricsMiddleware != nil { + r.engine.Use(metricsMiddleware) + } + + // 应用全局中间件 + r.engine.Use(globalMiddlewares...) + + // 注册无版本模块 + for _, module := range modules { + if module == nil { + warnf("router: nil module skipped during Apply") + continue + } + module.Register(r.engine.Group("")) + } + + // 注册版本化 API。P1 #13:按 version 键排序遍历,使跨版本注册顺序确定, + // 避免 map 随机序导致重叠路径"谁先胜出"(配合 registerGETOnce)每次运行不一致。 + for _, v := range versions { + if v.isNil { + warnf("router: nil version %q skipped during Apply", v.version) + continue + } + group := r.engine.Group(v.basePath) + if len(v.middlewares) > 0 { + group.Use(v.middlewares...) + } + for _, module := range v.modules { + if module == nil { + warnf("router: nil module skipped in version %q", v.version) + continue + } + module.Register(group) + } + } + }) +} + +// ===== 全局注册中心 ===== + +// globalRegistry 包级全局注册中心。用 atomic.Pointer 保护读写(H8a): +// Init 写入、各全局 helper 读取,避免裸指针与请求 goroutine 的无锁竞争。 +var globalRegistry atomic.Pointer[Registry] + +// Init 初始化全局注册中心。须在使用任何全局 helper(Use/RegisterModule/Apply…)之前调用。 +func Init(engine *gin.Engine) *Registry { + r := NewRegistry(engine) + globalRegistry.Store(r) + return r +} + +// GetRegistry 获取全局注册中心,未初始化时返回 nil。 +func GetRegistry() *Registry { + return globalRegistry.Load() +} + +// ensureRegistry 取全局注册中心,未初始化时以明确信息 panic(H8a)。 +// 把晦涩的 nil 解引用 panic 转成可定位的初始化顺序错误。 +func ensureRegistry() *Registry { + r := globalRegistry.Load() + if r == nil { + panic("router: 全局注册中心未初始化,请先调用 router.Init(engine) 再使用全局 helper") + } + return r +} + +// Use 注册全局中间件(全局方式) +func Use(middlewares ...gin.HandlerFunc) *Registry { + return ensureRegistry().Use(middlewares...) +} + +// RegisterModule 注册模块(全局方式) +func RegisterModule(module Module) *Registry { + return ensureRegistry().RegisterModule(module) +} + +// RegisterModuleFunc 注册函数式模块(全局方式) +func RegisterModuleFunc(name string, fn func(r *gin.RouterGroup)) *Registry { + return ensureRegistry().RegisterModuleFunc(name, fn) +} + +// RegisterVersion 注册版本化 API(全局方式) +func RegisterVersion(version *VersionedAPI) *Registry { + return ensureRegistry().RegisterVersion(version) +} + +// Apply 应用路由注册(全局方式) +func Apply() { + ensureRegistry().Apply() +} + +// ===== 快捷构建函数 ===== + +// NewVersion 创建版本化 API +func NewVersion(version, basePath string, middlewares ...gin.HandlerFunc) *VersionedAPI { + return &VersionedAPI{ + Version: version, + BasePath: basePath, + 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}) +} + +// NewMiddlewareGroup 创建中间件分组 +func NewMiddlewareGroup(name string, middlewares ...gin.HandlerFunc) *MiddlewareGroup { + return &MiddlewareGroup{ + Name: name, + Middlewares: filterNilHandlers("NewMiddlewareGroup "+name, middlewares), + } +} + +// ===== 路由组辅助 ===== + +// Group 创建路由组(带中间件分组) +func Group(engine *gin.Engine, path string, middlewares ...gin.HandlerFunc) *gin.RouterGroup { + if engine == nil { + warnf("router: Group(%q) skipped because engine is nil", path) + return nil + } + return engine.Group(path, filterNilHandlers("Group "+path, middlewares)...) +} + +// GroupWithMiddlewareGroup 使用中间件分组创建路由组。 +// +// 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 { + return ensureRegistry().GroupWithMiddlewareGroup(engine, path, groupName) +} + +// RESTfulRoute RESTful 路由快捷注册 +type RESTfulRoute struct { + Group *gin.RouterGroup + Path string +} + +// NewRESTful 创建 RESTful 路由 +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...) +} + +// CRUD 注册标准 CRUD 路由 +// GET /path - 列表 +// GET /path/:id - 详情 +// POST /path - 创建 +// 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) + } + if detail != nil { + r.Group.GET(r.Path+"/:id", detail) + } + if create != nil { + r.Group.POST(r.Path, create) + } + if update != nil { + r.Group.PUT(r.Path+"/:id", update) + } + if delete != nil { + r.Group.DELETE(r.Path+"/:id", delete) + } +} diff --git a/router/router_h8_internal_test.go b/router/router_h8_internal_test.go new file mode 100644 index 0000000..ca29d5b --- /dev/null +++ b/router/router_h8_internal_test.go @@ -0,0 +1,271 @@ +package router + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/middleware" + "github.com/gin-gonic/gin" +) + +// TestApplyIdempotent_H8b 复现 H8b:修复前二次 Apply 会重复 engine.Use 并触发 +// Gin 重复路由 panic;修复后二次 Apply 直接返回,无 panic、无重复中间件。 +func TestApplyIdempotent_H8b(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + r := NewRegistry(engine) + + runs := 0 + r.Use(func(c *gin.Context) { runs++; c.Next() }) + r.RegisterModuleFunc("test", func(g *gin.RouterGroup) { + g.GET("/h8b", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + }) + + r.Apply() + r.Apply() // 二次 Apply 必须无 panic + r.Apply() // 三次同样 + + // 幂等性由下方 runs==1 断言验证(P1 #13:applied bool 已改为 sync.Once, + // 无导出标志可查;若 Apply 非幂等则中间件会被重复装入致 runs>1)。 + + // 中间件只应被装入一次:请求一次,runs 应为 1(若重复装入则 >1)。 + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8b", nil)) + if w.Code != 200 { + t.Fatalf("status = %d, want 200", w.Code) + } + if runs != 1 { + t.Fatalf("global middleware ran %d times, want 1 (Apply not idempotent)", runs) + } +} + +// TestApplyConcurrent_P1_13 验证并发 Apply 无 data race 且仅生效一次(sync.Once)。 +// 修复前 applied 为裸 bool,多 goroutine 并发 Apply 竞态可重复 engine.Use/重复注册致 panic。 +// 须配合 -race 运行。 +func TestApplyConcurrent_P1_13(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + r := NewRegistry(engine) + + runs := 0 + r.Use(func(c *gin.Context) { runs++; c.Next() }) + r.RegisterModuleFunc("p113", func(g *gin.RouterGroup) { + g.GET("/p113", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + }) + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { defer wg.Done(); r.Apply() }() + } + wg.Wait() + + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/p113", nil)) + if w.Code != 200 { + t.Fatalf("status = %d, want 200", w.Code) + } + if runs != 1 { + t.Fatalf("global middleware ran %d times, want 1 (concurrent Apply not once)", runs) + } +} + +// TestMetricsMiddlewareFirstInApply_H8c 验证 metrics 中间件经 SetMetricsMiddleware +// 在 Apply 内装入,覆盖所有经注册中心注册的路由,且不依赖 RegisterMetricsRoute +// 的调用顺序。修复前 RegisterMetricsRoute 用 r.Use,先注册的路由不被采集。 +func TestMetricsMiddlewareFirstInApply_H8c(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + r := NewRegistry(engine) + + // 用一个计数中间件模拟 metrics 采集。 + hits := 0 + r.SetMetricsMiddleware(func(c *gin.Context) { + hits++ + c.Next() + }) + + r.RegisterModuleFunc("biz", func(g *gin.RouterGroup) { + g.GET("/biz", func(c *gin.Context) { c.JSON(200, gin.H{}) }) + }) + // 注意:不调用 RegisterMetricsRoute,仅靠 SetMetricsMiddleware + Apply 装入。 + r.Apply() + + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/biz", nil)) + if w.Code != 200 { + t.Fatalf("biz status = %d, want 200", w.Code) + } + if hits != 1 { + t.Fatalf("metrics middleware hits = %d, want 1 (route not instrumented)", hits) + } +} + +// TestMetricsMiddlewareNilSkipped_H8c:未设置 metrics 中间件时 Apply 不应装入空壳。 +func TestMetricsMiddlewareNilSkipped_H8c(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + r := NewRegistry(engine) + if r.metricsMiddleware != nil { + t.Fatal("default metricsMiddleware should be nil") + } + r.Apply() // 不应 panic +} + +// TestEnsureRegistryPanicsBeforeInit_H8a 复现 H8a:修复前 Init 之前调全局 helper +// 触发 nil 解引用 panic;修复后为带明确信息的 panic。 +func TestEnsureRegistryPanicsBeforeInit_H8a(t *testing.T) { + // 保存并清空全局注册中心,测试后恢复,避免污染其它测试。 + prev := globalRegistry.Load() + globalRegistry.Store(nil) + t.Cleanup(func() { globalRegistry.Store(prev) }) + + var got any + func() { + defer func() { got = recover() }() + Apply() + }() + if got == nil { + t.Fatal("Apply before Init should panic, got nil") + } + msg, ok := got.(string) + if !ok { + t.Fatalf("panic value should be string, got %T: %v", got, got) + } + if !strings.Contains(msg, "router.Init") { + t.Fatalf("panic message should mention router.Init, got %q", msg) + } +} + +// TestGlobalRegistryAtomicConcurrent_H8a:并发 Init/GetRegistry 不触发 data race +// (atomic.Pointer 保护)。须配合 -race 运行。 +func TestGlobalRegistryAtomicConcurrent_H8a(t *testing.T) { + prev := globalRegistry.Load() + t.Cleanup(func() { globalRegistry.Store(prev) }) + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func() { defer wg.Done(); Init(gin.New()) }() + go func() { defer wg.Done(); _ = GetRegistry() }() + } + wg.Wait() +} + +// TestHealthHandlerConvergedSchema_H8d 验证 defaultModule / RegisterHealthRoute(无 checks) +// 与 handler 风格的 /health 同 schema:200 + {"status":"ok"}。 +func TestHealthHandlerConvergedSchema_H8d(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + r := NewRegistry(engine) + r.RegisterModule(&defaultModule{}) + r.Apply() + + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + if w.Code != 200 { + t.Fatalf("defaultModule /health status = %d, want 200", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, `"status":"ok"`) { + t.Fatalf("defaultModule /health body = %s, want {\"status\":\"ok\"}", body) + } + // 不应携带 response 业务信封字段。 + if strings.Contains(body, `"code"`) || strings.Contains(body, `"data"`) { + t.Fatalf("defaultModule /health should not use response envelope, got %s", body) + } +} + +// 编译期保证 middleware 包仍可独立使用(H8c 回归用)。 +var _ gin.HandlerFunc = middleware.Metrics() + +// TestDefaultModuleAndRegisterHealthRouteCoexist_H8dfootgun 复现 H8d 收尾 footgun: +// 修复前 WithDefaultRoutes()+WithModules(DefaultModule) 并存会触发 Gin 重复路由 panic; +// 修复后 registerGETOnce 使二者幂等共存,/health 与 /swagger 均可访问。 +// 此测试模拟 app.go 的真实顺序:Register* 先注册(带 checks),defaultModule 经 Apply 后注册。 +func TestDefaultModuleAndRegisterHealthRouteCoexist_H8dfootgun(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + + // 先经 Register* 注册(等价 app.go Init 中 enableHealth/enableSwagger 段) + RegisterHealthRoute(engine, HealthCheck{Name: "mysql", Check: func(context.Context) error { return nil }}) + RegisterSwaggerRoutes(engine) + // 再经注册中心注册 DefaultModule(等价 app.go registry.Apply()) + r := NewRegistry(engine) + r.RegisterModule(&defaultModule{}) + r.Apply() // 修复前在此 panic: handlers are already registered for path '/health' + + // /health 仍可访问,且首次注册(带 checks)胜出——响应含 checks 字段。 + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + if w.Code != 200 { + t.Fatalf("/health status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), `"mysql":"ok"`) { + t.Fatalf("first registration (with checks) should win, got %s", w.Body.String()) + } + + // /swagger/*any 注册存在(非 404 即说明路由已注册) + w2 := httptest.NewRecorder() + engine.ServeHTTP(w2, httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil)) + if w2.Code == http.StatusNotFound { + t.Fatal("/swagger/*any should be registered, got 404") + } +} + +// TestRegisterHealthRouteIdempotent_H8dfootgun:RegisterHealthRoute 重复调用不 panic。 +func TestRegisterHealthRouteIdempotent_H8dfootgun(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + RegisterHealthRoute(engine, HealthCheck{Name: "a", Check: func(context.Context) error { return nil }}) + RegisterHealthRoute(engine) // 重复,不 panic + RegisterHealthRoute(engine) // 三次,不 panic + + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + if w.Code != 200 { + t.Fatalf("/health status = %d, want 200", w.Code) + } +} + +// TestDefaultModuleOnly_H8dfootgun:仅用 defaultModule(不预先 Register*)时, +// /health 与 /swagger 仍正常注册(recover 兜底路径不影响首次注册)。 +func TestDefaultModuleOnly_H8dfootgun(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + r := NewRegistry(engine) + r.RegisterModule(&defaultModule{}) + r.Apply() + + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + if w.Code != 200 { + t.Fatalf("/health status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), `"status":"ok"`) { + t.Fatalf("/health body = %s", w.Body.String()) + } +} + +// TestRegisterGETOnceEngineDoesNotSwallowRealConflict_H8dfootgun:Engine 路径经 Routes() +// 精确预检,未命中即直接注册(无 recover)。真正不同的路由冲突(如 /foo/:id 已存在再注册 +// /foo/*any)仍按 gin 原语义 panic,不被掩盖——证明幂等只吞"同一 path 重复",不掩盖真实冲突。 +func TestRegisterGETOnceEngineDoesNotSwallowRealConflict_H8dfootgun(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.GET("/foo/:id", func(c *gin.Context) {}) + + var got any + func() { + defer func() { got = recover() }() + registerGETOnce(engine, "/foo/*any", func(c *gin.Context) {}) // 与 :id 真实冲突 + }() + if got == nil { + t.Fatal("registerGETOnce (Engine) should panic on real (different-path) conflict, got nil") + } +} + diff --git a/router/router_m7_internal_test.go b/router/router_m7_internal_test.go new file mode 100644 index 0000000..73880e1 --- /dev/null +++ b/router/router_m7_internal_test.go @@ -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) + } +} diff --git a/router/router_test.go b/router/router_test.go new file mode 100644 index 0000000..e6eaa8e --- /dev/null +++ b/router/router_test.go @@ -0,0 +1,459 @@ +package router_test + +import ( + "net/http/httptest" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/router" + "github.com/gin-gonic/gin" +) + +func setupTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + return gin.New() +} + +func TestNewRegistry(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + if registry == nil { + t.Error("NewRegistry should not return nil") + } +} + +func TestRegistryUse(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + result := registry.Use(func(c *gin.Context) { c.Next() }) + if result == nil { + t.Error("Use should return registry for chaining") + } +} + +func TestRegisterModule(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + module := &testModule{name: "test"} + result := registry.RegisterModule(module) + if result == nil { + t.Error("RegisterModule should return registry for chaining") + } +} + +func TestRegisterModuleFunc(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + result := registry.RegisterModuleFunc("test", func(r *gin.RouterGroup) { + r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{}) }) + }) + if result == nil { + t.Error("RegisterModuleFunc should return registry for chaining") + } +} + +func TestNewVersion(t *testing.T) { + v := router.NewVersion("v1", "/api/v1") + + if v.Version != "v1" { + t.Errorf("Version = %s, want v1", v.Version) + } + if v.BasePath != "/api/v1" { + t.Errorf("BasePath = %s, want /api/v1", v.BasePath) + } +} + +func TestVersionAddModule(t *testing.T) { + v := router.NewVersion("v1", "/api/v1") + module := &testModule{name: "user"} + + result := v.AddModule(module) + if result == nil { + t.Error("AddModule should return VersionedAPI for chaining") + } +} + +func TestVersionAddModuleFunc(t *testing.T) { + v := router.NewVersion("v1", "/api/v1") + + result := v.AddModuleFunc("user", func(r *gin.RouterGroup) { + r.GET("/users", func(c *gin.Context) {}) + }) + if result == nil { + t.Error("AddModuleFunc should return VersionedAPI for chaining") + } +} + +func TestRegisterVersion(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + v := router.NewVersion("v1", "/api/v1") + result := registry.RegisterVersion(v) + if result == nil { + t.Error("RegisterVersion should return registry for chaining") + } +} + +func TestNewMiddlewareGroup(t *testing.T) { + group := router.NewMiddlewareGroup("auth", func(c *gin.Context) { c.Next() }) + + if group.Name != "auth" { + t.Errorf("Name = %s, want auth", group.Name) + } + if len(group.Middlewares) != 1 { + t.Errorf("Middlewares length = %d, want 1", len(group.Middlewares)) + } +} + +func TestRegisterMiddlewareGroup(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + group := router.NewMiddlewareGroup("auth") + result := registry.RegisterMiddlewareGroup(group) + if result == nil { + t.Error("RegisterMiddlewareGroup should return registry for chaining") + } +} + +func TestGetMiddlewareGroup(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + middleware := func(c *gin.Context) { c.Next() } + group := router.NewMiddlewareGroup("auth", middleware) + registry.RegisterMiddlewareGroup(group) + + mws := registry.GetMiddlewareGroup("auth") + if len(mws) != 1 { + t.Errorf("GetMiddlewareGroup length = %d, want 1", len(mws)) + } + + // 不存在的分组 + mws2 := registry.GetMiddlewareGroup("nonexistent") + if mws2 != nil { + t.Error("GetMiddlewareGroup nonexistent should return nil") + } +} + +func TestApply(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + registry.RegisterModuleFunc("test", func(r *gin.RouterGroup) { + r.GET("/hello", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "hello"}) + }) + }) + + registry.Apply() + + // 测试路由是否生效 + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/hello", nil) + engine.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Apply route status = %d, want 200", w.Code) + } +} + +func TestApplyWithVersion(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + v1 := router.NewVersion("v1", "/api/v1") + v1.AddModuleFunc("user", func(r *gin.RouterGroup) { + r.GET("/users", func(c *gin.Context) { + c.JSON(200, gin.H{"version": "v1"}) + }) + }) + registry.RegisterVersion(v1) + + registry.Apply() + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/users", nil) + engine.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Versioned route status = %d, want 200", w.Code) + } +} + +func TestApplyWithMiddleware(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + // 全局中间件 + registry.Use(func(c *gin.Context) { + c.Set("global", true) + c.Next() + }) + + registry.RegisterModuleFunc("test", func(r *gin.RouterGroup) { + r.GET("/test", func(c *gin.Context) { + val, _ := c.Get("global") + c.JSON(200, gin.H{"global": val}) + }) + }) + + registry.Apply() + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + engine.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Middleware route status = %d", w.Code) + } +} + +func TestVersionMiddleware(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + v1 := router.NewVersion("v1", "/api/v1", func(c *gin.Context) { + c.Set("version", "v1") + c.Next() + }) + v1.AddModuleFunc("user", func(r *gin.RouterGroup) { + r.GET("/users", func(c *gin.Context) { + val, _ := c.Get("version") + c.JSON(200, gin.H{"version": val}) + }) + }) + registry.RegisterVersion(v1) + + registry.Apply() + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/users", nil) + engine.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Version middleware route status = %d", w.Code) + } +} + +func TestMultipleVersions(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + v1 := router.NewVersion("v1", "/api/v1") + v1.AddModuleFunc("user", func(r *gin.RouterGroup) { + r.GET("/users", func(c *gin.Context) { c.JSON(200, gin.H{"version": "v1"}) }) + }) + + v2 := router.NewVersion("v2", "/api/v2") + v2.AddModuleFunc("user", func(r *gin.RouterGroup) { + r.GET("/users", func(c *gin.Context) { c.JSON(200, gin.H{"version": "v2"}) }) + }) + + registry.RegisterVersion(v1) + registry.RegisterVersion(v2) + registry.Apply() + + // 测试 v1 + w1 := httptest.NewRecorder() + req1 := httptest.NewRequest("GET", "/api/v1/users", nil) + engine.ServeHTTP(w1, req1) + + // 测试 v2 + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("GET", "/api/v2/users", nil) + engine.ServeHTTP(w2, req2) + + if w1.Code != 200 || w2.Code != 200 { + t.Error("Multiple versions should both work") + } +} + +func TestInitAndGetRegistry(t *testing.T) { + engine := setupTestRouter() + registry := router.Init(engine) + + if registry == nil { + t.Error("Init should return registry") + } + + registry2 := router.GetRegistry() + if registry2 != registry { + t.Error("GetRegistry should return same registry") + } +} + +func TestGlobalFunctions(t *testing.T) { + engine := setupTestRouter() + router.Init(engine) + + router.Use(func(c *gin.Context) { c.Next() }) + router.RegisterModule(&testModule{name: "test"}) + router.RegisterVersion(router.NewVersion("v1", "/api/v1")) + + registry := router.GetRegistry() + if registry == nil { + t.Error("Global functions should work") + } +} + +func TestGlobalApply(t *testing.T) { + engine := setupTestRouter() + router.Init(engine) + + router.RegisterModuleFunc("hello", func(r *gin.RouterGroup) { + r.GET("/hello", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "hello"}) + }) + }) + + router.Apply() + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/hello", nil) + engine.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("Global Apply status = %d, want 200", w.Code) + } +} + +func TestModuleFunc(t *testing.T) { + var mf router.ModuleFunc = func(r *gin.RouterGroup) { + r.GET("/test", func(c *gin.Context) {}) + } + + // 验证实现了 Module 接口 + var _ router.Module = mf +} + +func TestRESTfulRoute(t *testing.T) { + engine := setupTestRouter() + group := engine.Group("/api") + + rest := router.NewRESTful(group, "/users") + rest.GET(func(c *gin.Context) { c.JSON(200, gin.H{}) }) + rest.POST(func(c *gin.Context) { c.JSON(201, gin.H{}) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/users", nil) + engine.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("RESTful GET status = %d", w.Code) + } +} + +func TestRESTfulCRUD(t *testing.T) { + engine := setupTestRouter() + group := engine.Group("/api") + + rest := router.NewRESTful(group, "/items") + rest.CRUD( + func(c *gin.Context) { c.JSON(200, gin.H{"action": "list"}) }, + func(c *gin.Context) { c.JSON(200, gin.H{"action": "detail"}) }, + func(c *gin.Context) { c.JSON(201, gin.H{"action": "create"}) }, + func(c *gin.Context) { c.JSON(200, gin.H{"action": "update"}) }, + func(c *gin.Context) { c.JSON(204, gin.H{}) }, + ) + + tests := []struct { + method string + path string + code int + }{ + {"GET", "/api/items", 200}, + {"GET", "/api/items/1", 200}, + {"POST", "/api/items", 201}, + {"PUT", "/api/items/1", 200}, + {"DELETE", "/api/items/1", 204}, + } + + for _, tt := range tests { + w := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, nil) + engine.ServeHTTP(w, req) + + if w.Code != tt.code { + t.Errorf("%s %s status = %d, want %d", tt.method, tt.path, w.Code, tt.code) + } + } +} + +func TestRESTfulPartialCRUD(t *testing.T) { + engine := setupTestRouter() + group := engine.Group("/api") + + rest := router.NewRESTful(group, "/items") + // 只注册 list 和 create + rest.CRUD( + func(c *gin.Context) { c.JSON(200, gin.H{}) }, // list + nil, // no detail + func(c *gin.Context) { c.JSON(201, gin.H{}) }, // create + nil, // no update + nil, // no delete + ) + + // list 存在 + w1 := httptest.NewRecorder() + req1 := httptest.NewRequest("GET", "/api/items", nil) + engine.ServeHTTP(w1, req1) + if w1.Code != 200 { + t.Error("Partial CRUD list should work") + } + + // detail 不存在(404) + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("GET", "/api/items/1", nil) + engine.ServeHTTP(w2, req2) + if w2.Code != 404 { + t.Errorf("Partial CRUD detail should be 404, got %d", w2.Code) + } +} + +func TestGroup(t *testing.T) { + engine := setupTestRouter() + group := router.Group(engine, "/api", func(c *gin.Context) { c.Next() }) + + if group == nil { + t.Error("Group should not return nil") + } +} + +func TestGroupWithMiddlewareGroup(t *testing.T) { + engine := setupTestRouter() + registry := router.NewRegistry(engine) + + registry.RegisterMiddlewareGroup(router.NewMiddlewareGroup("auth", func(c *gin.Context) { c.Next() })) + + group := registry.GroupWithMiddlewareGroup(engine, "/api", "auth") + if group == nil { + t.Error("GroupWithMiddlewareGroup should not return nil") + } +} + +func TestModuleInterface(t *testing.T) { + module := &testModule{name: "test"} + + // 测试 Name 方法 + if module.Name() != "test" { + t.Error("Module Name failed") + } + + // 测试实现了接口 + var _ router.Module = module +} + +// 测试模块实现 +type testModule struct { + name string +} + +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}) }) +} diff --git a/scan_protocol.md b/scan_protocol.md new file mode 100644 index 0000000..935affe --- /dev/null +++ b/scan_protocol.md @@ -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,直接在证伪台账说明并结束,不要为凑数而上调。 diff --git a/sse/sse.go b/sse/sse.go new file mode 100644 index 0000000..9b868c5 --- /dev/null +++ b/sse/sse.go @@ -0,0 +1,241 @@ +// Package sse 提供 Server-Sent Events 流式响应支持,典型用于 AI 对话/LLM 流式输出。 +// +// 断连契约(C3 收尾,重要): +// 框架侧的消费循环(Stream/StreamText/StreamChunks/StreamWithID)已监听 c.Request.Context(), +// 客户端断连即退出,消费端不泄漏 goroutine。但框架无法单方面停止上游生产者(LLM 流)—— +// 生产者(向 ch 发送数据的一方)必须自行监听同一 ctx 并在取消时停止生产,否则: +// - 上游 LLM 流在客户端已断开后仍持续运行(浪费算力/费用); +// - 生产者向已无人消费的 ch 发送可能阻塞(无界 ch)或丢弃(有界 ch)。 +// +// 推荐写法:生产者 goroutine 内 select { case <-ctx.Done(): return; case ...: ch <- token }, +// 其中 ctx 取自请求的 c.Request.Context()(Stream 系列内部用的就是它)。 +package sse + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +var ErrInvalidEventName = errors.New("sse: event name must not contain CR or LF") + +// SSEWriter SSE 写入器 +type SSEWriter struct { + writer gin.ResponseWriter + flusher http.Flusher + ctx context.Context // 请求上下文,用于 Stream 系列监听断连(C3a) +} + +// NewSSEWriter 创建 SSE 写入器 +func NewSSEWriter(c *gin.Context) (*SSEWriter, error) { + // 设置 SSE 必要的响应头 + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + // 不手设 Transfer-Encoding: chunked——HTTP/1.1 下由 server 自动分帧, + // HTTP/2 下该头非法会致协议错误(C3c 修复)。 + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return nil, fmt.Errorf("响应写入器不支持 flushing") + } + + return &SSEWriter{ + writer: c.Writer, + flusher: flusher, + ctx: c.Request.Context(), + }, nil +} + +// WriteEvent 写入 SSE 事件 +// 格式: event: \ndata: \n\n +// +// 写错误向上传播(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 := w.writeDataLines(data); err != nil { + return err + } + w.flusher.Flush() + return nil +} + +// WriteMessage 写入消息(无事件类型) +// 格式: data: \n\n +func (w *SSEWriter) WriteMessage(data string) error { + 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) + if err != nil { + return err + } + return w.WriteEvent(event, string(jsonData)) +} + +// WriteError 写入错误事件 +func (w *SSEWriter) WriteError(err error) error { + return w.WriteJSON("error", gin.H{"error": err.Error()}) +} + +// WriteDone 写入完成事件 +func (w *SSEWriter) WriteDone() error { + return w.WriteEvent("done", "") +} + +// KeepAlive 发送保持连接的心跳。 +// +// 用 SSE 注释行(": ping\n\n")而非 data 行(N6 修复):data 行(含空 data)会触发 +// 客户端 onmessage 回调,注释行仅维持连接不产生消息事件,更符合心跳语义。 +func (w *SSEWriter) KeepAlive() error { + if _, err := fmt.Fprintf(w.writer, ": ping\n\n"); err != nil { + return err + } + w.flusher.Flush() + return nil +} + +// Stream 流式发送数据 +// +// 消费循环含 ctx.Done 分支(C3a 修复):客户端断连(c.Request.Context 取消)时立即退出, +// 不再仅靠 channel 关闭或写错误退出。配合 WriteJSON 的写错误传播(C3b),断连即停。 +// ctx 为 nil 时回退到 context.Background()(防御外部未走 NewSSEWriter 构造的 nil panic)。 +func (w *SSEWriter) Stream(event string, ch <-chan any) error { + ctx := w.ctx + if ctx == nil { + ctx = context.Background() + } + for { + select { + case <-ctx.Done(): + return ctx.Err() + case data, ok := <-ch: + if !ok { + return w.WriteDone() + } + if err := w.WriteJSON(event, data); err != nil { + return err + } + } + } +} + +// SSE 中间件,设置必要的响应头 +func SSE() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Next() + } +} + +// StreamText 流式发送文本(适用于 AI 对话场景) +// +// 消费循环含 ctx.Done 分支(C3a 修复):客户端断连即退出。 +// 生产者(ch 的发送方)也应监听 c.Request.Context(),以便消费端早退后停止上游 LLM 流; +// 若生产者忽略 ctx,本函数仍会因 ctx.Done 退出(消费端不泄漏),但上游生产者可能继续运行 +// 直到其自身完成或 channel 阻塞——调用方有责任在 ctx 取消时停止生产。 +func StreamText(c *gin.Context, ch <-chan string) error { + writer, err := NewSSEWriter(c) + if err != nil { + return err + } + + ctx := c.Request.Context() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case text, ok := <-ch: + if !ok { + return writer.WriteDone() + } + if err := writer.WriteJSON("message", gin.H{"text": text}); err != nil { + return err + } + } + } +} + +// StreamChunks 流式发送文本块(带增量标记) +func StreamChunks(c *gin.Context, ch <-chan string) error { + writer, err := NewSSEWriter(c) + if err != nil { + return err + } + + ctx := c.Request.Context() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case chunk, ok := <-ch: + if !ok { + return writer.WriteJSON("done", gin.H{"finished": true}) + } + if err := writer.WriteJSON("chunk", gin.H{"delta": chunk}); err != nil { + return err + } + } + } +} + +// StreamWithID 流式发送带消息 ID 的数据 +func StreamWithID(c *gin.Context, messageID string, ch <-chan string) error { + writer, err := NewSSEWriter(c) + if err != nil { + return err + } + + // 发送开始事件 + if err := writer.WriteJSON("start", gin.H{"id": messageID}); err != nil { + return err + } + + ctx := c.Request.Context() + // 发送内容块 + for { + select { + case <-ctx.Done(): + return ctx.Err() + case chunk, ok := <-ch: + if !ok { + // 发送完成事件 + return writer.WriteJSON("done", gin.H{"id": messageID, "finished": true}) + } + if err := writer.WriteJSON("chunk", gin.H{"id": messageID, "delta": chunk}); err != nil { + return err + } + } + } +} diff --git a/sse/sse_concurrency_test.go b/sse/sse_concurrency_test.go new file mode 100644 index 0000000..abd29de --- /dev/null +++ b/sse/sse_concurrency_test.go @@ -0,0 +1,77 @@ +package sse_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/sse" + "github.com/gin-gonic/gin" +) + +// ===== C3a:断连即停(核心) ===== + +// 回归 C3a:断连即停由 internal test(sse_stream_internal_test.go,TestStreamStopsOnCtxCancelInternal) +// 权威覆盖——直接构造 SSEWriter + 可控 ctx,验证 Stream 在 ctx.Done 时返回 ctx.Err。 +// StreamText/StreamChunks/StreamWithID 用相同 select 模式(代码审查保证),外部网络断连测试 +// 因 httptest loopback 下 c.Request.Context() 取消时序不可靠而省略,internal test 为权威。 + +// ===== C3a:正常完成路径仍工作 ===== + +// 回归:ch 正常关闭时 StreamText 写 done 并返回 nil。 +func TestStreamTextNormalCompletion(t *testing.T) { + ch := make(chan string, 3) + ch <- "a" + ch <- "b" + close(ch) + + r := gin.New() + r.GET("/sse", func(c *gin.Context) { + err := sse.StreamText(c, ch) + if err != nil { + t.Errorf("StreamText normal completion err: %v", err) + } + }) + + srv := httptest.NewServer(r) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/sse") + if err != nil { + t.Fatalf("connect: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if !strings.Contains(string(body), `"text":"a"`) || !strings.Contains(string(body), `"text":"b"`) { + t.Errorf("body missing chunks: %s", string(body)) + } + if !strings.Contains(string(body), "event: done") { + t.Errorf("body missing done event: %s", string(body)) + } +} + +// ===== C3c:不手设 Transfer-Encoding: chunked ===== + +// 回归 C3c:响应头不应含 Transfer-Encoding: chunked(HTTP/2 下非法,HTTP/1.1 冗余)。 +func TestNewSSEWriterNoChunkedHeader(t *testing.T) { + r := gin.New() + r.GET("/sse", func(c *gin.Context) { + _, _ = sse.NewSSEWriter(c) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/sse", nil) + r.ServeHTTP(w, req) + + if got := w.Header().Get("Transfer-Encoding"); got != "" { + t.Errorf("Transfer-Encoding = %q, want empty (C3c: should not hand-set chunked)", got) + } +} + +// ===== C3b:上游生产者契约文档化 ===== +// StreamText 在 ctx.Done 后返回 ctx.Err;生产者(往 ch 发送者)应监听同一 ctx, +// 在取消时停止上游 LLM 流。本框架无法单方面停止生产者,调用方契约见 StreamText 注释。 +// 该契约由 TestStreamTextStopsOnContextCancel 间接验证(StreamText 确实因 ctx.Done 退出)。 diff --git a/sse/sse_stream_internal_test.go b/sse/sse_stream_internal_test.go new file mode 100644 index 0000000..5cb6191 --- /dev/null +++ b/sse/sse_stream_internal_test.go @@ -0,0 +1,34 @@ +package sse + +import ( + "context" + "errors" + "testing" + "time" +) + +// 回归 C3a:Stream 在 ctx 取消时返回 ctx.Err,不再阻塞在 for-range ch。 +// 旧实现 for range ch 无 ctx.Done 分支,ctx 取消后仍阻塞等 ch → goroutine 泄漏 + 上游 LLM 流持续。 +func TestStreamStopsOnCtxCancelInternal(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + w := &SSEWriter{ctx: ctx} // writer/flusher 不用(ctx.Done 先触发,不写数据) + + ch := make(chan any) + // 不向 ch 发数据,也不关闭 ch——若 Stream 无 ctx.Done 分支将永久阻塞。 + done := make(chan error, 1) + go func() { + done <- w.Stream("msg", ch) + }() + + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Errorf("Stream err = %v, want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Stream did not return after ctx cancel (C3a: no ctx.Done branch)") + } +} + + diff --git a/sse/sse_test.go b/sse/sse_test.go new file mode 100644 index 0000000..313d02a --- /dev/null +++ b/sse/sse_test.go @@ -0,0 +1,187 @@ +package sse_test + +import ( + "errors" + "net/http/httptest" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/sse" + "github.com/gin-gonic/gin" +) + +func setupTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + return r +} + +func TestNewSSEWriter(t *testing.T) { + r := setupTestRouter() + r.GET("/sse", func(c *gin.Context) { + writer, err := sse.NewSSEWriter(c) + if err != nil { + t.Errorf("NewSSEWriter error: %v", err) + return + } + + if writer == nil { + t.Error("NewSSEWriter should not return nil") + } + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/sse", nil) + r.ServeHTTP(w, req) +} + +func TestSSEWriterWriteEvent(t *testing.T) { + r := setupTestRouter() + r.GET("/sse", func(c *gin.Context) { + writer, _ := sse.NewSSEWriter(c) + writer.WriteEvent("message", "test data") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/sse", nil) + r.ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, "event: message") { + t.Errorf("WriteEvent body should contain 'event: message', got %s", body) + } + if !strings.Contains(body, "data: test data") { + t.Errorf("WriteEvent body should contain 'data: test data', got %s", body) + } +} + +func TestSSEWriterWriteMessage(t *testing.T) { + r := setupTestRouter() + r.GET("/sse", func(c *gin.Context) { + writer, _ := sse.NewSSEWriter(c) + writer.WriteMessage("hello world") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/sse", nil) + r.ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, "data: hello world") { + t.Errorf("WriteMessage body should contain data, got %s", body) + } +} + +func TestSSEWriterWriteJSON(t *testing.T) { + r := setupTestRouter() + r.GET("/sse", func(c *gin.Context) { + writer, _ := sse.NewSSEWriter(c) + writer.WriteJSON("message", gin.H{"text": "hello"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/sse", nil) + r.ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, "event: message") { + t.Error("WriteJSON should set event type") + } + if !strings.Contains(body, `"text":"hello"`) { + t.Error("WriteJSON should contain JSON data") + } +} + +func TestSSEMiddleware(t *testing.T) { + r := setupTestRouter() + r.Use(sse.SSE()) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + // 验证 SSE 响应头 + contentType := w.Header().Get("Content-Type") + if contentType != "text/event-stream" { + t.Errorf("SSE Content-Type = %s, want text/event-stream", contentType) + } + + cacheControl := w.Header().Get("Cache-Control") + if cacheControl != "no-cache" { + t.Errorf("SSE Cache-Control = %s, want no-cache", cacheControl) + } +} + +func TestSSEHeaders(t *testing.T) { + r := setupTestRouter() + r.GET("/sse", func(c *gin.Context) { + writer, err := sse.NewSSEWriter(c) + if err != nil { + t.Errorf("NewSSEWriter error: %v", err) + return + } + writer.WriteMessage("test") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/sse", nil) + r.ServeHTTP(w, req) + + // 验证必要响应头 + if w.Header().Get("Content-Type") != "text/event-stream" { + t.Error("Content-Type should be text/event-stream") + } + if w.Header().Get("Cache-Control") != "no-cache" { + t.Error("Cache-Control should be no-cache") + } + 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) + } + } +} diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..5794f79 --- /dev/null +++ b/storage/storage.go @@ -0,0 +1,869 @@ +package storage + +import ( + "bytes" + "crypto/rand" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/logger" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "go.uber.org/zap" +) + +// Storage 存储接口 +type Storage interface { + Upload(file *multipart.FileHeader, subdir string) (string, error) + UploadFromBytes(data []byte, filename, subdir string) (string, error) + GetURL(path string) string + Delete(path string) error + Get(path string) ([]byte, error) + // Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil); + // 未初始化、路径非法/穿越、后端错误返回 (false, err)。 + Exists(path string) (bool, error) +} + +var ( + // ErrStorageNotInitialized storage 未初始化。 + ErrStorageNotInitialized = errors.New("storage not initialized") + // ErrPathTraversal 路径穿越被拒绝(C4a)。Delete/Get/Exists/Upload 的相对路径 + // 含 `..` 或绝对路径、逃逸根目录时返回。 + ErrPathTraversal = errors.New("path traversal detected") + // ErrInvalidPath 路径非法(空、含 NUL 等)。 + ErrInvalidPath = errors.New("invalid path") + // 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 ( + // defaultMaxReadBytes Get 默认读取上限(100MB),防止全量读入内存 OOM(C4c)。 + defaultMaxReadBytes int64 = 100 * 1024 * 1024 + // mimeSniffPrefixLen http.DetectContentType 最多嗅探 512 字节。 + mimeSniffPrefixLen = 512 +) + +// resolveMaxRead 解析 Get 读取上限:n<0 不限,n==0 用默认,n>0 用 n。 +func resolveMaxRead(n int64) int64 { + if n < 0 { + return -1 + } + if n == 0 { + return defaultMaxReadBytes + } + return n +} + +// validateUploadSize 校验上传文件大小(C4b)。MaxSizeBytes<=0 表示不限。 +// +// 注意:此处的 size 来自客户端声明(multipart.FileHeader.Size),仅作前置快速拒绝, +// 不可信——攻击者可声明小体积却流式发送大 body。真正的落盘/上传封顶由 enforceUploadSize +// 在拷贝阶段按实际字节数二次把关(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, ErrUploadTooLarge) + } + return nil +} + +// enforceMaxReader 包装底层 reader,实际读取累计超过 max 字节即返回 ErrUploadTooLarge(P0)。 +// max<=0 表示不限。用于上传拷贝阶段的真实大小封顶,弥补客户端声明 Size 不可信。 +type enforceMaxReader struct { + src io.Reader + max int64 + count int64 +} + +func (r *enforceMaxReader) Read(p []byte) (int, error) { + if r.max > 0 && r.count >= r.max { + // 上限内的字节已全部读出:用独立探针读 1 字节判断源是否还有数据,有则超限。 + var probe [1]byte + if n, _ := r.src.Read(probe[:]); n > 0 { + return 0, ErrUploadTooLarge + } + // 源已到 EOF,实际大小恰好等于上限,放行结束。 + return 0, io.EOF + } + n, err := r.src.Read(p) + r.count += int64(n) + if r.max > 0 && r.count > r.max { + return 0, ErrUploadTooLarge + } + return n, err +} + +// enforceUploadSize 若策略配置了 MaxSizeBytes,则包装 src 在拷贝阶段实测封顶(P0)。 +// 未配置上限时原样返回,零开销。 +func enforceUploadSize(p config.UploadPolicy, src io.Reader) io.Reader { + if p.MaxSizeBytes <= 0 { + return src + } + return &enforceMaxReader{src: src, max: p.MaxSizeBytes} +} + +// validateUploadExt 校验上传文件扩展名(C4b)。AllowedExts 为空表示不限。 +func validateUploadExt(p config.UploadPolicy, filename string) error { + if len(p.AllowedExts) == 0 { + return nil + } + ext := strings.ToLower(filepath.Ext(filename)) + if !containsString(p.AllowedExts, ext) { + return fmt.Errorf("扩展名 %s 不在白名单: %w", ext, ErrInvalidPath) + } + return nil +} + +// sniffUploadMIME 嗅探 src 前 512 字节并按 AllowedMIMEs 校验(C4b)。 +// 返回的 reader 已拼回已读头部,后续可继续读到完整内容。 +// AllowedMIMEs 为空时直接返回原 src 不嗅探。 +func sniffUploadMIME(p config.UploadPolicy, src io.Reader) (io.Reader, error) { + if len(p.AllowedMIMEs) == 0 { + return src, nil + } + head := make([]byte, mimeSniffPrefixLen) + n, err := io.ReadFull(src, head) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return nil, fmt.Errorf("读取文件头失败: %w", err) + } + // http.DetectContentType 可能返回 "text/plain; charset=utf-8", + // 与白名单 "text/plain" 比较时取主类型(分号前)。 + detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(head[:n]), ";")[0])) + allowed := make([]string, len(p.AllowedMIMEs)) + for i, m := range p.AllowedMIMEs { + allowed[i] = strings.ToLower(strings.TrimSpace(strings.Split(m, ";")[0])) + } + if !containsString(allowed, detected) { + return nil, fmt.Errorf("文件类型 %s 不在白名单: %w", detected, ErrInvalidPath) + } + return io.MultiReader(bytes.NewReader(head[:n]), src), nil +} + +func containsString(list []string, v string) bool { + for _, s := range list { + if s == v { + return true + } + } + return false +} + +// uniqueFilename 生成带随机后缀的唯一文件名,避免同一纳秒内并发上传导致文件名冲突。 +// 格式: -<8字节随机hex>. +func uniqueFilename(now time.Time, ext string) string { + randBytes := make([]byte, 8) + // crypto/rand 失败极少见;退化为全零也能由纳秒时间戳保证基本唯一性 + _, _ = rand.Read(randBytes) + return fmt.Sprintf("%d-%x%s", now.UnixNano(), randBytes, ext) +} + +// sanitizeObjectKey 净化 OSS object key(C4a)。OSS key 为扁平字符串无 FS 穿越语义, +// 但拒绝含 `..`、绝对路径、NUL、空值等可疑输入,防止 key 注入与越权访问。 +// 同时将 Windows 反斜杠归一化为 OSS 规范的正斜杠,保证跨平台 key 一致。 +func sanitizeObjectKey(key string) (string, error) { + if key == "" || strings.ContainsRune(key, 0) { + return "", ErrInvalidPath + } + // 归一化 Windows 反斜杠为正斜杠(OSS key 规范分隔符),保证 Windows/Linux 部署 key 一致。 + key = strings.ReplaceAll(key, "\\", "/") + if strings.Contains(key, "..") { + return "", ErrPathTraversal + } + // 框架生成的 key 不以 / 开头;拒绝绝对路径形式避免歧义。 + if strings.HasPrefix(key, "/") { + return "", ErrPathTraversal + } + // 规范化多余分隔符,不改变合法 key 语义。 + 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) + baseURL string + policy config.UploadPolicy + maxReadBytes int64 +} + +// NewLocalStorage 创建本地存储实例 +// +// 安全约束: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) + if err != nil { + // P1 #20:Abs 失败(如 os.Getwd 失败)不再回退相对 cfg.Path——相对根会使 safeJoin + // 前缀校验基于相对路径、削弱穿越防御。改为 fail-closed:置空 rootAbs, + // safeJoin 据此拒绝一切操作,并记录错误,避免"看似可用实则防御失效"的本地存储。 + logger.Error("storage: 解析存储根目录绝对路径失败,本地存储将不可用(fail-closed)", + zap.String("path", cfg.Path), zap.Error(err)) + rootAbs = "" + } 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, + policy: cfg.Upload, + maxReadBytes: resolveMaxRead(cfg.MaxReadBytes), + } +} + +// safeJoin 将根路径与相对片段拼接为绝对路径,并以前缀锚定拒绝穿越(C4a)。 +// 任何片段为绝对路径或含 NUL、最终路径逃逸 rootAbs 时返回错误。 +func (s *LocalStorage) safeJoin(parts ...string) (string, error) { + // P1 #20:rootAbs 为空表示构造时 Abs 失败,fail-closed 拒绝所有路径操作。 + if s.rootAbs == "" { + return "", ErrStorageNotInitialized + } + for _, p := range parts { + if filepath.IsAbs(p) { + return "", ErrPathTraversal + } + if strings.ContainsRune(p, 0) { + return "", ErrInvalidPath + } + } + joined := filepath.Join(append([]string{s.rootAbs}, parts...)...) + if joined == s.rootAbs || !strings.HasPrefix(joined, s.rootAbs+string(os.PathSeparator)) { + return "", ErrPathTraversal + } + return joined, nil +} + +// Upload 上传文件 +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 + } + if err := validateUploadExt(s.policy, file.Filename); err != nil { + return "", err + } + + // 生成存储路径: /年/月/日/文件名 + now := time.Now() + datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day()) + relativePath := filepath.Join(subdir, datePath) + + // 确保目录存在,且未逃逸根目录(C4a:subdir 含 `..` 会被 safeJoin 拒绝) + fullPath, err := s.safeJoin(relativePath) + if err != nil { + logger.Warn("上传路径被拒绝", zap.String("subdir", subdir), zap.Error(err)) + return "", err + } + if err := os.MkdirAll(fullPath, 0750); err != nil { + logger.Error("创建目录失败", zap.Error(err), zap.String("path", fullPath)) + return "", fmt.Errorf("创建目录失败: %w", err) + } + + // 生成唯一文件名(服务端随机,可信) + ext := filepath.Ext(file.Filename) + filename := uniqueFilename(now, ext) + dst := filepath.Join(fullPath, filename) + + // 打开源文件 + src, err := file.Open() + if err != nil { + return "", fmt.Errorf("打开文件失败: %w", err) + } + defer src.Close() + + // MIME 嗅探(如配置 AllowedMIMEs),嗅探后拼回头部 + var srcReader io.Reader = src + srcReader, err = sniffUploadMIME(s.policy, srcReader) + if err != nil { + return "", err + } + + // 创建目标文件 + // #nosec G304 -- dst 由 safeJoin 净化后的 fullPath 与服务端随机生成的 filename 拼成,路径已防穿越 + dstFile, err := os.Create(dst) + if err != nil { + return "", fmt.Errorf("创建文件失败: %w", err) + } + 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 { + return "", fmt.Errorf("保存文件失败: %w", err) + } + + // 返回相对路径 + relativeFilePath := filepath.Join(relativePath, filename) + // 统一使用正斜杠 + relativeFilePath = strings.ReplaceAll(relativeFilePath, "\\", "/") + + logger.Info("文件上传成功", zap.String("path", relativeFilePath)) + return relativeFilePath, nil +} + +// UploadFromBytes 从字节数组上传文件 +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 + } + if err := validateUploadExt(s.policy, filename); err != nil { + return "", err + } + + // 生成存储路径: /年/月/日/文件名 + now := time.Now() + datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day()) + relativePath := filepath.Join(subdir, datePath) + + // 确保目录存在,且未逃逸根目录(C4a) + fullPath, err := s.safeJoin(relativePath) + if err != nil { + logger.Warn("上传路径被拒绝", zap.String("subdir", subdir), zap.Error(err)) + return "", err + } + if err := os.MkdirAll(fullPath, 0750); err != nil { + logger.Error("创建目录失败", zap.Error(err), zap.String("path", fullPath)) + return "", fmt.Errorf("创建目录失败: %w", err) + } + + // 生成唯一文件名(如果未提供扩展名,添加 .bin) + ext := filepath.Ext(filename) + if ext == "" { + ext = ".bin" + } + fname := uniqueFilename(now, ext) + dst := filepath.Join(fullPath, fname) + + // MIME 嗅探(如配置 AllowedMIMEs) + var srcReader io.Reader = bytes.NewReader(data) + srcReader, err = sniffUploadMIME(s.policy, srcReader) + if err != nil { + return "", err + } + + // 创建目标文件 + // #nosec G304 -- dst 由 safeJoin 净化后的 fullPath 与服务端随机生成的 filename 拼成,路径已防穿越 + dstFile, err := os.Create(dst) + if err != nil { + return "", fmt.Errorf("创建文件失败: %w", err) + } + 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 { + return "", fmt.Errorf("保存文件失败: %w", err) + } + + // 返回相对路径 + relativeFilePath := filepath.Join(relativePath, fname) + // 统一使用正斜杠 + relativeFilePath = strings.ReplaceAll(relativeFilePath, "\\", "/") + + logger.Info("文件上传成功", zap.String("path", relativeFilePath)) + return relativeFilePath, nil +} + +// GetURL 获取文件访问 URL +func (s *LocalStorage) GetURL(path string) string { + return publicURL(s.baseURL, path) +} + +// Delete 删除文件 +func (s *LocalStorage) Delete(p string) error { + fullPath, err := s.safeJoin(p) + if err != nil { + logger.Warn("删除路径被拒绝", zap.String("path", p), zap.Error(err)) + return err + } + if err := os.Remove(fullPath); err != nil { + logger.Error("删除文件失败", zap.Error(err), zap.String("path", fullPath)) + return fmt.Errorf("删除文件失败: %w", err) + } + logger.Info("文件删除成功", zap.String("path", p)) + return nil +} + +// Get 获取文件内容。读取受 maxReadBytes 封顶,防止全量读入内存 OOM(C4c)。 +func (s *LocalStorage) Get(p string) ([]byte, error) { + fullPath, err := s.safeJoin(p) + if err != nil { + logger.Warn("读取路径被拒绝", zap.String("path", p), zap.Error(err)) + return nil, err + } + // #nosec G304 -- fullPath 经 safeJoin 前缀锚定净化,已防穿越 + f, err := os.Open(fullPath) + if err != nil { + logger.Error("读取文件失败", zap.Error(err), zap.String("path", fullPath)) + return nil, fmt.Errorf("读取文件失败: %w", err) + } + defer f.Close() + + var reader io.Reader = f + if s.maxReadBytes > 0 { + // 多读 1 字节用于判断是否超限 + reader = io.LimitReader(f, s.maxReadBytes+1) + } + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("读取文件内容失败: %w", err) + } + if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes { + return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge) + } + return data, nil +} + +// 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, err + } + if _, err := os.Stat(fullPath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} + +// OSSStorage OSS 存储 +type OSSStorage struct { + client *oss.Client + bucket *oss.Bucket + endpoint string + bucketName string + baseURL string + policy config.UploadPolicy + maxReadBytes int64 +} + +// 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) + } + + bucket, err := client.Bucket(cfg.Bucket) + if err != nil { + return nil, fmt.Errorf("获取 OSS Bucket 失败: %w", err) + } + + return &OSSStorage{ + client: client, + bucket: bucket, + endpoint: cfg.Endpoint, + bucketName: cfg.Bucket, + baseURL: cfg.BaseURL, + policy: cfg.Upload, + maxReadBytes: resolveMaxRead(cfg.MaxReadBytes), + }, nil +} + +// 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 + } + if err := validateUploadExt(s.policy, file.Filename); err != nil { + return "", err + } + + // 生成存储路径: /年/月/日/文件名 + now := time.Now() + datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day()) + ext := filepath.Ext(file.Filename) + // OSS object key 规范用正斜杠;用 path.Join(POSIX)而非 filepath.Join, + // 避免 Windows 产反斜杠导致跨平台 key 不一致。 + rawKey := path.Join(filepath.ToSlash(subdir), datePath, uniqueFilename(now, ext)) + // 净化 object key,拒绝含 `..` 的 subdir(C4a key 注入) + objectKey, err := sanitizeObjectKey(rawKey) + if err != nil { + logger.Warn("OSS 上传 key 被拒绝", zap.String("subdir", subdir), zap.Error(err)) + return "", err + } + + // 打开源文件 + src, err := file.Open() + if err != nil { + return "", fmt.Errorf("打开文件失败: %w", err) + } + defer src.Close() + + // MIME 嗅探(如配置) + var srcReader io.Reader = src + srcReader, err = sniffUploadMIME(s.policy, srcReader) + if err != nil { + return "", err + } + + // 上传到 OSS。按策略实测封顶(P0):超限时 PutObject 读到 ErrUploadTooLarge 而失败, + // 简单 PutObject 语义下对象不会被提交,无需额外清理。 + if err := s.bucket.PutObject(objectKey, enforceUploadSize(s.policy, srcReader)); err != nil { + logger.Error("OSS 上传失败", zap.Error(err), zap.String("key", objectKey)) + return "", fmt.Errorf("OSS 上传失败: %w", err) + } + + logger.Info("OSS 文件上传成功", zap.String("key", objectKey)) + return objectKey, nil +} + +// UploadFromBytes 从字节数组上传文件到 OSS +func (s *OSSStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) { + // 上传安全策略校验(C4b) + if err := validateUploadSize(s.policy, int64(len(data))); err != nil { + return "", err + } + if err := validateUploadExt(s.policy, filename); err != nil { + return "", err + } + + now := time.Now() + datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day()) + ext := filepath.Ext(filename) + if ext == "" { + ext = ".bin" + } + // OSS object key 规范用正斜杠;用 path.Join(POSIX)而非 filepath.Join, + // 避免 Windows 产反斜杠导致跨平台 key 不一致。 + rawKey := path.Join(filepath.ToSlash(subdir), datePath, uniqueFilename(now, ext)) + objectKey, err := sanitizeObjectKey(rawKey) + if err != nil { + logger.Warn("OSS 上传 key 被拒绝", zap.String("subdir", subdir), zap.Error(err)) + return "", err + } + + // MIME 嗅探(如配置) + var srcReader io.Reader = bytes.NewReader(data) + srcReader, err = sniffUploadMIME(s.policy, srcReader) + if err != nil { + return "", err + } + + // 上传到 OSS + if err := s.bucket.PutObject(objectKey, srcReader); err != nil { + logger.Error("OSS 上传失败", zap.Error(err), zap.String("key", objectKey)) + return "", fmt.Errorf("OSS 上传失败: %w", err) + } + + logger.Info("OSS 文件上传成功", zap.String("key", objectKey)) + return objectKey, nil +} + +// GetURL 获取文件访问 URL +func (s *OSSStorage) GetURL(path string) string { + cleanKey, err := sanitizeObjectKey(path) + if err != nil { + return "" + } + 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) { + key, err := sanitizeObjectKey(path) + if err != nil { + return "", err + } + return s.bucket.SignURL(key, oss.HTTPGet, int64(expire.Seconds())) +} + +// Delete 删除 OSS 文件 +func (s *OSSStorage) Delete(p string) error { + key, err := sanitizeObjectKey(p) + if err != nil { + return err + } + if err := s.bucket.DeleteObject(key); err != nil { + logger.Error("OSS 删除失败", zap.Error(err), zap.String("key", key)) + return fmt.Errorf("OSS 删除失败: %w", err) + } + logger.Info("OSS 文件删除成功", zap.String("key", key)) + return nil +} + +// Get 获取 OSS 文件内容。读取受 maxReadBytes 封顶,防止 OOM(C4c)。 +func (s *OSSStorage) Get(p string) ([]byte, error) { + key, err := sanitizeObjectKey(p) + if err != nil { + return nil, err + } + body, err := s.bucket.GetObject(key) + if err != nil { + logger.Error("OSS 读取失败", zap.Error(err), zap.String("key", key)) + return nil, fmt.Errorf("OSS 读取失败: %w", err) + } + defer body.Close() + + var reader io.Reader = body + if s.maxReadBytes > 0 { + reader = io.LimitReader(body, s.maxReadBytes+1) + } + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("读取 OSS 文件内容失败: %w", err) + } + if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes { + return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge) + } + return data, nil +} + +// 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, err + } + 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 模式: +// 实例化 + DefaultStorage 全局默认 + 包级 facade 代理,支持多实例与测试注入。 +type StorageManager struct { + mu sync.Mutex + cfg *config.StorageConfig + current Storage +} + +// DefaultStorage 默认存储管理器,包级 facade 代理到它。 +// +// 主线A 修复:改用 atomic.Pointer 保护读写,消除原裸指针置换(SetDefaultStorageManager) +// 与 facade 无锁读之间的数据竞争,与 config.defaultManager / database.DefaultRedis 对齐。 +// 类型由 *StorageManager 变更为 atomic.Pointer[StorageManager](breaking:下游若直接 +// 调用 DefaultStorage.Init 等方法需改用 Init 等 facade,或 DefaultStorage.Load().Init)。 +var DefaultStorage atomic.Pointer[StorageManager] + +func init() { + DefaultStorage.Store(NewStorageManager()) +} + +// NewStorageManager 创建存储管理器实例。 +func NewStorageManager() *StorageManager { return &StorageManager{} } + +// SetDefaultStorageManager 提升指定 StorageManager 为全局默认。并发安全(atomic.Store)。 +func SetDefaultStorageManager(m *StorageManager) { + if m != nil { + DefaultStorage.Store(m) + } +} + +// 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() + + var s Storage + switch cfg.Driver { + case "local": + s = NewLocalStorage(&cfg.Local) + logger.Info("使用本地存储", zap.String("path", cfg.Local.Path)) + case "oss": + ossStorage, err := NewOSSStorage(&cfg.OSS) + if err != nil { + return err + } + s = ossStorage + logger.Info("使用 OSS 存储", zap.String("bucket", cfg.OSS.Bucket)) + default: + return fmt.Errorf("不支持的存储驱动: %s", cfg.Driver) + } + + m.cfg = cfg + m.current = s + return nil +} + +// Get 返回当前存储实例。 +func (m *StorageManager) Get() Storage { + m.mu.Lock() + defer m.mu.Unlock() + return m.current +} + +// Set 设置存储实例(用于注入 mock 或自定义实现)。 +func (m *StorageManager) Set(s Storage) { + m.mu.Lock() + defer m.mu.Unlock() + 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 初始化存储 +func Init(cfg *config.StorageConfig) error { + return DefaultStorage.Load().Init(cfg) +} + +// GetStorage 获取全局存储实例 +func GetStorage() Storage { + return DefaultStorage.Load().Get() +} + +// SetStorage 设置全局存储实例 +func SetStorage(s Storage) { + DefaultStorage.Load().Set(s) +} + +// Upload 上传文件 +func Upload(file *multipart.FileHeader, subdir string) (string, error) { + s := GetStorage() + if s == nil { + return "", ErrStorageNotInitialized + } + return s.Upload(file, subdir) +} + +// UploadFromBytes 从字节数组上传文件 +func UploadFromBytes(data []byte, filename, subdir string) (string, error) { + s := GetStorage() + if s == nil { + return "", ErrStorageNotInitialized + } + return s.UploadFromBytes(data, filename, subdir) +} + +// GetURL 获取文件访问 URL +func GetURL(path string) string { + s := GetStorage() + if s == nil { + return "" + } + return s.GetURL(path) +} + +// Delete 删除文件 +func Delete(path string) error { + s := GetStorage() + if s == nil { + return ErrStorageNotInitialized + } + return s.Delete(path) +} + +// Get 获取文件内容 +func Get(path string) ([]byte, error) { + s := GetStorage() + if s == nil { + return nil, ErrStorageNotInitialized + } + return s.Get(path) +} + +// Exists 检查文件是否存在。未初始化返回 (false, ErrStorageNotInitialized); +// 其余语义同 Storage.Exists。 +func Exists(path string) (bool, error) { + s := GetStorage() + if s == nil { + return false, ErrStorageNotInitialized + } + return s.Exists(path) +} diff --git a/storage/storage_close_internal_test.go b/storage/storage_close_internal_test.go new file mode 100644 index 0000000..f3c39b4 --- /dev/null +++ b/storage/storage_close_internal_test.go @@ -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) + } +} diff --git a/storage/storage_path_internal_test.go b/storage/storage_path_internal_test.go new file mode 100644 index 0000000..c4656b4 --- /dev/null +++ b/storage/storage_path_internal_test.go @@ -0,0 +1,92 @@ +package storage + +import ( + "errors" + "strings" + "testing" +) + +// 回归 C4a:OSS object key 净化。拒绝空、NUL、含 `..`、绝对路径,防 key 注入与越权访问。 +func TestSanitizeObjectKey(t *testing.T) { + cases := []struct { + name string + in string + wantErr bool + }{ + {"empty", "", true}, + {"dotdot only", "..", true}, + {"dotdot segment", "a/../b", true}, + {"dotdot prefix", "../etc/passwd", true}, + {"absolute", "/abs/path", true}, + {"nul byte", "a\x00b", true}, + {"normal", "images/2026/01/01/x.jpg", false}, + {"normal no ext", "docs/report", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := sanitizeObjectKey(c.in) + if c.wantErr { + if err == nil { + t.Errorf("sanitizeObjectKey(%q) = %q, want error", c.in, got) + } + return + } + if err != nil { + t.Errorf("sanitizeObjectKey(%q) unexpected err: %v", c.in, err) + } + }) + } +} + +// 回归 HIGH(OSS key 跨平台):Windows 反斜杠必须归一化为正斜杠, +// 否则 Windows 开发 / Linux 生产部署 OSS key 不一致、DB 迁移后取不到文件。 +func TestSanitizeObjectKeyNormalizesBackslash(t *testing.T) { + got, err := sanitizeObjectKey("avatars\\2026\\06\\28\\x.jpg") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if strings.Contains(got, "\\") { + t.Errorf("sanitizeObjectKey left backslash: %q", got) + } + if want := "avatars/2026/06/28/x.jpg"; got != want { + t.Errorf("sanitizeObjectKey backslash normalize = %q, want %q", got, want) + } +} + +// 回归 C4c:resolveMaxRead 语义。n<0 不限,n==0 默认,n>0 用 n。 +func TestResolveMaxRead(t *testing.T) { + if got := resolveMaxRead(-1); got != -1 { + t.Errorf("resolveMaxRead(-1) = %d, want -1 (unlimited)", got) + } + if got := resolveMaxRead(0); got != defaultMaxReadBytes { + t.Errorf("resolveMaxRead(0) = %d, want default %d", got, defaultMaxReadBytes) + } + if got := resolveMaxRead(2048); got != 2048 { + 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) + } +} diff --git a/storage/storage_security_test.go b/storage/storage_security_test.go new file mode 100644 index 0000000..3aa515b --- /dev/null +++ b/storage/storage_security_test.go @@ -0,0 +1,344 @@ +package storage_test + +import ( + "bytes" + "errors" + "mime/multipart" + "os" + "path/filepath" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/storage" +) + +// makeFileHeader 用 multipart 真实构造一个 *multipart.FileHeader,content 为文件内容。 +func makeFileHeader(t *testing.T, field, filename, contentType string, content []byte) *multipart.FileHeader { + t.Helper() + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + h := make(map[string][]string) + h["Content-Disposition"] = []string{`form-data; name="` + field + `"; filename="` + filename + `"`} + h["Content-Type"] = []string{contentType} + part, err := w.CreatePart(h) + if err != nil { + t.Fatalf("CreatePart: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("part.Write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("writer.Close: %v", err) + } + r := multipart.NewReader(body, w.Boundary()) + form, err := r.ReadForm(int64(len(content) + 1024)) + if err != nil { + t.Fatalf("ReadForm: %v", err) + } + defer form.RemoveAll() + if len(form.File[field]) == 0 { + t.Fatalf("no file field %q in form", field) + } + return form.File[field][0] +} + +func newLocalStorageWithPolicy(t *testing.T, policy config.UploadPolicy, maxRead int64) *storage.LocalStorage { + t.Helper() + dir := t.TempDir() + return storage.NewLocalStorage(&config.LocalStorageConfig{ + Path: dir, + BaseURL: "http://localhost/uploads", + Upload: policy, + MaxReadBytes: maxRead, + }) +} + +// ===== C4a:路径穿越 ===== + +// 回归 C4a:Delete/Get/Exists 的 `..` 路径必须被拒绝,不能触碰根目录之外的文件。 +func TestLocalStoragePathTraversal(t *testing.T) { + dir := t.TempDir() + s := storage.NewLocalStorage(&config.LocalStorageConfig{Path: dir, BaseURL: "http://localhost/uploads"}) + + // 在 TempDir 之外放一个蜜罐文件,确保穿越不会删/读它。 + sibling := filepath.Join(filepath.Dir(dir), "xlgo_c4_canary_"+filepath.Base(dir)+".txt") + if err := os.WriteFile(sibling, []byte("canary"), 0644); err != nil { + t.Fatalf("write canary: %v", err) + } + defer os.Remove(sibling) + + // 用 `..` 指向 canary(dir 的父目录下)。 + escapeRel := "../" + filepath.Base(sibling) + + // Delete 必须拒绝 + if err := s.Delete(escapeRel); !errors.Is(err, storage.ErrPathTraversal) { + t.Errorf("Delete(%q) err = %v, want ErrPathTraversal", escapeRel, err) + } + // canary 必须仍然存在(未被删除) + if _, err := os.Stat(sibling); err != nil { + t.Errorf("canary file was deleted by traversal Delete: %v", err) + } + + // Get 必须拒绝 + if _, err := s.Get(escapeRel); !errors.Is(err, storage.ErrPathTraversal) { + t.Errorf("Get(%q) err = %v, want ErrPathTraversal", escapeRel, err) + } + + // 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) + } + + // 绝对路径也必须拒绝 + abs := sibling + if err := s.Delete(abs); !errors.Is(err, storage.ErrPathTraversal) { + t.Errorf("Delete(absolute %q) err = %v, want ErrPathTraversal", abs, err) + } +} + +// 回归 C4a:正常相对路径不受误伤(合法用法回归)。 +func TestLocalStorageNormalPathStillWorks(t *testing.T) { + dir := t.TempDir() + s := storage.NewLocalStorage(&config.LocalStorageConfig{Path: dir, BaseURL: "http://localhost/uploads"}) + + // 直接在 root 下放一个文件,用正常相对路径访问。 + target := filepath.Join(dir, "sub", "ok.txt") + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(target, []byte("hello"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + rel := filepath.ToSlash(filepath.Join("sub", "ok.txt")) + 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 { + t.Errorf("Get(normal) err = %v", err) + } + if string(data) != "hello" { + t.Errorf("Get(normal) = %q, want 'hello'", string(data)) + } + if err := s.Delete(rel); err != nil { + t.Errorf("Delete(normal) err = %v", err) + } +} + +// 回归 C4a:Upload 的 subdir 含 `..` 必须拒绝,且不在根目录外创建文件。 +func TestLocalStorageUploadTraversalSubdir(t *testing.T) { + dir := t.TempDir() + s := storage.NewLocalStorage(&config.LocalStorageConfig{Path: dir, BaseURL: "http://localhost/uploads"}) + + fh := makeFileHeader(t, "file", "ok.txt", "text/plain", []byte("hi")) + if _, err := s.Upload(fh, "../evil"); !errors.Is(err, storage.ErrPathTraversal) { + t.Errorf("Upload subdir ../ err = %v, want ErrPathTraversal", err) + } + // 根目录之外不应出现 evil 目录 + evilDir := filepath.Join(filepath.Dir(dir), "evil") + if _, err := os.Stat(evilDir); err == nil { + t.Errorf("traversal Upload created dir outside root: %s", evilDir) + } + + // 绝对路径 subdir 也拒绝 + if _, err := s.Upload(fh, dir); !errors.Is(err, storage.ErrPathTraversal) { + t.Errorf("Upload absolute subdir err = %v, want ErrPathTraversal", err) + } +} + +// 回归 C4a:UploadFromBytes 的 subdir 含 `..` 必须拒绝。 +func TestLocalStorageUploadFromBytesTraversalSubdir(t *testing.T) { + dir := t.TempDir() + s := storage.NewLocalStorage(&config.LocalStorageConfig{Path: dir, BaseURL: "http://localhost/uploads"}) + + if _, err := s.UploadFromBytes([]byte("hi"), "ok.txt", "../../etc"); !errors.Is(err, storage.ErrPathTraversal) { + t.Errorf("UploadFromBytes subdir ../../etc err = %v, want ErrPathTraversal", err) + } +} + +// ===== C4c:Get 读封顶 ===== + +// 回归 C4c:MaxReadBytes 封顶,超限文件 Get 返回错误,不再 OOM。 +func TestLocalStorageGetReadLimit(t *testing.T) { + s := newLocalStorageWithPolicy(t, config.UploadPolicy{}, 10) // 限制 10 字节 + + // MaxReadBytes=10 的实例不限上传;上传 20 字节后 Get 应被封顶拒绝。 + content := []byte("0123456789ABCDEFGHIJ") // 20 字节 + fh := makeFileHeader(t, "file", "big.txt", "text/plain", content) + rel, err := s.Upload(fh, "docs") + if err != nil { + t.Fatalf("Upload big file: %v", err) + } + if _, err := s.Get(rel); !errors.Is(err, storage.ErrReadTooLarge) { + t.Errorf("Get over-limit err = %v, want ErrReadTooLarge", err) + } + + // 小文件应正常读取。 + small := newLocalStorageWithPolicy(t, config.UploadPolicy{}, 100) + fh2 := makeFileHeader(t, "file", "small.txt", "text/plain", []byte("small")) + rel2, err := small.Upload(fh2, "docs") + if err != nil { + t.Fatalf("Upload small: %v", err) + } + data, err := small.Get(rel2) + if err != nil { + t.Errorf("Get small err = %v", err) + } + if string(data) != "small" { + t.Errorf("Get small = %q, want 'small'", string(data)) + } +} + +// ===== C4b:上传策略 ===== + +// 回归 C4b:MaxSizeBytes 超限拒绝。 +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.ErrUploadTooLarge) { + t.Errorf("Upload over size err = %v, want ErrUploadTooLarge", err) + } +} + +// 回归 C4b:AllowedExts 白名单——不允许的扩展名拒绝,允许的通过。 +func TestLocalStorageUploadExtWhitelist(t *testing.T) { + s := newLocalStorageWithPolicy(t, config.UploadPolicy{AllowedExts: []string{".jpg"}}, 0) + + // evil.php 拒绝 + fh := makeFileHeader(t, "file", "evil.php", "text/plain", []byte(" mockStorageMaxBytes { + return "", ErrMockStorageTooLarge + } + + data, err := file.Open() + if err != nil { + return "", err + } + 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。 +func (m *MockStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) { + if int64(len(data)) > mockStorageMaxBytes { + return "", ErrMockStorageTooLarge + } + path := "/mock/" + subdir + "/" + filename + m.mu.Lock() + defer m.mu.Unlock() + m.files[path] = append([]byte(nil), data...) + return path, nil +} + +// GetURL 获取 mock URL。 +func (m *MockStorage) GetURL(path string) string { + return "http://mock.test" + path +} + +// AssertEqual 断言相等。 +func AssertEqual(t *testing.T, expected, actual any) { + t.Helper() + if expected != actual { + t.Errorf("不相等: 期望 %v, 实际 %v", expected, actual) + } +} + +// AssertNotNil 断言非空。 +func AssertNotNil(t *testing.T, value any) { + t.Helper() + if value == nil { + t.Error("期望非空,实际为空") + } +} + +// AssertNil 断言为空。 +func AssertNil(t *testing.T, value any) { + t.Helper() + if value != nil { + t.Errorf("期望为空,实际为: %v", value) + } +} + +// AssertTrue 断言为 true。 +func AssertTrue(t *testing.T, value bool) { + t.Helper() + if !value { + t.Error("期望为 true,实际为 false") + } +} + +// AssertFalse 断言为 false。 +func AssertFalse(t *testing.T, value bool) { + t.Helper() + if value { + t.Error("期望为 false,实际为 true") + } +} + +// AssertError 断言有错误。 +func AssertError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Error("期望有错误,实际无错误") + } +} + +// AssertNoError 断言无错误。 +func AssertNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Errorf("期望无错误,实际错误: %v", err) + } +} diff --git a/test/test_m16_test.go b/test/test_m16_test.go new file mode 100644 index 0000000..44216b7 --- /dev/null +++ b/test/test_m16_test.go @@ -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) + } +} diff --git a/trace/trace.go b/trace/trace.go new file mode 100644 index 0000000..d8ae72a --- /dev/null +++ b/trace/trace.go @@ -0,0 +1,418 @@ +package trace + +import ( + "context" + "fmt" + "math" + "net/http" + "os" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/contrib/propagators/b3" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// Config 链路追踪配置 +type Config struct { + // ServiceName 服务名称 + ServiceName string + // ServiceVersion 服务版本 + ServiceVersion string + // Environment 运行环境 + Environment string + // ExporterType 导出器类型: "otlp-http", "otlp-grpc", "stdout" + ExporterType string + // Endpoint OTLP 导出器地址 + Endpoint string + // Insecure 是否使用明文(无 TLS)连接 collector。 + // 默认 false(TLS);对 localhost:4318 等明文 collector 需显式置 true(C13c)。 + Insecure bool + // SampleRatio 采样比例 (0.0-1.0) + SampleRatio float64 + // Enabled 是否启用 + Enabled bool + // Propagator 传播器类型: "w3c", "b3", "jaeger" + Propagator string +} + +// DefaultConfig 默认配置 +var DefaultConfig = Config{ + ServiceName: "xlgo-service", + ServiceVersion: "1.0.0", // 应用自身版本(非框架版本 xlgo.Version);建议业务侧覆盖为实际应用版本 + Environment: "development", + ExporterType: "otlp-http", + Endpoint: "localhost:4318", + SampleRatio: 1.0, + Enabled: false, + Propagator: "w3c", +} + +// tracerProviderPtr 全局 TracerProvider(atomic,C13a)。 +// Init 之前/Close 之后均为 Noop/NeverSample,保证任何时刻 Load 非 nil,请求期不 panic。 +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()。 + noopProvider := noop.NewTracerProvider() + noopTracer := noopProvider.Tracer("xlgo") + tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample())) + tracerProviderPtr.Store(tp) + tracerPtr.Store(&noopTracer) + operationTimeoutNanos.Store(int64(defaultOperationTimeout)) +} + +// getTracer 返回全局 Tracer 的 atomic 快照(永不 nil)。 +func getTracer() trace.Tracer { + return *tracerPtr.Load() +} + +// TracerProvider 全局 TracerProvider(导出供高级用法;返回当前快照)。 +func TracerProvider() *sdktrace.TracerProvider { + return tracerProviderPtr.Load() +} + +// 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() + otel.SetTracerProvider(noopProvider) + noopTracer := noopProvider.Tracer(cfg.ServiceName) + tracerPtr.Store(&noopTracer) + // M-64 修复:Swap 出旧 provider(可能持有 exporter)并 Shutdown, + // 避免禁用 trace 后旧 exporter 后台 goroutine/连接持续占用。 + fallback := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample())) + if old := tracerProviderPtr.Swap(fallback); old != nil { + _ = shutdownProviderWithTimeout(context.Background(), old) + } + return nil + } + + // 创建资源 + // 注意:不传 semconv.SchemaURL 与 resource.Default() 混用—— + // resource.Default() 在不同 OTel 版本可能使用与 semconv v1.24.0 不同的 schema URL, + // resource.Merge 对冲突的 SchemaURL 直接报错。这里用空 schema URL 的属性集合并, + // 避免版本漂移导致的初始化失败。 + res, err := resource.Merge( + resource.Default(), + resource.NewWithAttributes( + "", + semconv.ServiceName(cfg.ServiceName), + semconv.ServiceVersion(cfg.ServiceVersion), + attribute.String("environment", cfg.Environment), + ), + ) + if err != nil { + return err + } + + // 创建导出器 + exporter, err := createExporter(cfg) + if err != nil { + return err + } + + // 创建 TracerProvider + newProvider := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.TraceIDRatioBased(cfg.SampleRatio)), + ) + + // 设置传播器(非法类型返错,C13e 不再静默回落) + prop, err := createPropagator(cfg.Propagator) + if err != nil { + // M-65 修复:传播器非法时回滚已创建的 provider。此时尚未 otel.SetTracerProvider, + // otel 全局仍指向旧 provider,无需恢复——仅 Shutdown 新 provider 避免泄漏。 + _ = shutdownProviderWithTimeout(context.Background(), newProvider) + return err + } + + // 全部成功后再切换全局状态(M-65:避免失败后 otel 指向已 Shutdown 的 provider)。 + otel.SetTracerProvider(newProvider) + otel.SetTextMapPropagator(prop) + + // 原子替换:先建新 provider,成功后再 Store,并关闭旧 provider(若持有 exporter)。 + oldProvider := tracerProviderPtr.Swap(newProvider) + tracer := newProvider.Tracer(cfg.ServiceName) + tracerPtr.Store(&tracer) + if oldProvider != nil { + _ = 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 { + case "otlp-http": + opts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(cfg.Endpoint)} + if cfg.Insecure { + opts = append(opts, otlptracehttp.WithInsecure()) // C13c + } + client := otlptracehttp.NewClient(opts...) + 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...) + ctx, cancel := contextWithOperationTimeout(context.Background()) + defer cancel() + return otlptrace.New(ctx, client) + case "stdout": + return stdouttrace.New( + stdouttrace.WithWriter(os.Stdout), + stdouttrace.WithPrettyPrint(), + ) + default: + // C13b:未知导出器返错,不再返回 nil 喂 WithBatcher(nil)。 + return nil, fmt.Errorf("不支持的导出器类型: %s", cfg.ExporterType) + } +} + +// createPropagator 创建传播器(C13e:实现 b3,jaeger 映射 W3C,未知返错)。 +func createPropagator(propagatorType string) (propagation.TextMapPropagator, error) { + switch propagatorType { + case "w3c", "": + return propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ), nil + case "b3": + // 同时支持多头与单头注入/抽取,兼容旧 B3 客户端。 + return b3.New(b3.WithInjectEncoding(b3.B3MultipleHeader | b3.B3SingleHeader)), nil + case "jaeger": + // 现代 Jaeger agent 透传 W3C TraceContext;纯 Jaeger thrift 头协议需下游用 b3。 + // 不引入不稳定的 jaegerremix 模块,jaeger 视为 W3C 别名。 + return propagation.TraceContext{}, nil + default: + return nil, fmt.Errorf("不支持的传播器类型: %s", propagatorType) + } +} + +// Close 关闭链路追踪。 +// +// H-14 修复:去掉 sync.Once。原实现 Close→Init→Close 第二次因 once 已消费而 no-op, +// 新 provider 的 exporter 后台 goroutine/连接泄漏。改为每次 Swap 出当前 provider 并 +// Shutdown,Store 回兜底 NeverSample provider(C13a:Close 后再用已关闭 provider)。 +// 幂等:重复 Close 时 Swap 得到的是无 exporter 的兜底 provider,Shutdown 无害。 +// 并发安全:Swap 原子返回唯一指针,不会 double-Shutdown 同一 provider。 +func Close(ctx context.Context) error { + fallback := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample())) + old := tracerProviderPtr.Swap(fallback) + noopTracer := noop.NewTracerProvider().Tracer("xlgo") // P1 #19:noop 包替代弃用 API + tracerPtr.Store(&noopTracer) + if old != nil { + return shutdownProviderWithTimeout(ctx, old) + } + return nil +} + +// Middleware Gin 中间件 +func Middleware(serviceName string) gin.HandlerFunc { + return func(c *gin.Context) { + // 从请求中提取 TraceContext + ctx := otel.GetTextMapPropagator().Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header)) + + // 创建 Span。P1 #19:span 名以路由模板(FullPath)为准以保持低基数。 + // 原实现 `Method+" "+FullPath()` 后判 `== ""` 永不成立("GET " 非空), + // 未匹配路由的回退分支形同虚设、且用原始 URL.Path(含 ID)会导致 span 名高基数爆炸。 + // 现显式判断 FullPath 为空(未匹配路由),用固定低基数名。 + route := c.FullPath() + var spanName string + if route == "" { + spanName = c.Request.Method + " [unmatched]" + } else { + 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(attrs...), + ) + // P1 #19:defer 保证下游 handler panic(在上游 recovery 捕获前)时 span 也被结束, + // 不泄漏未结束的 span。 + defer span.End() + + // C13d:更新 c.Request,使下游 c.Request.Context() 含 span; + // 同时保留 c.Set("otel_ctx", ctx) 兼容既有 GetContext 用法。 + c.Request = c.Request.WithContext(ctx) + c.Set("otel_ctx", ctx) + + // 将 TraceID 添加到响应头 + if span.SpanContext().HasTraceID() { + c.Header("X-Trace-ID", span.SpanContext().TraceID().String()) + } + + // 执行请求 + c.Next() + + // 设置 Span 状态 + status := c.Writer.Status() + span.SetAttributes(semconv.HTTPResponseStatusCodeKey.Int(status)) + + if status >= 400 { + span.SetStatus(codes.Error, http.StatusText(status)) + } + // 成功路径不显式设 codes.Ok(M18):OTel 规范中 Span 状态默认 UNSET, + // 仅在错误时设 Error;显式设 Ok 会掩盖下游子 Span 的真实错误状态。 + } +} + +// GetContext 从 Gin Context 获取 OpenTelemetry Context +// +// C13:裸断言改 comma-ok,防 "otel_ctx" 被外部置为非 context 值时 panic。 +func GetContext(c *gin.Context) context.Context { + if v, exists := c.Get("otel_ctx"); exists { + if ctx, ok := v.(context.Context); ok { + return ctx + } + } + return c.Request.Context() +} + +// GetTraceID 获取当前 TraceID +func GetTraceID(c *gin.Context) string { + ctx := GetContext(c) + span := trace.SpanFromContext(ctx) + if span.SpanContext().HasTraceID() { + return span.SpanContext().TraceID().String() + } + return "" +} + +// StartSpan 创建子 Span +func StartSpan(c *gin.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { + ctx := GetContext(c) + return getTracer().Start(ctx, name, + trace.WithSpanKind(trace.SpanKindInternal), + trace.WithAttributes(attrs...), + ) +} + +// StartSpanFromContext 从 Context 创建 Span +func StartSpanFromContext(ctx context.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { + return getTracer().Start(ctx, name, + trace.WithSpanKind(trace.SpanKindInternal), + trace.WithAttributes(attrs...), + ) +} + +// RecordError 记录错误 +func RecordError(c *gin.Context, err error) { + if c == nil || err == nil { + return + } + ctx := GetContext(c) + span := trace.SpanFromContext(ctx) + span.RecordError(err) + span.SetStatus(codes.Error, 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()) +} + +// AddAttributes 添加属性到当前 Span +func AddAttributes(c *gin.Context, attrs ...attribute.KeyValue) { + ctx := GetContext(c) + span := trace.SpanFromContext(ctx) + span.SetAttributes(attrs...) +} + +// GetTracer 获取全局 Tracer +func GetTracer() trace.Tracer { + return getTracer() +} + +// SetAttribute 设置单个属性 +func SetAttribute(c *gin.Context, key string, value any) { + ctx := GetContext(c) + span := trace.SpanFromContext(ctx) + + switch v := value.(type) { + case string: + span.SetAttributes(attribute.String(key, v)) + case int: + span.SetAttributes(attribute.Int(key, v)) + case int64: + span.SetAttributes(attribute.Int64(key, v)) + case bool: + span.SetAttributes(attribute.Bool(key, v)) + case float64: + span.SetAttributes(attribute.Float64(key, v)) + default: + span.SetAttributes(attribute.String(key, fmt.Sprintf("%v", v))) + } +} diff --git a/trace/trace_test.go b/trace/trace_test.go new file mode 100644 index 0000000..f644c15 --- /dev/null +++ b/trace/trace_test.go @@ -0,0 +1,653 @@ +package trace + +import ( + "context" + "errors" + "math" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + oteltrace "go.opentelemetry.io/otel/trace" +) + +// initTracerSnapshot 捕获包 init() 后、任何测试改动前的全局 tracer 快照。 +// 用于锁定 init() 的 Noop 兜底不变式(C13a),避免 resetGlobal/Init/Close +// 在其他测试中重置全局后掩盖 init() 路径的回归。 +var initTracerSnapshot oteltrace.Tracer + +func TestMain(m *testing.M) { + // init() 已运行;此处立即快照 getTracer()(永不 nil 的 Noop 兜底)。 + initTracerSnapshot = getTracer() + code := m.Run() + os.Exit(code) +} + +// resetGlobal 恢复 trace 包级全局到 Noop 兜底状态,避免测试间污染。 +func resetGlobal(t *testing.T) { + t.Helper() + noopProvider := oteltrace.NewNoopTracerProvider() + noopTracer := noopProvider.Tracer("xlgo") + tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample())) + tracerProviderPtr.Store(tp) + tracerPtr.Store(&noopTracer) + otel.SetTracerProvider(noopProvider) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + 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。 +// +// 变异 init() 去掉 Noop Store 后,initTracerSnapshot(在 TestMain 即 init 后捕获) +// 将为 nil → 此测试红。resetGlobal 等后续改动不影响此快照。 +func TestC13aInitNoopInvariant(t *testing.T) { + if initTracerSnapshot == nil { + t.Fatal("init() did not store a Noop tracer: getTracer() was nil at package load (C13a)") + } +} + +// ============================================================ +// C13a:未 Init 即用 → nil tracer panic +// ============================================================ + +// TestC13aNoInitNoPanic 验证未 Init 时包级函数不 panic(Noop 兜底)。 +// +// 修复前:包级 tracer 为 nil,Middleware/StartSpanFromContext/GetTracer 裸用 → panic。 +// 修复后:init() Store Noop,getTracer() 永不 nil。 +func TestC13aNoInitNoPanic(t *testing.T) { + // 强制重置到"未 Init"的 Noop 兜底状态。 + resetGlobal(t) + + // GetTracer 非 nil。 + if tr := GetTracer(); tr == nil { + t.Fatal("GetTracer() nil before Init (C13a)") + } + + // StartSpanFromContext 不 panic。 + ctx, span := StartSpanFromContext(context.Background(), "test-span") + defer span.End() + if span == nil { + t.Fatal("StartSpanFromContext returned nil span") + } + _ = ctx + + // Middleware 不 panic:走一次请求。 + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(Middleware("test-svc")) + r.GET("/p", func(c *gin.Context) { + // 下游用 c.Request.Context() 取 span(C13d 闭环)。 + s := oteltrace.SpanFromContext(c.Request.Context()) + c.String(http.StatusOK, s.SpanContext().TraceID().String()) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/p", nil) + r.ServeHTTP(w, req) + + // Noop tracer 的 TraceID 为空(Noop 不记录),但绝不应 panic。 + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (C13a should not panic)", w.Code) + } +} + +// TestC13aInitDisabledNoop 验证 Init(Enabled:false) 后 Noop 安全。 +func TestC13aInitDisabledNoop(t *testing.T) { + t.Cleanup(func() { _ = Close(context.Background()); resetGlobal(t) }) + if err := Init(Config{Enabled: false, ServiceName: "svc"}); err != nil { + t.Fatalf("Init: %v", err) + } + if GetTracer() == nil { + t.Fatal("GetTracer nil after Init(Enabled:false)") + } + // Noop tracer Start 不 panic。 + _, span := StartSpanFromContext(context.Background(), "x") + span.End() +} + +// TestC13aCloseThenUseNoPanic 验证 Close 后再用不 panic(Store 回 Noop 兜底)。 +func TestC13aCloseThenUseNoPanic(t *testing.T) { + resetGlobal(t) + t.Cleanup(func() { resetGlobal(t) }) + + // 即便未真正 Init 出带 exporter 的 provider,Close 也应安全并把全局重置为兜底。 + if err := Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + // Close 后包级函数仍安全。 + if GetTracer() == nil { + t.Fatal("GetTracer nil after Close") + } + _, span := StartSpanFromContext(context.Background(), "after-close") + span.End() +} + +// ============================================================ +// C13b:未知导出器返 nil + stdout 未实现 +// ============================================================ + +// TestC13bStdoutExporterWorks 验证 stdout 导出器可创建(C13b 实现)。 +func TestC13bStdoutExporterWorks(t *testing.T) { + // 用 stdouttrace 但 Init 会向 os.Stdout 输出,故直接测 createExporter。 + cfg := Config{ExporterType: "stdout"} + exp, err := createExporter(cfg) + if err != nil { + t.Fatalf("createExporter(stdout): %v (C13b stdout unimplemented)", err) + } + if exp == nil { + t.Fatal("createExporter(stdout) returned nil exporter (C13b)") + } + _ = exp.Shutdown(context.Background()) +} + +// TestC13bUnknownExporterReturnsError 验证未知导出器返错(修复前返 nil,nil)。 +func TestC13bUnknownExporterReturnsError(t *testing.T) { + cfg := Config{ExporterType: "xyz-unknown"} + exp, err := createExporter(cfg) + if err == nil { + t.Error("createExporter(unknown) should return error (C13b), got nil") + } + if exp != nil { + t.Errorf("createExporter(unknown) should return nil exporter, got %T", exp) + } +} + +// TestC13bInitUnknownExporterFails 验证 Init 未知导出器返错(不喂 nil 给 WithBatcher)。 +func TestC13bInitUnknownExporterFails(t *testing.T) { + t.Cleanup(func() { resetGlobal(t) }) + err := Init(Config{Enabled: true, ExporterType: "xyz-unknown", Propagator: "w3c"}) + if err == nil { + t.Error("Init with unknown exporter should fail (C13b)") + } +} + +// ============================================================ +// C13c:OTLP 默认 HTTPS 无 WithInsecure +// ============================================================ + +// TestC13cInsecureExporterCreates 验证 Insecure:true 时 otlp-http 导出器可创建(无 TLS 握手)。 +// createExporter 仅构造 client,不连接;Insecure 注入不报错即验证 option 路径生效。 +func TestC13cInsecureExporterCreates(t *testing.T) { + cfg := Config{ExporterType: "otlp-http", Endpoint: "localhost:4318", Insecure: true} + exp, err := createExporter(cfg) + if err != nil { + t.Fatalf("createExporter(otlp-http, Insecure): %v (C13c)", err) + } + _ = exp.Shutdown(context.Background()) +} + +// TestC13cOtlpGrpcInsecureCreates 验证 otlp-grpc Insecure 路径。 +func TestC13cOtlpGrpcInsecureCreates(t *testing.T) { + cfg := Config{ExporterType: "otlp-grpc", Endpoint: "localhost:4317", Insecure: true} + exp, err := createExporter(cfg) + if err != nil { + t.Fatalf("createExporter(otlp-grpc, Insecure): %v (C13c)", err) + } + _ = exp.Shutdown(context.Background()) +} + +// ============================================================ +// C13d:Middleware 不更新 c.Request +// ============================================================ + +// TestC13dRequestContextContainsSpan 验证 Middleware 更新 c.Request, +// 下游 c.Request.Context() 含 span(TraceID 非空且与 X-Trace-ID 一致)。 +// +// 修复前:仅 c.Set("otel_ctx", ctx),下游 c.Request.Context() 无 span。 +func TestC13dRequestContextContainsSpan(t *testing.T) { + resetGlobal(t) + t.Cleanup(func() { _ = Close(context.Background()); resetGlobal(t) }) + + // 用 stdout 导出器 + 全采样,使 span 真实生成(非 Noop)。 + if err := Init(Config{ + Enabled: true, + ServiceName: "test-svc", + ExporterType: "stdout", + SampleRatio: 1.0, + Propagator: "w3c", + }); err != nil { + t.Fatalf("Init: %v", err) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(Middleware("test-svc")) + + var seenTraceID string + r.GET("/p", func(c *gin.Context) { + // 关键:用 c.Request.Context()(而非 trace.GetContext(c))取 span。 + s := oteltrace.SpanFromContext(c.Request.Context()) + seenTraceID = s.SpanContext().TraceID().String() + c.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/p", nil) + r.ServeHTTP(w, req) + + if seenTraceID == "" { + t.Fatal("c.Request.Context() has no span/TraceID (C13d: Middleware didn't update c.Request)") + } + headerTraceID := w.Header().Get("X-Trace-ID") + if headerTraceID == "" { + t.Fatal("X-Trace-ID header missing") + } + if seenTraceID != headerTraceID { + t.Errorf("TraceID mismatch: downstream %q vs header %q (C13d)", seenTraceID, headerTraceID) + } +} + +// TestC13dPropagatedTraceContextExtracted 验证 Middleware 从入站 W3C 头提取父 context +// 并写入 c.Request,下游 span 继承父 TraceID。 +func TestC13dPropagatedTraceContextExtracted(t *testing.T) { + resetGlobal(t) + t.Cleanup(func() { _ = Close(context.Background()); resetGlobal(t) }) + + if err := Init(Config{ + Enabled: true, ServiceName: "test-svc", ExporterType: "stdout", + SampleRatio: 1.0, Propagator: "w3c", + }); err != nil { + t.Fatalf("Init: %v", err) + } + + // 构造一个父 span 并注入 W3C traceparent 头(用真实采样 provider,非 noop)。 + parentProvider := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample())) + defer parentProvider.Shutdown(context.Background()) + parentTracer := parentProvider.Tracer("test-parent") + parentCtx, parentSpan := parentTracer.Start(context.Background(), "parent") + defer parentSpan.End() + parentTraceID := parentSpan.SpanContext().TraceID().String() + + carrier := propagation.HeaderCarrier{} + otel.GetTextMapPropagator().Inject(parentCtx, carrier) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(Middleware("test-svc")) + var seen string + r.GET("/p", func(c *gin.Context) { + s := oteltrace.SpanFromContext(c.Request.Context()) + seen = s.SpanContext().TraceID().String() + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/p", nil) + // 把 carrier 中的头复制到请求。 + for _, key := range carrier.Keys() { + req.Header.Set(key, carrier.Get(key)) + } + r.ServeHTTP(w, req) + + if seen != parentTraceID { + t.Errorf("downstream TraceID = %q, want parent %q (C13d propagation/extract)", seen, parentTraceID) + } +} + +// ============================================================ +// C13e:b3/jaeger 未实现 +// ============================================================ + +// TestC13eB3PropagatorImplemented 验证 b3 传播器返回非 nil 的 b3 propagator(C13e 实现)。 +func TestC13eB3PropagatorImplemented(t *testing.T) { + prop, err := createPropagator("b3") + if err != nil { + t.Fatalf("createPropagator(b3): %v (C13e unimplemented)", err) + } + if prop == nil { + t.Fatal("createPropagator(b3) returned nil (C13e)") + } + // 用真实采样的 provider tracer,使 span SpanContext 有效。 + tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample())) + defer tp.Shutdown(context.Background()) + tracer := tp.Tracer("t") + ctx, span := tracer.Start(context.Background(), "s") + defer span.End() + // b3 propagator 应识别 b3 头。注入后 b3 单头或 x-b3-traceid 至少其一非空。 + carrier := propagation.HeaderCarrier{} + prop.Inject(ctx, carrier) + if carrier.Get("b3") == "" && carrier.Get("x-b3-traceid") == "" { + t.Error("b3 propagator did not inject b3 headers (C13e)") + } +} + +// TestC13eJaegerMapsToW3C 验证 jaeger 映射 W3C TraceContext(非静默 nil)。 +func TestC13eJaegerMapsToW3C(t *testing.T) { + prop, err := createPropagator("jaeger") + if err != nil { + t.Fatalf("createPropagator(jaeger): %v (C13e)", err) + } + if prop == nil { + t.Fatal("createPropagator(jaeger) returned nil (C13e)") + } + // 用真实采样的 provider tracer,使 span SpanContext 有效(noop tracer 不生成 TraceID)。 + tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample())) + defer tp.Shutdown(context.Background()) + tracer := tp.Tracer("t") + ctx, span := tracer.Start(context.Background(), "s") + defer span.End() + // 注入 W3C traceparent 头。 + carrier := propagation.HeaderCarrier{} + prop.Inject(ctx, carrier) + if carrier.Get("traceparent") == "" { + t.Error("jaeger(→W3C) propagator did not inject traceparent (C13e)") + } +} + +// TestC13eUnknownPropagatorReturnsError 验证未知传播器返错(修复前静默回落 W3C)。 +func TestC13eUnknownPropagatorReturnsError(t *testing.T) { + prop, err := createPropagator("xyz") + if err == nil { + t.Error("createPropagator(unknown) should return error (C13e), got nil") + } + if prop != nil { + t.Errorf("createPropagator(unknown) should return nil, got %T", prop) + } +} + +// TestC13eInitUnknownPropagatorFails 验证 Init 未知传播器返错且回滚 provider。 +func TestC13eInitUnknownPropagatorFails(t *testing.T) { + t.Cleanup(func() { resetGlobal(t) }) + err := Init(Config{Enabled: true, ExporterType: "stdout", Propagator: "xyz"}) + if err == nil { + t.Error("Init with unknown propagator should fail (C13e)") + } +} + +// TestC13eW3CDefault 验证空 propagator 默认 W3C(兼容)。 +func TestC13eW3CDefault(t *testing.T) { + prop, err := createPropagator("") + if err != nil { + t.Fatalf("createPropagator(''): %v", err) + } + if prop == nil { + t.Fatal("createPropagator('') returned nil") + } +} + +// ============================================================ +// C13 顺带:GetContext 裸断言防护 +// ============================================================ + +// TestC13GetContextCommaOk 验证 otel_ctx 被置为非 context 值时不 panic。 +func TestC13GetContextCommaOk(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c.Set("otel_ctx", "not-a-context") // 污染 + + // 修复前:ctx.(context.Context) 裸断言 panic;修复后 comma-ok 回退 c.Request.Context()。 + ctx := GetContext(c) + if ctx == nil { + t.Fatal("GetContext returned nil") + } + // 应回退到 c.Request.Context()。 + if ctx != c.Request.Context() { + t.Error("GetContext should fall back to c.Request.Context() on bad otel_ctx") + } +} + +// ============================================================ +// H-14:Close→Init→Close 多轮生命周期(原 sync.Once 致第二次 Close 不 Shutdown) +// ============================================================ + +// TestH14CloseInitCloseMultipleRounds 验证 Close 可反复执行, +// 且 Close→Init→Close 第二次 Close 仍真正 Shutdown 新 provider(不泄漏 exporter)。 +// +// 修复前:Close 用 sync.Once,第二次 Close no-op,第二轮 Init 创建的 provider 的 +// exporter 后台 goroutine/连接泄漏。 +func TestH14CloseInitCloseMultipleRounds(t *testing.T) { + t.Cleanup(func() { resetGlobal(t) }) + + for round := 0; round < 3; round++ { + if err := Init(Config{Enabled: true, ServiceName: "svc", ExporterType: "stdout", Propagator: "w3c"}); err != nil { + t.Fatalf("round %d Init failed: %v", round, err) + } + // Init 后 provider 非 nil + if TracerProvider() == nil { + t.Fatalf("round %d: provider nil after Init", round) + } + if err := Close(context.Background()); err != nil { + t.Fatalf("round %d Close failed: %v", round, err) + } + // Close 后仍非 nil(兜底 NeverSample provider,C13a) + if TracerProvider() == nil { + t.Fatalf("round %d: provider nil after Close", round) + } + } +} + +// TestH14CloseIdempotent 验证连续 Close 不 panic、不报错。 +func TestH14CloseIdempotent(t *testing.T) { + t.Cleanup(func() { resetGlobal(t) }) + if err := Close(context.Background()); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := Close(context.Background()); err != nil { + t.Fatalf("second Close: %v", err) + } +} + +// ============================================================ +// 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) { + t.Cleanup(func() { resetGlobal(t) }) + + // 先成功 Init 一个 + if err := Init(Config{Enabled: true, ServiceName: "svc", ExporterType: "stdout", Propagator: "w3c"}); err != nil { + t.Fatalf("setup Init: %v", err) + } + + // 再用非法传播器 Init,应失败 + err := Init(Config{Enabled: true, ExporterType: "stdout", Propagator: "xyz"}) + if err == nil { + t.Fatal("Init with bad propagator should fail") + } + + // otel 全局 provider 仍可用:StartSpan 不 panic + tr := otel.Tracer("test") + _, span := tr.Start(context.Background(), "after-failure") + span.End() +} diff --git a/utils/convert.go b/utils/convert.go new file mode 100644 index 0000000..32e1732 --- /dev/null +++ b/utils/convert.go @@ -0,0 +1,106 @@ +package utils + +import ( + "strconv" +) + +// ToInt 字符串转 int,失败返回 0 +func ToInt(s string) int { + n, _ := strconv.Atoi(s) + 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) + if err != nil { + return def + } + return n +} + +// ToInt64 字符串转 int64,失败返回 0 +func ToInt64(s string) int64 { + n, _ := strconv.ParseInt(s, 10, 64) + 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) + if err != nil { + return def + } + return n +} + +// ToUint64 字符串转 uint64,失败返回 0 +func ToUint64(s string) uint64 { + n, _ := strconv.ParseUint(s, 10, 64) + return n +} + +// ToUint64Default 字符串转 uint64,失败返回默认值 +func ToUint64Default(s string, def uint64) uint64 { + n, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return def + } + return n +} + +// ToFloat64 字符串转 float64,失败返回 0 +func ToFloat64(s string) float64 { + n, _ := strconv.ParseFloat(s, 64) + return n +} + +// ToFloat64Default 字符串转 float64,失败返回默认值 +func ToFloat64Default(s string, def float64) float64 { + n, err := strconv.ParseFloat(s, 64) + if err != nil { + return def + } + return n +} + +// ToString 整数转字符串 +func ToString(n int) string { + return strconv.Itoa(n) +} + +// ToString64 int64 转字符串 +func ToString64(n int64) string { + return strconv.FormatInt(n, 10) +} + +// CalcPageCount 计算总页数(向上取整)。total<=0 或 pageSize<=0 时返回 0。 +func CalcPageCount(total, pageSize int64) int64 { + if total <= 0 || pageSize <= 0 { + return 0 + } + return (total + pageSize - 1) / pageSize +} + +// CalcOffset 计算分页偏移量 (page-1)*pageSize。page<=0 时按 1 处理,pageSize<=0 时按 20 处理。 +func CalcOffset(page, pageSize int) int { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + return (page - 1) * pageSize +} diff --git a/utils/crypto.go b/utils/crypto.go new file mode 100644 index 0000000..67d22fb --- /dev/null +++ b/utils/crypto.go @@ -0,0 +1,126 @@ +package utils + +import ( + "bytes" + "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" + "hash" + "io" + "os" +) + +// MD5 计算字符串的 MD5 哈希值 +// 注意: 不应用于密码存储 +func MD5(s string) string { + return MD5Bytes([]byte(s)) +} + +// 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)) +} + +// 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)) +} + +// SHA256 计算字符串的 SHA256 哈希值 +func SHA256(s string) string { + h := sha256.New() + h.Write([]byte(s)) + return hex.EncodeToString(h.Sum(nil)) +} + +// SHA256Bytes 计算字节数组的 SHA256 哈希值 +func SHA256Bytes(data []byte) string { + h := sha256.New() + h.Write(data) + return hex.EncodeToString(h.Sum(nil)) +} + +// HashFile 流式计算文件的哈希值,返回 hex 编码字符串。 +// +// 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 + } + defer f.Close() + h := newHash() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// Base64Encode Base64 编码 +func Base64Encode(data []byte) string { + return base64.StdEncoding.EncodeToString(data) +} + +// Base64Decode Base64 解码 +func Base64Decode(s string) ([]byte, error) { + return base64.StdEncoding.DecodeString(s) +} + +// Base64URLEncode URL 安全的 Base64 编码 +func Base64URLEncode(data []byte) string { + return base64.URLEncoding.EncodeToString(data) +} + +// Base64URLDecode URL 安全的 Base64 解码 +func Base64URLDecode(s string) ([]byte, error) { + return base64.URLEncoding.DecodeString(s) +} + +// Nl2br 将换行符替换为
标签 +func Nl2br(s string, isXhtml bool) string { + var br string + if isXhtml { + br = "
" + } else { + br = "
" + } + + var buf bytes.Buffer + runes := []rune(s) + length := len(runes) + + for i, r := range runes { + switch r { + case '\n': + // \n\r(罕见顺序)作为一个换行处理;普通 \n 单独处理。 + // (N4:原条件含 r == '\r' 半,但本 case 内 r 恒为 '\n',该半恒假,已清理。) + if i+1 < length && runes[i+1] == '\r' { + buf.WriteString(br) + continue + } + buf.WriteString(br) + case '\r': + // \r\n 由上面的 \n 分支处理(\r 在此跳过);单独 \r 作为一个换行。 + if i+1 < length && runes[i+1] == '\n' { + continue + } + buf.WriteString(br) + default: + buf.WriteRune(r) + } + } + return buf.String() +} diff --git a/utils/datetime.go b/utils/datetime.go new file mode 100644 index 0000000..5709f3d --- /dev/null +++ b/utils/datetime.go @@ -0,0 +1,101 @@ +package utils + +import ( + "strconv" + "time" +) + +// NowUnix 返回当前秒级时间戳 +func NowUnix() int64 { + return time.Now().Unix() +} + +// NowTimestamp 返回当前毫秒时间戳 +func NowTimestamp() int64 { + return time.Now().UnixMilli() +} + +// FromUnix 秒级时间戳转 time.Time +func FromUnix(unix int64) time.Time { + return time.Unix(unix, 0) +} + +// FromTimestamp 毫秒时间戳转 time.Time +func FromTimestamp(timestamp int64) time.Time { + return time.UnixMilli(timestamp) +} + +// FormatTime 格式化时间 +func FormatTime(t time.Time, layout string) string { + return t.Format(layout) +} + +// ParseTime 解析时间字符串 +func ParseTime(timeStr, layout string) (time.Time, error) { + return time.Parse(layout, timeStr) +} + +// FormatDateTime 格式化为标准日期时间 "2006-01-02 15:04:05" +func FormatDateTime(t time.Time) string { + return t.Format("2006-01-02 15:04:05") +} + +// FormatDate 格式化为日期 "2006-01-02" +func FormatDate(t time.Time) string { + return t.Format("2006-01-02") +} + +// FormatTimeOnly 格式化为时间 "15:04:05" +func FormatTimeOnly(t time.Time) string { + return t.Format("15:04:05") +} + +// StartOfDay 返回指定时间当天的开始时间 (00:00:00) +func StartOfDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} + +// EndOfDay 返回指定时间当天的结束时间 (23:59:59.999999999) +func EndOfDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location()) +} + +// StartOfWeek 返回指定时间当周的开始时间(周一为第一天,00:00:00)。 +// +// 用日历日回退而非 t.Add(-N*24h),避免 DST 切换日 24h ≠ 1 个日历日导致落错日(M4)。 +func StartOfWeek(t time.Time) time.Time { + weekday := int(t.Weekday()) + if weekday == 0 { + weekday = 7 // 周日归为 7,使周一为第一天 + } + // 回退到本周周一的日历日,再取当日 00:00:00(保留原时区)。 + monday := time.Date(t.Year(), t.Month(), t.Day()-(weekday-1), 0, 0, 0, 0, t.Location()) + return StartOfDay(monday) +} + +// 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 返回指定时间当月最后一天 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()) +} + +// GetDateInt 返回 yyyyMMdd 格式的日期整数 +func GetDateInt(t time.Time) int { + ret, _ := strconv.Atoi(t.Format("20060102")) + return ret +} + +// ParseDateInt 将 yyyyMMdd 格式的整数转为时间(当日 00:00:00,本地时区)。 +// +// 注意:非法输入(如 month=13、day=32)会被 time.Date 静默规范化(溢出进位), +// 调用方需自行保证输入合法或在使用前校验(M4)。 +func ParseDateInt(date int) time.Time { + year := date / 10000 + month := (date % 10000) / 100 + day := date % 100 + return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.Local) +} diff --git a/utils/file.go b/utils/file.go new file mode 100644 index 0000000..56528a9 --- /dev/null +++ b/utils/file.go @@ -0,0 +1,125 @@ +package utils + +import ( + "errors" + "io" + "os" + "path/filepath" +) + +// 本文件提供通用本地文件操作工具(读/写/复制/追加/存在性/删除)。 +// +// ⚠️ 安全说明(M3):这些函数直接操作调用方传入的路径,不做路径穿越校验。 +// 若路径可能来自不可信输入(用户上传文件名、URL 参数等),调用方必须自行净化 +// (如 filepath.Clean + 前缀锚定),否则存在任意文件读/写/删风险。 +// 框架自身的不可信文件处理(storage 上传/下载)已在 storage 包内做穿越防护(C4)。 + +// FileExists 检查文件是否存在 +func FileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// DirExists 检查目录是否存在 +func DirExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +// 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) +} + +// ReadFile 读取文件内容。 +// +// M-F 修复:去掉前置 FileExists 检查(TOCTOU 竞态——检查与读取之间文件可能被删除/替换), +// 直接 os.ReadFile 并返回其错误。文件不存在时返回 *os.PathError,调用方可经 +// errors.Is(err, os.ErrNotExist) 精确判断。 +// +// 注意:本函数无大小上限(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) +} + +// WriteFile 写入文件内容(覆盖) +func WriteFile(path string, data []byte) error { + dir := filepath.Dir(path) + 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) +} + +// AppendFile 追加内容到文件 +func AppendFile(path string, data []byte) error { + dir := filepath.Dir(path) + 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 + } + _, err = f.Write(data) + if cerr := f.Close(); cerr != nil { + return errors.Join(err, cerr) + } + return err +} + +// 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 func() { + _ = srcFile.Close() + }() + + // 确保目标目录存在 + dir := filepath.Dir(dst) + if err := EnsureDir(dir); err != nil { + return err + } + + // #nosec G304 -- generic filesystem helper intentionally creates caller-provided trusted destination paths. + dstFile, err := os.Create(dst) + if err != nil { + return err + } + + _, err = io.Copy(dstFile, srcFile) + if cerr := dstFile.Close(); cerr != nil { + return errors.Join(err, cerr) + } + return err +} + +// FileSize 获取文件大小 +func FileSize(path string) (int64, error) { + info, err := os.Stat(path) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +// RemoveFile 删除文件(忽略不存在的错误) +func RemoveFile(path string) error { + err := os.Remove(path) + if os.IsNotExist(err) { + return nil + } + return err +} diff --git a/utils/http.go b/utils/http.go new file mode 100644 index 0000000..774e425 --- /dev/null +++ b/utils/http.go @@ -0,0 +1,541 @@ +package utils + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" +) + +// HTTPClient 封装可运行期调整配置的 HTTP 客户端。 +type HTTPClient struct { + mu sync.RWMutex + client *http.Client + transport *http.Transport + cfg HTTPClientConfig + timeout time.Duration + headers map[string]string + cookies map[string]string + skipTLS bool + // maxRespBodySize 限制 do 读取的响应体大小;0 使用默认值,-1 表示不限制。 + maxRespBodySize int64 +} + +// UploadFile 描述 multipart 上传中的文件字段。 +type UploadFile struct { + FieldName string + FilePath string +} + +// HTTPClientConfig 是 HTTPClient 的配置。 +type HTTPClientConfig struct { + Timeout time.Duration + MaxIdleConns int + IdleConnTimeout time.Duration + MaxConnsPerHost int + MaxIdleConnsPerHost int + SkipTLSVerify bool + MaxResponseBodySize int64 + BlockPrivateNetworks bool +} + +// ErrSSRFBlocked 表示 SSRF 防护拦截了目标 IP。 +var ErrSSRFBlocked = errors.New("ssrf guard: 目标 IP 属于被拦截的网络范围") + +// isBlockedIP 判断 ip 是否属于应被 SSRF 防护拦截的网络范围。 +func isBlockedIP(ip net.IP) bool { + if ip == nil { + return true + } + return ip.IsLoopback() || ip.IsPrivate() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast() +} + +// ssrfControl 在拨号前拦截私有、回环、链路本地、未指定和多播 IP。 +func ssrfControl(network, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("%w: 无法解析地址 %q", ErrSSRFBlocked, address) + } + if isBlockedIP(ip) { + return fmt.Errorf("%w: %s", ErrSSRFBlocked, ip) + } + return nil +} + +// DefaultHTTPClientConfig 是包级默认 HTTP 客户端配置。 +var DefaultHTTPClientConfig = HTTPClientConfig{ + Timeout: 30 * time.Second, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + MaxConnsPerHost: 10, + MaxIdleConnsPerHost: 10, + SkipTLSVerify: false, + MaxResponseBodySize: 32 * 1024 * 1024, +} + +// NewHTTPClient 使用默认配置创建 HTTP 客户端。 +func NewHTTPClient() *HTTPClient { + cfg := DefaultHTTPClientConfig + return NewHTTPClientWithConfig(cfg) +} + +// NewSSRFSafeHTTPClient 创建启用 SSRF 防护的 HTTP 客户端。 +func NewSSRFSafeHTTPClient() *HTTPClient { + cfg := DefaultHTTPClientConfig + cfg.BlockPrivateNetworks = true + return NewHTTPClientWithConfig(cfg) +} + +// NewHTTPClientWithConfig 使用 cfg 创建 HTTP 客户端。 +func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient { + transport, client := buildHTTPClientPair(cfg) + return &HTTPClient{ + client: client, + transport: transport, + cfg: cfg, + timeout: cfg.Timeout, + headers: make(map[string]string), + cookies: make(map[string]string), + skipTLS: cfg.SkipTLSVerify, + maxRespBodySize: cfg.MaxResponseBodySize, + } +} + +// buildHTTPClientPair 根据 cfg 构造 transport/client 对。 +func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) { + transport := &http.Transport{ + // #nosec G402 -- SkipTLSVerify 仅在调用方显式配置时启用,默认校验 TLS。 + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: cfg.SkipTLSVerify, + }, + MaxIdleConns: cfg.MaxIdleConns, + IdleConnTimeout: cfg.IdleConnTimeout, + MaxConnsPerHost: cfg.MaxConnsPerHost, + MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost, + DisableCompression: false, + } + if cfg.BlockPrivateNetworks { + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: ssrfControl, + } + transport.DialContext = dialer.DialContext + } + client := &http.Client{ + Transport: transport, + Timeout: cfg.Timeout, + } + return transport, client +} + +// currentClient 返回当前 client 快照。 +func (c *HTTPClient) currentClient() *http.Client { + c.mu.RLock() + defer c.mu.RUnlock() + return c.client +} + +// currentTransport 返回当前 transport 快照。 +func (c *HTTPClient) currentTransport() *http.Transport { + c.mu.RLock() + defer c.mu.RUnlock() + return c.transport +} + +// 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) + client.Transport = c.transport + c.client = client + return c +} + +// SetHeader 设置后续请求使用的请求头。 +func (c *HTTPClient) SetHeader(key, value string) *HTTPClient { + c.mu.Lock() + c.headers[key] = value + c.mu.Unlock() + return c +} + +// SetHeaders 批量设置后续请求使用的请求头。 +func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient { + c.mu.Lock() + for k, v := range headers { + c.headers[k] = v + } + c.mu.Unlock() + return c +} + +// SetCookie 设置后续请求使用的 Cookie。 +func (c *HTTPClient) SetCookie(key, value string) *HTTPClient { + c.mu.Lock() + c.cookies[key] = value + c.mu.Unlock() + return c +} + +// snapshotHeadersCookies 在锁内复制可变的 headers/cookies。 +func (c *HTTPClient) snapshotHeadersCookies() (headers, cookies map[string]string) { + c.mu.RLock() + defer c.mu.RUnlock() + headers = make(map[string]string, len(c.headers)) + for k, v := range c.headers { + headers[k] = v + } + cookies = make(map[string]string, len(c.cookies)) + for k, v := range c.cookies { + cookies[k] = v + } + return headers, cookies +} + +// SetSkipTLS 切换后续请求是否校验 TLS 证书。 +func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient { + c.mu.Lock() + c.skipTLS = skip + c.cfg.SkipTLSVerify = skip + transport, client := buildHTTPClientPair(c.cfg) + oldTransport := c.transport + c.transport = transport + c.client = client + c.mu.Unlock() + // 在锁外释放旧 transport 的空闲连接。 + if oldTransport != nil { + oldTransport.CloseIdleConnections() + } + return c +} + +// SetBlockPrivateNetworks 切换后续请求的 SSRF 防护。 +func (c *HTTPClient) SetBlockPrivateNetworks(block bool) *HTTPClient { + c.mu.Lock() + c.cfg.BlockPrivateNetworks = block + transport, client := buildHTTPClientPair(c.cfg) + oldTransport := c.transport + c.transport = transport + c.client = client + c.mu.Unlock() + if oldTransport != nil { + oldTransport.CloseIdleConnections() + } + return c +} + +// Get 发送 GET 请求。 +func (c *HTTPClient) Get(urlStr string, params map[string]string) ([]byte, error) { + if len(params) > 0 { + u, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + q := u.Query() + for k, v := range params { + q.Set(k, v) + } + u.RawQuery = q.Encode() + urlStr = u.String() + } + + req, err := http.NewRequest("GET", urlStr, nil) + if err != nil { + return nil, err + } + return c.do(req) +} + +// Post 发送表单编码的 POST 请求。 +func (c *HTTPClient) Post(urlStr string, params map[string]string) ([]byte, error) { + data := url.Values{} + for k, v := range params { + data.Set(k, v) + } + + req, err := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return c.do(req) +} + +// PostJSON 发送 JSON POST 请求。 +func (c *HTTPClient) PostJSON(urlStr string, data any) ([]byte, error) { + jsonData, err := json.Marshal(data) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", urlStr, bytes.NewReader(jsonData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return c.do(req) +} + +// Put 发送 JSON PUT 请求。 +func (c *HTTPClient) Put(urlStr string, data any) ([]byte, error) { + jsonData, err := json.Marshal(data) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", urlStr, bytes.NewReader(jsonData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return c.do(req) +} + +// Delete 发送 DELETE 请求。 +func (c *HTTPClient) Delete(urlStr string) ([]byte, error) { + req, err := http.NewRequest("DELETE", urlStr, nil) + if err != nil { + return nil, err + } + return c.do(req) +} + +// Upload 以流式 multipart 上传文件,避免把完整请求体缓存在内存中。 +func (c *HTTPClient) Upload(urlStr string, files []UploadFile, params map[string]string) ([]byte, error) { + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + errCh := make(chan error, 1) + + go func() { + errCh <- writeMultipartUpload(pw, writer, files, params) + }() + + 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()) + + 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 +} + +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) + + part, err := writer.CreateFormFile(fieldName, filename) + if err != nil { + return nil, err + } + if _, err = io.Copy(part, bytes.NewReader(data)); err != nil { + return nil, err + } + + 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) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + return c.do(req) +} + +// 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 { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return c.do(req) +} + +// do 执行 req,并按配置封顶读取响应体。 +func (c *HTTPClient) do(req *http.Request) ([]byte, error) { + 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。 + 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 请求失败: %d %s", resp.StatusCode, resp.Status) + } + + // maxRespBodySize: 0 使用默认 32MiB 上限;-1 表示不限制。 + limit := c.maxRespBodySize + if limit == 0 { + limit = 32 * 1024 * 1024 + } + var reader io.Reader = resp.Body + if limit > 0 { + // 多读 1 字节用于判断响应体是否超限。 + reader = io.LimitReader(resp.Body, limit+1) + } + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + if limit > 0 && int64(len(data)) > limit { + return nil, fmt.Errorf("响应体超过限制 %d 字节", limit) + } + return data, nil +} + +// DoWithResponse 执行 req 并返回原始响应;调用方必须关闭 resp.Body。 +func (c *HTTPClient) DoWithResponse(req *http.Request) (*http.Response, error) { + 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 释放客户端 transport 持有的空闲连接。 +func (c *HTTPClient) Close() { + if t := c.currentTransport(); t != nil { + t.CloseIdleConnections() + } +} + +// JSONMarshal 将 v 序列化为 JSON。 +func JSONMarshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +// defaultClient 是包级共享 HTTP 客户端。 +var defaultClient *HTTPClient +var defaultClientOnce sync.Once + +// DefaultHTTPClient 返回包级共享 HTTP 客户端。 +func DefaultHTTPClient() *HTTPClient { + defaultClientOnce.Do(func() { + defaultClient = NewHTTPClient() + }) + return defaultClient +} + +// HTTPGet 使用默认客户端发送 GET 请求。 +func HTTPGet(url string, params map[string]string) ([]byte, error) { + return DefaultHTTPClient().Get(url, params) +} + +// HTTPPost 使用默认客户端发送表单 POST 请求。 +func HTTPPost(url string, params map[string]string) ([]byte, error) { + return DefaultHTTPClient().Post(url, params) +} + +// HTTPPostJSON 使用默认客户端发送 JSON POST 请求。 +func HTTPPostJSON(url string, data any) ([]byte, error) { + return DefaultHTTPClient().PostJSON(url, data) +} diff --git a/utils/http_test.go b/utils/http_test.go new file mode 100644 index 0000000..4f22a52 --- /dev/null +++ b/utils/http_test.go @@ -0,0 +1,295 @@ +package utils_test + +import ( + "crypto/tls" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/utils" +) + +// 回归 H2:默认 HTTPClient 必须 校验 TLS(InsecureSkipVerify=false), +// 访问自签证书的 https server 应失败。旧实现 DefaultHTTPClientConfig.SkipTLSVerify=true, +// HTTPGet/Post 默认可被 MITM。 +func TestHTTPClientDefaultVerifiesTLS(t *testing.T) { + // 启动一个使用自签证书的 TLS server(httptest 自动生成证书,未被客户端信任)。 + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + // 默认客户端(H2 修复后 SkipTLSVerify=false)应因证书校验失败而报错。 + c := utils.NewHTTPClient() + if _, err := c.Get(srv.URL, nil); err == nil { + t.Error("default HTTPClient should fail TLS verification against self-signed cert (H2: was InsecureSkipVerify=true)") + } +} + +// 回归 H2:HTTPGet 包级函数(经 DefaultHTTPClient)默认同样校验 TLS。 +func TestHTTPGetDefaultVerifiesTLS(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + if _, err := utils.HTTPGet(srv.URL, nil); err == nil { + t.Error("HTTPGet should fail TLS verification against self-signed cert by default") + } +} + +// 回归 H2:显式 SetSkipTLS(true) 后可访问自签证书 server(opt-in 跳过校验)。 +func TestHTTPClientSkipTLSOptIn(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + c := utils.NewHTTPClient() + c.SetSkipTLS(true) + + body, err := c.Get(srv.URL, nil) + if err != nil { + t.Fatalf("Get with SkipTLS=true should succeed against self-signed cert, got: %v", err) + } + if string(body) != "ok" { + t.Errorf("body = %q, want ok", string(body)) + } +} + +// 回归 H2:NewHTTPClientWithConfig 显式设 SkipTLSVerify=true 可跳过校验。 +func TestHTTPClientWithConfigSkipTLS(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + c := utils.NewHTTPClientWithConfig(utils.HTTPClientConfig{ + SkipTLSVerify: true, + }) + body, err := c.Get(srv.URL, nil) + if err != nil { + t.Fatalf("Get with config SkipTLSVerify=true should succeed, got: %v", err) + } + if string(body) != "ok" { + t.Errorf("body = %q, want ok", string(body)) + } +} + +// 回归 H2:DefaultHTTPClientConfig.SkipTLSVerify 默认 false。 +func TestDefaultHTTPClientConfigNoSkipTLS(t *testing.T) { + if utils.DefaultHTTPClientConfig.SkipTLSVerify { + t.Error("DefaultHTTPClientConfig.SkipTLSVerify = true, want false (H2: default must verify TLS)") + } +} + +// 回归 H2:默认 transport 的 TLSClientConfig.InsecureSkipVerify 为 false。 +// 直接构造一个对 https 的请求,确认未跳过校验。用 errors 判断是否为 x509 校验类错误。 +func TestDefaultClientTransportVerifiesTLS(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + c := utils.NewHTTPClient() + // 用自定义 client 配自定义 transport 不可行(封装),直接调 Get 验证错误类型。 + _, err := c.Get(srv.URL, nil) + if err == nil { + t.Fatal("expected TLS verification error, got nil") + } + // 错误应包含证书校验相关提示(如 x509 / certificate / tls)。 + if !isTLSError(err) { + t.Logf("err = %v (not strictly x509 classified, but default did reject — acceptable)", err) + } +} + +// isTLSError 粗略判断错误是否为 TLS 证书校验失败。 +func isTLSError(err error) bool { + if err == nil { + return false + } + var ve *tls.CertificateVerificationError + return errors.As(err, &ve) +} + +// TestHTTPClientSetSkipTLSConcurrent H-12 回归:并发 SetSkipTLS 与 Get 请求不应触发 +// 数据竞争,且 SetSkipTLS(true) 后能成功访问自签 TLS server。 +// 修复前 SetSkipTLS 直接覆盖 transport.TLSClientConfig 指针,与并发 Do 读该字段竞态。 +func TestHTTPClientSetSkipTLSConcurrent(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + c := utils.NewHTTPClient() + defer c.Close() + + var wg sync.WaitGroup + // 并发切换 TLS 策略 + for i := 0; i < 10; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + c.SetSkipTLS(i%2 == 0) + }(i) + } + // 并发发请求(自签 server,仅 skip=true 时成功,skip=false 时 TLS 失败,均不应 panic/竞态) + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = c.Get(srv.URL, nil) + }() + } + wg.Wait() + + // 最终切到 skip=true,应能成功访问自签 server(验证 SetSkipTLS 重建后功能正常) + c.SetSkipTLS(true) + if _, err := c.Get(srv.URL, nil); err != nil { + t.Errorf("after SetSkipTLS(true), GET should succeed against self-signed server, got %v", err) + } +} + +// ===== P0:headers/cookies map 并发安全 ===== + +// 回归 P0:并发 SetHeader/SetCookie 与 Get 请求不应触发数据竞争(-race)。 +// 修复前 Set* 无锁写 map、do 无锁读 map,并发即竞态可 panic。 +func TestHTTPClientConcurrentHeadersNoRace(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + c := utils.NewHTTPClient() + defer c.Close() + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + c.SetHeader("X-Req", string(rune('A'+i%26))) + c.SetCookie("sid", string(rune('0'+i%10))) + c.SetHeaders(map[string]string{"X-Batch": "v"}) + }(i) + } + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = c.Get(srv.URL, nil) + }() + } + wg.Wait() +} + +// ===== P0:SSRF 防护 ===== + +// 回归 P0:启用 SSRF 防护后,连接回环地址(httptest server 监听 127.0.0.1)必须被拒绝, +// 返回 ErrSSRFBlocked。 +func TestHTTPClientSSRFBlocksLoopback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("secret-internal")) + })) + defer srv.Close() + + c := utils.NewSSRFSafeHTTPClient() + defer c.Close() + + _, err := c.Get(srv.URL, nil) + if err == nil { + t.Fatal("SSRF-safe client should refuse to connect to loopback address") + } + if !errors.Is(err, utils.ErrSSRFBlocked) { + t.Errorf("err = %v, want ErrSSRFBlocked", err) + } +} + +// 回归 P0:未启用 SSRF 防护时(默认),回环连接照常放行(兼容性回归)。 +func TestHTTPClientSSRFDisabledAllowsLoopback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + c := utils.NewHTTPClient() // 默认不启用 SSRF 防护 + defer c.Close() + + body, err := c.Get(srv.URL, nil) + if err != nil { + t.Fatalf("default client should allow loopback, got %v", err) + } + if string(body) != "ok" { + t.Errorf("body = %q, want ok", string(body)) + } +} + +// 回归 P0:SetBlockPrivateNetworks 运行期开关生效——开启后回环被拒。 +func TestHTTPClientSetBlockPrivateNetworks(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + c := utils.NewHTTPClient() + defer c.Close() + + // 开启前放行 + if _, err := c.Get(srv.URL, nil); err != nil { + t.Fatalf("before enabling guard, GET should succeed, got %v", err) + } + // 开启后拒绝 + c.SetBlockPrivateNetworks(true) + if _, err := c.Get(srv.URL, nil); !errors.Is(err, utils.ErrSSRFBlocked) { + t.Errorf("after enabling guard, err = %v, want ErrSSRFBlocked", err) + } +} + +func TestHTTPClientUploadStreamsMultipartBody(t *testing.T) { + tmp := t.TempDir() + filePath := filepath.Join(tmp, "payload.txt") + if err := os.WriteFile(filePath, []byte("streamed payload"), 0644); err != nil { + t.Fatalf("write temp payload: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ContentLength != -1 { + t.Errorf("ContentLength = %d, want -1 for streaming upload", r.ContentLength) + } + if err := r.ParseMultipartForm(1024); err != nil { + t.Fatalf("ParseMultipartForm: %v", err) + } + if got := r.FormValue("kind"); got != "test" { + t.Errorf("form field kind = %q, want test", got) + } + file, _, err := r.FormFile("file") + if err != nil { + t.Fatalf("FormFile: %v", err) + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil { + t.Fatalf("read uploaded file: %v", err) + } + if string(data) != "streamed payload" { + t.Errorf("uploaded data = %q", string(data)) + } + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + c := utils.NewHTTPClient() + defer c.Close() + body, err := c.Upload(srv.URL, []utils.UploadFile{{FieldName: "file", FilePath: filePath}}, map[string]string{"kind": "test"}) + if err != nil { + t.Fatalf("Upload: %v", err) + } + if string(body) != "ok" { + t.Errorf("body = %q, want ok", string(body)) + } +} diff --git a/utils/random.go b/utils/random.go new file mode 100644 index 0000000..fe6e0ea --- /dev/null +++ b/utils/random.go @@ -0,0 +1,141 @@ +package utils + +import ( + "crypto/rand" + "errors" + "math/big" + mrand "math/rand" + "sync" + "time" +) + +// 本文件提供两类随机数: +// - 密码学安全(RandStringSecure/RandDigitSecure/RandIntSecure/RandInt64Secure):基于 +// crypto/rand,不可预测,适用于 token/OTP/重置码/会话 ID/安全 nonce 范围等。 +// - 非密码学(RandInt/RandInt64):基于 math/rand + sync.Pool,高性能但可预测, +// 仅用于非安全场景(负载均衡、游戏、A/B 分桶等)。 +// +// 注意(H1 收紧):RandString/RandDigit 已移除——字符串随机的用途几乎都是安全场景 +// (token/ID/验证码),保留 math/rand 版本会诱导误用。请用 RandStringSecure/RandDigitSecure。 + +var ( + // #nosec G404 -- math/rand 用于 RandInt/RandInt64 等非密码学函数(性能优先,仅非安全场景: + // 负载均衡、游戏、A/B 分桶等)。安全场景(token/OTP/重置码)用 RandStringSecure/RandDigitSecure。 + randPool = sync.Pool{ + New: func() any { + return mrand.New(mrand.NewSource(time.Now().UnixNano())) + }, + } +) + +const ( + letterBytes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + digitBytes = "0123456789" +) + +// ErrRandInvalidLength 长度过大(超过 1<<20,保护熵池)。n<=0 时返回空串而非此错误。 +var ErrRandInvalidLength = errors.New("invalid random length") + +// RandInt 返回 [min, max) 范围内的随机整数。min==max 时返回 min;max1<<20)返回错误避免过度消耗熵池。 +func RandStringSecure(n int) (string, error) { + if n <= 0 { + return "", nil + } + if n > 1<<20 { + return "", ErrRandInvalidLength + } + b := make([]byte, n) + for i := range b { + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterBytes)))) + if err != nil { + return "", err + } + b[i] = letterBytes[idx.Int64()] + } + return string(b), nil +} + +// RandDigitSecure 生成指定长度的密码学安全随机数字字符串。 +// 基于 crypto/rand,不可预测,适用于 OTP 验证码、密码重置码等安全场景。 +// n<=0 返回空,n 过大(>1<<20)返回错误。 +func RandDigitSecure(n int) (string, error) { + if n <= 0 { + return "", nil + } + if n > 1<<20 { + return "", ErrRandInvalidLength + } + b := make([]byte, n) + for i := range b { + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(digitBytes)))) + if err != nil { + return "", err + } + b[i] = digitBytes[idx.Int64()] + } + return string(b), nil +} + +// RandIntSecure 返回 [min, max) 范围内的密码学安全随机整数。 +// 基于 crypto/rand + big.Int 拒绝采样(无偏),适用于安全 nonce 范围、防猜抽奖、密钥分桶等。 +// min==max 返回 min;max 0; { + r, size := utf8.DecodeLastRuneInString(s[:i]) + if !f(r) { + return s[:i] + } + i -= size + } + return "" +} + +// Substr 截取子字符串(按 Unicode 字符计数)。 +// start 支持负数(从末尾计算);start 越界或 length<=0 返回空串;length 超出末尾自动截断。 +// 参数: start 起始位置(支持负数从末尾计算),length 截取长度 +func Substr(s string, start, length int) string { + runes := []rune(s) + lenRunes := len(runes) + + if lenRunes == 0 { + return "" + } + + // 处理负数起始位置 + if start < 0 { + start = lenRunes + start + } + if start < 0 { + start = 0 + } + if start >= lenRunes { + return "" + } + + end := start + length + if end > lenRunes { + end = lenRunes + } + if end <= start { + return "" + } + + return string(runes[start:end]) +} + +// StrLen 获取字符串的 Unicode 字符数 +func StrLen(s string) int { + return utf8.RuneCountInString(s) +} + +// EqualsIgnoreCase 不区分大小写比较字符串。 +// +// L-C 修复:改用 strings.EqualFold(标准库 Unicode 大小写折叠)。原实现仅做 ASCII +// 字节级折叠('A'-'Z' → +32),对非 ASCII 字符(如 "É" vs "é")误判为不等。 +// EqualFold 同时更快(无逐字节循环开销)。 +func EqualsIgnoreCase(a, b string) bool { + return strings.EqualFold(a, b) +} diff --git a/utils/url.go b/utils/url.go new file mode 100644 index 0000000..71aa07c --- /dev/null +++ b/utils/url.go @@ -0,0 +1,83 @@ +package utils + +import ( + "net/url" +) + +// URLBuilder URL 构建器 +type URLBuilder struct { + u *url.URL + query url.Values +} + +// ParseURL 解析 URL 字符串 +func ParseURL(rawURL string) (*URLBuilder, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + return &URLBuilder{ + u: u, + query: u.Query(), + }, nil +} + +// AddQuery 添加单个查询参数 +func (b *URLBuilder) AddQuery(key, value string) *URLBuilder { + b.query.Add(key, value) + return b +} + +// AddQueries 批量添加查询参数(追加,同 key 多值共存,M2 修复:原实现用 Set 会覆盖)。 +// params 为 nil 时无操作(遍历 nil map 安全,不 panic)。 +func (b *URLBuilder) AddQueries(params map[string]string) *URLBuilder { + for k, v := range params { + b.query.Add(k, v) + } + return b +} + +// SetQuery 设置查询参数(覆盖同名参数) +func (b *URLBuilder) SetQuery(key, value string) *URLBuilder { + b.query.Set(key, value) + return b +} + +// SetPath 设置路径 +func (b *URLBuilder) SetPath(path string) *URLBuilder { + b.u.Path = path + return b +} + +// SetScheme 设置协议(http/https) +func (b *URLBuilder) SetScheme(scheme string) *URLBuilder { + b.u.Scheme = scheme + return b +} + +// SetHost 设置主机 +func (b *URLBuilder) SetHost(host string) *URLBuilder { + b.u.Host = host + return b +} + +// Build 构建最终的 URL +func (b *URLBuilder) Build() *url.URL { + b.u.RawQuery = b.query.Encode() + return b.u +} + +// String 返回 URL 字符串 +func (b *URLBuilder) String() string { + return b.Build().String() +} + +// URLEncode URL 编码 +func URLEncode(s string) string { + return url.QueryEscape(s) +} + +// URLDecode URL 解码 +func URLDecode(s string) (string, error) { + return url.QueryUnescape(s) +} diff --git a/utils/utils_test.go b/utils/utils_test.go new file mode 100644 index 0000000..9f000a4 --- /dev/null +++ b/utils/utils_test.go @@ -0,0 +1,862 @@ +package utils_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/utils" +) + +// ===== Random Tests ===== +// +// 注意(H1 收紧):RandString/RandDigit 已移除(字符串随机的用途几乎都是安全场景, +// 保留 math/rand 版本会诱导误用)。测试覆盖 RandStringSecure/RandDigitSecure(crypto/rand) +// 与 RandInt/RandInt64(非安全范围随机)。 + +func TestRandInt(t *testing.T) { + // 正常范围 + for i := 0; i < 100; i++ { + r := utils.RandInt(1, 100) + if r < 1 || r >= 100 { + t.Errorf("RandInt(1, 100) = %d, out of range", r) + } + } + + // min == max + r := utils.RandInt(5, 5) + if r != 5 { + t.Errorf("RandInt(5, 5) = %d", r) + } + + // min > max (自动交换) + r = utils.RandInt(100, 1) + if r < 1 || r >= 100 { + t.Errorf("RandInt(100, 1) = %d, should swap", r) + } +} + +func TestRandInt64(t *testing.T) { + r := utils.RandInt64(0, 1000000) + if r < 0 || r >= 1000000 { + t.Errorf("RandInt64 out of range: %d", r) + } +} + +// ===== String Tests ===== + +func TestIsBlank(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"", true}, + {" ", true}, + {"\t\n", true}, + {"abc", false}, + {" abc ", false}, + } + + for _, tt := range tests { + if utils.IsBlank(tt.input) != tt.expected { + t.Errorf("IsBlank(%q) = %v, want %v", tt.input, !tt.expected, tt.expected) + } + } +} + +func TestIsAnyBlank(t *testing.T) { + if !utils.IsAnyBlank("", "a") { + t.Error("IsAnyBlank should return true") + } + if utils.IsAnyBlank("a", "b") { + t.Error("IsAnyBlank should return false") + } +} + +func TestIsAllBlank(t *testing.T) { + if !utils.IsAllBlank("", " ", "\t") { + t.Error("IsAllBlank should return true") + } + if utils.IsAllBlank("", "a") { + t.Error("IsAllBlank should return false") + } +} + +func TestDefaultIfBlank(t *testing.T) { + if utils.DefaultIfBlank("", "def") != "def" { + t.Error("DefaultIfBlank empty failed") + } + if utils.DefaultIfBlank("val", "def") != "val" { + t.Error("DefaultIfBlank non-empty failed") + } +} + +func TestSubstr(t *testing.T) { + tests := []struct { + input string + start int + length int + expected string + }{ + {"hello世界", 0, 7, "hello世界"}, + {"hello世界", 0, 5, "hello"}, + {"hello世界", 5, 2, "世界"}, + {"hello世界", -2, 2, "世界"}, + {"hello世界", 100, 2, ""}, + {"", 0, 5, ""}, + } + + for _, tt := range tests { + result := utils.Substr(tt.input, tt.start, tt.length) + if result != tt.expected { + t.Errorf("Substr(%q, %d, %d) = %q, want %q", tt.input, tt.start, tt.length, result, tt.expected) + } + } +} + +func TestStrLen(t *testing.T) { + if utils.StrLen("hello世界") != 7 { + t.Error("StrLen Unicode count failed") + } + if utils.StrLen("") != 0 { + t.Error("StrLen empty failed") + } +} + +func TestEqualsIgnoreCase(t *testing.T) { + tests := []struct { + a, b string + expected bool + }{ + {"ABC", "abc", true}, + {"Hello", "HELLO", true}, + {"abc", "abc", true}, + {"abc", "abd", false}, + {"ab", "abc", false}, + } + + for _, tt := range tests { + if utils.EqualsIgnoreCase(tt.a, tt.b) != tt.expected { + t.Errorf("EqualsIgnoreCase(%q, %q) failed", tt.a, tt.b) + } + } +} + +func TestTrim(t *testing.T) { + if utils.Trim(" hello ") != "hello" { + t.Error("Trim failed") + } + if utils.Trim("\t\nhello\n\t") != "hello" { + t.Error("Trim whitespace failed") + } +} + +// ===== DateTime Tests ===== + +func TestNowUnix(t *testing.T) { + n := utils.NowUnix() + if n == 0 { + t.Error("NowUnix returned 0") + } + // 应接近当前时间 + expected := time.Now().Unix() + if n < expected-1 || n > expected+1 { + t.Errorf("NowUnix = %d, expected ~%d", n, expected) + } +} + +func TestNowTimestamp(t *testing.T) { + n := utils.NowTimestamp() + if n == 0 { + t.Error("NowTimestamp returned 0") + } +} + +func TestFromUnix(t *testing.T) { + now := time.Now() + unix := now.Unix() + result := utils.FromUnix(unix) + if result.Unix() != unix { + t.Errorf("FromUnix mismatch") + } +} + +func TestFormatDateTime(t *testing.T) { + now := time.Now() + s := utils.FormatDateTime(now) + if len(s) != 19 { + t.Errorf("FormatDateTime length = %d", len(s)) + } +} + +func TestFormatDate(t *testing.T) { + now := time.Now() + s := utils.FormatDate(now) + if len(s) != 10 { + t.Errorf("FormatDate length = %d", len(s)) + } +} + +func TestStartEndOfDay(t *testing.T) { + now := time.Now() + start := utils.StartOfDay(now) + end := utils.EndOfDay(now) + + if start.Hour() != 0 || start.Minute() != 0 || start.Second() != 0 { + t.Error("StartOfDay not at 00:00:00") + } + if end.Hour() != 23 || end.Minute() != 59 || end.Second() != 59 { + t.Error("EndOfDay not at 23:59:59") + } +} + +func TestStartEndOfMonth(t *testing.T) { + now := time.Now() + start := utils.StartOfMonth(now) + end := utils.EndOfMonth(now) + + if start.Day() != 1 { + t.Error("StartOfMonth day != 1") + } + if end.Day() < 28 || end.Day() > 31 { + t.Errorf("EndOfMonth day = %d, unexpected", end.Day()) + } +} + +func TestGetDateInt(t *testing.T) { + now := time.Now() + dateInt := utils.GetDateInt(now) + expected := now.Year()*10000 + int(now.Month())*100 + now.Day() + if dateInt != expected { + t.Errorf("GetDateInt = %d, expected %d", dateInt, expected) + } +} + +func TestParseDateInt(t *testing.T) { + dateInt := 20260430 + result := utils.ParseDateInt(dateInt) + if result.Year() != 2026 || result.Month() != 4 || result.Day() != 30 { + t.Errorf("ParseDateInt(%d) = %v", dateInt, result) + } +} + +// ===== Convert Tests ===== + +func TestToInt(t *testing.T) { + if utils.ToInt("123") != 123 { + t.Error("ToInt failed") + } + if utils.ToInt("abc") != 0 { + t.Error("ToInt invalid should return 0") + } +} + +func TestToIntDefault(t *testing.T) { + if utils.ToIntDefault("123", 999) != 123 { + t.Error("ToIntDefault valid failed") + } + if utils.ToIntDefault("abc", 999) != 999 { + t.Error("ToIntDefault invalid should return default") + } +} + +func TestToIntE(t *testing.T) { + n, err := utils.ToIntE("123") + if err != nil || n != 123 { + t.Fatalf("ToIntE valid = %d, err=%v; want 123,nil", n, err) + } + if _, err := utils.ToIntE("abc"); err == nil { + t.Fatal("ToIntE invalid should return error") + } +} + +func TestToInt64(t *testing.T) { + if utils.ToInt64("1234567890123") != 1234567890123 { + t.Error("ToInt64 failed") + } +} + +func TestToInt64E(t *testing.T) { + n, err := utils.ToInt64E("1234567890123") + if err != nil || n != 1234567890123 { + t.Fatalf("ToInt64E valid = %d, err=%v; want 1234567890123,nil", n, err) + } + if _, err := utils.ToInt64E("abc"); err == nil { + t.Fatal("ToInt64E invalid should return error") + } +} + +func TestToInt64Default(t *testing.T) { + if utils.ToInt64Default("abc", 999) != 999 { + t.Error("ToInt64Default failed") + } +} + +func TestToUint64Default(t *testing.T) { + if utils.ToUint64Default("abc", 999) != 999 { + t.Error("ToUint64Default failed") + } + if utils.ToUint64Default("123", 0) != 123 { + t.Error("ToUint64Default valid failed") + } +} + +func TestToFloat64(t *testing.T) { + if utils.ToFloat64("3.14") != 3.14 { + t.Error("ToFloat64 failed") + } +} + +func TestToFloat64Default(t *testing.T) { + if utils.ToFloat64Default("abc", 1.5) != 1.5 { + t.Error("ToFloat64Default failed") + } +} + +func TestToString(t *testing.T) { + if utils.ToString(123) != "123" { + t.Error("ToString failed") + } +} + +func TestToString64(t *testing.T) { + if utils.ToString64(1234567890123) != "1234567890123" { + t.Error("ToString64 failed") + } +} + +func TestCalcPageCount(t *testing.T) { + tests := []struct { + total int64 + pageSize int64 + expected int64 + }{ + {100, 10, 10}, + {95, 10, 10}, + {0, 10, 0}, + {100, 0, 0}, + {1, 10, 1}, + } + + for _, tt := range tests { + result := utils.CalcPageCount(tt.total, tt.pageSize) + if result != tt.expected { + t.Errorf("CalcPageCount(%d, %d) = %d, want %d", tt.total, tt.pageSize, result, tt.expected) + } + } +} + +func TestCalcOffset(t *testing.T) { + tests := []struct { + page int + pageSize int + expected int + }{ + {1, 20, 0}, + {2, 20, 20}, + {3, 10, 20}, + {0, 20, 0}, // page <= 0 自动修正为 1 + {1, 0, 0}, // pageSize <= 0 自动修正为 20 + } + + for _, tt := range tests { + result := utils.CalcOffset(tt.page, tt.pageSize) + if result != tt.expected { + t.Errorf("CalcOffset(%d, %d) = %d, want %d", tt.page, tt.pageSize, result, tt.expected) + } + } +} + +func TestAppendFileAndCopyFile(t *testing.T) { + tmp := t.TempDir() + src := filepath.Join(tmp, "src.txt") + dst := filepath.Join(tmp, "nested", "dst.txt") + + if err := utils.AppendFile(src, []byte("hello")); err != nil { + t.Fatalf("AppendFile first write: %v", err) + } + if err := utils.AppendFile(src, []byte(" world")); err != nil { + t.Fatalf("AppendFile second write: %v", err) + } + if err := utils.CopyFile(dst, src); err != nil { + t.Fatalf("CopyFile: %v", err) + } + + data, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("ReadFile copied file: %v", err) + } + if string(data) != "hello world" { + t.Errorf("copied data = %q, want hello world", string(data)) + } +} + +// ===== Validator Tests ===== + +func TestIsPhone(t *testing.T) { + valid := []string{"13812345678", "15912345678", "18812345678"} + invalid := []string{"12345678901", "1381234567", "138123456789"} + + for _, p := range valid { + if !utils.IsPhone(p) { + t.Errorf("IsPhone(%s) should be true", p) + } + } + for _, p := range invalid { + if utils.IsPhone(p) { + t.Errorf("IsPhone(%s) should be false", p) + } + } +} + +func TestIsEmail(t *testing.T) { + valid := []string{"test@example.com", "user.name@domain.cn"} + invalid := []string{"invalid", "no@", "@nodomain.com"} + + for _, e := range valid { + if !utils.IsEmail(e) { + t.Errorf("IsEmail(%s) should be true", e) + } + } + for _, e := range invalid { + if utils.IsEmail(e) { + t.Errorf("IsEmail(%s) should be false", e) + } + } +} + +func TestIsIPv4(t *testing.T) { + valid := []string{"192.168.1.1", "0.0.0.0", "255.255.255.255"} + invalid := []string{"256.1.1.1", "1.1.1", "1.1.1.1.1"} + + for _, ip := range valid { + if !utils.IsIPv4(ip) { + t.Errorf("IsIPv4(%s) should be true", ip) + } + } + for _, ip := range invalid { + if utils.IsIPv4(ip) { + t.Errorf("IsIPv4(%s) should be false", ip) + } + } +} + +func TestIsIDCard(t *testing.T) { + // 18位身份证 + if !utils.IsIDCard("11010519900307293X") { + t.Error("IsIDCard valid 18-digit failed") + } + // 无效 + if utils.IsIDCard("123456789012345") { + t.Error("IsIDCard invalid should be false") + } +} + +func TestIsChinese(t *testing.T) { + if !utils.IsChinese("中文") { + t.Error("IsChinese should be true") + } + if utils.IsChinese("abc中文") { + t.Error("IsChinese mixed should be false") + } +} + +func TestIsNumeric(t *testing.T) { + if !utils.IsNumeric("12345") { + t.Error("IsNumeric should be true") + } + if utils.IsNumeric("123a") { + t.Error("IsNumeric should be false") + } +} + +func TestIsAlphanumeric(t *testing.T) { + if !utils.IsAlphanumeric("abc123") { + t.Error("IsAlphanumeric should be true") + } + if utils.IsAlphanumeric("abc-123") { + t.Error("IsAlphanumeric with dash should be false") + } +} + +// ===== Crypto Tests ===== + +func TestMD5(t *testing.T) { + if utils.MD5("hello") != "5d41402abc4b2a76b9719d911017c592" { + t.Error("MD5 failed") + } + if utils.MD5("") != "d41d8cd98f00b204e9800998ecf8427e" { + t.Error("MD5 empty failed") + } +} + +func TestSHA256(t *testing.T) { + // SHA256 有固定长度 + hash := utils.SHA256("hello") + if len(hash) != 64 { + t.Errorf("SHA256 length = %d", len(hash)) + } +} + +func TestBase64(t *testing.T) { + original := "hello world" + encoded := utils.Base64Encode([]byte(original)) + decoded, err := utils.Base64Decode(encoded) + if err != nil { + t.Errorf("Base64Decode error: %v", err) + } + if string(decoded) != original { + t.Errorf("Base64 roundtrip failed: %s", decoded) + } +} + +func TestBase64URL(t *testing.T) { + original := "hello+world" + encoded := utils.Base64URLEncode([]byte(original)) + decoded, err := utils.Base64URLDecode(encoded) + if err != nil { + t.Errorf("Base64URLDecode error: %v", err) + } + if string(decoded) != original { + t.Errorf("Base64URL roundtrip failed") + } +} + +// ===== UUID Tests ===== + +func TestUUID(t *testing.T) { + uuid := utils.UUID() + if len(uuid) != 36 { + t.Errorf("UUID length = %d", len(uuid)) + } + // 格式检查: 8-4-4-4-12 + if uuid[8] != '-' || uuid[13] != '-' || uuid[18] != '-' || uuid[23] != '-' { + t.Errorf("UUID format invalid: %s", uuid) + } +} + +func TestUUIDShort(t *testing.T) { + uuid := utils.UUIDShort() + if len(uuid) != 32 { + t.Errorf("UUIDShort length = %d", len(uuid)) + } + // U1 回归:确保无破折号 + if uuid[8] == '-' || uuid[12] == '-' || uuid[16] == '-' || uuid[20] == '-' { + t.Errorf("UUIDShort contains dashes at unexpected positions: %s", uuid) + } +} + +func TestUUIDShort_NoDashes(t *testing.T) { + // U1 回归:100 次生成全部不含破折号,长度精确 32 + for range 100 { + s := utils.UUIDShort() + if len(s) != 32 { + t.Fatalf("UUIDShort length = %d, want 32", len(s)) + } + for j, c := range s { + if c == '-' { + t.Fatalf("UUIDShort contains dash at position %d: %s", j, s) + } + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Fatalf("UUIDShort non-hex char at position %d: %c in %s", j, c, s) + } + } + } +} + +func TestUUIDValid(t *testing.T) { + valid := "550e8400-e29b-41d4-a716-446655440000" + invalid := "invalid-uuid" + + if !utils.UUIDValid(valid) { + t.Errorf("UUIDValid(%s) should be true", valid) + } + if utils.UUIDValid(invalid) { + t.Errorf("UUIDValid(%s) should be false", invalid) + } +} + +// ===== URL Tests ===== + +func TestURLEncodeDecode(t *testing.T) { + original := "hello world" + encoded := utils.URLEncode(original) + decoded, err := utils.URLDecode(encoded) + if err != nil { + t.Errorf("URLDecode error: %v", err) + } + if decoded != original { + t.Errorf("URL roundtrip failed: %s -> %s -> %s", original, encoded, decoded) + } +} + +func TestParseURL(t *testing.T) { + b, err := utils.ParseURL("https://example.com/path") + if err != nil { + t.Errorf("ParseURL error: %v", err) + } + result := b.AddQuery("key", "value").AddQuery("foo", "bar").String() + if !contains(result, "key=value") || !contains(result, "foo=bar") { + t.Errorf("URLBuilder result: %s", result) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsHelper(s, sub)) +} + +func containsHelper(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// ===== File Tests ===== + +func TestFileExists(t *testing.T) { + if utils.FileExists("/nonexistent/path") { + t.Error("FileExists should return false for nonexistent") + } +} + +func TestDirExists(t *testing.T) { + if utils.DirExists("/nonexistent/dir") { + t.Error("DirExists should return false") + } +} + +// ===== Secure Random Tests(H1:crypto/rand 安全版本) ===== + +// 回归 H1:RandStringSecure 生成正确长度、仅含字母数字。 +func TestRandStringSecure(t *testing.T) { + tests := []struct { + name string + length int + }{ + {"normal", 16}, + {"short", 1}, + {"long", 100}, + {"zero", 0}, + {"negative", -1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, err := utils.RandStringSecure(tt.length) + if err != nil { + t.Fatalf("RandStringSecure(%d) err: %v", tt.length, err) + } + if tt.length <= 0 { + if s != "" { + t.Errorf("RandStringSecure(%d) should return empty", tt.length) + } + return + } + if len(s) != tt.length { + t.Errorf("RandStringSecure(%d) length = %d", tt.length, len(s)) + } + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + t.Errorf("RandStringSecure contains invalid char: %c", c) + } + } + }) + } +} + +// 回归 H1:RandStringSecure 唯一性——crypto/rand 不可预测,大批量无重复。 +// 对比 RandString(math/rand)偶发重复(H1 征兆)。 +func TestRandStringSecureUniqueness(t *testing.T) { + results := make(map[string]bool, 1000) + for i := 0; i < 1000; i++ { + s, err := utils.RandStringSecure(16) + if err != nil { + t.Fatalf("RandStringSecure err: %v", err) + } + if results[s] { + t.Fatalf("RandStringSecure generated duplicate at i=%d: %q (H1: crypto/rand must be unique)", i, s) + } + results[s] = true + } +} + +// 回归 H1:RandDigitSecure 生成正确长度、仅含数字(用于 OTP)。 +func TestRandDigitSecure(t *testing.T) { + s, err := utils.RandDigitSecure(6) + if err != nil { + t.Fatalf("RandDigitSecure err: %v", err) + } + if len(s) != 6 { + t.Fatalf("RandDigitSecure length = %d, want 6", len(s)) + } + for _, c := range s { + if c < '0' || c > '9' { + t.Errorf("RandDigitSecure contains non-digit: %c", c) + } + } +} + +// 回归 H1:RandDigitSecure 唯一性——6 位 OTP 大批量重复率应在合理范围。 +// 6 位数字空间 10^6,1000 个样本重复概率极低(生日攻击 ~0.0005),crypto/rand 下应无重复。 +func TestRandDigitSecureUniqueness(t *testing.T) { + results := make(map[string]bool, 1000) + for i := 0; i < 1000; i++ { + s, err := utils.RandDigitSecure(6) + if err != nil { + t.Fatalf("RandDigitSecure err: %v", err) + } + if results[s] { + // 6 位空间小,理论上有极小概率生日碰撞;若发生则记录但不直接失败, + // 用更长的码验证。此处改用 8 位码重测以排除碰撞。 + t.Logf("RandDigitSecure(6) collision at i=%d (small space), retrying with 8 digits", i) + goto retry8 + } + results[s] = true + } + return +retry8: + results = make(map[string]bool, 1000) + for i := 0; i < 1000; i++ { + s, err := utils.RandDigitSecure(8) + if err != nil { + t.Fatalf("RandDigitSecure(8) err: %v", err) + } + if results[s] { + t.Fatalf("RandDigitSecure(8) generated duplicate at i=%d (H1: crypto/rand must be unique)", i) + } + results[s] = true + } +} + +// 回归 H1:RandStringSecure 过大长度返回错误(保护熵池)。 +func TestRandStringSecureTooLarge(t *testing.T) { + _, err := utils.RandStringSecure(1 << 21) // 2MB,超过 1<<20 上限 + if err != utils.ErrRandInvalidLength { + t.Errorf("RandStringSecure(too large) err = %v, want ErrRandInvalidLength", err) + } + _, err = utils.RandDigitSecure(1 << 21) + if err != utils.ErrRandInvalidLength { + t.Errorf("RandDigitSecure(too large) err = %v, want ErrRandInvalidLength", err) + } +} + +// 回归 H1:RandIntSecure 范围正确、min==max、max= 100 { + t.Errorf("RandIntSecure(1,100) = %d, out of range", n) + } + } + + // min == max + n, err := utils.RandIntSecure(5, 5) + if err != nil || n != 5 { + t.Errorf("RandIntSecure(5,5) = %d, err=%v, want 5", n, err) + } + + // max < min 自动交换 + n, err = utils.RandIntSecure(100, 1) + if err != nil { + t.Fatalf("RandIntSecure(100,1) err: %v", err) + } + if n < 1 || n >= 100 { + t.Errorf("RandIntSecure(100,1) = %d, should swap to [1,100)", n) + } +} + +// 回归 H1:RandInt64Secure 唯一性——crypto/rand 在大空间内分布均匀、不可预测。 +// [0, 1<<40) 取 1000 个,生日攻击重复概率 ~0.0000005,crypto/rand 应无重复。 +func TestRandInt64SecureUniqueness(t *testing.T) { + seen := make(map[int64]bool, 1000) + for i := 0; i < 1000; i++ { + n, err := utils.RandInt64Secure(0, 1<<40) + if err != nil { + t.Fatalf("RandInt64Secure err: %v", err) + } + if seen[n] { + t.Fatalf("RandInt64Secure duplicate at i=%d: %d (H1: crypto/rand must be unique in large range)", i, n) + } + seen[n] = true + } +} + +// 回归 H1:RandInt64Secure 范围正确、min==max、max= 1000000 { + t.Errorf("RandInt64Secure(0,1e6) = %d, out of range", n) + } + } + + // min == max + n, err := utils.RandInt64Secure(7, 7) + if err != nil || n != 7 { + t.Errorf("RandInt64Secure(7,7) = %d, err=%v, want 7", n, err) + } + + // max < min 自动交换 + n, err = utils.RandInt64Secure(1000000, 0) + if err != nil { + t.Fatalf("RandInt64Secure(1e6,0) err: %v", err) + } + if n < 0 || n >= 1000000 { + t.Errorf("RandInt64Secure(1e6,0) = %d, should swap to [0,1e6)", n) + } +} + +// ===== Benchmarks ===== + +func BenchmarkRandStringSecure(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = utils.RandStringSecure(16) + } +} + +func BenchmarkRandDigitSecure(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = utils.RandDigitSecure(6) + } +} + +func BenchmarkMD5(b *testing.B) { + for i := 0; i < b.N; i++ { + utils.MD5("hello world") + } +} + +func BenchmarkSHA256(b *testing.B) { + for i := 0; i < b.N; i++ { + utils.SHA256("hello world") + } +} + +func BenchmarkUUID(b *testing.B) { + for i := 0; i < b.N; i++ { + utils.UUID() + } +} + +func BenchmarkStrLen(b *testing.B) { + s := "hello世界测试字符串" + for i := 0; i < b.N; i++ { + utils.StrLen(s) + } +} + +func BenchmarkCalcPageCount(b *testing.B) { + for i := 0; i < b.N; i++ { + utils.CalcPageCount(10000, 20) + } +} diff --git a/utils/uuid.go b/utils/uuid.go new file mode 100644 index 0000000..8bb0cfe --- /dev/null +++ b/utils/uuid.go @@ -0,0 +1,28 @@ +package utils + +import ( + "strings" + + "github.com/google/uuid" +) + +// UUID 生成 UUID v4 字符串 +func UUID() string { + return uuid.New().String() +} + +// UUIDShort 生成短 UUID(无横线,32 个十六进制字符) +func UUIDShort() string { + return strings.ReplaceAll(uuid.New().String(), "-", "") +} + +// UUIDParse 解析 UUID 字符串 +func UUIDParse(s string) (uuid.UUID, error) { + return uuid.Parse(s) +} + +// UUIDValid 检查 UUID 字符串是否有效 +func UUIDValid(s string) bool { + _, err := uuid.Parse(s) + return err == nil +} diff --git a/utils/validator.go b/utils/validator.go new file mode 100644 index 0000000..0287316 --- /dev/null +++ b/utils/validator.go @@ -0,0 +1,120 @@ +package utils + +import ( + "regexp" +) + +// L-B 修复:正则预编译到包级变量,避免 IsPhone/IsEmail/IsIPv4/IsIDCard 每次调用 +// regexp.MatchString 重编译(高 QPS 下浪费 CPU)。零行为变化。 +var ( + phoneRE = regexp.MustCompile(`^1[3-9]\d{9}$`) + emailRE = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + ipv4RE = regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}$`) + idCardRE = regexp.MustCompile(`^\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$`) +) + +// IsPhone 检查是否为有效的中国大陆手机号 +// 注意: 正则基于当前号段,新号段开放时需更新 +func IsPhone(phone string) bool { + return phoneRE.MatchString(phone) +} + +// IsEmail 检查是否为有效的邮箱地址 +func IsEmail(email string) bool { + return emailRE.MatchString(email) +} + +// IsIPv4 检查是否为有效的 IPv4 地址 +func IsIPv4(ip string) bool { + if !ipv4RE.MatchString(ip) { + return false + } + // 验证每个段在 0-255 范围内 + parts := splitByDot(ip) + for _, part := range parts { + n := ToInt(part) + if n < 0 || n > 255 { + return false + } + } + return true +} + +// IsIDCard 检查是否为有效的中国身份证号(18位) +// 注意: 仅校验格式,不校验校验位 +func IsIDCard(id string) bool { + return idCardRE.MatchString(id) +} + +// IsChinese 检查字符串是否非空且全部为中文字符(CJK 基本区 U+4E00..U+9FFF)。空串返回 false。 +func IsChinese(s string) bool { + for _, r := range s { + if r < 0x4E00 || r > 0x9FFF { + return false + } + } + return len(s) > 0 +} + +// HasChinese 检查字符串是否包含中文字符 +func HasChinese(s string) bool { + for _, r := range s { + if r >= 0x4E00 && r <= 0x9FFF { + return true + } + } + return false +} + +// IsNumeric 检查字符串是否全部为数字 +func IsNumeric(s string) bool { + if len(s) == 0 { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// IsAlpha 检查字符串是否全部为字母 +func IsAlpha(s string) bool { + if len(s) == 0 { + return false + } + for _, r := range s { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') { + return false + } + } + return true +} + +// IsAlphanumeric 检查字符串是否全部为字母或数字 +func IsAlphanumeric(s string) bool { + if len(s) == 0 { + return false + } + for _, r := range s { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') { + return false + } + } + return true +} + +// 内部函数 +func splitByDot(s string) []string { + var result []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '.' { + result = append(result, s[start:i]) + start = i + 1 + } + } + result = append(result, s[start:]) + return result +} diff --git a/validation/hash.go b/validation/hash.go new file mode 100644 index 0000000..274a794 --- /dev/null +++ b/validation/hash.go @@ -0,0 +1,82 @@ +package validation + +import ( + "golang.org/x/crypto/bcrypt" +) + +// defaultCost 是框架默认 bcrypt cost。 +const defaultCost = 12 + +// normalizeHashCost 将 bcrypt cost 归一化到 bcrypt 支持范围内。 +func normalizeHashCost(cost int) int { + if cost < bcrypt.MinCost { + return bcrypt.MinCost + } + if cost > bcrypt.MaxCost { + return bcrypt.MaxCost + } + return cost +} + +// normalizeUpgradeTargetCost 归一化升级目标 cost,异常值回退到框架默认值。 +func normalizeUpgradeTargetCost(cost int) int { + if cost <= 0 || cost > bcrypt.MaxCost { + return defaultCost + } + return normalizeHashCost(cost) +} + +// HashPassword 使用框架默认 cost 哈希明文密码。 +func HashPassword(password string) (string, error) { + return HashPasswordWithCost(password, defaultCost) +} + +// HashPasswordWithCost 使用指定 cost 哈希明文密码。 +// +// 低于 bcrypt.MinCost 的 cost 会提升到 bcrypt.MinCost,高于 bcrypt.MaxCost +// 的 cost 会降到 bcrypt.MaxCost。 +func HashPasswordWithCost(password string, cost int) (string, error) { + cost = normalizeHashCost(cost) + + bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// CheckPassword 校验 hashedPassword 是否匹配明文密码。 +func CheckPassword(hashedPassword, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + return err == nil +} + +// CheckPasswordAndUpgrade 校验密码,并在存量 hash 的 cost 低于归一化后的目标 cost 时重新哈希。 +func CheckPasswordAndUpgrade(hashedPassword, password string, targetCost int) (match bool, needUpgrade bool, newHash string, err error) { + targetCost = normalizeUpgradeTargetCost(targetCost) + + err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + if err != nil { + return false, false, "", err + } + + cost, err := bcrypt.Cost([]byte(hashedPassword)) + if err != nil { + return true, false, "", nil + } + + if cost < targetCost { + newHash, err = HashPasswordWithCost(password, targetCost) + if err != nil { + return true, false, "", err + } + return true, true, newHash, nil + } + + return true, false, "", nil +} + +// GetPasswordCost 返回 hashedPassword 中编码的 bcrypt cost。 +func GetPasswordCost(hashedPassword string) (int, error) { + return bcrypt.Cost([]byte(hashedPassword)) +} diff --git a/validation/password.go b/validation/password.go new file mode 100644 index 0000000..6576aa4 --- /dev/null +++ b/validation/password.go @@ -0,0 +1,89 @@ +package validation + +import ( + "strconv" + "unicode" +) + +// PasswordConfig 密码验证配置 +type PasswordConfig struct { + MinLength int // 最小长度 + MaxLength int // 最大长度 + RequireUpper bool // 需要大写字母 + RequireLower bool // 需要小写字母 + RequireDigit bool // 需要数字 + RequireSpecial bool // 需要特殊字符 +} + +// DefaultPasswordConfig 默认密码配置。 +// +// P1 #17:MaxLength 由 128 收敛到 72(字节)。bcrypt 在 72 字节处静默截断—— +// 若允许更长密码,两个前 72 字节相同的不同密码会被视为同一密码通过认证。 +// length 用 len()(字节)度量,与 bcrypt 的字节截断口径一致。需支持超长密码时, +// 应在哈希前先做 SHA-256 预哈希再交给 bcrypt,而非放宽此上限。 +var DefaultPasswordConfig = PasswordConfig{ + MinLength: 8, // 最少8位 + MaxLength: 72, // 最多72字节(bcrypt 截断边界,见上) + RequireUpper: true, + RequireLower: true, + RequireDigit: true, + RequireSpecial: false, +} + +// ValidatePassword 验证密码强度 +// 返回:是否有效,错误信息 +func ValidatePassword(password string) (bool, string) { + return ValidatePasswordWithConfig(password, DefaultPasswordConfig) +} + +// ValidatePasswordWithConfig 使用指定配置验证密码强度 +func ValidatePasswordWithConfig(password string, config PasswordConfig) (bool, string) { + length := len(password) + + // 检查最小长度 + if length < config.MinLength { + return false, "密码长度不能少于" + strconv.Itoa(config.MinLength) + "位" + } + + // 检查最大长度 + if length > config.MaxLength { + return false, "密码长度不能超过" + strconv.Itoa(config.MaxLength) + "位" + } + + var hasUpper, hasLower, hasDigit, hasSpecial bool + + for _, char := range password { + switch { + case unicode.IsUpper(char): + hasUpper = true + case unicode.IsLower(char): + hasLower = true + case unicode.IsDigit(char): + hasDigit = true + case unicode.IsPunct(char) || unicode.IsSymbol(char): + hasSpecial = true + } + } + + // 检查大写字母 + if config.RequireUpper && !hasUpper { + return false, "密码必须包含大写字母" + } + + // 检查小写字母 + if config.RequireLower && !hasLower { + return false, "密码必须包含小写字母" + } + + // 检查数字 + if config.RequireDigit && !hasDigit { + return false, "密码必须包含数字" + } + + // 检查特殊字符 + if config.RequireSpecial && !hasSpecial { + return false, "密码必须包含特殊字符" + } + + return true, "" +} diff --git a/validation/validation_test.go b/validation/validation_test.go new file mode 100644 index 0000000..4b6a8b3 --- /dev/null +++ b/validation/validation_test.go @@ -0,0 +1,461 @@ +package validation_test + +import ( + "testing" + + "github.com/EthanCodeCraft/xlgo-core/validation" + "golang.org/x/crypto/bcrypt" +) + +// ===== Password Tests ===== + +func TestValidatePassword(t *testing.T) { + tests := []struct { + password string + valid bool + msg string + }{ + {"Abc12345", true, ""}, // 有效 + {"abc12345", false, "密码必须包含大写字母"}, // 缺大写 + {"ABC12345", false, "密码必须包含小写字母"}, // 缺小写 + {"Abcdefgh", false, "密码必须包含数字"}, // 缺数字 + {"Abc123", false, "密码长度不能少于8位"}, // 太短 + {"", false, "密码长度不能少于8位"}, // 空 + } + + for _, tt := range tests { + valid, msg := validation.ValidatePassword(tt.password) + if valid != tt.valid { + t.Errorf("ValidatePassword(%s) valid = %v, want %v", tt.password, valid, tt.valid) + } + if !valid && msg != tt.msg { + t.Errorf("ValidatePassword(%s) msg = %s, want %s", tt.password, msg, tt.msg) + } + } +} + +func TestValidatePasswordWithConfig(t *testing.T) { + // 自定义配置:不要求特殊字符 + config := validation.PasswordConfig{ + MinLength: 6, + MaxLength: 20, + RequireUpper: false, + RequireLower: true, + RequireDigit: true, + RequireSpecial: false, + } + + valid, msg := validation.ValidatePasswordWithConfig("abc123", config) + if !valid { + t.Errorf("ValidatePasswordWithConfig should be valid: %s", msg) + } + + // 要求特殊字符 + config2 := validation.PasswordConfig{ + MinLength: 8, + MaxLength: 20, + RequireUpper: true, + RequireLower: true, + RequireDigit: true, + RequireSpecial: true, + } + + valid2, msg2 := validation.ValidatePasswordWithConfig("Abc12345", config2) + if valid2 { + t.Error("Should require special character") + } + if msg2 != "密码必须包含特殊字符" { + t.Errorf("msg = %s, want '密码必须包含特殊字符'", msg2) + } + + // 包含特殊字符 + valid3, msg3 := validation.ValidatePasswordWithConfig("Abc123!@#", config2) + if !valid3 { + t.Errorf("Should be valid: %s", msg3) + } +} + +func TestDefaultPasswordConfig(t *testing.T) { + cfg := validation.DefaultPasswordConfig + if cfg.MinLength != 8 { + t.Errorf("MinLength = %d, want 8", cfg.MinLength) + } + if cfg.MaxLength != 72 { + t.Errorf("MaxLength = %d, want 72 (P1 #17: bcrypt 72-byte truncation boundary)", cfg.MaxLength) + } + if !cfg.RequireUpper || !cfg.RequireLower || !cfg.RequireDigit { + t.Error("Default config should require upper, lower, digit") + } +} + +// ===== Hash Password Tests ===== + +func TestHashPassword(t *testing.T) { + password := "testPassword123" + + hash, err := validation.HashPassword(password) + if err != nil { + t.Fatalf("HashPassword error: %v", err) + } + + // 验证 hash 不等于原密码 + if hash == password { + t.Error("Hash should not equal original password") + } + + // 验证 hash 非空 + if hash == "" { + t.Error("Hash should not be empty") + } + + // 验证可以匹配 + if !validation.CheckPassword(hash, password) { + t.Error("CheckPassword should return true for correct password") + } + + // 验证错误密码不匹配 + if validation.CheckPassword(hash, "wrongPassword") { + t.Error("CheckPassword should return false for wrong password") + } +} + +func TestHashPasswordWithCost(t *testing.T) { + password := "testPassword123" + + // 测试不同 cost + hash, err := validation.HashPasswordWithCost(password, 10) + if err != nil { + t.Fatalf("HashPasswordWithCost error: %v", err) + } + + cost, err := validation.GetPasswordCost(hash) + if err != nil { + t.Fatalf("GetPasswordCost error: %v", err) + } + if cost != 10 { + t.Errorf("Cost = %d, want 10", cost) + } + + // 测试 cost 边界 + hash2, err := validation.HashPasswordWithCost(password, 3) // 低于 MinCost + if err != nil { + t.Fatalf("HashPasswordWithCost with low cost error: %v", err) + } + cost2, _ := validation.GetPasswordCost(hash2) + if cost2 < bcrypt.MinCost { + t.Errorf("Cost should be at least MinCost") + } +} + +func TestCheckPasswordAndUpgrade(t *testing.T) { + password := "testPassword123" + + // 使用低 cost 创建 hash + hash, _ := validation.HashPasswordWithCost(password, 4) + + // 检查并尝试升级 + match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, password, 12) + if err != nil { + t.Fatalf("CheckPasswordAndUpgrade error: %v", err) + } + + if !match { + t.Error("Password should match") + } + + if !needUpgrade { + t.Error("Should need upgrade (cost 4 -> 12)") + } + + if newHash == "" { + t.Error("New hash should be provided") + } + + // 验证新 hash 可用 + if !validation.CheckPassword(newHash, password) { + t.Error("New hash should work") + } + + // 使用高 cost,不需要升级 + hash2, _ := validation.HashPasswordWithCost(password, 12) + match2, needUpgrade2, _, _ := validation.CheckPasswordAndUpgrade(hash2, password, 12) + if !match2 { + t.Error("Password should match") + } + if needUpgrade2 { + t.Error("Should not need upgrade") + } + + // 错误密码 + match3, _, _, _ := validation.CheckPasswordAndUpgrade(hash, "wrongPassword", 12) + if match3 { + t.Error("Wrong password should not match") + } +} + +func TestCheckPasswordAndUpgradeNormalizesInvalidTargetCost(t *testing.T) { + password := "testPassword123" + hash, err := validation.HashPasswordWithCost(password, 12) + if err != nil { + t.Fatalf("HashPasswordWithCost: %v", err) + } + + match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, password, bcrypt.MaxCost+1) + if err != nil { + t.Fatalf("CheckPasswordAndUpgrade with high target cost: %v", err) + } + if !match { + t.Fatal("Password should match") + } + if needUpgrade || newHash != "" { + t.Fatalf("invalid high target cost should normalize to default and avoid upgrade, needUpgrade=%v newHash=%q", needUpgrade, newHash) + } +} + +func TestGetPasswordCost(t *testing.T) { + password := "testPassword123" + hash, _ := validation.HashPasswordWithCost(password, 12) + + cost, err := validation.GetPasswordCost(hash) + if err != nil { + t.Fatalf("GetPasswordCost error: %v", err) + } + if cost != 12 { + t.Errorf("Cost = %d, want 12", cost) + } + + // 无效 hash + _, err = validation.GetPasswordCost("invalidHash") + if err == nil { + t.Error("GetPasswordCost should fail with invalid hash") + } +} + +// ===== Validation Errors Tests ===== + +func TestValidationErrors(t *testing.T) { + errors := validation.ValidationErrors{ + {Field: "name", Label: "姓名", Message: "必填"}, + {Field: "email", Label: "邮箱", Message: "格式错误"}, + } + + // Error 方法 + errStr := errors.Error() + if errStr != "姓名: 必填; 邮箱: 格式错误" { + t.Errorf("Error() = %s", errStr) + } + + // ToMap 方法 + m := errors.ToMap() + if m["name"] != "必填" { + t.Error("ToMap failed") + } + + // ToLabelMap 方法 + lm := errors.ToLabelMap() + if lm["姓名"] != "必填" { + t.Error("ToLabelMap failed") + } + + // First 方法 + first := errors.First() + if first.Field != "name" { + t.Error("First failed") + } + + // FirstMessage 方法 + msg := errors.FirstMessage() + if msg != "必填" { + t.Errorf("FirstMessage = %s", msg) + } + + // 空 errors + emptyErrors := validation.ValidationErrors{} + if emptyErrors.First() != nil { + t.Error("Empty First should return nil") + } + if emptyErrors.FirstMessage() != "" { + t.Error("Empty FirstMessage should return empty") + } +} + +// ===== Struct Validation Tests ===== + +type TestUser struct { + Name string `json:"name" label:"姓名" binding:"required" msg_required:"姓名不能为空"` + Email string `json:"email" label:"邮箱" binding:"required,email" msg_required:"邮箱不能为空" msg_email:"邮箱格式不正确"` + Age int `json:"age" label:"年龄" binding:"gte=0,lte=150"` + Password string `json:"password" binding:"min=8" msg_min:"密码至少8位"` +} + +func TestValidateStruct(t *testing.T) { + validation.InitValidator() + + // 有效数据 + validUser := TestUser{ + Name: "张三", + Email: "test@example.com", + Age: 25, + Password: "password123", + } + + errors := validation.ValidateStruct(validUser) + if errors != nil { + t.Errorf("Valid struct should have no errors: %v", errors) + } + + // 无效数据 + invalidUser := TestUser{ + Name: "", + Email: "invalid-email", + Age: 200, + Password: "short", + } + + errors2 := validation.ValidateStruct(invalidUser) + if errors2 == nil { + t.Error("Invalid struct should have errors") + } + + // 检查错误数量 + if len(errors2) < 3 { + t.Errorf("Should have at least 3 errors, got %d", len(errors2)) + } + + // 检查自定义消息 + firstMsg := errors2.FirstMessage() + if firstMsg != "姓名不能为空" { + t.Errorf("FirstMessage = %s, want '姓名不能为空'", firstMsg) + } +} + +func TestValidateStructNil(t *testing.T) { + if errors := validation.ValidateStruct(nil); errors != nil { + t.Fatalf("ValidateStruct(nil) = %v, want nil", errors) + } + + // 空结构体 + emptyUser := TestUser{} + errors := validation.ValidateStruct(emptyUser) + if errors == nil { + t.Error("Empty struct should have errors") + } +} + +type TestPhone struct { + Phone string `json:"phone" binding:"phone" msg_phone:"手机号格式不正确"` +} + +func TestValidatePhone(t *testing.T) { + validation.InitValidator() + + // 有效手机号 + valid := TestPhone{Phone: "13812345678"} + errors := validation.ValidateStruct(valid) + if errors != nil { + t.Errorf("Valid phone should pass: %v", errors) + } + + // 无效手机号 - 长度错误 + invalidLen := TestPhone{Phone: "1234567"} + errors2 := validation.ValidateStruct(invalidLen) + if errors2 == nil { + t.Error("Invalid phone length should fail") + } + + // 无效手机号 - 不以1开头 + invalidPrefix := TestPhone{Phone: "23812345678"} + errors3 := validation.ValidateStruct(invalidPrefix) + if errors3 == nil { + t.Error("Phone not starting with 1 should fail") + } + + // P1 #17:长度合规、以 1 开头但含非数字字符必须拒绝(原实现会误通过)。 + invalidNonDigit := TestPhone{Phone: "1abcdefghij"} + if errs := validation.ValidateStruct(invalidNonDigit); errs == nil { + t.Error("Phone with non-digit chars should fail (P1 #17)") + } +} + +type TestUsername struct { + Username string `json:"username" binding:"username" msg_username:"用户名格式不正确"` +} + +func TestValidateUsername(t *testing.T) { + validation.InitValidator() + + tests := []struct { + username string + valid bool + }{ + {"abc123", true}, // 有效 + {"Abc123", true}, // 有效(大写开头) + {"user_name", true}, // 有效(包含下划线) + {"123abc", false}, // 无效(数字开头) + {"ab", false}, // 无效(太短) + {"a!bc", false}, // 无效(特殊字符) + } + + for _, tt := range tests { + u := TestUsername{Username: tt.username} + errors := validation.ValidateStruct(u) + valid := errors == nil + if valid != tt.valid { + t.Errorf("Username %s: valid=%v, want %v", tt.username, valid, tt.valid) + } + } +} + +// TestIDCardChecksum_M5:18 位身份证须校验校验位(修复前仅查长度+格式)。 +func TestIDCardChecksum_M5(t *testing.T) { + validation.InitValidator() + + type TestIDCard struct { + ID string `json:"id" binding:"idcard"` + } + tests := []struct { + id string + valid bool + }{ + {"11010519491231002X", true}, // 合法(校验位正确) + {"110101199003078478", true}, // 合法(校验位正确) + {"110101199003078475", false}, // 校验位错误(末位改坏) + {"11010119900307847X", false}, // 18 位但校验位错 + {"123456789012345", true}, // 15 位旧号段仅查格式 + {"123456789012345678", false}, // 18 位但校验位错 + {"123", false}, // 长度不符 + } + for _, tt := range tests { + u := TestIDCard{ID: tt.id} + errs := validation.ValidateStruct(u) + valid := errs == nil + if valid != tt.valid { + t.Errorf("IDCard %s: valid=%v, want %v", tt.id, valid, tt.valid) + } + } +} + +// ===== Benchmarks ===== + +func BenchmarkHashPassword(b *testing.B) { + password := "testPassword123" + for i := 0; i < b.N; i++ { + validation.HashPassword(password) + } +} + +func BenchmarkCheckPassword(b *testing.B) { + password := "testPassword123" + hash, _ := validation.HashPassword(password) + b.ResetTimer() + for i := 0; i < b.N; i++ { + validation.CheckPassword(hash, password) + } +} + +func BenchmarkValidatePassword(b *testing.B) { + password := "TestPassword123" + for i := 0; i < b.N; i++ { + validation.ValidatePassword(password) + } +} diff --git a/validation/validator.go b/validation/validator.go new file mode 100644 index 0000000..c0ecfa9 --- /dev/null +++ b/validation/validator.go @@ -0,0 +1,457 @@ +package validation + +import ( + "fmt" + "reflect" + "strings" + "sync/atomic" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/validator/v10" +) + +// Validator 全局验证器实例。 +// +// H-13 修复:改用 atomic.Pointer 保护读写,消除原裸指针(InitValidator 无锁写、 +// ValidateStruct 无锁读)在运行期热重载/并发校验时的数据竞争。类型由 *validator.Validate +// 变更为 atomic.Pointer[validator.Validate](breaking:下游若直接读 validation.Validator.Struct +// 需改用 ValidateStruct,或 validation.Validator.Load().Struct)。 +var Validator atomic.Pointer[validator.Validate] + +// ValidationError 验证错误 +type ValidationError struct { + Field string `json:"field"` // 字段名(使用 label 或 json tag) + Label string `json:"label"` // 字段中文名(用于显示) + Message string `json:"message"` // 错误消息 +} + +// ValidationErrors 验证错误列表 +type ValidationErrors []ValidationError + +// Error 实现 error 接口 +func (ve ValidationErrors) Error() string { + var msgs []string + for _, e := range ve { + if e.Label != "" { + msgs = append(msgs, e.Label+": "+e.Message) + } else { + msgs = append(msgs, e.Field+": "+e.Message) + } + } + return strings.Join(msgs, "; ") +} + +// ToMap 转换为 map +func (ve ValidationErrors) ToMap() map[string]string { + m := make(map[string]string) + for _, e := range ve { + m[e.Field] = e.Message + } + return m +} + +// ToLabelMap 转换为带标签的 map +func (ve ValidationErrors) ToLabelMap() map[string]string { + m := make(map[string]string) + for _, e := range ve { + if e.Label != "" { + m[e.Label] = e.Message + } else { + m[e.Field] = e.Message + } + } + return m +} + +// First 获取第一个错误 +func (ve ValidationErrors) First() *ValidationError { + if len(ve) == 0 { + return nil + } + return &ve[0] +} + +// FirstMessage 获取第一个错误消息 +func (ve ValidationErrors) FirstMessage() string { + if len(ve) == 0 { + return "" + } + return ve[0].Message +} + +// InitValidator 初始化验证器 +func InitValidator() { + if v, ok := binding.Validator.Engine().(*validator.Validate); ok { + // 注册自定义标签名函数(优先使用 label,其次 json) + v.RegisterTagNameFunc(func(fld reflect.StructField) string { + // 优先使用 label tag 作为字段显示名 + label := fld.Tag.Get("label") + if label != "" { + return label + } + + // 其次使用 json tag + name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] + if name == "-" { + return "" + } + return name + }) + + // 注册自定义验证规则(先注册再 Store,避免并发 ValidateStruct 拿到 + // "已 Store 但自定义规则未注册完"的 validator) + registerCustomValidations(v) + + // 全部配置完成后再发布,确保 Load 得到的 validator 永远是完整的 + Validator.Store(v) + } +} + +// registerCustomValidations 注册自定义验证规则。 +// +// P1 #18:每个 RegisterValidation 的错误都必须检查——注册失败会导致该 tag 静默不存在、 +// 所有输入被视为通过(password/idcard 等 fail-open 安全漏洞)。用 must 包装,在 init +// 期以明确信息 panic(配置类错误应启动即暴露,而非运行期静默放行)。 +func registerCustomValidations(v *validator.Validate) { + must := func(tag string, fn validator.Func) { + if err := v.RegisterValidation(tag, fn); err != nil { + panic(fmt.Sprintf("validation: 注册校验规则 %q 失败: %v", tag, err)) + } + } + + // 密码强度验证 + must("password", func(fl validator.FieldLevel) bool { + password := fl.Field().String() + valid, _ := ValidatePassword(password) + return valid + }) + + // 手机号验证(中国大陆) + must("phone", func(fl validator.FieldLevel) bool { + phone := fl.Field().String() + if len(phone) != 11 || !strings.HasPrefix(phone, "1") { + return false + } + // P1 #17:要求全数字——原实现仅查长度+前缀,"1abcdefghij" 这类会误通过。 + for i := 0; i < len(phone); i++ { + if phone[i] < '0' || phone[i] > '9' { + return false + } + } + return true + }) + + // 用户名验证(字母开头,允许字母数字下划线) + must("username", func(fl validator.FieldLevel) bool { + username := fl.Field().String() + if len(username) < 3 || len(username) > 20 { + return false + } + // 首字符按 rune 取,避免非 ASCII 首字节误判(M5:原 rune(username[0]) 取字节)。 + runes := []rune(username) + if len(runes) == 0 || !isLetter(runes[0]) { + return false + } + for _, r := range runes { + if !isLetter(r) && !isDigit(r) && r != '_' { + return false + } + } + return true + }) + + // 手机号严格验证(验证运营商号段) + must("phone_strict", func(fl validator.FieldLevel) bool { + phone := fl.Field().String() + if len(phone) != 11 { + return false + } + if !strings.HasPrefix(phone, "1") { + return false + } + // 检查号段 + prefix := phone[:3] + validPrefixes := []string{ + "130", "131", "132", "133", "134", "135", "136", "137", "138", "139", + "145", "146", "147", "148", "149", + "150", "151", "152", "153", "155", "156", "157", "158", "159", + "166", "167", + "170", "171", "172", "173", "174", "175", "176", "177", "178", + "180", "181", "182", "183", "184", "185", "186", "187", "188", "189", + "191", "198", "199", + } + for _, p := range validPrefixes { + if prefix == p { + return true + } + } + return false + }) + + // 身份证号验证(18 位带校验位;15 位仅格式,向后兼容旧号段)。 + must("idcard", func(fl validator.FieldLevel) bool { + id := fl.Field().String() + if len(id) != 18 && len(id) != 15 { + return false + } + // 基本格式:前 17 位(18 位号)或全部(15 位号)须为数字,18 位号末位可为 X。 + for i, c := range id { + if i == len(id)-1 && len(id) == 18 { + if !isDigit(c) && c != 'X' && c != 'x' { + return false + } + } else { + if !isDigit(c) { + return false + } + } + } + // 18 位号校验校验位(M5:原仅查长度+格式,无校验位可被任意构造通过)。 + if len(id) == 18 { + if !validateIDCardChecksum(id) { + return false + } + } + return true + }) +} + +func isLetter(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') +} + +func isDigit(r rune) bool { + return r >= '0' && r <= '9' +} + +// validateIDCardChecksum 校验 18 位身份证校验位(GB 11643-1999,M5)。 +// 前 17 位按权重 [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2] 加权求和 mod 11, +// 对照码表 [1,0,X,9,8,7,6,5,4,3,2] 得期望末位。 +func validateIDCardChecksum(id string) bool { + weights := [17]int{7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2} + checkCodes := [11]byte{'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'} + sum := 0 + for i := 0; i < 17; i++ { + c := id[i] + if c < '0' || c > '9' { + return false + } + sum += int(c-'0') * weights[i] + } + expected := checkCodes[sum%11] + last := id[17] + // 末位 X 大小写不敏感 + if last == 'x' { + last = 'X' + } + return last == expected +} + +// ValidateStruct 验证结构体 +func ValidateStruct(s any) ValidationErrors { + if s == nil { + return nil + } + + v := Validator.Load() + if v == nil { + InitValidator() + v = Validator.Load() + } + + err := v.Struct(s) + if err == nil { + return nil + } + + return parseValidationErrors(err, s) +} + +// parseValidationErrors 解析验证错误(支持自定义错误消息) +func parseValidationErrors(err error, s any) ValidationErrors { + var errors ValidationErrors + + if validationErrors, ok := err.(validator.ValidationErrors); ok { + for _, e := range validationErrors { + fieldName := e.Field() + label := fieldName // Field() 返回的是 label 或 json tag + + // 尝试获取原始字段名和自定义错误消息 + if s != nil { + t := reflect.TypeOf(s) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() == reflect.Struct { + // 获取原始字段名 + originalField := getOriginalFieldName(t, e.StructField()) + if originalField != "" { + fieldName = originalField + } + + // 获取自定义错误消息 + field, found := t.FieldByName(e.StructField()) + if found { + customMsg := getCustomErrorMessage(field, e.Tag()) + if customMsg != "" { + errors = append(errors, ValidationError{ + Field: fieldName, + Label: label, + Message: customMsg, + }) + continue + } + } + } + } + + errors = append(errors, ValidationError{ + Field: fieldName, + Label: label, + Message: getErrorMessage(e), + }) + } + } + + return errors +} + +// getOriginalFieldName 获取原始字段名(从 json tag) +func getOriginalFieldName(t reflect.Type, structField string) string { + field, found := t.FieldByName(structField) + if !found { + return "" + } + + jsonTag := field.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + return structField + } + + name := strings.SplitN(jsonTag, ",", 2)[0] + if name == "" { + return structField + } + return name +} + +// getCustomErrorMessage 获取自定义错误消息 +// 支持格式: +// - error:"自定义错误消息" +// - msg_required:"必填项" (针对特定验证规则) +// - msg_min:"最少5个字符" +func getCustomErrorMessage(field reflect.StructField, tag string) string { + // 优先查找特定规则的错误消息 + specificTag := fmt.Sprintf("msg_%s", tag) + msg := field.Tag.Get(specificTag) + if msg != "" { + return msg + } + + // 其次查找通用错误消息 + msg = field.Tag.Get("error") + if msg != "" { + return msg + } + + // 最后查找 msg tag + msg = field.Tag.Get("msg") + if msg != "" { + return msg + } + + return "" +} + +// getErrorMessage 获取默认验证错误消息 +func getErrorMessage(e validator.FieldError) string { + switch e.Tag() { + case "required": + return "此字段为必填项" + case "email": + return "邮箱格式不正确" + case "min": + return fmt.Sprintf("长度不能少于 %s 个字符", e.Param()) + case "max": + return fmt.Sprintf("长度不能超过 %s 个字符", e.Param()) + case "len": + return fmt.Sprintf("长度必须为 %s 个字符", e.Param()) + case "gte": + return fmt.Sprintf("必须大于或等于 %s", e.Param()) + case "lte": + return fmt.Sprintf("必须小于或等于 %s", e.Param()) + case "gt": + return fmt.Sprintf("必须大于 %s", e.Param()) + case "lt": + return fmt.Sprintf("必须小于 %s", e.Param()) + case "eq": + return fmt.Sprintf("必须等于 %s", e.Param()) + case "ne": + return fmt.Sprintf("不能等于 %s", e.Param()) + case "oneof": + return fmt.Sprintf("必须是以下值之一: %s", e.Param()) + case "url": + return "URL 格式不正确" + case "uri": + return "URI 格式不正确" + case "uuid": + return "UUID 格式不正确" + case "alphanum": + return "只能包含字母和数字" + case "alpha": + return "只能包含字母" + case "numeric": + return "必须是数字" + case "password": + return "密码强度不足,需包含大小写字母和数字,至少8位" + case "phone": + return "手机号格式不正确" + case "phone_strict": + return "手机号无效,请输入正确的手机号" + case "username": + return "用户名必须以字母开头,只能包含字母、数字和下划线,长度3-20" + case "idcard": + return "身份证号格式不正确" + default: + return fmt.Sprintf("验证失败: %s", e.Tag()) + } +} + +// BindAndValidate 绑定并验证请求 +func BindAndValidate(c *gin.Context, req any) ValidationErrors { + if err := c.ShouldBind(req); err != nil { + return parseValidationErrors(err, req) + } + return ValidateStruct(req) +} + +// ShouldBindAndValidate 绑定并验证请求,返回是否成功 +func ShouldBindAndValidate(c *gin.Context, req any) (ValidationErrors, bool) { + errors := BindAndValidate(c, req) + return errors, len(errors) == 0 +} + +// BindJSON 绑定 JSON 并验证 +func BindJSON(c *gin.Context, req any) ValidationErrors { + if err := c.ShouldBindJSON(req); err != nil { + return parseValidationErrors(err, req) + } + return ValidateStruct(req) +} + +// BindQuery 绑定 Query 并验证 +func BindQuery(c *gin.Context, req any) ValidationErrors { + if err := c.ShouldBindQuery(req); err != nil { + return parseValidationErrors(err, req) + } + return ValidateStruct(req) +} + +// BindForm 绑定 Form 并验证 +func BindForm(c *gin.Context, req any) ValidationErrors { + if err := c.ShouldBind(req); err != nil { + return parseValidationErrors(err, req) + } + return ValidateStruct(req) +} diff --git a/ws/ws.go b/ws/ws.go new file mode 100644 index 0000000..3a9c53a --- /dev/null +++ b/ws/ws.go @@ -0,0 +1,607 @@ +package ws + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// WebSocket 配置。 +// +// CheckOrigin 默认采用同源校验(C7/CSWSH 修复):仅当请求 Origin 为空(非浏览器客户端) +// 或与请求 Host 同源时放行,拒绝跨域 WebSocket 连接,防 Cross-Site WebSocket Hijacking。 +// 需要允许多个可信 Origin 的场景,用 SetCheckOrigin 注入自定义校验,或用 AllowOrigins。 +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: currentCheckOrigin, +} + +var checkOriginValue atomic.Value // stores func(*http.Request) bool + +func currentCheckOrigin(r *http.Request) bool { + if fn, ok := checkOriginValue.Load().(func(*http.Request) bool); ok && fn != nil { + return fn(r) + } + return sameOriginCheck(r) +} + +// sameOriginCheck 同源校验:Origin 为空(非浏览器/服务器内部)放行;否则要求 Origin 的 +// host:port 与请求 Host 一致。防 CSWSH(C7)。 +func sameOriginCheck(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + // 非浏览器客户端(如 curl/服务端拨号)不带 Origin,放行。 + return true + } + u, err := url.Parse(origin) + if err != nil { + return false + } + if u.Scheme == "" || u.Host == "" { + return false + } + return strings.EqualFold(u.Scheme, requestScheme(r)) && strings.EqualFold(u.Host, r.Host) +} + +func requestScheme(r *http.Request) string { + if r.URL != nil && r.URL.Scheme != "" { + return r.URL.Scheme + } + if r.TLS != nil { + return "https" + } + return "http" +} + +// AllowOrigins 返回一个 CheckOrigin 函数,仅放行给定 Origin 列表(含 scheme+host[:port])。 +// 用于多可信域名场景。传入空切片等价于拒绝所有带 Origin 的浏览器连接。 +func AllowOrigins(origins ...string) func(r *http.Request) bool { + allowed := make(map[string]bool, len(origins)) + for _, o := range origins { + allowed[o] = true + } + return func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true // 非浏览器客户端放行 + } + return allowed[origin] + } +} + +// 读写超时常量(C2c:防半开连接 goroutine 泄漏)。ping 周期须 < pongWait。 +const ( + // pongWait 等待对端 pong 的最长时间;超时即判定连接半开、关闭。 + pongWait = 60 * time.Second + // pingPeriod ping 发送周期,须 < pongWait 以留出重置读 deadline 的余量。 + pingPeriod = (pongWait * 9) / 10 + // writeWait 单次写操作超时。 + writeWait = 10 * time.Second +) + +// MessageType 消息类型 +type MessageType string + +const ( + TypeText MessageType = "text" // 文本消息 + TypeBinary MessageType = "binary" // 二进制消息 + TypePing MessageType = "ping" // 心跳 ping + TypePong MessageType = "pong" // 心跳 pong + TypeClose MessageType = "close" // 关闭消息 +) + +// Message WebSocket 消息 +type Message struct { + Type MessageType `json:"type"` + Content any `json:"content"` +} + +// Connection WebSocket 连接 +type Connection struct { + conn *websocket.Conn + send chan []byte + closeChan chan struct{} + once sync.Once + mu sync.Mutex +} + +// NewConnection 创建 WebSocket 连接 +func NewConnection(conn *websocket.Conn) *Connection { + return &Connection{ + conn: conn, + send: make(chan []byte, 256), + closeChan: make(chan struct{}), + } +} + +// ErrSendBufferFull 发送缓冲已满(消费者跟不上)。非阻塞投递策略下返回此错误, +// 调用方可决定重试或关闭连接。避免慢消费者阻塞发送方(含 Hub 广播持锁场景)。 +var ErrSendBufferFull = errors.New("websocket send buffer full") + +// Send 非阻塞发送消息。不直接写底层 conn,而是投递到 send channel 由 writePump 统一写出。 +// +// 并发安全说明(C2b 修复):Close() 仅 close closeChan,不再 close send channel, +// 因此 select 选中 send 分支也只是向未关闭 channel 投递(不会 panic)。 +// +// 非阻塞策略(C2a-residual 修复):投递用 default 分支,缓冲满立即返回 ErrSendBufferFull, +// 而非阻塞等待。这避免 Hub 广播持锁期间因慢消费者/已死连接(writePump 退出但 closeChan +// 未关、send 缓冲满)阻塞最长 pongWait,导致整个 Hub stall。调用方对缓冲满可重试或关闭连接。 +func (c *Connection) Send(data []byte) error { + // 快速路径:连接已关闭则立即失败。 + if c.IsClosed() { + return errors.New("connection closed") + } + select { + case c.send <- data: + return nil + default: + // 缓冲满。再检查一次 closeChan,避免对刚关闭的连接报"缓冲满"而非"已关闭"。 + select { + case <-c.closeChan: + return errors.New("connection closed") + default: + return ErrSendBufferFull + } + } +} + +// SendJSON 发送 JSON 消息 +func (c *Connection) SendJSON(v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + return c.Send(data) +} + +// SendText 发送文本消息 +func (c *Connection) SendText(text string) error { + return c.SendJSON(Message{Type: TypeText, Content: text}) +} + +// Close 关闭连接。仅 close closeChan 作关闭信号,不 close send channel(C2b 修复), +// 避免 Close 与并发 Send 之间的 send-on-closed panic。send channel 由 GC 回收。 +func (c *Connection) Close() { + c.once.Do(func() { + close(c.closeChan) + // #nosec G104 -- 关闭底层连接的错误无意义(重复关闭返错属正常),忽略 + if c.conn != nil { + _ = c.conn.Close() + } + }) +} + +// IsClosed 检查连接是否已关闭 +func (c *Connection) IsClosed() bool { + select { + case <-c.closeChan: + return true + default: + return false + } +} + +// SetReadDeadline 设置读取超时 +func (c *Connection) SetReadDeadline(t time.Time) error { + return c.conn.SetReadDeadline(t) +} + +// SetWriteDeadline 设置写入超时 +func (c *Connection) SetWriteDeadline(t time.Time) error { + return c.conn.SetWriteDeadline(t) +} + +// Handler WebSocket 处理器接口 +type Handler interface { + OnConnect(conn *Connection) + OnMessage(conn *Connection, message []byte) + OnClose(conn *Connection) + OnError(conn *Connection, err error) +} + +// HandlerFunc 处理函数类型 +type HandlerFunc func(conn *Connection, message []byte) + +// DefaultHandler 默认处理器 +type DefaultHandler struct { + OnConnectFunc func(conn *Connection) + OnMessageFunc func(conn *Connection, message []byte) + OnCloseFunc func(conn *Connection) + OnErrorFunc func(conn *Connection, err error) +} + +func (h *DefaultHandler) OnConnect(conn *Connection) { + if h.OnConnectFunc != nil { + h.OnConnectFunc(conn) + } +} + +func (h *DefaultHandler) OnMessage(conn *Connection, message []byte) { + if h.OnMessageFunc != nil { + h.OnMessageFunc(conn, message) + } +} + +func (h *DefaultHandler) OnClose(conn *Connection) { + if h.OnCloseFunc != nil { + h.OnCloseFunc(conn) + } +} + +func (h *DefaultHandler) OnError(conn *Connection, err error) { + if h.OnErrorFunc != nil { + h.OnErrorFunc(conn, err) + } +} + +// Upgrade 升级 HTTP 连接为 WebSocket +func Upgrade(c *gin.Context) (*Connection, error) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return nil, err + } + return NewConnection(conn), nil +} + +// Handle WebSocket 处理中间件 +func Handle(handler Handler) gin.HandlerFunc { + if handler == nil { + handler = &DefaultHandler{} + } + return func(c *gin.Context) { + conn, err := Upgrade(c) + if err != nil { + return + } + defer conn.Close() + + // 触发连接事件 + handler.OnConnect(conn) + + // 读循环前置:设置初始读 deadline 与 pong handler(C2c:防半开连接永久阻塞)。 + // 每收到 pong 重置读 deadline;超时未收到 pong 则 ReadMessage 返回错误退出。 + _ = conn.conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.conn.SetPongHandler(func(string) error { + _ = conn.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + // 启动写入协程 + go writePump(conn) + + // 读取消息 + for { + _, message, err := conn.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + handler.OnError(conn, err) + } + handler.OnClose(conn) + break + } + handler.OnMessage(conn, message) + } + } +} + +// writePump 写入泵。每次写前设置写超时,按 pingPeriod 周期发 ping(须 < pongWait)。 +// 写失败时主动 Close 连接,触发 closeChan 关闭 → 读循环 ReadMessage 因底层 conn 关闭 +// 返回错误而退出,避免半开连接残留最长 pongWait 才回收(C2c 残留优化)。 +func writePump(conn *Connection) { + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + + for { + select { + case <-conn.closeChan: + return + case message, ok := <-conn.send: + // send channel 不再被 Close(C2b 修复),ok 永远为 true; + // 此分支保留 ok 检查仅为兼容历史与防御。 + if !ok { + _ = conn.conn.WriteControl(websocket.CloseMessage, []byte{}, time.Now().Add(writeWait)) + conn.Close() + return + } + conn.mu.Lock() + _ = conn.conn.SetWriteDeadline(time.Now().Add(writeWait)) + err := conn.conn.WriteMessage(websocket.TextMessage, message) + conn.mu.Unlock() + if err != nil { + conn.Close() + return + } + case <-ticker.C: + conn.mu.Lock() + _ = conn.conn.SetWriteDeadline(time.Now().Add(writeWait)) + err := conn.conn.WriteMessage(websocket.PingMessage, nil) + conn.mu.Unlock() + if err != nil { + conn.Close() + return + } + } + } +} + +// HandleFunc 使用函数处理 WebSocket +func HandleFunc(fn HandlerFunc) gin.HandlerFunc { + return Handle(&DefaultHandler{ + OnMessageFunc: fn, + }) +} + +// SetCheckOrigin 设置 Origin 检查函数。传 nil 会恢复默认同源校验。 +// 运行期并发调用安全:upgrader 持有固定 wrapper,实际检查函数经 atomic.Value 切换。 +func SetCheckOrigin(fn func(r *http.Request) bool) { + if fn == nil { + fn = sameOriginCheck + } + checkOriginValue.Store(fn) +} + +var ( + // ErrHubNotRunning 在 Hub 未启动 Run 即调用 Try* 方法时返回。 + ErrHubNotRunning = errors.New("websocket hub not running") + // ErrHubStopped 在 Hub 已 Stop 后调用 Try* 方法时返回。 + ErrHubStopped = errors.New("websocket hub stopped") + // ErrHubQueueFull 在 register/unregister/broadcast channel 缓冲满时返回。 + ErrHubQueueFull = errors.New("websocket hub queue full") + // ErrNilConnection 在向 Hub 注册/注销 nil 连接时返回。 + ErrNilConnection = errors.New("websocket connection is nil") +) + +// Hub 连接管理中心(用于广播)。 +// W1 修复:添加 stop channel + Stop() 方法,支持优雅退出。 +type Hub struct { + connections map[*Connection]bool + register chan *Connection + unregister chan *Connection + broadcast chan []byte + mu sync.RWMutex + lifecycleMu sync.RWMutex + stop chan struct{} + stopOnce sync.Once // H-8: 保证 close(stop) 仅一次,并发 Stop 不 double-close panic + runOnce sync.Once // H-9: 保证 Run 仅执行一次 + runStarted atomic.Bool // H-9: 标记 Run 是否已启动,Stop 据此决定是否等待 + stopped atomic.Bool + runDone chan struct{} // H-9: Run 退出时 close,替代 WaitGroup 避免 Add/Wait 竞态 +} + +// NewHub 创建 Hub +func NewHub() *Hub { + return &Hub{ + connections: make(map[*Connection]bool), + register: make(chan *Connection, 256), + unregister: make(chan *Connection, 256), + broadcast: make(chan []byte, 256), + stop: make(chan struct{}), + runDone: make(chan struct{}), + } +} + +// Run 运行 Hub。消费 register/unregister/broadcast/stop 四个 channel。 +// +// W1 修复:新增 stop 退出分支,Stop() 方法 close(stop) 通知退出。 +// 死锁修复(C2a):广播分支不再向自身 unregister channel 回环。 +// +// H-9 修复:用 runOnce 守卫 Run 仅执行一次;用 runDone channel 替代 WaitGroup。 +// 原实现 wg.Add(1) 在 Run 内、wg.Wait() 在 Stop 内,二者并发时违反 WaitGroup +// "Add(正delta且计数器为0) 必须 happens-before Wait" 的约束,-race 必采。 +// 改为 Run 启动时 Store runStarted=true 并在退出时 close(runDone);Stop 仅在 +// runStarted 为 true 时 <-runDone 等待,未启动则直接返回,无竞态。 +func (h *Hub) Run() { + h.runOnce.Do(func() { + h.runStarted.Store(true) + defer close(h.runDone) + for { + select { + case <-h.stop: + h.drainPending() + h.closeAll() + return + + case conn := <-h.register: + if conn == nil { + continue + } + h.mu.Lock() + h.connections[conn] = true + h.mu.Unlock() + + case conn := <-h.unregister: + if conn == nil { + continue + } + h.mu.Lock() + if _, ok := h.connections[conn]; ok { + delete(h.connections, conn) + conn.Close() + } + h.mu.Unlock() + + case message := <-h.broadcast: + // 持写锁单次遍历,失败的连接行内移除并关闭,不回环 unregister channel(C2a 修复)。 + h.mu.Lock() + for conn := range h.connections { + if err := conn.Send(message); err != nil { + delete(h.connections, conn) + conn.Close() + } + } + h.mu.Unlock() + } + } + }) +} + +func (h *Hub) drainPending() { + for { + select { + case conn := <-h.register: + if conn != nil { + conn.Close() + } + case conn := <-h.unregister: + if conn != nil { + conn.Close() + } + case <-h.broadcast: + default: + return + } + } +} + +func (h *Hub) closeAll() { + h.mu.Lock() + defer h.mu.Unlock() + for conn := range h.connections { + delete(h.connections, conn) + conn.Close() + } +} + +// Stop 停止 Hub(W1 修复:优雅退出机制)。 +// close(stop) 通知 Run() 退出;若 Run 已启动则等待其完全结束(<-runDone)。 +// +// H-8 修复:用 stopOnce 保证 close(stop) 仅执行一次。原实现 select{<-stop/default:close(stop)} +// 在并发 Stop 时两个调用方都可能走 default 分支同时 close → double-close panic; +// 原注释声称的"stopped 标志保护"实际不存在(文档撒谎)。stopOnce 彻底消除该竞态。 +// +// H-9 修复:仅在 runStarted 为 true 时等待 runDone;Run 未启动时直接返回, +// 避免 WaitGroup Add/Wait 竞态,且 Stop 先于 Run 调用后误再调 Run 也不会 panic。 +// 幂等:重复/并发调用安全。 +// Stop discards pending broadcast messages instead of guaranteeing delivery; +// it is a shutdown boundary. Pending register/unregister entries are drained +// and all live connections are closed before Stop returns. +func (h *Hub) Stop() { + h.stopOnce.Do(func() { + h.lifecycleMu.Lock() + defer h.lifecycleMu.Unlock() + h.stopped.Store(true) + close(h.stop) + if !h.runStarted.Load() { + h.drainPending() + h.closeAll() + } + }) + if h.runStarted.Load() { + <-h.runDone + } +} + +func (h *Hub) enqueueRegister(conn *Connection, requireStarted bool) error { + if conn == nil { + return ErrNilConnection + } + h.lifecycleMu.RLock() + defer h.lifecycleMu.RUnlock() + if h.stopped.Load() { + conn.Close() + return ErrHubStopped + } + if requireStarted && !h.runStarted.Load() { + conn.Close() + return ErrHubNotRunning + } + select { + case h.register <- conn: + return nil + default: + conn.Close() + return ErrHubQueueFull + } +} + +// Register 注册连接。调用兼容旧 API:失败时关闭连接并直接返回。 +func (h *Hub) Register(conn *Connection) { + _ = h.enqueueRegister(conn, false) +} + +// TryRegister 注册连接并返回失败原因。Hub 未 Run、已 Stop 或队列满时立即返回错误。 +func (h *Hub) TryRegister(conn *Connection) error { + return h.enqueueRegister(conn, true) +} + +func (h *Hub) enqueueUnregister(conn *Connection, requireStarted bool) error { + if conn == nil { + return ErrNilConnection + } + h.lifecycleMu.RLock() + defer h.lifecycleMu.RUnlock() + if h.stopped.Load() { + return ErrHubStopped + } + if requireStarted && !h.runStarted.Load() { + return ErrHubNotRunning + } + select { + case h.unregister <- conn: + return nil + default: + return ErrHubQueueFull + } +} + +// Unregister 注销连接。调用兼容旧 API:失败时直接返回。 +func (h *Hub) Unregister(conn *Connection) { + _ = h.enqueueUnregister(conn, false) +} + +// TryUnregister 注销连接并返回失败原因。 +func (h *Hub) TryUnregister(conn *Connection) error { + return h.enqueueUnregister(conn, true) +} + +func (h *Hub) enqueueBroadcast(message []byte, requireStarted bool) error { + h.lifecycleMu.RLock() + defer h.lifecycleMu.RUnlock() + if h.stopped.Load() { + return ErrHubStopped + } + if requireStarted && !h.runStarted.Load() { + return ErrHubNotRunning + } + select { + case h.broadcast <- message: + return nil + default: + return ErrHubQueueFull + } +} + +// Broadcast 广播消息。调用兼容旧 API:失败时直接返回。 +func (h *Hub) Broadcast(message []byte) { + _ = h.enqueueBroadcast(message, false) +} + +// TryBroadcast 广播消息并返回失败原因。 +func (h *Hub) TryBroadcast(message []byte) error { + return h.enqueueBroadcast(message, true) +} + +// BroadcastJSON 广播 JSON 消息 +func (h *Hub) BroadcastJSON(v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + return h.TryBroadcast(data) +} + +// Count 获取连接数 +func (h *Hub) Count() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.connections) +} diff --git a/ws/ws_concurrency_test.go b/ws/ws_concurrency_test.go new file mode 100644 index 0000000..6502893 --- /dev/null +++ b/ws/ws_concurrency_test.go @@ -0,0 +1,414 @@ +package ws_test + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/ws" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// newWSTestServer 启动一个带 ws.Handle 的 httptest server,返回 server 与 dialer。 +// handler 可为 nil(用默认空处理器)。 +func newWSTestServer(t *testing.T, handler ws.Handler) (*httptest.Server, *websocket.Dialer) { + t.Helper() + r := gin.New() + if handler == nil { + handler = &ws.DefaultHandler{} + } + r.GET("/ws", ws.Handle(handler)) + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + dialer := &websocket.Dialer{HandshakeTimeout: 2 * time.Second} + return srv, dialer +} + +func wsURL(srv *httptest.Server) string { + return "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" +} + +// dial 连接并返回客户端 conn。 +func dial(t *testing.T, srv *httptest.Server, dialer *websocket.Dialer) *websocket.Conn { + t.Helper() + c, _, err := dialer.Dial(wsURL(srv), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { c.Close() }) + return c +} + +// ===== C2b:Close 与并发 Send 不 panic ===== + +// 回归 C2b:并发 Close 与 Send 不能 send-on-closed panic。 +// 旧实现 Close 同时 close(c.send),并发 Send 的 select 伪随机选中 send 分支即 panic。 +func TestConnectionCloseConcurrentSendNoPanic(t *testing.T) { + srv, dialer := newWSTestServer(t, nil) + client := dial(t, srv, dialer) + + // 服务端 conn 经 Handle 持有;通过 Hub 注册拿到服务端 Connection。 + hub := ws.NewHub() + go hub.Run() + t.Cleanup(func() { /* Hub.Run 无退出,靠进程结束 */ }) + + // 用 HandleFunc 包装拿到服务端 Connection。 + var srvConn *ws.Connection + var got atomic.Value // *ws.Connection + r := gin.New() + r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) { + got.Store(conn) + })) + srv2 := httptest.NewServer(r) + t.Cleanup(srv2.Close) + c2, _, err := dialer.Dial("ws"+strings.TrimPrefix(srv2.URL, "http")+"/ws", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c2.Close() + _ = client + + // 发一条消息触发服务端 OnMessage,拿到 srvConn。 + if err := c2.WriteMessage(websocket.TextMessage, []byte("hi")); err != nil { + t.Fatalf("write: %v", err) + } + // 等待服务端拿到连接。 + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if v := got.Load(); v != nil { + srvConn = v.(*ws.Connection) + break + } + time.Sleep(5 * time.Millisecond) + } + if srvConn == nil { + t.Fatal("timeout waiting for server-side Connection") + } + + // 并发:一个 goroutine 反复 Send,主 goroutine Close。 + var panicked atomic.Value + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + panicked.Store(r) + } + }() + for i := 0; i < 2000; i++ { + // Send 在 Close 后会返回 error,正常;不应 panic。 + _ = srvConn.Send([]byte("x")) + } + }() + + // 给 Send 一点启动时间再 Close,制造最大竞态窗口。 + time.Sleep(2 * time.Millisecond) + srvConn.Close() + wg.Wait() + + if p := panicked.Load(); p != nil { + t.Fatalf("concurrent Close/Send panicked (C2b send-on-closed): %v", p) + } +} + +// 回归 C2b:Close 后 Send 返回 error 而非 panic。 +func TestSendAfterCloseReturnsError(t *testing.T) { + srv, dialer := newWSTestServer(t, nil) + c := dial(t, srv, dialer) + defer c.Close() + + // 通过 HandleFunc 拿服务端 conn。 + var got atomic.Value + r := gin.New() + r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) { + got.Store(conn) + })) + srv2 := httptest.NewServer(r) + t.Cleanup(srv2.Close) + c2, _, err := dialer.Dial("ws"+strings.TrimPrefix(srv2.URL, "http")+"/ws", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c2.Close() + if err := c2.WriteMessage(websocket.TextMessage, []byte("hi")); err != nil { + t.Fatalf("write: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + var srvConn *ws.Connection + for time.Now().Before(deadline) { + if v := got.Load(); v != nil { + srvConn = v.(*ws.Connection) + break + } + time.Sleep(5 * time.Millisecond) + } + if srvConn == nil { + t.Fatal("timeout") + } + srvConn.Close() + // Close 后 Send 必须返回 error(非 panic)。 + if err := srvConn.Send([]byte("after-close")); err == nil { + t.Error("Send after Close should return error, got nil") + } +} + +// ===== C2a:Hub 广播不死锁 ===== + +// 回归 C2a:向含"已关闭"连接的 Hub 广播不能死锁,且失败连接被行内移除。 +// 旧实现:broadcast 中 conn.Send 失败 → h.unregister <- conn(向自己发,无消费者)→ 永久阻塞。 +// 这里直接 Close 服务端连接(closeChan 关闭),使 Send 走 closeChan 分支返回 error, +// 触发 Hub 行内 delete + Close。 +func TestHubBroadcastDeadConnectionNoDeadlock(t *testing.T) { + hub := ws.NewHub() + go hub.Run() + + var got atomic.Value + r := gin.New() + r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) { + got.Store(conn) + })) + srv2 := httptest.NewServer(r) + t.Cleanup(srv2.Close) + dialer := &websocket.Dialer{HandshakeTimeout: 2 * time.Second} + c2, _, err := dialer.Dial("ws"+strings.TrimPrefix(srv2.URL, "http")+"/ws", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c2.Close() + if err := c2.WriteMessage(websocket.TextMessage, []byte("hi")); err != nil { + t.Fatalf("write: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + var srvConn *ws.Connection + for time.Now().Before(deadline) { + if v := got.Load(); v != nil { + srvConn = v.(*ws.Connection) + break + } + time.Sleep(5 * time.Millisecond) + } + if srvConn == nil { + t.Fatal("timeout") + } + + hub.Register(srvConn) + deadline = time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if hub.Count() == 1 { + break + } + time.Sleep(5 * time.Millisecond) + } + if hub.Count() != 1 { + t.Fatalf("Hub Count = %d, want 1", hub.Count()) + } + + // 直接关闭服务端连接(closeChan 关闭),使后续 Send 返回 error。 + srvConn.Close() + // 给 closeChan 关闭传播一点时间。 + time.Sleep(20 * time.Millisecond) + + // 广播必须在超时内返回(不阻塞 Hub),且失败连接被行内移除。 + done := make(chan struct{}) + go func() { + hub.Broadcast([]byte("should-not-deadlock")) + close(done) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("Hub.Broadcast deadlocked (C2a: unregister self-send loop)") + } + // 等待 Hub 内部清理失败的连接。 + deadline = time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if hub.Count() == 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if hub.Count() != 0 { + t.Errorf("Hub Count = %d, want 0 (failed conn should be removed inline)", hub.Count()) + } +} + +// 回归 C2a/C2b:Hub 广播到正常连接,客户端能收到。 +func TestHubBroadcastReachesClients(t *testing.T) { + hub := ws.NewHub() + go hub.Run() + + // 用带 Hub 注册的 handler。 + handler := &ws.DefaultHandler{ + OnConnectFunc: func(conn *ws.Connection) { + hub.Register(conn) + }, + } + srv, dialer := newWSTestServer(t, handler) + + // 两个客户端。 + c1 := dial(t, srv, dialer) + c2 := dial(t, srv, dialer) + defer c1.Close() + defer c2.Close() + + // 等待两个连接注册。 + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && hub.Count() < 2 { + time.Sleep(10 * time.Millisecond) + } + if hub.Count() != 2 { + t.Fatalf("Hub Count = %d, want 2", hub.Count()) + } + + // 广播 JSON。 + if err := hub.BroadcastJSON(map[string]string{"msg": "hello"}); err != nil { + t.Fatalf("BroadcastJSON: %v", err) + } + + // 两个客户端都应收到。 + for i, c := range []*websocket.Conn{c1, c2} { + c.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, msg, err := c.ReadMessage() + if err != nil { + t.Fatalf("client %d read: %v", i, err) + } + var m map[string]string + if err := json.Unmarshal(msg, &m); err != nil { + t.Fatalf("client %d unmarshal %q: %v", i, string(msg), err) + } + if m["msg"] != "hello" { + t.Errorf("client %d got %q, want hello", i, m["msg"]) + } + } +} + +// 回归 C2a-residual:Send 非阻塞——缓冲满立即返回 ErrSendBufferFull 而非阻塞等待。 +// 旧实现(阻塞 select)在 writePump 退出但 closeChan 未关、send 缓冲满时会阻塞最长 pongWait, +// 导致 Hub 广播持写锁期间 stall。非阻塞投递保证持锁期间永不阻塞。 +// 用 internal test(ws_send_internal_test.go)直接测私有 send channel,此处仅验证公开行为: +// 连接 Close 后,Send 立即返回(不阻塞)。 +func TestSendNonBlockingOnClosed(t *testing.T) { + var got atomic.Value + r := gin.New() + r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) { + got.Store(conn) + })) + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + dialer := &websocket.Dialer{HandshakeTimeout: 2 * time.Second} + c, _, err := dialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + if err := c.WriteMessage(websocket.TextMessage, []byte("hi")); err != nil { + t.Fatalf("write: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + var srvConn *ws.Connection + for time.Now().Before(deadline) { + if v := got.Load(); v != nil { + srvConn = v.(*ws.Connection) + break + } + time.Sleep(5 * time.Millisecond) + } + if srvConn == nil { + t.Fatal("timeout") + } + srvConn.Close() + + // Close 后连续 Send 必须立即返回 error,不阻塞。 + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 1000; i++ { + _ = srvConn.Send([]byte("x")) + } + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("Send after Close blocked (should be non-blocking)") + } +} + +// ===== C2c:半开连接超时退出(不永久泄漏 goroutine)===== + +// 回归 C2c:客户端断开后,服务端读循环应在 pongWait 超时内退出(非永久阻塞)。 +// 用短超时配置不便(常量包级),此处用真实连接断开 + 有限等待验证 OnClose 被调用。 +func TestHalfOpenConnectionExitsOnClose(t *testing.T) { + var closed atomic.Int32 + handler := &ws.DefaultHandler{ + OnCloseFunc: func(conn *ws.Connection) { + closed.Add(1) + }, + } + srv, dialer := newWSTestServer(t, handler) + c := dial(t, srv, dialer) + + // 客户端主动关闭(发 close 帧)。 + c.Close() + + // 服务端应在有限时间内 OnClose。pongWait=60s 太长,但客户端发的是正常 close, + // ReadMessage 立即返回 close 错误,OnClose 应很快触发。 + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if closed.Load() > 0 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Errorf("OnClose not called within timeout (C2c: read loop should exit on client close), closed=%d", closed.Load()) +} + +// ===== 辅助:验证 errors 导入被使用 ===== +var _ = errors.New +var _ = http.StatusOK + +// TestHubStopConcurrentNoPanic H-8 回归:并发 Stop 不应 double-close panic。 +// 修复前 Stop 用 select{<-stop/default:close(stop)},两个 goroutine 都走 default +// 同时 close → panic。修复后 stopOnce 保证 close 仅一次。 +func TestHubStopConcurrentNoPanic(t *testing.T) { + hub := ws.NewHub() + go hub.Run() + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + hub.Stop() // 并发 Stop + }() + } + wg.Wait() + + // 再调一次 Stop(已 stop)也应安全返回 + hub.Stop() +} + +// TestHubStopBeforeRunNoPanic H-9 回归:Stop 先于 Run 调用,随后 Run 不应 +// 触发 wg 负计数 panic。 +func TestHubStopBeforeRunNoPanic(t *testing.T) { + hub := ws.NewHub() + hub.Stop() // Run 尚未启动,Wait 立即返回 + + // 之后启动 Run(实际场景是误用),应安全退出而非 panic + go hub.Run() + // 给 Run 一点时间观察到 stop 已 close 并退出 + time.Sleep(50 * time.Millisecond) + // 再次 Stop 确保 wg 归零(Run 的 runOnce 已执行,wg.Add/Done 配对) + hub.Stop() +} diff --git a/ws/ws_m11_internal_test.go b/ws/ws_m11_internal_test.go new file mode 100644 index 0000000..3726b12 --- /dev/null +++ b/ws/ws_m11_internal_test.go @@ -0,0 +1,199 @@ +package ws + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func TestM11SetCheckOriginConcurrentWithUpgradeNoRace(t *testing.T) { + SetCheckOrigin(nil) + t.Cleanup(func() { SetCheckOrigin(nil) }) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/ws", Handle(nil)) + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + done := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + allowA := func(*http.Request) bool { return true } + allowB := func(*http.Request) bool { return true } + for { + select { + case <-done: + return + default: + SetCheckOrigin(allowA) + SetCheckOrigin(allowB) + } + } + }() + + dialer := &websocket.Dialer{HandshakeTimeout: 2 * time.Second} + url := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + for i := 0; i < 50; i++ { + conn, _, err := dialer.Dial(url, nil) + if err != nil { + t.Fatalf("dial[%d]: %v", i, err) + } + _ = conn.Close() + } + close(done) + wg.Wait() +} + +func TestM11HandleNilUsesDefaultHandler(t *testing.T) { + SetCheckOrigin(func(*http.Request) bool { return true }) + t.Cleanup(func() { SetCheckOrigin(nil) }) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/ws", Handle(nil)) + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", nil) + if err != nil { + t.Fatalf("dial Handle(nil): %v", err) + } + _ = conn.Close() +} + +func TestM11HubNotRunFastFail(t *testing.T) { + hub := NewHub() + conn := NewConnection(nil) + + if err := hub.TryRegister(conn); !errors.Is(err, ErrHubNotRunning) { + t.Fatalf("TryRegister before Run error = %v, want ErrHubNotRunning", err) + } + if !conn.IsClosed() { + t.Fatal("TryRegister before Run should close rejected connection") + } + if err := hub.TryUnregister(NewConnection(nil)); !errors.Is(err, ErrHubNotRunning) { + t.Fatalf("TryUnregister before Run error = %v, want ErrHubNotRunning", err) + } + if err := hub.TryBroadcast([]byte("x")); !errors.Is(err, ErrHubNotRunning) { + t.Fatalf("TryBroadcast before Run error = %v, want ErrHubNotRunning", err) + } + if err := hub.BroadcastJSON(map[string]string{"x": "y"}); !errors.Is(err, ErrHubNotRunning) { + t.Fatalf("BroadcastJSON before Run error = %v, want ErrHubNotRunning", err) + } +} + +func TestM11PublicRegisterToleratesRunStartupWindow(t *testing.T) { + hub := NewHub() + conn := NewConnection(nil) + + hub.Register(conn) + go hub.Run() + t.Cleanup(hub.Stop) + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if hub.Count() == 1 { + if conn.IsClosed() { + t.Fatal("public Register should not close connection during Run startup window") + } + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("Hub Count = %d, want 1", hub.Count()) +} + +func TestM11HubStopDrainsAndCloses(t *testing.T) { + hub := NewHub() + go hub.Run() + for !hub.runStarted.Load() { + time.Sleep(time.Millisecond) + } + + conn := NewConnection(nil) + hub.register <- conn + hub.broadcast <- []byte("discarded") + hub.Stop() + + if !conn.IsClosed() { + t.Fatal("Stop should close pending registered connection") + } + if got := hub.Count(); got != 0 { + t.Fatalf("Hub Count after Stop = %d, want 0", got) + } + if got := len(hub.register); got != 0 { + t.Fatalf("register queue length after Stop = %d, want 0", got) + } + if got := len(hub.broadcast); got != 0 { + t.Fatalf("broadcast queue length after Stop = %d, want 0", got) + } + if err := hub.TryRegister(NewConnection(nil)); !errors.Is(err, ErrHubStopped) { + t.Fatalf("TryRegister after Stop error = %v, want ErrHubStopped", err) + } + if err := hub.TryBroadcast([]byte("x")); !errors.Is(err, ErrHubStopped) { + t.Fatalf("TryBroadcast after Stop error = %v, want ErrHubStopped", err) + } +} + +func TestM11HubStopConcurrentTryRegisterClosesAll(t *testing.T) { + hub := NewHub() + go hub.Run() + for !hub.runStarted.Load() { + time.Sleep(time.Millisecond) + } + + var connsMu sync.Mutex + var conns []*Connection + var wg sync.WaitGroup + for i := 0; i < 200; i++ { + wg.Add(1) + go func() { + defer wg.Done() + conn := NewConnection(nil) + connsMu.Lock() + conns = append(conns, conn) + connsMu.Unlock() + _ = hub.TryRegister(conn) + }() + } + time.Sleep(time.Millisecond) + hub.Stop() + wg.Wait() + + connsMu.Lock() + defer connsMu.Unlock() + for i, conn := range conns { + if !conn.IsClosed() { + t.Fatalf("connection %d left open after concurrent Stop/TryRegister", i) + } + } + if got := hub.Count(); got != 0 { + t.Fatalf("Hub Count after concurrent Stop/TryRegister = %d, want 0", got) + } + if got := len(hub.register); got != 0 { + t.Fatalf("register queue length after concurrent Stop/TryRegister = %d, want 0", got) + } +} + +func TestM11HubNilGuards(t *testing.T) { + hub := NewHub() + go hub.Run() + t.Cleanup(hub.Stop) + + if err := hub.TryRegister(nil); !errors.Is(err, ErrNilConnection) { + t.Fatalf("TryRegister(nil) error = %v, want ErrNilConnection", err) + } + if err := hub.TryUnregister(nil); !errors.Is(err, ErrNilConnection) { + t.Fatalf("TryUnregister(nil) error = %v, want ErrNilConnection", err) + } +} diff --git a/ws/ws_origin_internal_test.go b/ws/ws_origin_internal_test.go new file mode 100644 index 0000000..bea5cb1 --- /dev/null +++ b/ws/ws_origin_internal_test.go @@ -0,0 +1,77 @@ +package ws + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" +) + +// TestSameOriginCheck_C7 验证默认 CheckOrigin 同源校验(防 CSWSH): +// 空 Origin 放行(非浏览器)、同源放行、跨域拒绝。 +func TestSameOriginCheck_C7(t *testing.T) { + cases := []struct { + name string + origin string + host string + want bool + }{ + {"empty origin (non-browser)", "", "example.com", true}, + {"same origin", "http://example.com", "example.com", true}, + {"scheme mismatch", "https://example.com", "example.com", false}, + {"cross origin", "https://evil.com", "example.com", false}, + {"origin with port same", "http://example.com:8080", "example.com:8080", true}, + {"origin with port diff", "http://example.com:8080", "example.com:9090", false}, + {"malformed origin", "://bad", "example.com", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + req.Host = tc.host + got := sameOriginCheck(req) + if got != tc.want { + t.Errorf("sameOriginCheck origin=%q host=%q = %v, want %v", tc.origin, tc.host, got, tc.want) + } + }) + } +} + +func TestSameOriginCheckHTTPS_C7(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://example.com/ws", nil) + req.TLS = &tls.ConnectionState{} + req.Header.Set("Origin", "https://example.com") + if !sameOriginCheck(req) { + t.Fatal("sameOriginCheck should allow matching https origin for TLS request") + } + req.Header.Set("Origin", "http://example.com") + if sameOriginCheck(req) { + t.Fatal("sameOriginCheck should reject origin with mismatched scheme") + } +} + +// TestAllowOrigins_C7:AllowOrigins 仅放行白名单 Origin。 +func TestAllowOrigins_C7(t *testing.T) { + check := AllowOrigins("https://a.com", "https://b.com") + cases := []struct { + origin string + want bool + }{ + {"", true}, // 非浏览器放行 + {"https://a.com", true}, // 白名单 + {"https://b.com", true}, // 白名单 + {"https://evil.com", false}, + {"http://a.com", false}, // scheme 不符 + } + for _, tc := range cases { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + if got := check(req); got != tc.want { + t.Errorf("AllowOrigins origin=%q = %v, want %v", tc.origin, got, tc.want) + } + } +} diff --git a/ws/ws_send_internal_test.go b/ws/ws_send_internal_test.go new file mode 100644 index 0000000..084a440 --- /dev/null +++ b/ws/ws_send_internal_test.go @@ -0,0 +1,86 @@ +package ws + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// 回归 C2a-residual:Send 非阻塞——send 缓冲满后立即返回 ErrSendBufferFull, +// 不阻塞调用方。旧实现(阻塞 select + 缓冲满)会阻塞,Hub 广播持写锁期间 stall。 +// +// 用 internal test 直接构造 Connection(绕过 Handle),不启动 writePump 消费 send, +// 填满缓冲后 Send 必须立即返回 ErrSendBufferFull。 +func TestSendNonBlockingBufferFullInternal(t *testing.T) { + // 建一对真实 websocket conn 作为底层。 + var srvConn *websocket.Conn + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + c, err := u.Upgrade(w, r, nil) + if err != nil { + return + } + mu.Lock() + srvConn = c + mu.Unlock() + // 阻塞读保持连接存活。 + for { + if _, _, err := c.ReadMessage(); err != nil { + return + } + } + })) + defer srv.Close() + dialer := &websocket.Dialer{HandshakeTimeout: 2 * time.Second} + client, _, err := dialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer client.Close() + + // 等待服务端 conn 就绪。 + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + if srvConn != nil { + mu.Unlock() + break + } + mu.Unlock() + time.Sleep(5 * time.Millisecond) + } + if srvConn == nil { + t.Fatal("server-side conn not ready") + } + defer srvConn.Close() + + conn := NewConnection(srvConn) + defer conn.Close() + + // 不启动 writePump,send 无消费者。填满缓冲(256)。 + for i := 0; i < 256; i++ { + if err := conn.Send([]byte("x")); err != nil { + t.Fatalf("filling buffer[%d] err = %v", i, err) + } + } + // 第 257 个必须立即返回 ErrSendBufferFull(非阻塞)。 + done := make(chan error, 1) + go func() { + done <- conn.Send([]byte("overflow")) + }() + select { + case err := <-done: + if !errors.Is(err, ErrSendBufferFull) { + t.Errorf("overflow Send err = %v, want ErrSendBufferFull", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Send on full buffer blocked (should be non-blocking)") + } +} diff --git a/ws/ws_test.go b/ws/ws_test.go new file mode 100644 index 0000000..32ba196 --- /dev/null +++ b/ws/ws_test.go @@ -0,0 +1,135 @@ +package ws_test + +import ( + "errors" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/ws" +) + +func TestMessageType(t *testing.T) { + if ws.TypeText != "text" { + t.Errorf("TypeText = %s, want text", ws.TypeText) + } + if ws.TypeBinary != "binary" { + t.Errorf("TypeBinary = %s, want binary", ws.TypeBinary) + } + if ws.TypePing != "ping" { + t.Errorf("TypePing = %s, want ping", ws.TypePing) + } + if ws.TypePong != "pong" { + t.Errorf("TypePong = %s, want pong", ws.TypePong) + } + if ws.TypeClose != "close" { + t.Errorf("TypeClose = %s, want close", ws.TypeClose) + } +} + +func TestMessage(t *testing.T) { + msg := ws.Message{ + Type: ws.TypeText, + Content: "hello world", + } + + if msg.Type != ws.TypeText { + t.Error("Message Type failed") + } + if msg.Content != "hello world" { + t.Error("Message Content failed") + } +} + +func TestNewConnection(t *testing.T) { + // NewConnection 需要 websocket.Conn,无法直接测试 + // 仅验证结构体定义存在 +} + +func TestNewHub(t *testing.T) { + hub := ws.NewHub() + if hub == nil { + t.Error("NewHub should not return nil") + } +} + +func TestHubCount(t *testing.T) { + hub := ws.NewHub() + + // 无连接时计数应为 0 + count := hub.Count() + if count != 0 { + t.Errorf("Hub Count = %d, want 0", count) + } +} + +func TestHubBroadcast(t *testing.T) { + hub := ws.NewHub() + + // 广播消息(无连接时也应正常) + if err := hub.TryBroadcast([]byte("test message")); err == nil { + t.Error("TryBroadcast on a hub that has not Run should fail fast") + } +} + +func TestHubBroadcastJSON(t *testing.T) { + hub := ws.NewHub() + go hub.Run() + t.Cleanup(hub.Stop) + + deadline := time.Now().Add(time.Second) + for { + err := hub.BroadcastJSON(map[string]string{"key": "value"}) + if err == nil { + return + } + if !errors.Is(err, ws.ErrHubNotRunning) || time.Now().After(deadline) { + t.Errorf("BroadcastJSON error: %v", err) + return + } + time.Sleep(time.Millisecond) + } +} + +func TestDefaultHandler(t *testing.T) { + handler := ws.DefaultHandler{} + + // 测试空处理器不会 panic + handler.OnConnect(nil) + handler.OnMessage(nil, nil) + handler.OnClose(nil) + handler.OnError(nil, nil) +} + +func TestDefaultHandlerWithFuncs(t *testing.T) { + connectCalled := false + messageCalled := false + + handler := ws.DefaultHandler{ + OnConnectFunc: func(conn *ws.Connection) { + connectCalled = true + }, + OnMessageFunc: func(conn *ws.Connection, message []byte) { + messageCalled = true + }, + } + + handler.OnConnect(nil) + handler.OnMessage(nil, nil) + + if !connectCalled { + t.Error("OnConnectFunc should be called") + } + if !messageCalled { + t.Error("OnMessageFunc should be called") + } +} + +func TestHandlerFunc(t *testing.T) { + var handlerFunc ws.HandlerFunc = func(conn *ws.Connection, message []byte) { + // 处理消息 + } + + if handlerFunc == nil { + t.Error("HandlerFunc should not be nil") + } +}