From 0f13292a46a310f2faac046296e402f2979233d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=AD=E5=B7=9E=E6=98=8E=E5=A9=B3=E7=A7=91=E6=8A=80?= Date: Thu, 2 Jul 2026 18:26:27 +0800 Subject: [PATCH] =?UTF-8?q?GLM=205.2=E4=BF=AE=E6=94=B9=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 298 +++++ DELIVERY_REPORT_v1.1.1.md | 205 +++ GUIDE.md | 76 +- app.go | 3 + app_test.go | 36 + cache/cache.go | 44 +- cache/keybuilder.go | 35 +- cache/keybuilder_m13_internal_test.go | 31 + cache/lock.go | 107 +- cache/lock_concurrency_test.go | 302 +++++ cmd/xlgo/commands.go | 26 +- cmd/xlgo/utils.go | 6 + compress/compress.go | 317 ++++- compress/compress_security_test.go | 347 +++++ config/config.go | 242 +++- config/config_c10_test.go | 326 +++++ config/config_test.go | 45 +- console/console_windows.go | 56 +- cron/cron.go | 346 +++-- cron/cron_c12_internal_test.go | 190 +++ cron/cron_c12_test.go | 312 +++++ database/dialect.go | 7 +- database/manager.go | 258 ++-- database/manager_c11_internal_test.go | 243 ++++ go.mod | 12 + go.sum | 25 + handler/handler.go | 32 +- handler/handler_test.go | 135 +- jwt/jwt.go | 77 +- jwt/jwt_c9c_internal_test.go | 142 ++ jwt/jwt_test.go | 145 +- logger/field.go | 25 +- logger/logger.go | 151 ++- logger/logger_h7_internal_test.go | 259 ++++ middleware/cors.go | 94 +- middleware/cors_internal_test.go | 94 ++ middleware/csrf.go | 57 +- middleware/csrf_internal_test.go | 46 + middleware/logger.go | 64 +- middleware/logger_internal_test.go | 235 ++++ middleware/middleware_test.go | 878 +++++++++++- middleware/ratelimit.go | 263 +++- middleware/recover.go | 17 +- middleware/requestid.go | 29 +- middleware/timeout.go | 5 + model/base.go | 9 +- repository/repository.go | 319 +++-- repository/repository_h6_internal_test.go | 491 +++++++ repository/repository_test.go | 67 +- response/error.go | 10 +- response/mode.go | 2 +- response/mode_test.go | 13 + response/response.go | 36 +- response/response_test.go | 23 + router/metrics.go | 15 +- router/router.go | 151 ++- router/router_h8_internal_test.go | 241 ++++ sse/sse.go | 124 +- sse/sse_concurrency_test.go | 77 ++ sse/sse_stream_internal_test.go | 34 + storage/storage.go | 377 +++++- storage/storage_path_internal_test.go | 66 + storage/storage_security_test.go | 272 ++++ test/test.go | 36 +- trace/trace.go | 161 ++- trace/trace_test.go | 392 ++++++ utils/crypto.go | 16 +- utils/datetime.go | 16 +- utils/file.go | 7 + utils/http.go | 58 +- utils/http_test.go | 114 ++ utils/random.go | 158 ++- utils/strings.go | 7 +- utils/url.go | 5 +- utils/utils_test.go | 253 +++- v1.1.1_fix_progress.md | 1486 +++++++++++++++++++++ v_1.1.1_fix.md | 260 ++++ validation/validation_test.go | 29 + validation/validator.go | 40 +- version_1.1.1_report.md | 366 +++++ ws/ws.go | 127 +- ws/ws_concurrency_test.go | 379 ++++++ ws/ws_origin_internal_test.go | 62 + ws/ws_send_internal_test.go | 86 ++ 84 files changed, 11910 insertions(+), 1118 deletions(-) create mode 100644 DELIVERY_REPORT_v1.1.1.md create mode 100644 cache/keybuilder_m13_internal_test.go create mode 100644 cache/lock_concurrency_test.go create mode 100644 compress/compress_security_test.go create mode 100644 config/config_c10_test.go create mode 100644 cron/cron_c12_internal_test.go create mode 100644 cron/cron_c12_test.go create mode 100644 database/manager_c11_internal_test.go create mode 100644 jwt/jwt_c9c_internal_test.go create mode 100644 logger/logger_h7_internal_test.go create mode 100644 middleware/cors_internal_test.go create mode 100644 middleware/csrf_internal_test.go create mode 100644 middleware/logger_internal_test.go create mode 100644 repository/repository_h6_internal_test.go create mode 100644 router/router_h8_internal_test.go create mode 100644 sse/sse_concurrency_test.go create mode 100644 sse/sse_stream_internal_test.go create mode 100644 storage/storage_path_internal_test.go create mode 100644 storage/storage_security_test.go create mode 100644 trace/trace_test.go create mode 100644 utils/http_test.go create mode 100644 v1.1.1_fix_progress.md create mode 100644 v_1.1.1_fix.md create mode 100644 version_1.1.1_report.md create mode 100644 ws/ws_concurrency_test.go create mode 100644 ws/ws_origin_internal_test.go create mode 100644 ws/ws_send_internal_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d63eda0..8d5a55c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,304 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange --- +## [Unreleased] + +> 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)` 严格校验;`ParseCron` 保留原签名,非法回退默认全 `*`。 +- 无 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 过时/错误描述。 diff --git a/DELIVERY_REPORT_v1.1.1.md b/DELIVERY_REPORT_v1.1.1.md new file mode 100644 index 0000000..8f86114 --- /dev/null +++ b/DELIVERY_REPORT_v1.1.1.md @@ -0,0 +1,205 @@ +# xlgo v1.1.1 缺陷修复 · 最终交付报告 + +> 交付日期:2026-06-30 +> 修复依据:`version_1.1.1_report.md`(13 CRITICAL + 8 HIGH 权威清单)+ `v_1.1.1_fix.md`(逐条源码复核) +> 执行范围:P0 → P1 → P2 → P3 全量条目 +> 本机约束:H:\worker 有 svn 干扰,`go build/test` 需 `-buildvcs=false`;gcc/cgo 已安装,`-race` 可运行;staticcheck 因 go1.24/go1.25 版本不匹配不可用(显式跳过)。 + +--- + +## 一、总体结论 + +`version_1.1.1_report.md` 全量条目处理完毕,可交付。 + +| 等级 | 数量 | 状态 | +|------|------|------| +| CRITICAL | 13 | ✅ 全部修复闭环 | +| HIGH | 8 | ✅ 全部修复闭环 | +| P2 收尾(C3 生产者契约 / M14 文档) | 2 | ✅ 完成 | +| P3 MEDIUM | 20 | ✅ 全部处理 | +| P3 MINOR | 7 | ✅ 全部处理 | +| H8 独立复审追加 LOW | 4 | ✅ 已文档化(既有设计约束,不阻断) | + +**验证基线**:`go build -buildvcs=false ./...` 通过;`go test -race -buildvcs=false ./...` 全量通过;`go vet ./...` 通过;`gosec` 改动包 0 新增 issue(G704/G115 为既有误报,非本次引入)。 + +**交付前独立对抗性复审**:H8 由未参与编码的独立 agent 以源码 `file:line` 为证据 + 变异证伪实验复核,裁定 PASS(详见 `v1.1.1_fix_progress.md` H8 章节)。其余各批按风险分级处理,触及并发/安全/全局状态的均配 `-race` + 闭环用例。 + +--- + +## 二、修复清单(按报告条目) + +### CRITICAL(13 项) + +| 条目 | 文件 | 核心修复 | +|------|------|---------| +| C1 | `cache/lock.go` | 续期改"父关停 + 子 ack"双 channel(`stop`/`finished`),仅父方 `close(stop)`;`Unlock` 用 `context.Background()` 派生超时,防原 ctx 已取消致解锁失败再泄漏;TryLock 用 `select ctx.Done`;类型断言改 comma-ok。 | +| C2 | `ws/ws.go` | Hub 广播分支改持写锁单次遍历 + 行内 `delete`/`Close`,去 channel 回环死锁;`Close` 仅 `close(closeChan)` 不 `close(send)`,消除 send-on-closed panic;读循环前置 `SetReadDeadline`+`SetPongHandler`,写前 `SetWriteDeadline`,ping < pongWait。 | +| C3 | `sse/sse.go` | `WriteEvent`/`WriteMessage` 传播 `fmt.Fprintf` 写错误;Stream 系列改 `for { select { case <-ctx.Done(); case v,ok:=<-ch } }`;删 chunked 手设头;包文档化断连契约(生产者须监听 ctx)。 | +| C4 | `storage/storage.go` | 新增 `resolve(rel)` 前缀锚定校验,`Delete`/`Get`/`Exists`/`Upload` 统一经过;上传前查 `file.Size` 上限 + 扩展名白名单 + `http.DetectContentType` 嗅探;`Get` 流式 / `LimitReader` 封顶。 | +| C5 | `compress/compress.go` | `unzipFile` `filepath.Clean` + 前缀锚定,拒 `..` + 拒符号链接条目;`io.CopyN` 单条目封顶 + 累计上限;`GzipDecompress` 用 `LimitReader`;写侧 defer Close 改显式关闭传播错误。 | +| C6 | `middleware/csrf.go` | 删 `CSRFForAPI` 内局部 `tokens`/`mu` 声明,绑定包级;改单次消费 + TTL;`DoubleSubmitCookie` cookie 改 `HttpOnly=false`。 | +| C7 | `middleware/cors.go` | `*.` 通配改用 `net/url` 解析 host,要求真实子域边界;开发态兜底限 localhost 列表,不回显任意 Origin,不与 credentials 并存。 | +| C8 | `middleware/recover.go` | 用 `response.Custom` 显式写 500 + `c.Abort()`,去事后 `AbortWithStatus`(已 Written 无效)。 | +| C9 | `jwt/jwt.go` | `RefreshToken` 对 `Add` 错误 fail-closed;无 Redis 时 `Add` 返 `ErrBlacklistUnavailable`;包级 `DefaultJWT`/`tokenBlacklist` 改 `atomic.Pointer`。 | +| C10 | `config/config.go` | `defaultManager` 改 `atomic.Pointer[Manager]`;`Reload`/`OnConfigChange` 补 `Validate()`,非法保留旧配置;`Load` 返防御性拷贝;自管 `fsnotify.Watcher` 使 `StopWatcher` 真正释放。 | +| C11 | `database/manager.go` | `InitDB` 重试覆盖前关旧池;`InitDBWithReplicas` 重建前关旧主/从 + 重置健康状态;`Master`/`Replicas`/`Replica` 全程加锁;包级 `Close` 委托 `CloseAll()`。 | +| C12 | `cron/cron.go` | `runTask` 计数写入纳入锁 + Getter 返回拷贝;per-task `running atomic.Bool`;Interval 锚定上次 `NextRun`;Weekly `((day-now)+7)%7`;cron 解析改 `strconv.Atoi` + 字段范围校验 + 周日 `7→0` + 列表分支独立。 | +| C13 | `trace/trace.go` | `getTracer()` 懒初始化默认 tracer;实现 stdout 导出器 + `default` 返错;`Config` 增 `Insecure`;Middleware 补 `c.Request = c.Request.WithContext(ctx)`;接入 b3/jaeger propagator。 | + +### HIGH(8 项) + +| 条目 | 文件 | 核心修复 | +|------|------|---------| +| H1 | `utils/random.go` | 删 `RandString`/`RandDigit`(math/rand 诱导误用);新增 `RandStringSecure`/`RandDigitSecure`/`RandIntSecure`/`RandInt64Secure`(crypto/rand)。 | +| H2 | `utils/http.go` | `DefaultHTTPClientConfig.SkipTLSVerify` 默认 `false`;自签证书需显式 `SetSkipTLS(true)`。 | +| H3 | `middleware/logger.go` | 请求/响应 body 读取源头 `io.LimitReader`/`MaxBytesReader` 封顶,下游仍得完整 body。 | +| H4 | `middleware/ratelimit.go` | H4a 放行不更新 `lastSeen`(真正固定窗口);H4b `CustomRateLimit` 登记入全局表,`StopRateLimiters` 统一停止;H4c Redis 断言 comma-ok + fail-closed 可配置。 | +| H5 | `handler/handler.go` | `BadRequest`/`InternalError` 委托 `response.FailWithCode`/`ServerError`,遵循 Mode + 写 RequestID,不再硬编 HTTP 状态码。 | +| H6 | `repository/repository.go` | 新增 `readConn`/`writeConn` 接入 `GetDBFromContext`(读写分离 + 事务 join via `r.tx` + `database.WithTx`/`TxFromContext`);`FindPage*` count+list 包单事务;`UpdateFields`(局部更新);QueryBuilder 终结方法克隆。 | +| H7 | `logger/logger.go` | `loggerPtr`/`sugarPtr`/`apiLogPtr`/`dbLogPtr` 改 `atomic.Pointer`,读侧原子 load;`Field.Duration` 签名改 `func(string, time.Duration) zap.Field`。 | +| H8 | `router/router.go`/`metrics.go`/`handler/handler.go`/`app.go` | `globalRegistry` 改 `atomic.Pointer` + `ensureRegistry` 守卫;`Apply` 幂等位;metrics 经 `SetMetricsMiddleware` 在 Apply 内作首个全局中间件(去调用顺序依赖);`/health` 收敛单一 `healthHandler` + `handler.HealthCheck` schema 对齐;框架路由注册幂等(`registerGETOnce`)消除 defaultModule 重复路由 footgun。 | + +### P3 第一批:安全 + 明显 bug(9 项) + +| 条目 | 文件 | 核心修复 | +|------|------|---------| +| M15 | `middleware/requestid.go` | `sanitizeRequestID`:仅接受可见 ASCII、无 CRLF、≤128,防头注入/日志伪造。 | +| C7/N7 | `ws/ws.go` | 默认 `CheckOrigin` 改同源校验(防 CSWSH);新增 `AllowOrigins`。**Breaking**:默认拒绝跨域 WS。 | +| C5/N5 | `utils/http.go` | `Upload` 循环内 defer 改显式关闭(FD 累积);`do` 的 `ReadAll` 加 `LimitReader`(默认 32MB,可配 `MaxResponseBodySize`)。 | +| M16/B18 | `compress/compress.go` | `GzipCompressFile`/`Zip` defer Close 改显式关闭传播错误。 | +| M6 | `response/response.go` | `Content-Disposition` 改 RFC 5987(ASCII 回退 + `filename*=UTF-8''`),中文不乱码。 | +| M8 | `response/mode.go` | `CodeDataAlreadyExists` ModeREST 下映射 409。 | +| M10 | `database/dialect.go` | 未注册驱动回退 MySQL 时 `logger.Warnf` 告警。 | +| M2 | `utils/url.go` | `AddQueries` 改 `query.Add`(追加,原 Set 覆盖)。 | +| M3 | `utils/file.go` | 包级文档警告:工具函数不做穿越校验,不可信输入须调用方净化。 | + +### P3 第二批:文档/正确性/校验(11 项) + +| 条目 | 文件 | 核心修复 | +|------|------|---------| +| M4 | `utils/datetime.go` | `StartOfWeek` 改按日历日计算(DST 不落错日);`ParseDateInt` 补规范化警告。 | +| M11 | `database/manager.go` | 包级 `HealthCheck()` ping 加 3s 超时;`WriteQuery` 补命名误导注释。 | +| M9 | `config/config.go` | MySQL/Postgres DSN 密码转义;新增 `DatabaseConfig.Timezone`(空值保持原默认)。 | +| M7 | `response/error.go` | `Error.ToResponse()` 把 `Detail` 放入 `data.detail`。 | +| M14 | `middleware/timeout.go` | 注释明确软超时语义(不主动中断、纯 CPU handler 不生效)。 | +| C3 收尾 | `sse/sse.go` | 包文档化断连契约(生产者须监听 ctx)。 | +| M20 | `cmd/xlgo/commands.go`/`utils.go` | `make handler my-thing` → `MyThingHandler`(`sanitizeIdent`);`fileExists` 注释澄清。 | +| N1 | `model/base.go` | `BaseModelWithTime` 命名误导注释。 | +| N4 | `utils/crypto.go`/`strings.go` | `Nl2br` 清理恒假分支;`IsEmpty` 文档修正为实际支持类型。 | +| N6 | `sse/sse.go` | `KeepAlive` 改 SSE 注释行 `: ping`(不触发 onmessage)。 | + +### P3 第三批:校验收紧/全局并发/Windows/trace/logger/测试质量(8 项) + +| 条目 | 文件 | 核心修复 | +|------|------|---------| +| M5 | `validation/validator.go` | 18 位身份证补 GB 11643-1999 校验位;`username` 首字符按 `[]rune` 取。 | +| M13 | `cache/keybuilder.go` | `globalKeyBuilder` 加 `sync.RWMutex` + `GetKeyBuilder` 用 `sync.Once`。 | +| M12 | `cache/cache.go` | `redisCache` 改每次操作实时取 client(不再构造时快照)。 | +| M18 | `trace/trace.go` | 成功路径不设 `codes.Ok`(默认 UNSET);`Close` `sync.Once` 幂等。 | +| M19 | `logger/logger.go` | 三个 core 共享 `zap.AtomicLevel`;新增 `SetLevel`/`GetLevel`(方法 + 包级)。 | +| M17 | `console/console_windows.go` | 移除 `EnableVirtualTerminal` 死代码;`printColor` 按输出实际类型取句柄。 | +| N2 | `repository/repository_test.go` | 改编译期 `var _ BaseRepository[T] = (*BaseRepo[T])(nil)` 接口契约断言。 | +| N3 | `test/test.go` | `MockStorage.Upload`/`UploadFromBytes` 签名对齐真实 storage;`SetupRouter` 补文档。 | +| N5 收尾 | `utils/http.go` | 移除 `HTTPClient.once` 死字段。 | + +> H8 独立复审追加 LOW(4 项,已文档化,不阻断):L1 `Init` 覆盖旧 registry 无迁移告警(既有设计);L2 `applied` 无锁(`Apply` 单线程调用);L3 `SetMetricsMiddleware` 在 Apply 后调用静默无效(文档已声明须在 Apply 前);L4 注释"首个全局中间件"措辞。 + +--- + +## 三、Breaking Changes(升级必读) + +以下变更需下游显式适配,均已写入 CHANGELOG `[Unreleased]`「升级说明」: + +1. **`handler.HealthCheck` 响应体变更**(H8d):由 `{code,msg,data:{status:"ok"}}` 改为 `{"status":"ok"}`,与 `router.RegisterHealthRoute` 同 schema。直接断言旧信封字段的下游需改断言 `status`。需依赖探活(失败 503)改用 `router.RegisterHealthRoute(checks...)`。 +2. **`ws` 默认 CheckOrigin 收紧为同源**(C7/N7):原默认放行所有跨域 WS,现拒绝。依赖跨域 WS 的下游需 `ws.SetCheckOrigin` 或 `ws.AllowOrigins(...)` 显式放行。 +3. **`utils.RandString`/`RandDigit` 移除**(H1):删 math/rand 字符串随机函数(诱导安全误用),改用 `RandStringSecure`/`RandDigitSecure`。 +4. **`DefaultHTTPClientConfig.SkipTLSVerify` 默认 false**(H2):原默认 `true` 可 MITM,现默认校验 TLS;自签证书需显式 `SetSkipTLS(true)`。 +5. **`Field.Duration` 签名收紧**(H7b):`func(key string, value interface{})` → `func(key string, value time.Duration)`。传 `time.Duration` 不受影响;传 `zap.Field` 等非 Duration 类型编译失败(修复目的)。 +6. **`PostgresDSN` 密码加单引号**(M9):对 GORM 透明,但下游若自行解析 DSN 需注意。 +7. **`CodeDataAlreadyExists` ModeREST 下映射 409**(M8):原落 200,现与 `CodeDataConflict` 一致。 +8. **身份证校验位收紧**(M5):18 位身份证现校验校验位,"格式正确但校验位错"的输入将被拒绝(修复目的)。 + +非破坏性变更(新增 API / 行为更正确): +- `database.WithTx`/`TxFromContext`、`repository.BaseRepo.UpdateFields`、`router.Registry.SetMetricsMiddleware`、`ws.AllowOrigins`、`utils.HTTPClientConfig.MaxResponseBodySize`、`config.DatabaseConfig.Timezone`、`logger.SetLevel`/`GetLevel`、`trace.Config.Insecure`。 +- 框架基础路由注册(health/livez/readyz/swagger/metrics/defaultModule)改幂等,重复注册静默跳过(H8d 收尾)。 + +--- + +## 四、验证方式 + +### 机械验证 +- `go build -buildvcs=false ./...`:通过。 +- `go test -race -buildvcs=false ./...`:全量通过(含 `-race`,覆盖所有并发相关改动)。 +- `go vet -buildvcs=false ./...`:通过。 +- `gosec ./<改动包>/`:0 新增 issue(G704 SSRF on 通用 HTTP client `Do`、G115 `RoundRobinPicker` 取模 为既有误报,非本次引入)。 +- `staticcheck`:因 go1.24 vs go1.25 版本不匹配不可用,已显式说明跳过(依据 memory)。 + +### 行为闭环用例(重点项) +- **认证/CSRF/JWT**:颁发→校验→吊销/刷新闭环,断言旧 token 失效(C9b fail-closed)。 +- **Recover**:真实触发 panic,断言 HTTP 500 + body(C8)。 +- **限流**:构造稳态客户端,断言正常客户端未被误限 / 超限被拦(H4a 固定窗口)。 +- **流式/长连接**:SSE/WS 客户端断连后断言生产者停止、无 goroutine 泄漏(C2/C3)。 +- **文件/压缩**:路径穿越(`../` 拒绝)、Zip-Slip、超大输入封顶(C4/C5)。 +- **DSN 密码转义**:含 `@`/`:`/空格/引号 的密码不破坏 DSN(M9)。 +- **身份证校验位**:合法通过、校验位错拒绝(M5)。 +- **metrics 全量采集**:经注册中心的路由被采集,不依赖调用顺序(H8c)。 +- **/health schema 收敛**:三处 `/health` 同 schema(H8d)。 + +### 红绿验证(关键项) +- H8b:变异 `Apply` 守卫去除后 `runs!=1` 断言变红,恢复后绿。 +- H7a:变异 `currentLogger()` 为裸读 `Logger` 后 `-race` 实跑复现 `DATA RACE`,恢复后绿。 +- H6c:变异 `readConn`/`writeConn` 为旧 `r.db.WithContext` 后路由/事务用例变红,恢复后绿。 + +### 独立对抗性复审 +H8 由未参与编码的独立 agent 复核(源码 `file:line` + 变异证伪 + `-race`/vet),裁定 PASS。其余各批按风险分级:安全/并发项配 `-race` + 闭环用例;纯文档/命名项仅 `go build` + `go test` 通过。 + +--- + +## 五、新增测试文件 + +| 文件 | 覆盖 | +|------|------| +| `cache/lock_concurrency_test.go` | C1 锁续期/取消并发 | +| `cache/keybuilder_m13_internal_test.go` | M13 全局构建器并发 | +| `compress/compress_security_test.go` | C5 Zip-Slip/炸弹 | +| `config/config_c10_test.go` | C10 热重载校验/原子置换 | +| `cron/cron_c12_internal_test.go`/`cron_c12_test.go` | C12 竞争/重叠/解析 | +| `database/manager_c11_internal_test.go` | C11 池泄漏/锁/健康状态 | +| `jwt/jwt_c9c_internal_test.go` | C9c atomic 置换 | +| `logger/logger_h7_internal_test.go` | H7 atomic + Duration 签名 + M19 SetLevel | +| `middleware/cors_internal_test.go` | C7 后缀绕过 | +| `middleware/csrf_internal_test.go` | C6 闭环 | +| `middleware/logger_internal_test.go` | H3 body 封顶 | +| `repository/repository_h6_internal_test.go` | H6 路由/事务/分页/克隆 | +| `router/router_h8_internal_test.go` | H8a-d + 重复路由 footgun | +| `sse/sse_concurrency_test.go`/`sse_stream_internal_test.go` | C3 断连泄漏 | +| `storage/storage_path_internal_test.go`/`storage_security_test.go` | C4 穿越/校验 | +| `trace/trace_test.go` | C13 nil-panic/导出器/传播器 | +| `utils/http_test.go` | C5/N5 响应封顶 | + +--- + +## 六、工作区状态 + +- 所有改动**未提交**(按规则,用户未要求 commit 前不自行 commit)。 +- 进度/依据文件(`v1.1.1_fix_progress.md`/`v_1.1.1_fix.md`/`version_1.1.1_report.md`)为 untracked,记录修复全过程与逐条裁定。 +- CHANGELOG `[Unreleased]` 已按"修复批次"分类记录全部条目(含 Breaking 标注)。 +- 临时变异实验目录 `_mut/`(独立复审 agent 产物)已清理。 + +--- + +## 七、后续优化建议(非阻断,技术债) + +1. **`database.TransactionWithContext` 自动注入 tx**:当前 fn 签名不接收 ctx,跨层 join 需手动 `WithTx`。可提供 `TransactionWithContext2(ctx, fn func(ctx, tx) error)` 变体。 +2. **QueryBuilder 读写分离路由**:当前经构造时注入的 db(通常主库),不路由到从库。需读写分离用具体方法(FindPage/FindWhere 等)。 +3. **`defaultModule` 拆分**:可拆为 `SwaggerModule` + 弃用 `/health` 注册,彻底消除与 `RegisterHealthRoute` 并存冲突(当前由 `registerGETOnce` 幂等已规避)。 +4. **metrics 中间件可配置排除路由**:当前硬编码"基础路由不采集",可支持 `WithPath` 排除项。 +5. **`SetDefaultLogManager`/`DefaultJWT` 兼容别名裸写**:框架内部读路径已走 atomic,外部直接读导出变量仍非并发安全(已注释 + CHANGELOG 标注),完全消除需 breaking 类型变更。 + +--- + +## 八、交付裁定 + +**可交付 PASS。** + +13 项 CRITICAL + 8 项 HIGH 全部修复并经 `-race`/vet/gosec + 行为闭环 + 红绿验证 + 独立复审(H8)确认;P3 全量 MEDIUM/MINOR 处理完毕;Breaking Changes 已在 CHANGELOG 显式声明并给出迁移指引;无遗留 CRITICAL/HIGH/MEDIUM。 diff --git a/GUIDE.md b/GUIDE.md index 2818ed2..2fed71b 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -393,13 +393,23 @@ import "github.com/EthanCodeCraft/xlgo-core/repository" // 创建仓库 userRepo := repository.NewBaseRepo[model.User](database.GetDB()) +// 连接路由(v1.1.1+ 修复 H6c): +// - 读操作(FindByID/FindAll/FindPage/FindWhere/Count/Exists/...)默认路由到从库, +// 支持 database.UseMaster(ctx)/UseReplica(ctx) 显式路由;未配置从库时回退主库。 +// - 写操作(Create/Update/Delete/*Batch/Restore/...)始终走主库,不路由到只读从库。 +// - DefaultManager 未初始化(如单测注入 sqlite)时回退到 NewBaseRepo 传入的 db。 + // 基础 CRUD user, err := userRepo.FindByID(ctx, 1) users, err := userRepo.FindAll(ctx) users, err := userRepo.FindByIDs(ctx, []uint{1, 2, 3}) err := userRepo.Create(ctx, &user) -err := userRepo.Update(ctx, &user) -err := userRepo.Delete(ctx, 1) +err := userRepo.Update(ctx, &user) // Save 全列覆写(含零值),见下方 UpdateFields +err := userRepo.Delete(ctx, 1) // T 含 gorm.Model 时软删除,否则硬删除 + +// 局部更新(推荐,H6a):避免 Save 全列覆写丢失更新/零值不可辨 +err := userRepo.UpdateFields(ctx, &model.User{Name: "new"}, "id = ?", id) // struct 仅更新非零字段 +err := userRepo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map 可显式置零 // 统计数量 count, err := userRepo.Count(ctx) @@ -442,7 +452,7 @@ err := userRepo.RestoreBatch(ctx, []uint{1, 2}) // 查询已删除的记录 deletedUsers, err := userRepo.FindDeleted(ctx) -// 链式查询(灵活构建) +// 链式查询(灵活构建;QueryBuilder 为单次使用、非并发安全) users, err := userRepo.NewQueryBuilder(). Where("status = ?", 1). Where("role = ?", "admin"). @@ -465,6 +475,15 @@ err := userRepo.WithTransaction(ctx, func(txRepo *repository.BaseRepo[model.User // 更新关联数据 return txRepo.Update(ctx, &profile) }) + +// 跨层/跨 repo 事务 join(H6c):把外层事务注入 ctx 后传给任意 repo 方法 +err := database.TransactionWithContext(ctx, func(tx *gorm.DB) error { + ctx2 := database.WithTx(ctx, tx) + if err := userRepo.Create(ctx2, &user); err != nil { + return err + } + return otherRepo.Update(ctx2, &other) // 同一事务 +}) ``` ### 4.4 Model 基础模型 @@ -886,13 +905,14 @@ r.Use(middleware.APIRateLimit()) // 每分钟10 r.Use(middleware.CustomRateLimit(50, time.Minute)) // 每分钟50次 // Redis 分布式限流(多实例共享) -r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次 -r.Use(middleware.LoginRedisRateLimit()) // 登录限流 -r.Use(middleware.APIRedisRateLimit()) // API限流 -r.Use(middleware.UploadRedisRateLimit()) // 上传限流 +r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次(fail-open:Redis 故障时放行) +r.Use(middleware.LoginRedisRateLimit()) // 登录限流(fail-closed:Redis 故障时拒绝,防爆破) +r.Use(middleware.APIRedisRateLimit()) // API限流(fail-open) +r.Use(middleware.UploadRedisRateLimit()) // 上传限流(fail-open) // 自定义 Redis 限流 -r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) +r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open +r.Use(middleware.CustomRedisRateLimitFailClosed("sensitive", 50, time.Minute)) // fail-closed(安全场景) // 自定义标识限流(如按用户ID) r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Context) string { @@ -903,6 +923,11 @@ r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Con defer middleware.StopRateLimiters() ``` +> **fail-open vs fail-closed(H4c)**:Redis 限流器在 Redis 故障时有两种策略—— +> - **fail-open**(`RedisRateLimit`/`APIRedisRateLimit`/`UploadRedisRateLimit`/`CustomRedisRateLimit`,默认):Redis 故障时放行,避免影响业务,但限流静默失效。 +> - **fail-closed**(`RedisRateLimitFailClosed`/`LoginRedisRateLimit`/`CustomRedisRateLimitFailClosed`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、敏感操作)必须用 fail-closed**。 +> `RedisRateLimiter` 可经 `NewRedisRateLimiterFailClosed` 构造或 `SetFailClosed(true)` 切换策略。 + **内存限流 vs Redis 限流:** - 内存限流:单实例使用,简单高效 - Redis 限流:多实例共享,滑动窗口算法,分布式场景必需 @@ -1201,19 +1226,32 @@ if storage.Exists(path) { ### 11.1 随机数生成 +**安全选型**:字符串随机(token/OTP/验证码/会话 ID/nonce)用 `RandStringSecure`/`RandDigitSecure` +(基于 `crypto/rand`,不可预测)。`RandString`/`RandDigit`(math/rand 版本)已移除—— +字符串随机的用途几乎都是安全场景,保留 math/rand 版本会诱导误用。 + ```go import "github.com/EthanCodeCraft/xlgo-core/utils" -// 随机字符串(16位) -token := utils.RandString(16) +// 安全场景:token、会话 ID、API key(crypto/rand,不可预测) +token, err := utils.RandStringSecure(32) +if err != nil { + // 处理 crypto/rand 失败(极罕见,通常仅系统熵池耗尽) +} -// 随机数字(6位验证码) -code := utils.RandDigit(6) +// 安全场景:6 位 OTP 验证码、密码重置码(crypto/rand) +code, err := utils.RandDigitSecure(6) -// 范围随机数 +// 安全场景:安全 nonce 范围、防猜抽奖、密钥分桶(crypto/rand 无偏) +idx, err := utils.RandIntSecure(0, 1000) + +// 范围随机数(非密码学安全,仅用于负载均衡/游戏/A-B 分桶等非安全场景) n := utils.RandInt(1, 100) ``` +> 非安全场景需要高性能随机串时,直接用标准库 `math/rand` 即可,框架不再提供 +> 易误用的 `RandString`/`RandDigit`。 + ### 11.2 字符串处理 ```go @@ -1394,7 +1432,8 @@ if utils.UUIDValid(uuid) { } | 分类 | 高分函数(⭐⭐⭐⭐⭐) | 说明 | |------|---------------------|------| -| **随机** | `RandString/RandDigit` | sync.Pool 优化性能 | +| **随机(安全)** | `RandStringSecure/RandDigitSecure/RandIntSecure/RandInt64Secure` | crypto/rand,token/OTP/nonce 用 | +| **随机(范围)** | `RandInt/RandInt64` | math/rand,负载均衡/游戏等非安全场景 | | **字符串** | `IsBlank/DefaultIfBlank/StrLen` | 空值处理、Unicode支持 | | **时间** | `FormatDateTime/StartOfDay/EndOfMonth` | 标准格式、边界计算 | | **转换** | `ToIntDefault/CalcPageCount/CalcOffset` | 安全转换、分页计算 | @@ -1409,7 +1448,7 @@ if utils.UUIDValid(uuid) { } | 改进 | 说明 | |------|------| -| 性能优化 | `RandString/RandDigit` 使用 sync.Pool 复用随机源 | +| 性能优化 | `RandString/RandDigit` 使用 sync.Pool 复用随机源(非安全场景);安全场景用 `RandStringSecure/RandDigitSecure`(crypto/rand) | | 类型安全 | 移除使用反射的函数,保持类型安全 | | 式调用 | `HTTPClient` 和 `URLBuilder` 支持链式调用 | | 零依赖 | 仅依赖 `google/uuid`,其余使用标准库 | @@ -1527,11 +1566,18 @@ trace.Init(trace.Config{ 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 diff --git a/app.go b/app.go index 77634a8..30629c0 100644 --- a/app.go +++ b/app.go @@ -445,6 +445,9 @@ func (a *App) Init() error { router.RegisterReadinessRoute(a.router, a.healthChecks...) } if a.enableMetrics { + // 采集中间件交给注册中心,Apply 时作为首个全局中间件装入(H8c), + // 覆盖所有经注册中心注册的业务路由,不依赖调用顺序。 + a.registry.SetMetricsMiddleware(middleware.Metrics()) if a.metricsPath != "" { router.RegisterMetricsRoute(a.router, a.metricsPath) } else { diff --git a/app_test.go b/app_test.go index 2381991..56e162f 100644 --- a/app_test.go +++ b/app_test.go @@ -241,3 +241,39 @@ func TestAppWithConfigPathDrivesManager(t *testing.T) { t.Fatalf("expected GetInt(server.port)=18092, got %d", config.GetInt("server.port")) } } + +// TestAppMetricsInstrumentsRegistryRoutes_H8c 端到端验证 H8c:经注册中心注册的业务路由 +// 被指标中间件采集,不依赖 RegisterMetricsRoute 的调用顺序。修复前 RegisterMetricsRoute +// 用 r.Use 仅采集其后注册的路由;修复后采集中间件在 Apply 内作首个全局中间件装入。 +func TestAppMetricsInstrumentsRegistryRoutes_H8c(t *testing.T) { + app := xlgo.New( + xlgo.WithConfig(testConfig(18093)), + xlgo.WithMetricsRoute(), + xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) { + r.GET("/h8c-biz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + })), + ) + if err := app.Init(); err != nil { + t.Fatalf("Init error: %v", err) + } + + r := app.GetRouter() + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8c-biz", nil)) + if w.Code != http.StatusOK { + t.Fatalf("/h8c-biz status = %d, want 200", w.Code) + } + } + + // 拉取 /metrics,断言 /h8c-biz 路由被采集(route 标签存在)。 + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + if w.Code != http.StatusOK { + t.Fatalf("/metrics status = %d, want 200", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, `route="/h8c-biz"`) { + t.Fatalf("metrics output should contain route=\"/h8c-biz\" series, got:\n%s", body) + } +} diff --git a/cache/cache.go b/cache/cache.go index 41dd440..f07868b 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -28,25 +28,31 @@ type CacheService interface { Exists(ctx context.Context, key string) bool } -// redisCache Redis 缓存实现 -type redisCache struct { - client *redis.Client +// redisCache Redis 缓存实现。 +// +// 不在构造时快照 redis.Client(M12 修复:原 NewRedisCache 构造时取 database.GetRedis(), +// 若在 database.InitRedis 之前构造则永久 nil、即使后续 Redis 就绪也是 no-op)。 +// 改为每次操作实时取 database.GetRedis(),使"先构造后 Init Redis"的顺序也能正确工作。 +type redisCache struct{} + +// client 返回当前 Redis 客户端(实时取,未初始化则 nil)。 +func (c *redisCache) client() *redis.Client { + return database.GetRedis() } // NewRedisCache 创建 Redis 缓存实例 func NewRedisCache() CacheService { - return &redisCache{ - client: database.GetRedis(), - } + return &redisCache{} } // Get 获取缓存值 func (c *redisCache) Get(ctx context.Context, key string, dest any) bool { - if c.client == nil { + cli := c.client() + if cli == nil { return false } - val, err := c.client.Get(ctx, key).Result() + val, err := cli.Get(ctx, key).Result() if err != nil { if err != redis.Nil { logger.Warn("缓存获取失败", zap.String("key", key), zap.Error(err)) @@ -64,7 +70,8 @@ func (c *redisCache) Get(ctx context.Context, key string, dest any) bool { // Set 设置缓存值 func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error { - if c.client == nil { + cli := c.client() + if cli == nil { return nil // Redis 未启用,跳过缓存 } @@ -74,7 +81,7 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du return err } - if err := c.client.Set(ctx, key, data, ttl).Err(); err != nil { + if err := cli.Set(ctx, key, data, ttl).Err(); err != nil { logger.Warn("缓存设置失败", zap.String("key", key), zap.Error(err)) return err } @@ -84,11 +91,12 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du // Delete 删除缓存 func (c *redisCache) Delete(ctx context.Context, key string) error { - if c.client == nil { + cli := c.client() + if cli == nil { return nil } - if err := c.client.Del(ctx, key).Err(); err != nil { + if err := cli.Del(ctx, key).Err(); err != nil { logger.Warn("缓存删除失败", zap.String("key", key), zap.Error(err)) return err } @@ -98,7 +106,8 @@ func (c *redisCache) Delete(ctx context.Context, key string) error { // DeleteByPattern 按模式删除缓存(使用 SCAN 避免阻塞 Redis) func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error { - if c.client == nil { + cli := c.client() + if cli == nil { return nil } @@ -107,7 +116,7 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error for { // 使用 SCAN 命令迭代查找匹配的键 - keys, nextCursor, err := c.client.Scan(ctx, cursor, pattern, 100).Result() + keys, nextCursor, err := cli.Scan(ctx, cursor, pattern, 100).Result() if err != nil { logger.Warn("缓存键扫描失败", zap.String("pattern", pattern), zap.Error(err)) return err @@ -115,7 +124,7 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error // 删除找到的键 if len(keys) > 0 { - if err := c.client.Del(ctx, keys...).Err(); err != nil { + if err := cli.Del(ctx, keys...).Err(); err != nil { logger.Warn("缓存批量删除失败", zap.Strings("keys", keys), zap.Error(err)) return err } @@ -140,11 +149,12 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error // Exists 检查缓存是否存在 func (c *redisCache) Exists(ctx context.Context, key string) bool { - if c.client == nil { + cli := c.client() + if cli == nil { return false } - return c.client.Exists(ctx, key).Val() > 0 + return cli.Exists(ctx, key).Val() > 0 } // CacheManager 缓存管理器(#10)。照 database.Manager 模式: diff --git a/cache/keybuilder.go b/cache/keybuilder.go index 0a70222..b1d769f 100644 --- a/cache/keybuilder.go +++ b/cache/keybuilder.go @@ -3,6 +3,7 @@ package cache import ( "context" "strings" + "sync" "time" "github.com/EthanCodeCraft/xlgo-core/config" @@ -137,7 +138,10 @@ func (kb *KeyBuilder) GetPrefix() string { return kb.prefix } -// SetPrefix 动态设置前缀 +// SetPrefix 动态设置前缀。 +// +// 注意(M13):KeyBuilder 实例非并发安全——并发 Build 与 SetPrefix 会竞争 prefix。 +// 全局构建器主要在启动期配置,运行期改前缀请自行加锁或重建实例。 func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder { kb.prefix = prefix return kb @@ -145,7 +149,12 @@ func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder { // ===== 全局键名构建器 ===== -var globalKeyBuilder *KeyBuilder +// globalKeyBuilder 全局构建器,受 globalKBMu 保护;globalKBOnce 保证自动初始化只执行一次(M13)。 +var ( + globalKeyBuilder *KeyBuilder + globalKBMu sync.RWMutex + globalKBOnce sync.Once +) // InitKeyBuilder 初始化全局键名构建器 // 参数: prefix 站点别名,如果为空则自动从配置读取 @@ -164,7 +173,10 @@ func InitKeyBuilder(prefix string, opts ...KeyBuilderOption) { } opts = append([]KeyBuilderOption{WithPrefix(prefix)}, opts...) - globalKeyBuilder = NewKeyBuilder(opts...) + kb := NewKeyBuilder(opts...) + globalKBMu.Lock() + globalKeyBuilder = kb + globalKBMu.Unlock() } // AutoInitKeyBuilder 自动从配置初始化键名构建器 @@ -177,12 +189,19 @@ func AutoInitKeyBuilder(opts ...KeyBuilderOption) { InitKeyBuilder("", opts...) } -// GetKeyBuilder 获取全局键名构建器 +// GetKeyBuilder 获取全局键名构建器,未初始化时用 sync.Once 自动初始化一次(M13)。 func GetKeyBuilder() *KeyBuilder { - if globalKeyBuilder == nil { - // 自动从配置初始化 - AutoInitKeyBuilder() - } + globalKBOnce.Do(func() { + // 仅在仍为 nil 时自动初始化(已由 InitKeyBuilder 设置则跳过)。 + globalKBMu.RLock() + kb := globalKeyBuilder + globalKBMu.RUnlock() + if kb == nil { + AutoInitKeyBuilder() + } + }) + globalKBMu.RLock() + defer globalKBMu.RUnlock() return globalKeyBuilder } 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/lock.go b/cache/lock.go index d0aa156..a3310d9 100644 --- a/cache/lock.go +++ b/cache/lock.go @@ -3,6 +3,7 @@ package cache import ( "context" "errors" + "fmt" "time" "github.com/EthanCodeCraft/xlgo-core/database" @@ -11,12 +12,32 @@ import ( // 分布式锁错误 var ( - ErrLockNotHeld = errors.New("锁未被当前客户端持有") - ErrLockExpired = errors.New("锁已过期") - ErrRedisNotReady = errors.New("Redis 未初始化") + ErrLockNotHeld = errors.New("锁未被当前客户端持有") + ErrLockExpired = errors.New("锁已过期") + ErrRedisNotReady = errors.New("Redis 未初始化") + // ErrLockUnexpectedResult Lua 脚本返回了非预期的结果类型(C1b:裸类型断言防护)。 + ErrLockUnexpectedResult = errors.New("锁脚本返回非预期结果") ) -// LockToken 锁令牌(用于安全释放锁) +// toInt64 将 Lua 脚本返回值安全断言为 int64(C1b:禁止裸断言 panic)。 +// go-redis 对整数返回 int64,但 nil/错误响应下可能为其他类型。 +func toInt64(v any) (int64, error) { + n, ok := v.(int64) + if !ok { + return 0, fmt.Errorf("脚本返回类型 %T: %w", v, ErrLockUnexpectedResult) + } + return n, nil +} + +// 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) @@ -73,7 +94,11 @@ func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, er return nil, err } - if result.(int64) == 1 { + n, err := toInt64(result) + if err != nil { + return nil, err + } + if n == 1 { return &LockToken{Key: key, Token: token}, nil } @@ -105,7 +130,11 @@ func Unlock(ctx context.Context, token *LockToken) error { return err } - if result.(int64) == 0 { + n, err := toInt64(result) + if err != nil { + return err + } + if n == 0 { return ErrLockNotHeld } @@ -139,14 +168,18 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error return err } - if result.(int64) == 0 { + n, err := toInt64(result) + if err != nil { + return err + } + if n == 0 { return ErrLockNotHeld } return nil } -// TryLock 尝试获取锁,失败时等待重试 +// TryLock 尝试获取锁,失败时等待重试。重试等待响应 ctx 取消(C1c 修复)。 func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval time.Duration, maxRetry int) (*LockToken, error) { for i := 0; i < maxRetry; i++ { token, err := NewLock(ctx, key, ttl) @@ -156,14 +189,22 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t if token != nil { return token, nil } - time.Sleep(retryInterval) + // 响应 ctx 取消,避免最长阻塞 maxRetry*retryInterval(C1c:禁止 time.Sleep 无视 ctx)。 + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(retryInterval): + } } return nil, nil } -// WithLock 使用分布式锁执行函数(自动管理锁) +// WithLock 使用分布式锁执行函数(自动管理锁)。 // 参数: key 锁名称,ttl 锁定时长,fn 业务函数 -// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend +// 注意: 如果任务执行时间超过 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() error) error { token, err := NewLock(ctx, key, ttl) if err != nil { @@ -172,13 +213,23 @@ func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() erro if token == nil { return nil // 未获取到锁,跳过执行 } - defer Unlock(ctx, token) + defer func() { + unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = Unlock(unlockCtx, token) + }() return fn() } -// WithLockAutoExtend 使用分布式锁执行函数(自动续期) +// 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() error) error { token, err := NewLock(ctx, key, initialTTL) if err != nil { @@ -188,10 +239,12 @@ func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duratio return nil // 未获取到锁,跳过执行 } - // 启动续期协程 - done := make(chan struct{}) + // 父关停信号(仅父 close)与子 ack 信号(仅子 close)。 + stop := make(chan struct{}) + finished := make(chan struct{}) + go func() { - defer close(done) + defer close(finished) // 子退出时 ack,父等待 finished ticker := time.NewTicker(extendInterval) defer ticker.Stop() @@ -199,23 +252,29 @@ func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duratio select { case <-ctx.Done(): return - case <-done: + case <-stop: return case <-ticker.C: - // 续期锁(每次续期为 initialTTL) + // 续期锁(每次续期为 initialTTL)。续期失败则停止续期,fn 应尽快结束。 if err := ExtendLock(ctx, token, initialTTL); err != nil { - return // 续期失败,停止续期 + return } } } }() - // 执行业务函数 - err = fn() + // defer 兜底:fn panic 时也要停止续期 goroutine 并释放锁(C1a panic 路径修复)。 + // 无 defer 时 fn panic 会导致 close(stop) 不执行 → 续期 goroutine 永久泄漏,且 Unlock 不执行 → 锁泄漏到 TTL。 + defer func() { + close(stop) + <-finished + unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = Unlock(unlockCtx, token) + }() - // 停止续期并释放锁 - done <- struct{}{} - Unlock(ctx, token) + // 执行业务函数。 + err = fn() return err } diff --git a/cache/lock_concurrency_test.go b/cache/lock_concurrency_test.go new file mode 100644 index 0000000..507e4e4 --- /dev/null +++ b/cache/lock_concurrency_test.go @@ -0,0 +1,302 @@ +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.RedisClient,返回清理函数。 +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() }) + // 保存旧值,测试结束恢复,避免污染其他测试。 + old := database.RedisClient + database.RedisClient = client + t.Cleanup(func() { database.RedisClient = old }) + 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() error { + close(ran) + // 阻塞直到 ctx 被取消,模拟长任务。 + <-ctx.Done() + return ctx.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() 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() 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() 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() 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() error { + close(fnStarted) + <-ctx.Done() + return ctx.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)") + } +} + +// ===== 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) + } +} diff --git a/cmd/xlgo/commands.go b/cmd/xlgo/commands.go index b226af1..efe2477 100644 --- a/cmd/xlgo/commands.go +++ b/cmd/xlgo/commands.go @@ -129,9 +129,13 @@ func createProject(name string) { } func makeFile(fileType, name string) { + // 文件名小写,但保留原分隔用于多词;标识符须为合法 Go 标识符(仅字母数字下划线)。 + // 将连字符/空格等转为下划线后再 Title,避免 "my-thing" → "My-Thing" 生成非法标识符(M20)。 name = strings.ToLower(name) + identBase := sanitizeIdent(name) caser := cases.Title(language.English) - nameTitle := caser.String(name) + nameTitle := caser.String(strings.ReplaceAll(identBase, "_", " ")) + nameTitle = strings.ReplaceAll(nameTitle, " ", "") // 拼回 CamelCase switch fileType { case "handler": @@ -148,6 +152,26 @@ func makeFile(fileType, name string) { } } +// sanitizeIdent 把 name 中的非字母数字字符替换为下划线,生成合法 Go 标识符基串(M20)。 +// 如 "my-thing" → "my_thing",后续 Title 后得到 "MyThing"。 +func sanitizeIdent(name string) string { + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else if r >= 'A' && r <= 'Z' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + s := strings.Trim(b.String(), "_") + if s == "" { + return "xlgo" + } + return s +} + func createHandler(name, nameTitle string) { path := fmt.Sprintf("handler/%s.go", name) if fileExists(path) { diff --git a/cmd/xlgo/utils.go b/cmd/xlgo/utils.go index 12558aa..e27da71 100644 --- a/cmd/xlgo/utils.go +++ b/cmd/xlgo/utils.go @@ -21,6 +21,12 @@ func writeFile(path, content string) { func fileExists(path string) bool { _, err := os.Stat(path) + if err == nil { + return true + } + // 仅当"不存在"才返回 false;权限错误等其它错误视为"可能存在/不可覆写", + // 避免把无权限访问的路径误判为可创建(M20:原 !os.IsNotExist 把权限错误当存在, + // 反而安全;但语义模糊,显式区分更清晰)。 return !os.IsNotExist(err) } diff --git a/compress/compress.go b/compress/compress.go index 21b71e4..cf784bf 100644 --- a/compress/compress.go +++ b/compress/compress.go @@ -4,14 +4,67 @@ import ( "archive/zip" "bytes" "compress/gzip" + "errors" + "fmt" "io" "io/fs" "os" - "path" "path/filepath" "strings" ) +// 解压安全相关错误。 +var ( + // ErrPathTraversal zip 条目名逃逸目标目录(C5a Zip-Slip)。 + ErrPathTraversal = errors.New("zip entry escapes destination directory") + // ErrSymlinkEntry zip 条目为符号链接,已拒绝(防经软链二次穿越)。 + ErrSymlinkEntry = errors.New("zip entry is a symlink, rejected") + // ErrDecompressLimit 解压大小超过上限(C5b 解压炸弹)。 + ErrDecompressLimit = errors.New("decompress size limit exceeded") +) + +const ( + // defaultDecompressLimit 单流/单条目默认解压上限(100MB),防 OOM / 磁盘耗尽(C5b)。 + defaultDecompressLimit int64 = 100 * 1024 * 1024 + // defaultDecompressTotalLimit Unzip 默认累计解压上限(1GB)。 + defaultDecompressTotalLimit int64 = 1 * 1024 * 1024 * 1024 +) + +// DecompressOptions 解压安全选项。Zip-Slip 防护(前缀锚定 + 拒绝符号链接)始终启用,无需配置; +// 本选项仅控制解压大小上限以防解压炸弹(C5b)。 +type DecompressOptions struct { + // MaxBytes 单流 / 单条目解压大小上限(字节)。0 = 默认 100MB,-1 = 不限制。 + MaxBytes int64 + // MaxTotalBytes Unzip 累计解压大小上限(字节)。0 = 默认 1GB,-1 = 不限制。 + // 仅 Unzip 生效。 + MaxTotalBytes int64 +} + +// resolveLimit 解析大小上限:n<0 不限,n==0 用 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 @@ -26,33 +79,53 @@ func GzipCompress(data []byte) ([]byte, error) { return buf.Bytes(), nil } -// GzipDecompress 解压缩数据 +// GzipDecompress 解压缩数据。默认上限 100MB 防解压炸弹 OOM(C5b); +// 需解压更大文件请用 GzipDecompressWithOptions。 func GzipDecompress(data []byte) ([]byte, error) { + return GzipDecompressWithOptions(data, DecompressOptions{}) +} + +// GzipDecompressWithOptions 解压缩数据,可配置大小上限(C5b)。 +func GzipDecompressWithOptions(data []byte, opts DecompressOptions) ([]byte, error) { buf := bytes.NewReader(data) gz, err := gzip.NewReader(buf) if err != nil { return nil, err } defer gz.Close() - return io.ReadAll(gz) + + limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit) + var reader io.Reader = gz + if limit > 0 { + // 多读 1 字节用于判断是否超限。 + reader = io.LimitReader(gz, limit+1) + } + out, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + if limit > 0 && int64(len(out)) > limit { + return nil, fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit) + } + return out, nil } // GzipCompressFile 压缩文件 func GzipCompressFile(src, dst string) error { + // #nosec G304 -- src/dst 为调用方提供的本地文件路径,压缩 API 固有语义,非不可信输入 srcFile, err := os.Open(src) if err != nil { return err } defer srcFile.Close() + // #nosec G304 -- 同上,dst 为调用方指定输出路径 dstFile, err := os.Create(dst) if err != nil { return err } - defer dstFile.Close() gz := gzip.NewWriter(dstFile) - defer gz.Close() // 保留原文件名和时间戳 if info, err := srcFile.Stat(); err == nil { @@ -60,12 +133,29 @@ func GzipCompressFile(src, dst string) error { gz.ModTime = info.ModTime() } - _, err = io.Copy(gz, srcFile) - return err + _, copyErr := io.Copy(gz, srcFile) + // gz.Close 刷出 gzip 尾部(含 CRC/大小校验),失败说明归档损坏,必须向上传播(M16/B18)。 + closeErr := gz.Close() + // dstFile.Close 失败(如延迟写盘)同样意味着归档可能不完整。 + dstErr := dstFile.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + return dstErr } -// GzipDecompressFile 解压文件 +// GzipDecompressFile 解压文件。默认上限 100MB 防磁盘耗尽(C5b); +// 需解压更大文件请用 GzipDecompressFileWithOptions。 func GzipDecompressFile(src, dst string) error { + return GzipDecompressFileWithOptions(src, dst, DecompressOptions{}) +} + +// GzipDecompressFileWithOptions 解压文件,可配置大小上限(C5b)。 +func GzipDecompressFileWithOptions(src, dst string, opts DecompressOptions) error { + // #nosec G304 -- src 为调用方提供的本地文件路径,解压 API 固有语义,非不可信输入 srcFile, err := os.Open(src) if err != nil { return err @@ -78,126 +168,219 @@ func GzipDecompressFile(src, dst string) error { } defer gz.Close() + // #nosec G304 -- dst 为调用方指定输出路径,解压 API 固有语义 dstFile, err := os.Create(dst) if err != nil { return err } defer dstFile.Close() - _, err = io.Copy(dstFile, gz) - return err + limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit) + var written int64 + if limit > 0 { + // CopyN 最多读 limit+1 字节,超限即判定为炸弹。 + written, err = io.CopyN(dstFile, gz, limit+1) + } else { + // #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹 + written, err = io.Copy(dstFile, gz) + } + if err != nil && err != io.EOF { + return err + } + if limit > 0 && written > limit { + return fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit) + } + return nil } // Zip 压缩文件或目录 // 参数: zipPath 目标zip文件路径,paths 要压缩的文件或目录列表 func Zip(zipPath string, paths []string) error { // 创建目标目录 - if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(zipPath), 0750); err != nil { return err } // 创建 zip 文件 + // #nosec G304 -- zipPath 为调用方指定输出路径,压缩 API 固有语义 archive, err := os.Create(zipPath) if err != nil { return err } - defer archive.Close() zipWriter := zip.NewWriter(archive) - defer zipWriter.Close() - for _, srcPath := range paths { - srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator)) + walkErr := func() error { + for _, srcPath := range paths { + srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator)) - err = filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error { + // #nosec G122 -- 压缩调用方提供的源路径,非解压不可信输入;symlink 跟随是压缩场景的可接受行为 + err := filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + // 创建文件头 + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Method = zip.Deflate + + // 设置相对路径 + header.Name, err = filepath.Rel(filepath.Dir(srcPath), path) + if err != nil { + return err + } + if info.IsDir() { + header.Name += string(os.PathSeparator) + } + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + // #nosec G304 -- path 为 Walk 遍历调用方源路径产生,非不可信输入 + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + return err + }) if err != nil { return err } - - // 创建文件头 - header, err := zip.FileInfoHeader(info) - if err != nil { - return err - } - header.Method = zip.Deflate - - // 设置相对路径 - header.Name, err = filepath.Rel(filepath.Dir(srcPath), path) - if err != nil { - return err - } - if info.IsDir() { - header.Name += string(os.PathSeparator) - } - - writer, err := zipWriter.CreateHeader(header) - if err != nil { - return err - } - - if info.IsDir() { - return nil - } - - file, err := os.Open(path) - if err != nil { - return err - } - defer file.Close() - - _, err = io.Copy(writer, file) - return err - }) - if err != nil { - return err } + return nil + }() + + // zipWriter.Close 刷出中央目录记录,失败说明归档损坏,必须向上传播(M16/B18)。 + zipCloseErr := zipWriter.Close() + archiveCloseErr := archive.Close() + if walkErr != nil { + return walkErr } - return nil + if zipCloseErr != nil { + return zipCloseErr + } + return archiveCloseErr } -// Unzip 解压 zip 文件 -// 参数: zipPath zip文件路径,dstDir 目标目录 +// Unzip 解压 zip 文件。默认启用 Zip-Slip 防护(前缀锚定 + 拒绝符号链接), +// 单条目上限 100MB、累计上限 1GB 防解压炸弹(C5b);需自定义上限请用 UnzipWithOptions。 func Unzip(zipPath, dstDir string) error { + return UnzipWithOptions(zipPath, dstDir, DecompressOptions{}) +} + +// UnzipWithOptions 解压 zip 文件,可配置大小上限(C5b)。Zip-Slip 防护始终启用。 +func UnzipWithOptions(zipPath, dstDir string, opts DecompressOptions) error { reader, err := zip.OpenReader(zipPath) if err != nil { return err } defer reader.Close() + // 用绝对路径作目标锚定根,避免相对路径 + `..` 组合绕过前缀校验(C5a)。 + absDst, err := filepath.Abs(dstDir) + if err != nil { + absDst = dstDir + } + absDst = filepath.Clean(absDst) + + entryLimit := resolveLimit(opts.MaxBytes, defaultDecompressLimit) + totalLimit := resolveLimit(opts.MaxTotalBytes, defaultDecompressTotalLimit) + var total int64 + for _, file := range reader.File { - if err := unzipFile(file, dstDir); err != nil { + written, err := unzipFile(file, absDst, entryLimit, totalLimit, total) + if err != nil { return err } + total += written } return nil } -func unzipFile(file *zip.File, dstDir string) error { - filePath := path.Join(dstDir, file.Name) +// unzipFile 解压单个 zip 条目到 absDst 下。 +// entryLimit: 单条目上限(-1 不限);totalLimit: 累计上限(-1 不限);accrued: 已解压累计字节。 +// 返回本条目写入字节数。 +func unzipFile(file *zip.File, absDst string, entryLimit, totalLimit, accrued int64) (int64, error) { + // 拒绝符号链接条目,防经软链二次穿越(C5a)。 + if file.Mode()&os.ModeSymlink != 0 { + return 0, fmt.Errorf("条目 %s 为符号链接: %w", file.Name, ErrSymlinkEntry) + } + + // zip 条目名规范用正斜杠;转成当前平台分隔符后再 Join,并以前缀锚定拒绝 `..` 逃逸(C5a)。 + name := filepath.FromSlash(file.Name) + // 拒绝绝对路径与以分隔符开头的条目(非标准、可疑,避免平台语义差异)。 + // filepath.IsAbs 在 Windows 不认 "/x"(无盘符)为绝对路径,故补充分隔符前缀检查。 + if filepath.IsAbs(name) || strings.HasPrefix(name, string(os.PathSeparator)) || strings.HasPrefix(file.Name, "/") { + return 0, fmt.Errorf("条目 %s 为绝对路径: %w", file.Name, ErrPathTraversal) + } + target := filepath.Join(absDst, name) + if target == absDst || !strings.HasPrefix(target, absDst+string(os.PathSeparator)) { + return 0, fmt.Errorf("条目 %s 逃逸目标目录: %w", file.Name, ErrPathTraversal) + } if file.FileInfo().IsDir() { - return os.MkdirAll(filePath, 0755) + if err := os.MkdirAll(target, 0750); err != nil { + return 0, err + } + return 0, nil } // 创建父目录 - if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { - return err + if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil { + return 0, err } // 打开 zip 中的文件 rc, err := file.Open() if err != nil { - return err + return 0, err } defer rc.Close() // 创建目标文件 - dstFile, err := os.Create(filePath) + // #nosec G304 -- target 经前缀锚定校验(absDst+sep),已防 Zip-Slip 逃逸 + dstFile, err := os.Create(target) if err != nil { - return err + return 0, err } defer dstFile.Close() - _, err = io.Copy(dstFile, rc) - return err + // 计算本次拷贝上限:单条目上限与累计剩余上限中较小者(-1 视为无限)。 + // remaining: 累计剩余(-1 表示累计不限);totalLimit>0 时若已无剩余,直接判超限。 + remaining := int64(-1) + if totalLimit > 0 { + remaining = totalLimit - accrued + if remaining <= 0 { + return 0, fmt.Errorf("累计解压超过上限 %d 字节: %w", totalLimit, ErrDecompressLimit) + } + } + cap := minLimit(entryLimit, remaining) + // cap 为 -1(两者皆不限)或 >0(有限上限,剩余已保证 >0),不会是 0。 + + var written int64 + 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/config/config.go b/config/config.go index 5d4de75..dbea6d7 100644 --- a/config/config.go +++ b/config/config.go @@ -2,8 +2,11 @@ package config import ( "fmt" + "net/url" + "path/filepath" "strings" "sync" + "sync/atomic" "time" "github.com/fsnotify/fsnotify" @@ -225,6 +228,9 @@ type DatabaseConfig struct { Password string `mapstructure:"password"` // Name 数据库名 Name string `mapstructure:"name"` + // Timezone 连接时区。MySQL 用作 loc 参数、Postgres 用作 TimeZone 参数。 + // 空时 MySQL 默认 "Local"、Postgres 默认 "Asia/Shanghai"(向后兼容,M9)。 + Timezone string `mapstructure:"timezone"` // CustomDSN 自定义连接字符串,设置后优先于由 Host/Port 等字段生成的 DSN CustomDSN string `mapstructure:"dsn"` // MaxIdleConns 最大空闲连接数 @@ -253,16 +259,28 @@ func (c *DatabaseConfig) DSN() string { return c.MySQLDSN() } -// MySQLDSN 返回 MySQL 连接字符串 +// MySQLDSN 返回 MySQL 连接字符串。 +// 密码经 url.QueryEscape 转义,避免含 @/:/空格 等特殊字符破坏 DSN(M9)。 +// loc 由 Timezone 配置,空则默认 "Local"(向后兼容)。 func (c *DatabaseConfig) MySQLDSN() string { - return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", - c.User, c.Password, c.Host, c.Port, c.Name) + loc := c.Timezone + if loc == "" { + loc = "Local" + } + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s", + c.User, url.QueryEscape(c.Password), c.Host, c.Port, c.Name, url.QueryEscape(loc)) } -// PostgresDSN 返回 PostgreSQL 连接字符串 +// PostgresDSN 返回 PostgreSQL 连接字符串。 +// 密码经单引号转义(内嵌单引号翻倍),避免含空格/引号/反斜杠破坏 key=value DSN(M9)。 +// TimeZone 由 Timezone 配置,空则默认 "Asia/Shanghai"(向后兼容)。 func (c *DatabaseConfig) PostgresDSN() string { - return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai", - c.Host, c.Port, c.User, c.Password, c.Name) + tz := c.Timezone + if tz == "" { + tz = "Asia/Shanghai" + } + return fmt.Sprintf("host=%s port=%d user=%s password='%s' dbname=%s sslmode=disable TimeZone=%s", + c.Host, c.Port, c.User, strings.ReplaceAll(c.Password, "'", "''"), c.Name, tz) } // RedisConfig Redis 配置 @@ -304,10 +322,26 @@ type StorageConfig struct { OSS OSSStorageConfig `mapstructure:"oss"` } +// UploadPolicy 上传安全策略(C4b)。零值表示不限制,向后兼容; +// 生产环境强烈建议显式配置 MaxSizeBytes 与 AllowedExts / AllowedMIMEs。 +type UploadPolicy struct { + // MaxSizeBytes 单文件大小上限(字节)。0 = 不限制。 + MaxSizeBytes int64 `mapstructure:"max_size_bytes"` + // AllowedExts 允许的扩展名白名单(小写、含点,如 ".jpg")。空 = 不限制。 + AllowedExts []string `mapstructure:"allowed_exts"` + // AllowedMIMEs 允许的 MIME 类型白名单(小写,如 "image/jpeg")。 + // 非空时用 http.DetectContentType 嗅探文件前 512 字节校验。空 = 不嗅探。 + AllowedMIMEs []string `mapstructure:"allowed_mime_types"` +} + // LocalStorageConfig 本地存储配置 type LocalStorageConfig struct { Path string `mapstructure:"path"` BaseURL string `mapstructure:"base_url"` + // Upload 上传安全策略(可选,零值不限制)。 + Upload UploadPolicy `mapstructure:"upload"` + // MaxReadBytes Get 读取单文件上限(字节)。0 = 默认 100MB,-1 = 不限制。 + MaxReadBytes int64 `mapstructure:"max_read_bytes"` } // OSSStorageConfig OSS 存储配置 @@ -317,6 +351,10 @@ type OSSStorageConfig struct { AccessKeyID string `mapstructure:"access_key_id"` AccessKeySecret string `mapstructure:"access_key_secret"` BaseURL string `mapstructure:"base_url"` + // Upload 上传安全策略(可选,零值不限制)。 + Upload UploadPolicy `mapstructure:"upload"` + // MaxReadBytes Get 读取单文件上限(字节)。0 = 默认 100MB,-1 = 不限制。 + MaxReadBytes int64 `mapstructure:"max_read_bytes"` } // LogConfig 日志配置 @@ -394,9 +432,20 @@ type Manager struct { v *viper.Viper cfg *Config callbacks []func(*Config) + // watcher 是自管的 fsnotify 监听器(C10d)。nil 表示未启用文件监听。 + // 由 StartWatcher 创建、StopWatcher 关闭,避免依赖 viper 内部无法停止的 watcher。 + watcher *fsnotify.Watcher + // watchDone 在监听 goroutine 退出时被 close,供 StopWatcher 等待退出确认。 + watchDone chan struct{} } -var defaultManager = NewManager("") +// defaultManager 是包级默认管理器(C10a)。改用 atomic.Pointer 保护读写, +// 消除原裸指针置换与请求 goroutine 无锁读之间的数据竞争。 +var defaultManager atomic.Pointer[Manager] + +func init() { + defaultManager.Store(NewManager("")) +} // NewManager 创建配置管理器 func NewManager(configPath string) *Manager { @@ -441,7 +490,12 @@ func (m *Manager) Load() (*Config, error) { m.cfg = &cfg m.mu.Unlock() - return &cfg, nil + // 防御性拷贝(C10c):返回独立副本,避免调用方与框架内部 m.cfg 共享同一可变指针。 + // 调用方修改返回值的标量字段不影响 Get() 等读取路径。 + // 注意:这是浅拷贝——切片字段(如 CORSConfig.AllowedOrigins)仍与 m.cfg 共享底层数组, + // 调用方不得修改切片元素(约定配置对象为只读)。需要完全独立可变副本时自行深拷贝。 + out := cfg + return &out, nil } // LoadWithWatch 加载配置文件并启用热更新 @@ -469,44 +523,112 @@ func (m *Manager) RegisterCallback(cb func(*Config)) { m.callbacks = append(m.callbacks, cb) } -// StartWatcher 启动配置文件监听 +// StartWatcher 启动配置文件监听。使用自管的 fsnotify.Watcher(监听配置文件 +// 所在目录以兼容编辑器改写/k8s ConfigMap 原子替换),文件变更时去抖后重新加载。 +// 幂等:重复调用不会创建多个监听 goroutine。 func (m *Manager) StartWatcher() error { if m == nil { return ErrConfigNotLoaded } - m.mu.RLock() - v := m.v - m.mu.RUnlock() - if v == nil { + m.mu.Lock() + if m.watcher != nil { + // 已在监听,幂等返回 + m.mu.Unlock() + return nil + } + if m.v == nil || m.path == "" { + m.mu.Unlock() return ErrConfigNotLoaded } - - v.WatchConfig() - v.OnConfigChange(func(e fsnotify.Event) { - var newCfg Config - if err := unmarshalConfig(v, &newCfg); err != nil { - return - } - - m.mu.Lock() - m.cfg = &newCfg - cbs := make([]func(*Config), len(m.callbacks)) - copy(cbs, m.callbacks) + w, err := fsnotify.NewWatcher() + if err != nil { m.mu.Unlock() + return fmt.Errorf("创建文件监听失败: %w", err) + } + // 监听父目录而非文件本身:vim/k8s 等通过"写临时文件 + rename"替换配置, + // 直接监听文件会在 rename 后丢失。监听目录并按文件名过滤更稳健。 + dir := filepath.Dir(m.path) + if err := w.Add(dir); err != nil { + m.mu.Unlock() + _ = w.Close() + return fmt.Errorf("监听配置目录失败: %w", err) + } + m.watcher = w + m.watchDone = make(chan struct{}) + target := filepath.Base(m.path) + done := m.watchDone + m.mu.Unlock() - for _, cb := range cbs { - cb(&newCfg) - } - }) - + go m.watchLoop(w, target, done) return nil } -// StopWatcher 停止配置文件监听 -func (m *Manager) StopWatcher() {} +// watchLoop 是文件监听 goroutine 主体。文件变更经去抖后调用 reload; +// watcher 被 Close(Events 通道关闭)时退出并 close done。 +// done 由 StartWatcher 在锁内捕获传入,避免本 goroutine 读取 m.watchDone 字段 +// 与 StopWatcher 写入竞争。 +func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct{}) { + defer close(done) + const debounce = 200 * time.Millisecond + var timer *time.Timer + for { + select { + 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 != nil { + timer.Stop() + } + timer = time.AfterFunc(debounce, func() { + // reload 内部对非法配置保留旧配置(C10b),错误被忽略—— + // 监听路径无法向上传播错误,保留旧配置即正确语义。 + _ = m.reload() + }) + case _, ok := <-w.Errors: + if !ok { + return + } + // 非致命错误:继续监听。 + } + } +} -// Get 获取配置 +// StopWatcher 停止配置文件监听并释放 watcher(C10d)。幂等。 +// 关闭 fsnotify watcher → Events 通道关闭 → watchLoop 退出 → 等待 watchDone。 +func (m *Manager) StopWatcher() { + if m == nil { + return + } + m.mu.Lock() + w := m.watcher + done := m.watchDone + m.watcher = nil + m.watchDone = nil + m.mu.Unlock() + if w == nil { + return + } + _ = w.Close() + if done != nil { + <-done + } +} + +// Get 获取配置。 +// +// 返回的是 Manager 内部持有的配置指针(共享),调用方**必须视为只读**: +// 修改返回值的标量或切片元素会污染全局配置并与其他读取 goroutine 竞争。 +// 需要可变副本时用 Load()(返回防御性拷贝)或自行深拷贝。 func (m *Manager) Get() *Config { if m == nil { return nil @@ -539,29 +661,38 @@ func (m *Manager) Set(cfg *Config) { } } -// Reload 重新加载配置文件 +// Reload 重新加载配置文件。读取、解析、校验(C10b)任一步失败均保留旧配置并返回错误; +// 仅当新配置通过 Validate 后才替换 m.cfg 并触发回调。 func (m *Manager) Reload() error { if m == nil { return ErrConfigNotLoaded } + return m.reload() +} - m.mu.RLock() +// reload 是 Reload 与文件监听共享的重载实现。全程持写锁以串行化对 viper 的 +// ReadInConfig 访问(viper 非完全并发安全),并在替换前强制 Validate(C10b)。 +func (m *Manager) reload() error { + m.mu.Lock() v := m.v - m.mu.RUnlock() if v == nil { + m.mu.Unlock() return ErrConfigNotLoaded } - if err := v.ReadInConfig(); err != nil { + m.mu.Unlock() return fmt.Errorf("读取配置文件失败: %w", err) } - var newCfg Config if err := unmarshalConfig(v, &newCfg); err != nil { + m.mu.Unlock() return fmt.Errorf("解析配置文件失败: %w", err) } - - m.mu.Lock() + if err := newCfg.Validate(); err != nil { + // 非法配置保留旧配置,不得静默发布(C10b) + m.mu.Unlock() + return err + } m.cfg = &newCfg cbs := make([]func(*Config), len(m.callbacks)) copy(cbs, m.callbacks) @@ -570,67 +701,70 @@ func (m *Manager) Reload() error { for _, cb := range cbs { cb(&newCfg) } - return nil } // Load 加载配置文件 func Load(configPath string) (*Config, error) { - defaultManager = NewManager(configPath) - return defaultManager.Load() + m := NewManager(configPath) + defaultManager.Store(m) + return m.Load() } // LoadWithWatch 加载配置文件并启用热更新 func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) { - defaultManager = NewManager(configPath) - return defaultManager.LoadWithWatch(onChange) + m := NewManager(configPath) + defaultManager.Store(m) + return m.LoadWithWatch(onChange) } // RegisterCallback 注册配置变更回调 func RegisterCallback(cb func(*Config)) { - defaultManager.RegisterCallback(cb) + defaultManager.Load().RegisterCallback(cb) } // StartWatcher 启动配置文件监听 func StartWatcher() error { - return defaultManager.StartWatcher() + return defaultManager.Load().StartWatcher() } // StopWatcher 停止配置文件监听 func StopWatcher() { - defaultManager.StopWatcher() + defaultManager.Load().StopWatcher() } // Get 获取全局配置 func Get() *Config { - return defaultManager.Get() + return defaultManager.Load().Get() } // GetViper 获取 viper 实例(用于扩展配置) func GetViper() *viper.Viper { - return defaultManager.GetViper() + return defaultManager.Load().GetViper() } // Set 手动设置配置(用于测试或动态修改) func Set(cfg *Config) { - defaultManager.Set(cfg) + defaultManager.Load().Set(cfg) } // Reload 重新加载配置文件 func Reload() error { - return defaultManager.Reload() + return defaultManager.Load().Reload() } // SetDefaultManager 替换全局默认配置管理器。 // 主要供应用层(如 App)在持有自己的 Manager 时使用, // 使 config.Get / config.GetString 等便捷函数仍然能取到正确的配置。 // 传入 nil 表示重置为空管理器。 +// +// C10a:经 atomic.Pointer.Store 原子置换,消除与并发读取(Get 等)的数据竞争。 func SetDefaultManager(m *Manager) { if m == nil { - defaultManager = NewManager("") + defaultManager.Store(NewManager("")) return } - defaultManager = m + defaultManager.Store(m) } // GetString 获取字符串配置 diff --git a/config/config_c10_test.go b/config/config_c10_test.go new file mode 100644 index 0000000..99d9d9a --- /dev/null +++ b/config/config_c10_test.go @@ -0,0 +1,326 @@ +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 固化 C10c 的浅拷贝语义契约: +// 标量字段独立(修改不污染全局),切片字段共享底层数组(文档化为只读契约, +// 调用方不得修改切片元素)。本测试锁定该行为,防止未来误改。 +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) + } + + // 切片共享底层数组(浅拷贝局限):修改切片元素会污染全局。 + // 这是文档化的只读契约——本断言锁定该行为,提醒调用方不得改切片元素。 + cfg.CORS.AllowedOrigins[0] = "https://mutated.example.com" + if got := config.Get().CORS.AllowedOrigins[0]; got != "https://mutated.example.com" { + t.Errorf("slice backing array should be shared (shallow copy), got %q", 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() +} diff --git a/config/config_test.go b/config/config_test.go index f61dc47..5cb8688 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -3,6 +3,7 @@ package config_test import ( "os" "path/filepath" + "strings" "testing" "github.com/EthanCodeCraft/xlgo-core/config" @@ -101,7 +102,7 @@ func TestDatabaseConfigPostgresDSN(t *testing.T) { } dsn := db.DSN() - expected := "host=localhost port=5432 user=postgres password=password dbname=testdb sslmode=disable TimeZone=Asia/Shanghai" + expected := "host=localhost port=5432 user=postgres password='password' dbname=testdb sslmode=disable TimeZone=Asia/Shanghai" if dsn != expected { t.Errorf("Postgres DSN = %s, want %s", dsn, expected) } @@ -112,6 +113,48 @@ func TestDatabaseConfigPostgresDSN(t *testing.T) { } } +// 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, diff --git a/console/console_windows.go b/console/console_windows.go index f5eb43b..baa4b2d 100644 --- a/console/console_windows.go +++ b/console/console_windows.go @@ -4,6 +4,7 @@ package console import ( "fmt" + "os" "syscall" "unsafe" ) @@ -21,31 +22,48 @@ var colorMap = map[string]uintptr{ "1;31": 12, // 亮红色 } -// printColor 彩色打印 +// printColor 彩色打印。 +// +// 修复 M17:原实现对 syscall.Stdout 设置颜色、却把文本写到 c.output—— +// 当 c.output 非 stdout(如 WithOutput 指向文件)时,颜色落到错误句柄、文本落文件, +// 二者分裂。现按 c.output 实际类型取句柄:*os.File 用其 Fd(),否则放弃着色只写文本。 func (c *Console) printColor(code, msg string) { color := colorMap[code] if color == 0 { color = 7 // 默认淡灰色 } - proc := kernel32.NewProc("SetConsoleTextAttribute") - _, _, _ = proc.Call(uintptr(syscall.Stdout), color) - fmt.Fprintln(c.output, msg) - _, _, _ = proc.Call(uintptr(syscall.Stdout), 7) // 恢复默认颜色 -} - -// EnableVirtualTerminal 启用虚拟终端支持(Windows 10+) -func EnableVirtualTerminal() error { - // 尝试启用 ANSI 转义序列支持 - proc := kernel32.NewProc("GetConsoleMode") - var mode uint32 - _, _, err := proc.Call(uintptr(syscall.Stdout), uintptr(unsafe.Pointer(&mode))) - if err != syscall.Errno(0) { - return err + handle, ok := consoleHandle(c.output) + if !ok { + // 非 *os.File(如 bytes.Buffer/文件),无法设置控制台属性,退化为纯文本。 + fmt.Fprintln(c.output, msg) + return } - mode |= 0x0004 // ENABLE_VIRTUAL_TERMINAL_PROCESSING - proc = kernel32.NewProc("SetConsoleMode") - _, _, err = proc.Call(uintptr(syscall.Stdout), uintptr(mode)) - return err + proc := kernel32.NewProc("SetConsoleTextAttribute") + _, _, _ = proc.Call(handle, color) + fmt.Fprintln(c.output, msg) + _, _, _ = proc.Call(handle, 7) // 恢复默认颜色 } + +// consoleHandle 从 io.Writer 取 Windows 控制台句柄;非 *os.File 返回 (0, false)。 +func consoleHandle(w interface{ Write([]byte) (int, error) }) (uintptr, bool) { + f, ok := w.(*os.File) + if !ok { + return 0, false + } + fd := f.Fd() + switch fd { + case uintptr(syscall.Stdout), uintptr(syscall.Stderr), uintptr(syscall.Stdin): + return fd, true + default: + // 重定向到文件/管道的 *os.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 index 7af65ab..7c9f9ea 100644 --- a/cron/cron.go +++ b/cron/cron.go @@ -3,8 +3,10 @@ package cron import ( "context" "fmt" + "strconv" "strings" "sync" + "sync/atomic" "time" ) @@ -17,6 +19,12 @@ type Task struct { LastRun time.Time // 上次运行时间 NextRun time.Time // 下次运行时间 RunCount int // 运行次数 + + // running 防止长任务跨 tick 重叠执行(C12b)。 + // 用指针以便 GetTask/ListTasks 返回 Task 拷贝时不触发 atomic.Bool 值类型的 + // copylocks 警告;拷贝共享同一守卫状态,但该字段未导出,外部无法操作。 + // nil 表示未初始化(仅非 AddTask 构造的 Task),checkAndRun/RunTask 以 nil 守卫跳过。 + running *atomic.Bool } // TaskHandler 任务处理函数 @@ -55,6 +63,7 @@ func (s *Scheduler) AddTask(name string, schedule Schedule, handler TaskHandler) Handler: handler, Enabled: true, NextRun: schedule.Next(time.Now()), + running: &atomic.Bool{}, } s.mu.Lock() @@ -100,7 +109,7 @@ func (s *Scheduler) DisableTask(name string) error { return nil } -// GetTask 获取任务 +// GetTask 获取任务(返回拷贝快照,避免外部并发读 live 指针,C12a)。 func (s *Scheduler) GetTask(name string) (*Task, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -109,43 +118,51 @@ func (s *Scheduler) GetTask(name string) (*Task, error) { if !ok { return nil, fmt.Errorf("任务不存在: %s", name) } - return task, nil + cp := *task + return &cp, nil } -// ListTasks 获取所有任务 +// ListTasks 获取所有任务(返回拷贝快照,C12a)。 func (s *Scheduler) ListTasks() []*Task { s.mu.RLock() defer s.mu.RUnlock() tasks := make([]*Task, 0, len(s.tasks)) for _, task := range s.tasks { - tasks = append(tasks, task) + cp := *task + tasks = append(tasks, &cp) } return tasks } -// RunTask 立即运行任务 +// RunTask 立即运行任务(手动触发,同步返回 handler 错误)。 +// +// 占用 per-task running 守卫,与调度循环互斥,防止同一任务重叠执行(C12b)。 +// 不推进 NextRun(手动触发不影响调度节奏,C12c)。LastRun/RunCount 在锁内更新(C12a)。 func (s *Scheduler) RunTask(name string) error { - s.mu.RLock() + s.mu.Lock() task, ok := s.tasks[name] - s.mu.RUnlock() + s.mu.Unlock() if !ok { return fmt.Errorf("任务不存在: %s", name) } - return s.runTask(task) -} - -// runTask 执行任务 -func (s *Scheduler) runTask(task *Task) error { - task.LastRun = time.Now() - task.RunCount++ + // 占用 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 := task.Handler(s.ctx) s.mu.Lock() - task.NextRun = task.Schedule.Next(time.Now()) + task.LastRun = time.Now() + task.RunCount++ s.mu.Unlock() return err @@ -196,17 +213,45 @@ func (s *Scheduler) run() { } } -// checkAndRun 检查并运行到期任务 +// checkAndRun 检查并运行到期任务。 +// +// C12b:per-task running 守卫(CAS)防止长任务跨 tick 重叠 spawn。 +// C12c:占用守卫后**先推进 NextRun**(以上次 NextRun 锚定,非 time.Now()), +// 避免每周期累积 handler 时长致调度漂移;推进在 spawn 前,下次 tick 不会重复 spawn。 +// C12a:NextRun 推进在写锁内;LastRun/RunCount 在 goroutine 内写锁更新。 func (s *Scheduler) checkAndRun() { now := time.Now() - s.mu.RLock() + s.mu.Lock() for _, task := range s.tasks { - if task.Enabled && !task.NextRun.IsZero() && now.After(task.NextRun) { - go s.runTask(task) + 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) + go func(t *Task) { + defer s.wg.Done() + defer func() { + if t.running != nil { + t.running.Store(false) + } + }() + + err := t.Handler(s.ctx) + + s.mu.Lock() + t.LastRun = time.Now() + t.RunCount++ + s.mu.Unlock() + _ = err + }(task) } - s.mu.RUnlock() + s.mu.Unlock() } // IntervalSchedule 固定间隔调度 @@ -251,14 +296,19 @@ type WeeklySchedule struct { Minute int } -// Next 计算下次运行时间 +// Next 计算下次运行时间。 +// +// C12d:原实现 `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻, +// 当天目标时刻未到(如周一 9:00 目标、当前周一 12:00 之前的 8:00)被错误跳一周。 +// 改为:先算今天的目标时刻,按 `((day-now)+7)%7` 加天数,再与 now 比较—— +// 当天未到点则本周,当天已过则下周。 func (s *WeeklySchedule) Next(now time.Time) time.Time { - daysUntil := int(s.Day) - int(now.Weekday()) - if daysUntil <= 0 { - daysUntil += 7 + next := time.Date(now.Year(), now.Month(), now.Day(), s.Hour, s.Minute, 0, 0, now.Location()) + daysUntil := (int(s.Day) - int(now.Weekday()) + 7) % 7 + next = next.AddDate(0, 0, daysUntil) + if !next.After(now) { + next = next.AddDate(0, 0, 7) } - - next := time.Date(now.Year(), now.Month(), now.Day()+daysUntil, s.Hour, s.Minute, 0, 0, now.Location()) return next } @@ -306,42 +356,115 @@ func (s *FullCronSchedule) match(t time.Time) bool { s.matchField(s.Weekday, int(t.Weekday()), 0, 6) } -// matchField 匹配单个字段 +// matchField 匹配单个字段(C12e 重写)。 +// +// 旧实现先判 `-` 再判 `*/` 最后列表,且 parseInt 忽略非数字逐位累积: +// - `1-5,8` 因整字段含 `-` 被当范围,parseInt("5,8")=58 → 范围被破坏为 1..58,列表项丢失。 +// - `garbage` → parseInt=0,分/时/周字段 value=0 时误触发。 +// - `*/garbage` → step=0 → return true 匹配全部。 +// - 周日 `7` 不匹配(Go Sunday=0)。 +// +// 新实现:先按逗号拆列表,每项独立判 `*/n` / `a-b/n` / `a-b` / 单值(列表分支独立于范围分支); +// 全部用 strconv.Atoi 返错;weekday 字段(min=0,max=6)7→0,范围 lo>hi 环绕。 func (s *FullCronSchedule) matchField(field string, value int, min, max int) bool { if field == "*" { return true } - - // 处理范围 "a-b" - if strings.Contains(field, "-") { - parts := strings.Split(field, "-") - if len(parts) == 2 { - start := parseInt(parts[0]) - end := parseInt(parts[1]) - return value >= start && value <= end + for _, raw := range strings.Split(field, ",") { + item := strings.TrimSpace(raw) + if item == "" { + continue } - } - - // 处理步长 "*/n" - if strings.HasPrefix(field, "*/") { - step := parseInt(strings.TrimPrefix(field, "*/")) - if step > 0 { - return (value - min) % step == 0 - } - return true - } - - // 处理列表 "a,b,c" - for _, p := range strings.Split(field, ",") { - if parseInt(strings.TrimSpace(p)) == value { + if matchCronItem(item, value, min, max) { return true } } - return false } -// ParseCron 解析完整 Cron 表达式 +// matchCronItem 处理单个 cron 字段项(已按逗号拆分)。 +func matchCronItem(item string, value, min, max int) bool { + isWeekday := min == 0 && max == 6 + + // 步长 "*/n" 或 "a-b/n" + if idx := strings.Index(item, "/"); idx >= 0 { + base := item[:idx] + step, err := strconv.Atoi(item[idx+1:]) + if err != nil || step <= 0 { + return false + } + lo, hi := min, max + if base != "*" { + rlo, rhi, err := parseCronRange(base, min, max) + if err != nil { + return false + } + lo, hi = rlo, rhi + } + return value >= lo && value <= hi && (value-lo)%step == 0 + } + + // 范围 "a-b" + if strings.Contains(item, "-") { + lo, hi, err := parseCronRange(item, min, max) + if err != nil { + return false + } + if isWeekday && lo > hi { + // 环绕:lo..6 ∪ 0..hi(如 "6-1" = 周六、周日、周一) + return (value >= lo && value <= max) || (value >= min && value <= hi) + } + return value >= lo && value <= hi + } + + // 单值 + v, err := strconv.Atoi(item) + if err != nil { + return false + } + if isWeekday && v == 7 { + v = 0 + } + if v < min || v > max { + return false + } + return v == value +} + +// parseCronRange 解析 "a-b" 范围,含边界校验与 weekday 7→0 归一化。 +func parseCronRange(s string, min, max int) (int, int, error) { + parts := strings.SplitN(s, "-", 2) + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid range %q", s) + } + lo, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + hi, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil { + return 0, 0, fmt.Errorf("invalid range %q", s) + } + isWeekday := min == 0 && max == 6 + if isWeekday { + // 7 → 0(周日)。但若两端归一化后都为 0 而原始值不同(如 "0-7"/"7-0"), + // 语义为"整周"却坍缩成"仅周日",属歧义范围,拒绝以免静默错误匹配。 + nlo, nhi := lo, hi + if nlo == 7 { + nlo = 0 + } + if nhi == 7 { + nhi = 0 + } + if nlo == nhi && lo != hi { + return 0, 0, fmt.Errorf("ambiguous weekday range %q (0 与 7 均为周日)", s) + } + lo, hi = nlo, nhi + } + if lo < min || lo > max || hi < min || hi > max { + return 0, 0, fmt.Errorf("range out of bounds %q", s) + } + return lo, hi, nil +} + +// ParseCron 解析完整 Cron 表达式。 // 格式: "分钟 小时 日 月 星期" // 示例: // @@ -350,11 +473,37 @@ func (s *FullCronSchedule) matchField(field string, value int, min, max int) boo // "0 9-17 * * 1-5" - 工作日9-17点每小时 // "0 0 1 * *" - 每月1号凌晨 // "0 0 * * 0" - 每周日凌晨 +// +// 非法表达式回退默认全 "*"(每分钟执行)。需要严格校验请用 ParseCronStrict。 func ParseCron(expr string) *FullCronSchedule { + if sched, err := ParseCronStrict(expr); err == nil { + return sched + } + return &FullCronSchedule{"*", "*", "*", "*", "*"} +} + +// ParseCronStrict 严格解析 Cron 表达式,校验字段数与各字段范围,非法返 error。 +// 字段范围:分钟 0-59,小时 0-23,日 1-31,月 1-12,星期 0-6(周日=0,7 归一为 0)。 +func ParseCronStrict(expr string) (*FullCronSchedule, error) { fields := strings.Fields(expr) if len(fields) != 5 { - // 默认每分钟执行 - return &FullCronSchedule{"*", "*", "*", "*", "*"} + return nil, fmt.Errorf("cron: 需要 5 个字段,实际 %d", len(fields)) + } + specs := []struct { + val string + min, max int + name string + }{ + {fields[0], 0, 59, "minute"}, + {fields[1], 0, 23, "hour"}, + {fields[2], 1, 31, "day"}, + {fields[3], 1, 12, "month"}, + {fields[4], 0, 6, "weekday"}, + } + for _, sp := range specs { + if err := validateCronField(sp.val, sp.min, sp.max, sp.name); err != nil { + return nil, err + } } return &FullCronSchedule{ Minute: fields[0], @@ -362,7 +511,65 @@ func ParseCron(expr string) *FullCronSchedule { Day: fields[2], Month: fields[3], Weekday: fields[4], + }, nil +} + +// validateCronField 校验单个 cron 字段语法与范围。 +func validateCronField(field string, min, max int, name string) error { + if field == "*" { + return nil } + for _, raw := range strings.Split(field, ",") { + item := strings.TrimSpace(raw) + if item == "" { + return fmt.Errorf("cron %s: 空列表项", name) + } + if err := validateCronItem(item, min, max, name); err != nil { + return err + } + } + return nil +} + +func validateCronItem(item string, min, max int, name string) error { + isWeekday := min == 0 && max == 6 + norm := func(v int) int { + if isWeekday && v == 7 { + return 0 + } + return v + } + if idx := strings.Index(item, "/"); idx >= 0 { + base := item[:idx] + step, err := strconv.Atoi(item[idx+1:]) + if err != nil || step <= 0 { + return fmt.Errorf("cron %s: 非法步长 %q", name, item) + } + if base == "*" { + return nil + } + lo, hi, err := parseCronRange(base, min, max) + if err != nil { + return fmt.Errorf("cron %s: %v", name, err) + } + _ = lo + _ = hi + return nil + } + if strings.Contains(item, "-") { + if _, _, err := parseCronRange(item, min, max); err != nil { + return fmt.Errorf("cron %s: %v", name, err) + } + return nil + } + v, err := strconv.Atoi(item) + if err != nil { + return fmt.Errorf("cron %s: 非数字 %q", name, item) + } + if v = norm(v); v < min || v > max { + return fmt.Errorf("cron %s: %d 超出范围 [%d,%d]", name, v, min, max) + } + return nil } // CronSchedule 简化 Cron 调度(仅分钟和小时) @@ -399,40 +606,19 @@ func (s *CronSchedule) matchValue(pattern string, value int) bool { return false } +// splitPattern 将逗号分隔的模式解析为值列表(C12e:用 strconv.Atoi,非法项跳过)。 func splitPattern(pattern string) []int { var values []int - for _, p := range split(pattern, ',') { - v := parseInt(p) - if v >= 0 { - values = append(values, v) + for _, p := range strings.Split(pattern, ",") { + v, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + continue } + values = append(values, v) } return values } -func split(s string, sep byte) []string { - var parts []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == sep { - parts = append(parts, s[start:i]) - start = i + 1 - } - } - parts = append(parts, s[start:]) - return parts -} - -func parseInt(s string) int { - var v int - for _, c := range s { - if c >= '0' && c <= '9' { - v = v*10 + int(c-'0') - } - } - return v -} - // Cron 创建类 Cron 调度 func Cron(minute, hour string) *CronSchedule { return &CronSchedule{Minute: minute, Hour: hour} 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..700145c --- /dev/null +++ b/cron/cron_c12_test.go @@ -0,0 +1,312 @@ +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 验证 ParseCron 非法回退默认全 *(保持原行为)。 +func TestC12eParseCronFallback(t *testing.T) { + s := cron.ParseCron("invalid") + if s.Minute != "*" || s.Hour != "*" || s.Day != "*" || s.Month != "*" || s.Weekday != "*" { + t.Errorf("ParseCron(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/database/dialect.go b/database/dialect.go index dcdbcee..cd648c6 100644 --- a/database/dialect.go +++ b/database/dialect.go @@ -6,6 +6,7 @@ import ( "sync" "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/logger" "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -101,7 +102,11 @@ func dialectorForDSN(driver, dsn string) gorm.Dialector { if f, ok := LookupDialect(driver); ok { return f(dsn) } - // 未注册时回退到 MySQL,与 config.DSN() 的回退保持一致 + // 未注册时回退到 MySQL,与 config.DSN() 的回退保持一致。 + // 拼写错误的驱动名(如 "mysq"/"postgrs")会静默回退 MySQL,导致连接错误难排查(M10), + // 故在此告警一次,提示用户驱动名未注册。 + logger.Warnf("database: 驱动 %q 未注册,回退到 MySQL(已注册: %s);若是拼写错误请在配置中修正 driver", + normalizeDriver(driver), strings.Join(RegisteredDialects(), ", ")) return mysql.Open(dsn) } diff --git a/database/manager.go b/database/manager.go index b0c1321..cc8f8f2 100644 --- a/database/manager.go +++ b/database/manager.go @@ -2,6 +2,7 @@ package database import ( "context" + "database/sql" "errors" "fmt" "math/rand" @@ -20,6 +21,10 @@ import ( type dbModeContextKey struct{} +// txContextKey 携带外层事务的 *gorm.DB,使 repository 等上层在调用时能 join 到外层事务 +// (H6c:外层 ctx 事务无法 join)。由 WithTx 注入、TxFromContext 读取。 +type txContextKey struct{} + const ( dbModeMaster = "master" dbModeReplica = "replica" @@ -93,25 +98,37 @@ func (m *Manager) Picker() ReplicaPicker { return m.picker } -// Master 返回主库实例 +// Master 返回主库实例。 +// 经 m.mu 锁保护读取,避免与 InitDB/Close 的写竞争返回已关闭/nil 池(C11d)。 func (m *Manager) Master() *gorm.DB { + m.mu.Lock() + defer m.mu.Unlock() return m.master } -// Replicas 返回所有从库实例 +// Replicas 返回所有从库实例的拷贝。 +// 经 m.mu 锁保护读取并返回拷贝,避免调用方持活切片与 InitDBWithReplicas/Close 重置竞争(C11d)。 func (m *Manager) Replicas() []*gorm.DB { - return m.replicas + m.mu.Lock() + defer m.mu.Unlock() + if m.replicas == nil { + return nil + } + out := make([]*gorm.DB, len(m.replicas)) + copy(out, m.replicas) + return out } // Replica 按策略选择一个从库;无从库时返回主库。 // #21:启用探活后,自动过滤不健康的从库;全不健康时回退到全部从库(仍可服务)。 +// 全程持 m.mu 锁,避免 replicas/master 与重建路径写竞争(C11d)。 func (m *Manager) Replica() *gorm.DB { - if len(m.replicas) == 0 { - return m.master - } m.mu.Lock() defer m.mu.Unlock() + if len(m.replicas) == 0 { + return m.master + } pool := m.replicas // 启用探活且至少有一个健康标记时,仅从健康从库中选取 if m.replicaHealthSet { @@ -141,6 +158,8 @@ func (m *Manager) IsHealthy() bool { } // initReplicaHealth 按 replicas 数量初始化健康标记(全部为健康)。 +// 已初始化(replicaHealthSet=true)时早返回;重建从库前须先 resetReplicaHealth 重置, +// 否则健康切片长度与新 replicas 错位(C11a)。 func (m *Manager) initReplicaHealth() { m.mu.Lock() defer m.mu.Unlock() @@ -154,6 +173,14 @@ func (m *Manager) initReplicaHealth() { 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); @@ -211,6 +238,7 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) { replicas := make([]*gorm.DB, len(m.replicas)) copy(replicas, m.replicas) healthSet := m.replicaHealthSet + replicaHealthy := m.replicaHealthy // 快照切片头,避免与 resetReplicaHealth 写竞争 m.mu.Unlock() if !healthSet { return @@ -221,21 +249,21 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) { } sqlDB, err := r.DB() if err != nil { - if i < len(m.replicaHealthy) { - m.replicaHealthy[i].Store(false) + if i < len(replicaHealthy) { + replicaHealthy[i].Store(false) } continue } if err := sqlDB.PingContext(ctx); err != nil { - if i < len(m.replicaHealthy) && m.replicaHealthy[i].Load() { + if i < len(replicaHealthy) && replicaHealthy[i].Load() { logger.Warnf("数据库从库 #%d 探活失败,暂时剔除读流量: %v", i, err) } - if i < len(m.replicaHealthy) { - m.replicaHealthy[i].Store(false) + if i < len(replicaHealthy) { + replicaHealthy[i].Store(false) } } else { - if i < len(m.replicaHealthy) { - m.replicaHealthy[i].Store(true) + if i < len(replicaHealthy) { + replicaHealthy[i].Store(true) } } } @@ -249,7 +277,7 @@ func (m *Manager) FromContext(ctx context.Context) *gorm.DB { } switch mode { case dbModeMaster: - return m.master + return m.Master() case dbModeReplica: return m.Replica() default: @@ -273,45 +301,52 @@ func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) er return m.InitDBWithReplicas(m.cfg, replicaDSNs) } -// Close 关闭主库与全部从库连接 +// 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() +} + +// Close 关闭主库与全部从库连接,并重置从库健康状态。 +// 字段置空在锁内完成(保证新读取得到 nil),实际关闭在锁外执行避免持锁阻塞。 func (m *Manager) Close() error { - var errs []error - - if m.master != nil { - sqlDB, err := m.master.DB() - if err != nil { - errs = append(errs, err) - } else if err := sqlDB.Close(); err != nil { - errs = append(errs, err) - } - } - - for _, replica := range m.replicas { - if replica == nil { - continue - } - sqlDB, err := replica.DB() - if err != nil { - errs = append(errs, err) - continue - } - if err := sqlDB.Close(); err != nil { - errs = append(errs, err) - } - } - + m.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 健康检查,主库不可达时返回错误 func (m *Manager) HealthCheck(ctx context.Context) error { - if m.master == nil { + m.mu.Lock() + db := m.master + m.mu.Unlock() + if db == nil { return errors.New("数据库主库未初始化") } - sqlDB, err := m.master.DB() + sqlDB, err := db.DB() if err != nil { return err } @@ -323,7 +358,6 @@ var DefaultManager = &Manager{picker: &RandomPicker{}} // InitDB 初始化数据库连接(带重试机制),驱动由配置决定 func (m *Manager) InitDB(cfg *config.Config) error { - var err error m.cfg = cfg // GORM 日志配置 @@ -344,20 +378,36 @@ func (m *Manager) InitDB(cfg *config.Config) error { var lastErr error for i := range maxRetries { - // 连接主库 - m.master, err = gorm.Open(Dialector(cfg), gormConfig) - if err == nil { - sqlDB, err := m.master.DB() - if err == nil { + // 连接主库:先打开到局部变量,仅 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 + _ = closeDB(db) // C11b: 关闭刚打开的池,避免下轮泄漏 + } else { sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns) sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns) sqlDB.SetConnMaxLifetime(time.Hour) if cfg.Database.ConnMaxIdleTime > 0 { sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime) } - m.healthy.Store(true) // 主库连通即标记健康(#21) if err := sqlDB.Ping(); err == nil { + // 成功:安装为新主库,关闭旧主库池(重建路径覆盖前先释放旧资源,C11b) + m.mu.Lock() + old := m.master + m.master = db + m.mu.Unlock() + m.healthy.Store(true) // Ping 通过才标记健康(#21) + _ = closeDB(old) logger.Info("数据库主库连接成功", zap.String("driver", driverDescription(cfg.Database.Driver)), zap.String("host", cfg.Database.Host), @@ -366,15 +416,8 @@ func (m *Manager) InitDB(cfg *config.Config) error { } else { // Ping 失败(如服务端暂时不可达)视作可重试 lastErr = err + _ = closeDB(db) // C11b: 关闭刚打开的池,避免下轮覆盖泄漏 } - } else { - lastErr = err - } - } else { - lastErr = err - // 不可恢复的错误(认证失败、未知数据库、DSN 非法等)直接返回,不必重试 - if !isTransientDBError(err) { - return fmt.Errorf("数据库连接失败(不可恢复): %w", err) } } @@ -420,7 +463,15 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e return err } + // C11c: 重建从库前关闭旧从库池;C11a: 重置健康状态,使下次 initReplicaHealth 按新 replicas 长度重建 + m.mu.Lock() + oldReplicas := m.replicas m.replicas = nil + m.resetReplicaHealth() + m.mu.Unlock() + for _, r := range oldReplicas { + _ = closeDB(r) + } // 初始化从库 if len(replicaDSNs) > 0 { @@ -435,6 +486,8 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e Logger: gormlogger.Default.LogMode(gormLogLevel), } + // 先构建到局部切片,全部成功后再安装,避免部分构建期间外部读到中间态 + var newReplicas []*gorm.DB for i, dsn := range replicaDSNs { replicaDB, err := gorm.Open(dialectorForDSN(cfg.Database.Driver, dsn), gormConfig) if err != nil { @@ -445,6 +498,7 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e sqlDB, err := replicaDB.DB() if err != nil { logger.Warnf("数据库从库 %d 获取连接池失败: %v", i+1, err) + _ = closeDB(replicaDB) // C11c: 关闭刚打开的池避免泄漏 continue } @@ -454,12 +508,17 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e if err := sqlDB.Ping(); err != nil { logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err) + _ = closeDB(replicaDB) // C11c: 关闭刚打开的池避免泄漏 continue } - m.replicas = append(m.replicas, replicaDB) + newReplicas = append(newReplicas, replicaDB) logger.Info("数据库从库连接成功", zap.Int("index", i+1)) } + + m.mu.Lock() + m.replicas = newReplicas + m.mu.Unlock() } return nil @@ -515,24 +574,43 @@ func GetDBFromContext(ctx context.Context) *gorm.DB { return DefaultManager.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 { + if tx == nil { + return ctx + } + return context.WithValue(ctx, txContextKey{}, tx) +} + +// TxFromContext 取出 ctx 携带的外层事务;无则返回 nil。 +func TxFromContext(ctx context.Context) *gorm.DB { + 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) +// Close 关闭所有数据库连接(主库与从库),等价于 CloseAll。 +// 历史上仅关闭主库、遗留从库池泄漏(C11f),已修正为委托 CloseAll。 func Close() error { - if DefaultManager.master == nil { - return nil - } - sqlDB, err := DefaultManager.master.DB() - if err != nil { - return err - } - err = sqlDB.Close() - DefaultManager.master = nil - return err + return CloseAll() } // CloseAll 关闭所有数据库连接(包括从库) @@ -542,18 +620,20 @@ func CloseAll() error { // Transaction 事务操作(自动使用主库) func Transaction(fn func(tx *gorm.DB) error) error { - if DefaultManager.master == nil { + db := DefaultManager.Master() + if db == nil { return errors.New("数据库未初始化") } - return DefaultManager.master.Transaction(fn) + return db.Transaction(fn) } // TransactionWithContext 带上下文的事务操作 func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error { - if DefaultManager.master == nil { + db := DefaultManager.Master() + if db == nil { return errors.New("数据库未初始化") } - return DefaultManager.master.WithContext(ctx).Transaction(fn) + return db.WithContext(ctx).Transaction(fn) } // ReadQuery 读查询(自动路由到从库) @@ -565,22 +645,36 @@ func ReadQuery(ctx context.Context, model any, query string, args ...any) error return db.WithContext(ctx).Where(query, args...).Find(model).Error } -// WriteQuery 写查询(强制使用主库) +// WriteQuery 在主库上执行查询并扫描到 model(强制主库,绕过从库延迟)。 +// 注意:命名沿用历史,实际用 .Find() 扫描结果集(读取语义),并非写操作—— +// 强制主库是为了读到刚写入的最新数据(read-your-writes)。命名误导见 M11。 func WriteQuery(ctx context.Context, model any, query string, args ...any) error { - if DefaultManager.master == nil { + db := DefaultManager.Master() + if db == nil { return errors.New("数据库未初始化") } - return DefaultManager.master.WithContext(ctx).Where(query, args...).Find(model).Error + return db.WithContext(ctx).Where(query, args...).Find(model).Error } -// HealthCheck 健康检查 +// healthCheckTimeout 健康检查单次 ping 超时,避免探针被慢/挂起的 DB 长期阻塞(M11)。 +const healthCheckTimeout = 3 * time.Second + +// pingWithTimeout 带超时的 ping,ctx 由调用方传入时优先尊重其 deadline。 +func pingWithTimeout(sqlDB *sql.DB, parent context.Context) error { + 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() // 检查主库 - if DefaultManager.master != nil { - sqlDB, err := DefaultManager.master.DB() - if err == nil && sqlDB.Ping() == nil { + if master := DefaultManager.Master(); master != nil { + sqlDB, err := master.DB() + if err == nil && pingWithTimeout(sqlDB, ctx) == nil { result["master"] = true } else { result["master"] = false @@ -590,10 +684,10 @@ func HealthCheck() map[string]bool { } // 检查从库 - for i, replica := range DefaultManager.replicas { + for i, replica := range DefaultManager.Replicas() { if replica != nil { sqlDB, err := replica.DB() - if err == nil && sqlDB.Ping() == nil { + if err == nil && pingWithTimeout(sqlDB, ctx) == nil { result[fmt.Sprintf("replica_%d", i+1)] = true } else { result[fmt.Sprintf("replica_%d", i+1)] = false diff --git a/database/manager_c11_internal_test.go b/database/manager_c11_internal_test.go new file mode 100644 index 0000000..feb7b66 --- /dev/null +++ b/database/manager_c11_internal_test.go @@ -0,0 +1,243 @@ +package database + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/logger" + "gorm.io/gorm" +) + +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") + } + } +} + +// 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.mu.Lock() + DefaultManager.master = nil + DefaultManager.replicas = nil + DefaultManager.resetReplicaHealth() + DefaultManager.healthy.Store(false) + DefaultManager.mu.Unlock() + }() + + DefaultManager.mu.Lock() + DefaultManager.master = sentinelDB() + DefaultManager.replicas = []*gorm.DB{sentinelDB(), sentinelDB()} + DefaultManager.mu.Unlock() + + // 包级 Close → CloseAll → DefaultManager.Close(),应同时清空 master 与 replicas + _ = Close() + + DefaultManager.mu.Lock() + master := DefaultManager.master + repl := DefaultManager.replicas + DefaultManager.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") + } +} diff --git a/go.mod b/go.mod index 1e38fe2..8fbb00b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/EthanCodeCraft/xlgo-core go 1.25.0 require ( + github.com/alicebob/miniredis/v2 v2.38.0 github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible github.com/fsnotify/fsnotify v1.7.0 github.com/gin-gonic/gin v1.9.1 @@ -16,10 +17,12 @@ require ( github.com/spf13/viper v1.18.2 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 + go.opentelemetry.io/contrib/propagators/b3 v1.43.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/zap v1.27.0 @@ -41,8 +44,11 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect @@ -75,6 +81,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -85,6 +92,7 @@ require ( github.com/swaggo/swag v1.16.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect @@ -104,4 +112,8 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/go.sum b/go.sum index f8d55a3..537c871 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible h1:Sg/2xHwDrioHpxTN6WMiwbXTpUEinBpHsN7mG21Rc2k= github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -29,6 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -41,6 +45,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -148,6 +156,9 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= @@ -189,8 +200,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A= +go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= @@ -199,6 +214,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+J go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -302,4 +319,12 @@ gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkw gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/handler/handler.go b/handler/handler.go index 2f573fc..98d0fe9 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -16,12 +16,14 @@ import ( // @Tags 系统 // @Accept json // @Produce json -// @Success 200 {object} response.Response +// @Success 200 {object} map[string]string // @Router /health [get] +// +// 响应体与 router.RegisterHealthRoute 收敛为同一 schema(H8d): +// 恒 200 + {"status":"ok"},不走 response 业务信封,便于 K8s 探针直读。 +// 需要依赖探活(mysql/redis…失败 503)时改用 router.RegisterHealthRoute 传入 checks。 func HealthCheck(c *gin.Context) { - response.Success(c, gin.H{ - "status": "ok", - }) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // BindJSON 绑定 JSON 请求 @@ -153,18 +155,20 @@ func ParseInt(s string, defaultValue int) int { return utils.ToIntDefault(s, defaultValue) } -// BadRequest 返回 400 错误 +// BadRequest 返回参数/请求错误响应。 +// +// 委托 response.FailWithCode(CodeFail),遵循当前响应模式(ModeBusiness 下 HTTP 200, +// 错误信息通过 body 中的 code 表达;ModeREST 下 CodeFail 属业务失败不映射 HTTP 错误,仍 200), +// 并写入 RequestID——与 response 模式系统一致,不再硬编 HTTP 状态码、不再丢失链路追踪。 func BadRequest(c *gin.Context, msg string) { - c.JSON(http.StatusBadRequest, response.Response{ - Code: response.CodeFail, - Msg: msg, - }) + response.FailWithCode(c, response.CodeFail, msg) } -// InternalError 返回 500 错误 +// InternalError 返回服务器错误响应。 +// +// 委托 response.ServerError(CodeServerError),遵循当前响应模式(ModeBusiness 下 HTTP 200, +// ModeREST 下 HTTP 500),并写入 RequestID——与 response 模式系统一致, +// 不再硬编 HTTP 状态码、不再丢失链路追踪。 func InternalError(c *gin.Context, msg string) { - c.JSON(http.StatusInternalServerError, response.Response{ - Code: response.CodeServerError, - Msg: msg, - }) + response.ServerError(c, msg) } diff --git a/handler/handler_test.go b/handler/handler_test.go index 50d9b72..a10ca55 100644 --- a/handler/handler_test.go +++ b/handler/handler_test.go @@ -1,12 +1,14 @@ package handler_test import ( + "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/EthanCodeCraft/xlgo-core/handler" + "github.com/EthanCodeCraft/xlgo-core/response" "github.com/gin-gonic/gin" ) @@ -29,6 +31,25 @@ func TestHealthCheck(t *testing.T) { } } +// TestHealthCheckSchemaConverged_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) { @@ -206,7 +227,26 @@ func TestGetIDFromPath(t *testing.T) { } } +// withResponseMode 切换响应模式并在测试结束后恢复为默认 ModeBusiness。 +func withResponseMode(t *testing.T, m response.Mode) { + t.Helper() + response.SetMode(m) + t.Cleanup(func() { response.SetMode(response.ModeBusiness) }) +} + +// decodeBody 解析统一响应体。 +func decodeBody(t *testing.T, w *httptest.ResponseRecorder) response.Response { + t.Helper() + var body response.Response + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v (body=%q)", err, w.Body.String()) + } + return body +} + func TestBadRequest(t *testing.T) { + // 默认 ModeBusiness:所有失败响应 HTTP 200,错误经 body code 表达。 + withResponseMode(t, response.ModeBusiness) r := setupTestRouter() r.GET("/test", func(c *gin.Context) { handler.BadRequest(c, "参数错误") @@ -216,12 +256,21 @@ func TestBadRequest(t *testing.T) { req := httptest.NewRequest("GET", "/test", nil) r.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("BadRequest status = %d, want 400", w.Code) + if w.Code != http.StatusOK { + t.Errorf("BadRequest (ModeBusiness) status = %d, want 200", w.Code) + } + body := decodeBody(t, w) + if body.Code != response.CodeFail { + t.Errorf("BadRequest code = %d, want %d", body.Code, response.CodeFail) + } + if body.Msg != "参数错误" { + t.Errorf("BadRequest msg = %q, want %q", body.Msg, "参数错误") } } func TestInternalError(t *testing.T) { + // 默认 ModeBusiness:服务器错误也走 HTTP 200,错误经 body code 表达。 + withResponseMode(t, response.ModeBusiness) r := setupTestRouter() r.GET("/test", func(c *gin.Context) { handler.InternalError(c, "服务器错误") @@ -231,8 +280,88 @@ func TestInternalError(t *testing.T) { req := httptest.NewRequest("GET", "/test", nil) r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("InternalError (ModeBusiness) status = %d, want 200", w.Code) + } + body := decodeBody(t, w) + if body.Code != response.CodeServerError { + t.Errorf("InternalError code = %d, want %d", body.Code, response.CodeServerError) + } +} + +// 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 status = %d, want 500", w.Code) + t.Errorf("InternalError (ModeREST) status = %d, want 500", w.Code) + } + body := decodeBody(t, w) + if body.Code != response.CodeServerError { + t.Errorf("InternalError code = %d, want %d", body.Code, response.CodeServerError) + } +} + +// TestBadRequestWritesRequestID:修复前 BadRequest 直接 c.JSON 不写 RequestID(丢链路); +// 修复后委托 response 体系,经 writeResp 写入上下文中的 request_id。 +func TestBadRequestWritesRequestID(t *testing.T) { + r := setupTestRouter() + r.Use(func(c *gin.Context) { c.Set("request_id", "req-123"); c.Next() }) + r.GET("/test", func(c *gin.Context) { + handler.BadRequest(c, "参数错误") + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + body := decodeBody(t, w) + if body.RequestID != "req-123" { + t.Errorf("BadRequest request_id = %q, want %q (修复前为空)", body.RequestID, "req-123") + } +} + +// TestInternalErrorWritesRequestID:同上,覆盖 InternalError。 +func TestInternalErrorWritesRequestID(t *testing.T) { + r := setupTestRouter() + r.Use(func(c *gin.Context) { c.Set("request_id", "req-456"); c.Next() }) + r.GET("/test", func(c *gin.Context) { + handler.InternalError(c, "服务器错误") + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + body := decodeBody(t, w) + if body.RequestID != "req-456" { + t.Errorf("InternalError request_id = %q, want %q (修复前为空)", body.RequestID, "req-456") } } diff --git a/jwt/jwt.go b/jwt/jwt.go index 01e772e..f003e71 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" "sync" + "sync/atomic" "time" "github.com/EthanCodeCraft/xlgo-core/config" @@ -37,6 +38,12 @@ var ( ErrTokenNotValidYet = errors.New("令牌尚未生效") //ErrTokenRevoked 令牌已被撤销 ErrTokenRevoked = errors.New("令牌已被撤销") + // ErrBlacklistUnavailable Redis 未初始化或不可用,黑名单功能失效(C9a 修复)。 + // Add 返回此错误使调用方(RefreshToken/InvalidateToken)感知黑名单不可用并 fail-closed, + // 避免无 Redis 时静默成功致撤销/刷新失效、新旧 token 双有效。 + // IsBlacklisted 在无 Redis 时仍返回 false(验证侧 fail-open 是无 Redis 部署的固有局限, + // 文档约束:安全敏感场景必须启用 Redis)。 + ErrBlacklistUnavailable = errors.New("token 黑名单不可用:Redis 未初始化") ) // generateJTI 生成唯一的 JWT ID @@ -68,11 +75,14 @@ func (tb *TokenBlacklist) redisClient() *redis.Client { // 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 { client := tb.redisClient() if client == nil { - // Redis 未启用,跳过黑名单 - return nil + // Redis 未启用,黑名单不可用——fail-closed 让调用方决策。 + return ErrBlacklistUnavailable } ctx := context.Background() @@ -107,8 +117,37 @@ type Manager struct { blacklist *TokenBlacklist } -// DefaultJWT 默认 JWT 管理器,包级 facade 代理到它的 blacklist。 -var DefaultJWT = NewJWTManager() +// defaultManager 是全局默认 JWT 管理器的真实存储,经 atomic 读写(C9c)。 +// 历史上 DefaultJWT/tokenBlacklist 为裸包级指针,SetDefaultJWTManager 写与 +// 请求 goroutine(ParseToken/RefreshToken/InvalidateToken/IsTokenRevoked)读存在数据竞争。 +var defaultManager atomic.Pointer[Manager] + +// DefaultJWT 默认 JWT 管理器的兼容导出别名,指向 defaultManager 当前实例。 +// 直接读此变量非并发安全(SetDefaultJWTManager 裸写维护),仅供启动期或兼容存量代码; +// 并发安全的访问请使用包级函数(ParseToken/RefreshToken/...)或 SetDefaultJWTManager。 +var DefaultJWT *Manager + +func init() { + m := NewJWTManager() + defaultManager.Store(m) + DefaultJWT = m +} + +// 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 +} + +// currentBlacklist 返回全局默认 Manager 持有的黑名单(atomic 读取 Manager 后经 Blacklist(),C9c)。 +func currentBlacklist() *TokenBlacklist { + return currentManager().Blacklist() +} // NewJWTManager 创建 JWT 管理器实例(blacklist 懒取全局 Redis)。 func NewJWTManager() *Manager { @@ -121,11 +160,14 @@ func NewJWTManagerWithRedis(client *redis.Client) *Manager { } // SetDefaultJWTManager 提升指定 Manager 为全局默认。 +// 经 atomic.Pointer 原子置换真实存储(C9c),并同步维护 DefaultJWT 兼容别名。 +// 典型在启动期调用;并发请求期调用也安全(包级函数经 atomic 读取不会观察到撕裂值)。 func SetDefaultJWTManager(m *Manager) { - if m != nil { - DefaultJWT = m - tokenBlacklist = m.blacklist + if m == nil { + return } + defaultManager.Store(m) + DefaultJWT = m // 兼容别名同步(直接读 DefaultJWT 非并发安全,见其注释) } // Blacklist 返回 Manager 持有的黑名单实例。 @@ -135,9 +177,6 @@ func (m *Manager) Blacklist() *TokenBlacklist { return m.blacklist } -// 全局黑名单实例(指向 DefaultJWT 的 blacklist,兼容存量包级函数) -var tokenBlacklist = DefaultJWT.blacklist - // GenerateToken 生成 JWT Token func GenerateToken(userID uint, username, role, userType string) (string, error) { cfg := config.Get() @@ -240,7 +279,7 @@ func ParseToken(tokenString string) (*Claims, error) { if claims, ok := token.Claims.(*Claims); ok && token.Valid { // 使用 JTI 检查黑名单(更高效) - if claims.JTI != "" && tokenBlacklist.IsBlacklisted(claims.JTI) { + if claims.JTI != "" && currentBlacklist().IsBlacklisted(claims.JTI) { return nil, ErrTokenRevoked } return claims, nil @@ -264,7 +303,7 @@ func InvalidateToken(tokenString string) error { if claims, ok := token.Claims.(*Claims); ok { if claims.JTI != "" && claims.ExpiresAt != nil { - return tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time) + return currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time) } } @@ -274,19 +313,25 @@ func InvalidateToken(tokenString string) error { // InvalidateTokenByID 直接通过 JTI 使 Token 失效 // 参数: jti JWT ID,expiry 过期时间 func InvalidateTokenByID(jti string, expiry time.Time) error { - return tokenBlacklist.Add(jti, expiry) + 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 加入黑名单;失败则不签发新 token(C9b:禁止吞 Add 错误)。 if claims.JTI != "" && claims.ExpiresAt != nil { - tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time) + if err := currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time); err != nil { + return "", fmt.Errorf("刷新令牌失败:旧令牌撤销失败: %w", err) + } } return GenerateToken(claims.UserID, claims.Username, claims.Role, claims.UserType) @@ -309,7 +354,7 @@ func GetJTI(tokenString string) (string, error) { // IsTokenRevoked 检查 Token 是否被撤销(通过 JTI) func IsTokenRevoked(jti string) bool { - return tokenBlacklist.IsBlacklisted(jti) + return currentBlacklist().IsBlacklisted(jti) } // GetClaimsFromToken 获取 Token 的 Claims(不验证过期) diff --git a/jwt/jwt_c9c_internal_test.go b/jwt/jwt_c9c_internal_test.go new file mode 100644 index 0000000..a8e8651 --- /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 验证 DefaultJWT 兼容别名与 defaultManager 真实存储一致。 +func TestC9cDefaultJWTAliasConsistent(t *testing.T) { + t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) }) + + // init 后 DefaultJWT 与 currentManager 应指向同一实例 + if DefaultJWT != currentManager() { + t.Fatal("DefaultJWT alias should equal currentManager after init") + } + + // SetDefaultJWTManager 后同步 + m := NewJWTManagerWithRedis(nil) + SetDefaultJWTManager(m) + if DefaultJWT != m { + t.Fatal("DefaultJWT alias 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 index f02a93b..54c53dc 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -1,11 +1,14 @@ package jwt_test import ( + "errors" "testing" "time" "github.com/EthanCodeCraft/xlgo-core/config" "github.com/EthanCodeCraft/xlgo-core/jwt" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" ) func setupTestConfig() { @@ -108,25 +111,27 @@ func TestRefreshToken(t *testing.T) { // 生成 token token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin") - // 刷新 token - newToken, err := jwt.RefreshToken(token) - if err != nil { - t.Fatalf("RefreshToken error: %v", err) + // 无 Redis 时 RefreshToken 必须 fail-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) } +} - if newToken == "" { - t.Error("RefreshToken should return non-empty token") - } - - // 新 token 应可解析 - claims, err := jwt.ParseToken(newToken) - if err != nil { - t.Fatalf("ParseToken new token error: %v", err) - } - - if claims.Username != "testuser" { - t.Error("RefreshToken claims should match original") - } +// setupMiniRedis 启动 miniredis 并注入到 jwt 包级 tokenBlacklist(经 SetDefaultJWTManager)。 +// 测试结束 cleanup 还原为无 Redis 的默认 Manager,避免污染后续测试(-shuffle 下尤其关键)。 +func setupMiniRedis(t *testing.T) *miniredis.Miniredis { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { + _ = client.Close() + // 还原包级 tokenBlacklist 为无 Redis 默认 Manager,避免残留指向已关闭 miniredis 的 client。 + jwt.SetDefaultJWTManager(jwt.NewJWTManager()) + }) + // 用注入 Redis 的 Manager 替换默认,使包级 tokenBlacklist 指向 miniredis。 + jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(client)) + return mr } func TestClaimsStructure(t *testing.T) { @@ -169,13 +174,13 @@ func TestErrorDefinitions(t *testing.T) { func TestTokenBlacklist(t *testing.T) { tb := jwt.TokenBlacklist{} - // 无 Redis 时,Add 应返回 nil + // 无 Redis 时,Add 应返回 ErrBlacklistUnavailable(C9a 修复:fail-closed) err := tb.Add("test-token", time.Now().Add(time.Hour)) - if err != nil { - t.Errorf("TokenBlacklist.Add without Redis should return nil, got %v", err) + if !errors.Is(err, jwt.ErrBlacklistUnavailable) { + t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err) } - // 无 Redis 时,IsBlacklisted 应返回 false + // 无 Redis 时,IsBlacklisted 仍返回 false(验证侧 fail-open 是无 Redis 部署的固有局限) if tb.IsBlacklisted("test-token") { t.Error("TokenBlacklist.IsBlacklisted without Redis should return false") } @@ -186,10 +191,10 @@ func TestInvalidateToken(t *testing.T) { token, _ := jwt.GenerateToken(1, "test", "admin", "admin") - // 无 Redis 时应返回 nil + // 无 Redis 时应返回 ErrBlacklistUnavailable(C9a 修复:fail-closed,不再静默成功) err := jwt.InvalidateToken(token) - if err != nil { - t.Errorf("InvalidateToken without Redis should return nil, got %v", err) + if !errors.Is(err, jwt.ErrBlacklistUnavailable) { + t.Errorf("InvalidateToken without Redis should return ErrBlacklistUnavailable, got %v", err) } } @@ -215,3 +220,95 @@ func splitToken(token string) []string { 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) + } +} + diff --git a/logger/field.go b/logger/field.go index 407c488..11446a9 100644 --- a/logger/field.go +++ b/logger/field.go @@ -1,6 +1,8 @@ package logger import ( + "time" + "go.uber.org/zap" ) @@ -12,23 +14,16 @@ var Field = struct { Bool func(key string, value bool) zap.Field Uint func(key string, value uint) zap.Field Float64 func(key string, value float64) zap.Field - Duration func(key string, value interface{}) zap.Field + Duration func(key string, value time.Duration) zap.Field Error func(err error) zap.Field }{ - String: zap.String, - Int: zap.Int, - Int64: zap.Int64, - Bool: zap.Bool, - Uint: zap.Uint, - Float64: zap.Float64, - Duration: func(key string, value interface{}) zap.Field { - switch v := value.(type) { - case zap.Field: - return v - default: - return zap.Any(key, value) - } - }, + String: zap.String, + Int: zap.Int, + Int64: zap.Int64, + Bool: zap.Bool, + Uint: zap.Uint, + Float64: zap.Float64, + Duration: zap.Duration, Error: func(err error) zap.Field { return zap.Error(err) }, diff --git a/logger/logger.go b/logger/logger.go index c5b0a9a..4e1ba0f 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "github.com/EthanCodeCraft/xlgo-core/config" @@ -15,25 +16,81 @@ import ( ) var ( - // Logger 全局通用日志实例。Init 之前为 Nop,调用安全。 + // 内部 atomic 存储——四个 logger 的真实源(H7 修复)。 + // 所有包级函数(Info/Debug/.../APILog/DBLog/Sync)经 atomic Load 读取, + // 使请求 goroutine 不与 Init/Close 重新装配全局变量竞争。 + // 原实现用 m.mu(实例锁)保护包级全局变量,锁与被保护对象作用域错配, + // 读侧无锁裸读 → 热重载 re-Init/Close 与请求日志存在数据竞争。 + loggerPtr atomic.Pointer[zap.Logger] + sugarPtr atomic.Pointer[zap.SugaredLogger] + apiLogPtr atomic.Pointer[zap.Logger] + dbLogPtr atomic.Pointer[zap.Logger] + + // Logger 全局通用日志实例(兼容别名)。Init 之前为 Nop,调用安全。 + // + // 兼容性说明(H7):此导出变量由 Init/Close 在 m.mu 下同步维护,但直接读它 + // 在 re-Init/Close 期间非并发安全;框架内部读路径已改走 atomic(currentLogger)。 + // 并发安全访问请用包级 Info/Debug/Warn/Error/... 函数,不要直接读此变量。 Logger = zap.NewNop() - sugar = Logger.Sugar() - apiLog = zap.NewNop() - dbLog = zap.NewNop() // fileWriters 持有所有 lumberjack 实例引用, // Close() 调用时显式释放文件句柄。 // 必要性:lumberjack 不依赖 GC 关闭文件,进程长跑或测试场景下 // 不显式关闭会持有句柄导致 Windows 上无法删除日志目录。 + // 仅在 m.mu 下访问(Init/Close/closeFileWriters),无读侧竞争。 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) +} + +// 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 } // DefaultLogger 默认日志管理器,包级 facade 代理到它。 @@ -42,6 +99,34 @@ var DefaultLogger = NewLogManager() // NewLogManager 创建日志管理器实例。 func NewLogManager() *LogManager { return &LogManager{} } +// 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 DefaultLogger.SetLevel(l) } + +// GetLevel 包级 facade:返回默认日志当前级别。 +func GetLevel() zapcore.Level { return DefaultLogger.GetLevel() } + // SetDefaultLogManager 提升指定 LogManager 为全局默认。 func SetDefaultLogManager(m *LogManager) { if m != nil { @@ -64,8 +149,8 @@ func (m *LogManager) Init(cfg *config.Config) error { return errors.New("logger: 配置为空") } - // 确保日志目录存在 - if err := os.MkdirAll(cfg.Log.Dir, 0o755); err != nil { + // 确保日志目录存在(0750:owner+group 可访问,与 storage 目录权限一致) + if err := os.MkdirAll(cfg.Log.Dir, 0o750); err != nil { return err } @@ -85,11 +170,13 @@ func (m *LogManager) Init(cfg *config.Config) error { EncodeCaller: zapcore.ShortCallerEncoder, } - // 根据运行模式设置日志级别 - level := zapcore.DebugLevel + // 根据运行模式设置日志级别(M19:用 AtomicLevel 支持运行期 SetLevel 热切换) + level := zap.NewAtomicLevelAt(zapcore.DebugLevel) if cfg.IsProduction() { - level = zapcore.InfoLevel + level = zap.NewAtomicLevelAt(zapcore.InfoLevel) } + // 记录到 manager,供 SetLevel/GetLevel 热切换(H7:在 m.mu 下写入)。 + m.level = level jsonEncoder := zapcore.NewJSONEncoder(encoderConfig) consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig) @@ -122,11 +209,14 @@ func (m *LogManager) Init(cfg *config.Config) error { defer m.mu.Unlock() // 全部构造成功后再原子替换全局变量,避免半初始化状态。 // 同时关闭旧 writer 释放句柄(重复 Init 场景,主要服务于测试)。 + // H7:四个 logger 经 atomic.Pointer Store,读侧(请求 goroutine)无锁原子 load; + // fileWriters 仅在 m.mu 下访问。Logger 兼容别名同步维护。 closeFileWriters() + loggerPtr.Store(newLogger) + sugarPtr.Store(newLogger.Sugar()) + apiLogPtr.Store(newAPILog) + dbLogPtr.Store(newDBLog) Logger = newLogger - sugar = Logger.Sugar() - apiLog = newAPILog - dbLog = newDBLog fileWriters = []*lumberjack.Logger{appWriter, apiWriter, dbWriter} m.cfg = cfg @@ -166,7 +256,7 @@ func closeFileWriters() { // 这里把这类错误识别并忽略,只返回真实的写入失败。 func (m *LogManager) Sync() error { var errs []error - for _, l := range []*zap.Logger{Logger, apiLog, dbLog} { + for _, l := range []*zap.Logger{currentLogger(), currentAPILog(), currentDBLog()} { if l == nil { continue } @@ -193,10 +283,13 @@ func (m *LogManager) Close() error { m.mu.Lock() closeFileWriters() - Logger = zap.NewNop() - sugar = Logger.Sugar() - apiLog = zap.NewNop() - dbLog = zap.NewNop() + // H7:重置为 Nop 经 atomic Store,与读侧一致;Logger 兼容别名同步。 + nop := zap.NewNop() + loggerPtr.Store(nop) + sugarPtr.Store(nop.Sugar()) + apiLogPtr.Store(nop) + dbLogPtr.Store(nop) + Logger = nop m.mu.Unlock() return syncErr @@ -228,60 +321,60 @@ func isHarmlessSyncError(err error) bool { // Debug 调试日志 func Debug(msg string, fields ...zap.Field) { - Logger.Debug(msg, fields...) + currentLogger().Debug(msg, fields...) } // Info 信息日志 func Info(msg string, fields ...zap.Field) { - Logger.Info(msg, fields...) + currentLogger().Info(msg, fields...) } // Warn 警告日志 func Warn(msg string, fields ...zap.Field) { - Logger.Warn(msg, fields...) + currentLogger().Warn(msg, fields...) } // Error 错误日志 func Error(msg string, fields ...zap.Field) { - Logger.Error(msg, fields...) + currentLogger().Error(msg, fields...) } // Fatal 致命错误日志(仅供应用层使用,框架内部禁止调用) func Fatal(msg string, fields ...zap.Field) { - Logger.Fatal(msg, fields...) + currentLogger().Fatal(msg, fields...) } // Debugf 格式化调试日志 func Debugf(template string, args ...any) { - sugar.Debugf(template, args...) + currentSugar().Debugf(template, args...) } // Infof 格式化信息日志 func Infof(template string, args ...any) { - sugar.Infof(template, args...) + currentSugar().Infof(template, args...) } // Warnf 格式化警告日志 func Warnf(template string, args ...any) { - sugar.Warnf(template, args...) + currentSugar().Warnf(template, args...) } // Errorf 格式化错误日志 func Errorf(template string, args ...any) { - sugar.Errorf(template, args...) + currentSugar().Errorf(template, args...) } // Fatalf 格式化致命错误日志(仅供应用层使用,框架内部禁止调用) func Fatalf(template string, args ...any) { - sugar.Fatalf(template, args...) + currentSugar().Fatalf(template, args...) } // APILog 返回 API 专用日志器(写 logs/api.log + console) func APILog() *zap.Logger { - return apiLog + return currentAPILog() } // DBLog 返回数据库专用日志器(写 logs/database.log + console) func DBLog() *zap.Logger { - return dbLog + return currentDBLog() } diff --git a/logger/logger_h7_internal_test.go b/logger/logger_h7_internal_test.go new file mode 100644 index 0000000..930e453 --- /dev/null +++ b/logger/logger_h7_internal_test.go @@ -0,0 +1,259 @@ +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) + + // 确保 DefaultLogger 起点干净。 + _ = DefaultLogger.Close() + t.Cleanup(func() { _ = DefaultLogger.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: + } + _ = DefaultLogger.Init(cfg) + _ = DefaultLogger.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,避免后续测试持有临时目录句柄。 + _ = DefaultLogger.Close() +} + +// TestH7CurrentLoggerReflectsInitAndClose 验证 atomic 快照随 Init/Close 正确切换: +// Init 后 currentLogger 写入文件;Close 后回到 Nop(不写文件)。 +func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) { + dir := t.TempDir() + cfg := tmpCfg(dir) + + _ = DefaultLogger.Close() + t.Cleanup(func() { _ = DefaultLogger.Close() }) + + // Init 前:Nop,调用安全且不写文件。 + before := currentLogger() + if before == nil { + t.Fatal("currentLogger nil before Init") + } + + if err := DefaultLogger.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) + _ = DefaultLogger.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 := DefaultLogger.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") + _ = DefaultLogger.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") + } +} + +// 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) + _ = DefaultLogger.Close() + t.Cleanup(func() { _ = DefaultLogger.Close() }) + + if err := DefaultLogger.Init(cfg); err != nil { + t.Fatalf("Init: %v", err) + } + + // Init 后(tmpCfg 为 production 模式,默认 InfoLevel)可热切换到 ErrorLevel。 + before := DefaultLogger.GetLevel() + if before != zapcore.InfoLevel { + t.Errorf("default level = %v, want InfoLevel (production)", before) + } + if ok := DefaultLogger.SetLevel(zapcore.ErrorLevel); !ok { + t.Fatal("SetLevel after Init should return true") + } + if got := DefaultLogger.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) + } +} diff --git a/middleware/cors.go b/middleware/cors.go index ea5fe53..43df38f 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -2,6 +2,7 @@ package middleware import ( "net/http" + "net/url" "strconv" "strings" @@ -48,15 +49,7 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc { matchedWildcard = true break } - // 支持通配符匹配(如 *.example.com) - if strings.HasPrefix(ao, "*.") { - domain := ao[2:] - if strings.HasSuffix(origin, domain) { - allowedOrigin = origin - break - } - } - if ao == origin { + if matchOrigin(origin, ao) { allowedOrigin = origin break } @@ -72,32 +65,34 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc { } else { allowedOrigin = "*" } - } else if cfg != nil && cfg.IsDevelopment() && origin != "" { - // 开发环境兜底:回显具体 Origin(兼容 credentials) + } else if cfg != nil && cfg.IsDevelopment() && origin != "" && isLocalhostOrigin(origin) { + // 开发环境兜底:仅对 localhost 来源回显具体 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)) } - // 从配置获取允许的方法、请求头等 - methods := corsConfig.GetAllowedMethods() - headers := corsConfig.GetAllowedHeaders() - exposedHeaders := corsConfig.GetExposedHeaders() - maxAge := corsConfig.GetMaxAge() - - c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", ")) - c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", ")) - c.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", ")) - c.Header("Access-Control-Max-Age", strconv.Itoa(maxAge)) - // 仅在显式启用且 Origin 不是 "*" 时才发 Allow-Credentials // (CORS 规范:Allow-Origin: * 时禁止携带凭证) if allowCredentials && allowedOrigin != "" && allowedOrigin != "*" { @@ -114,6 +109,61 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc { } } +// matchOrigin 判断 origin 是否匹配允许的 Origin 模式 ao。 +// +// 支持三种模式(C7a 修复): +// 1. 精确匹配:ao == 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 { 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 index 4324008..4a4fca9 100644 --- a/middleware/csrf.go +++ b/middleware/csrf.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" "sync" + "time" "github.com/EthanCodeCraft/xlgo-core/response" "github.com/gin-gonic/gin" @@ -223,11 +224,13 @@ func CSRFWithSkip(skipPaths []string) gin.HandlerFunc { } // CSRFForAPI 适用于 API 的 CSRF 中间件(不使用 Cookie) -// 客户端需要先调用 /csrf-token 获取 Token +// 客户端需要先调用 GenerateAPIToken 获取 Token,随后在每个非安全方法请求的 +// X-CSRF-Token 头中携带。Token 单次消费(验证通过即删除)且受 TTL 约束, +// 防止重放与内存无限增长。 +// +// 注意:存储为进程内内存,仅适用于单实例部署。多实例请自行用 Redis +// SETEX + GETDEL 实现等价语义。 func CSRFForAPI() gin.HandlerFunc { - tokens := make(map[string]bool) - var mu sync.RWMutex - return func(c *gin.Context) { // 安全方法不需要验证 if isSafeMethod(c.Request.Method) { @@ -243,12 +246,25 @@ func CSRFForAPI() gin.HandlerFunc { return } - // 验证 Token - mu.RLock() - valid := tokens[clientToken] - mu.RUnlock() + // 单次消费 + TTL:校验通过即删除,防止同一 token 被重放。 + // 写锁内完成“查—删—过期清理”以保证原子性。 + apiTokensMu.Lock() + issuedAt, ok := apiTokens[clientToken] + if ok { + delete(apiTokens, clientToken) + } + // 懒清理:map 较大时顺带淘汰过期项,避免内存无限增长。 + if len(apiTokens) > 256 { + now := time.Now() + for t, at := range apiTokens { + if now.Sub(at) > apiTokenTTL { + delete(apiTokens, t) + } + } + } + apiTokensMu.Unlock() - if !valid { + if !ok || time.Since(issuedAt) > apiTokenTTL { response.Fail(c, "CSRF Token 无效") c.Abort() return @@ -259,6 +275,7 @@ func CSRFForAPI() gin.HandlerFunc { } // GenerateAPIToken 生成 API CSRF Token(用于 API 模式) +// 颁发的 Token 写入进程内存储,供 CSRFForAPI 校验。 func GenerateAPIToken(c *gin.Context) { token, err := generateCSRFToken(CSRFTokenLength) if err != nil { @@ -266,23 +283,25 @@ func GenerateAPIToken(c *gin.Context) { return } - // 存储 Token(实际应用中应使用 Redis) - // 这里简化为内存存储 - mu.Lock() - tokens[token] = true - mu.Unlock() + apiTokensMu.Lock() + apiTokens[token] = time.Now() + apiTokensMu.Unlock() response.Success(c, gin.H{ "csrf_token": token, }) } -// 内存存储(用于 API 模式) +// API 模式 CSRF Token 存储:token -> 颁发时间。 +// 受 apiTokensMu 保护,单次消费 + TTL(见 CSRFForAPI)。 var ( - tokens = make(map[string]bool) - mu sync.RWMutex + apiTokens = make(map[string]time.Time) + apiTokensMu sync.RWMutex ) +// apiTokenTTL API 模式 CSRF Token 有效期 +const apiTokenTTL = 30 * time.Minute + // CSRFExempt 标记路由不需要 CSRF 保护 // 使用方法:在路由组上使用此中间件 func CSRFExempt() gin.HandlerFunc { @@ -325,7 +344,9 @@ func DoubleSubmitCookie() gin.HandlerFunc { cookieToken, err := c.Cookie(CSRFCookieName) if err != nil || cookieToken == "" { token, _ := generateCSRFToken(CSRFTokenLength) - c.SetCookie(CSRFCookieName, token, 3600, "/", "", false, true) + // 双重提交模式要求前端 JS 读取 cookie 并回填 X-CSRF-Token 头, + // 故 HttpOnly 必须为 false(与 CSRF() cookie 模式相反)。 + c.SetCookie(CSRFCookieName, token, 3600, "/", "", false, false) c.Set("csrf_token", token) } else { c.Set("csrf_token", cookieToken) diff --git a/middleware/csrf_internal_test.go b/middleware/csrf_internal_test.go new file mode 100644 index 0000000..9fdd60c --- /dev/null +++ b/middleware/csrf_internal_test.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/response" + "github.com/gin-gonic/gin" +) + +// 回归 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) + } +} diff --git a/middleware/logger.go b/middleware/logger.go index 5e081cf..7324e00 100644 --- a/middleware/logger.go +++ b/middleware/logger.go @@ -44,6 +44,10 @@ func Logger() gin.HandlerFunc { // LoggerWithConfig 使用自定义配置的日志中间件 func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc { + // 统一封顶:MaxBodyLength<=0 时回退默认值,确保请求/响应 body 捕获均有上限(防 OOM) + if cfg.MaxBodyLength <= 0 { + cfg.MaxBodyLength = DefaultLoggerConfig.MaxBodyLength + } return func(c *gin.Context) { // 检查是否跳过此路径 path := c.Request.URL.Path @@ -57,26 +61,17 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc { // 记录请求体(可选) var requestBody []byte if cfg.LogRequestBody && c.Request.Body != nil { - requestBody, _ = io.ReadAll(c.Request.Body) - // 恢复请求体供后续处理 - c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) - // 限制记录长度 - if len(requestBody) > cfg.MaxBodyLength { - requestBody = requestBody[:cfg.MaxBodyLength] - } + requestBody = readBodyBounded(c, cfg.MaxBodyLength) } // 记录响应体(可选) var responseBody []byte if cfg.LogResponseBody { - // 使用 ResponseWriter 包装器捕获响应体 - blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer} + // 使用 ResponseWriter 包装器捕获响应体(缓冲区封顶,防止大响应 OOM) + blw := &bodyLogWriter{body: bytes.NewBufferString(""), maxLen: cfg.MaxBodyLength, ResponseWriter: c.Writer} c.Writer = blw c.Next() responseBody = blw.body.Bytes() - if len(responseBody) > cfg.MaxBodyLength { - responseBody = responseBody[:cfg.MaxBodyLength] - } } else { c.Next() } @@ -135,21 +130,58 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc { } } +// readBodyBounded 读取请求体用于日志记录,封顶 maxLen 字节以防 OOM。 +// +// 仅向内存读入最多 maxLen+1 字节(+1 用于检测截断),其余部分不读入内存; +// 通过 io.MultiReader 把「已读前缀 + 原始 body 剩余」复原为 c.Request.Body, +// 因此下游处理器仍能拿到完整请求体。返回的日志副本截断为 maxLen。 +func readBodyBounded(c *gin.Context, maxLen int) []byte { + if maxLen <= 0 { + maxLen = DefaultLoggerConfig.MaxBodyLength + } + // io.ReadAll 永远返回非 nil 切片(即使出错也带已读部分)。LimitReader 封顶至 maxLen+1 字节。 + read, _ := io.ReadAll(io.LimitReader(c.Request.Body, int64(maxLen)+1)) + // 复原完整请求体供后续处理:已读前缀 + 原始 body 剩余部分(出错时保留已读字节,下游可重试读取剩余) + c.Request.Body = io.NopCloser(io.MultiReader(bytes.NewReader(read), c.Request.Body)) + // 日志副本截断到 maxLen + if len(read) > maxLen { + return read[:maxLen] + } + return read +} + // bodyLogWriter 响应体记录包装器 type bodyLogWriter struct { gin.ResponseWriter - body *bytes.Buffer + body *bytes.Buffer + maxLen int // 缓冲区上限(字节),<=0 表示不限制 } -// Write 捕获响应体 -func (w *bodyLogWriter) Write(b []byte) (int, error) { +// appendBounded 向缓冲区追加字节,但不超过 maxLen 上限,防止大响应 OOM。 +func (w *bodyLogWriter) appendBounded(b []byte) { + if w.maxLen <= 0 { + w.body.Write(b) + return + } + remaining := w.maxLen - w.body.Len() + if remaining <= 0 { + return + } + if len(b) > remaining { + b = b[:remaining] + } w.body.Write(b) +} + +// Write 捕获响应体(缓冲区封顶,完整响应仍写入下游 ResponseWriter) +func (w *bodyLogWriter) Write(b []byte) (int, error) { + w.appendBounded(b) return w.ResponseWriter.Write(b) } // WriteString 捕获字符串响应 func (w *bodyLogWriter) WriteString(s string) (int, error) { - w.body.WriteString(s) + w.appendBounded([]byte(s)) return w.ResponseWriter.WriteString(s) } diff --git a/middleware/logger_internal_test.go b/middleware/logger_internal_test.go new file mode 100644 index 0000000..6f4c488 --- /dev/null +++ b/middleware/logger_internal_test.go @@ -0,0 +1,235 @@ +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)) + } +} + +// 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") + } +} diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go index 4d65189..cb34fc8 100644 --- a/middleware/middleware_test.go +++ b/middleware/middleware_test.go @@ -2,15 +2,36 @@ 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() @@ -80,6 +101,44 @@ func TestGetRequestIDEmpty(t *testing.T) { } } +// 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) { @@ -116,6 +175,86 @@ func TestRecoverNoPanic(t *testing.T) { } } +// 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) { @@ -213,6 +352,180 @@ func TestDoubleSubmitCookie(t *testing.T) { } } +// ===== 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) { @@ -382,6 +695,144 @@ func TestCORSOriginNotAllowed(t *testing.T) { } } +// ===== 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) { @@ -443,6 +894,206 @@ func TestCustomRateLimit(t *testing.T) { } } +// ===== 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 TestLoginRateLimit(t *testing.T) { middleware.InitRateLimiters() defer middleware.StopRateLimiters() @@ -534,6 +1185,13 @@ func TestRedisRateLimitMiddleware(t *testing.T) { } func TestLoginRedisRateLimit(t *testing.T) { + // H4c: LoginRedisRateLimit 改 fail-closed——无 Redis 时拒绝(503), + // 防爆破场景下 Redis 故障不能静默放行(原 fail-open 致限流失效)。 + // 测试环境无 Redis,故预期 503。 + prev := database.RedisClient + database.RedisClient = nil + defer func() { database.RedisClient = prev }() + r := setupTestRouter() r.Use(middleware.LoginRedisRateLimit()) r.GET("/test", func(c *gin.Context) { @@ -544,8 +1202,8 @@ func TestLoginRedisRateLimit(t *testing.T) { req := httptest.NewRequest("GET", "/test", nil) r.ServeHTTP(w, req) - if w.Code != 200 { - t.Errorf("LoginRedisRateLimit status = %d", w.Code) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("LoginRedisRateLimit (no Redis, fail-closed) status = %d, want 503", w.Code) } } @@ -603,4 +1261,220 @@ func TestRedisRateLimiterReset(t *testing.T) { 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.RedisClient + database.RedisClient = client + t.Cleanup(func() { database.RedisClient = old }) + return mr +} + +// 回归 H4c-1a:无 Redis 时 fail-open 放行(兼容旧行为)。 +func TestRedisRateLimiterFailOpenNoRedis(t *testing.T) { + prev := database.RedisClient + database.RedisClient = nil + defer func() { database.RedisClient = 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.RedisClient + database.RedisClient = nil + defer func() { database.RedisClient = prev }() + + limiter := middleware.NewRedisRateLimiterFailClosed("test", 10, time.Minute) + 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.NewRedisRateLimiterFailClosed("test_closed", 10, time.Minute) + 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.RedisClient + database.RedisClient = nil // 无 Redis + defer func() { database.RedisClient = prev }() + + r := setupTestRouter() + r.Use(middleware.RedisRateLimitFailClosed("login_limit", 10)) + 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.RedisClient + database.RedisClient = nil + defer func() { database.RedisClient = 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.RedisClient.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.RedisClient + database.RedisClient = nil + defer func() { database.RedisClient = 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) + } } \ No newline at end of file diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index 7724f51..a7bbf37 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -2,7 +2,9 @@ package middleware import ( "context" + "errors" "fmt" + "net/http" "sync" "time" @@ -20,11 +22,15 @@ type RateLimiter struct { ctx context.Context cancel context.CancelFunc wg sync.WaitGroup + nowFunc func() time.Time // 时间源(默认 time.Now,测试可注入可控时钟) } type visitor struct { - lastSeen time.Time - count int + // windowStart 当前固定窗口的起点。仅在新窗口开始时设置,放行时不变更(H4a 修复)。 + // 旧实现每次放行都更新 lastSeen,导致 time.Since(lastSeen) > window 重置分支对持续 + // 客户端永不成立、count 单调累加,稳态客户端(低于 rate)被误限流。 + windowStart time.Time + count int } // NewRateLimiter 创建速率限制器(内存版) @@ -36,6 +42,7 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter { window: window, ctx: ctx, cancel: cancel, + nowFunc: time.Now, } limiter.wg.Add(1) @@ -44,32 +51,58 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter { return limiter } -// Allow 检查是否允许请求 +// now 返回当前时间(可被测试注入 nowFunc 覆盖)。 +func (rl *RateLimiter) now() time.Time { + if rl.nowFunc != nil { + return rl.nowFunc() + } + return time.Now() +} + +// SetNowFunc 注入时间源(默认 time.Now),供测试用可控时钟验证窗口语义。 +// 生产代码通常无需调用。 +func (rl *RateLimiter) SetNowFunc(f func() time.Time) { + rl.mu.Lock() + rl.nowFunc = f + rl.mu.Unlock() +} + +// Allow 检查是否允许请求。 +// +// 固定窗口语义(H4a 修复):windowStart 是当前窗口起点,仅在窗口过期重置时变更; +// 放行时不再更新 windowStart。窗口内 count 达到 rate 即拒绝,窗口过期则重置为新窗口。 +// 旧实现每次放行更新 lastSeen,致重置分支对持续客户端永不成立、稳态客户端被误限流。 +// +// 注意:固定窗口算法允许窗口边界突发(两窗口交界处瞬时可达 2×rate)。 +// 如需平滑限流(无突发),请用 Redis 版 RedisRateLimiter(滑动窗口)。 func (rl *RateLimiter) Allow(ip string) bool { rl.mu.Lock() defer rl.mu.Unlock() + now := rl.now() v, exists := rl.visitors[ip] if !exists { rl.visitors[ip] = &visitor{ - lastSeen: time.Now(), - count: 1, + windowStart: now, + count: 1, } return true } - if time.Since(v.lastSeen) > rl.window { + // 窗口过期:开新窗口,count 重置为 1。 + if now.Sub(v.windowStart) > rl.window { + v.windowStart = now v.count = 1 - v.lastSeen = time.Now() return true } + // 当前窗口内已达上限:拒绝(不更新 windowStart)。 if v.count >= rl.rate { return false } + // 放行:count++,windowStart 不变(固定窗口语义)。 v.count++ - v.lastSeen = time.Now() return true } @@ -87,7 +120,8 @@ func (rl *RateLimiter) cleanupVisitors() { case <-ticker.C: rl.mu.Lock() for ip, v := range rl.visitors { - if time.Since(v.lastSeen) > rl.window { + // 窗口起点超 window 未活跃即淘汰(H4a:windowStart 是窗口起点,不再被放行更新)。 + if rl.now().Sub(v.windowStart) > rl.window { delete(rl.visitors, ip) } } @@ -104,11 +138,22 @@ func (rl *RateLimiter) Stop() { // ===== Redis 分布式限流器 ===== +// H4c: Redis 限流器故障相关错误。 +var ( + // ErrRedisRateLimiterUnavailable Redis 未启用(database.RedisClient == nil)。 + // fail-closed 限流器在此情况下拒绝请求;fail-open 限流器放行。 + ErrRedisRateLimiterUnavailable = errors.New("redis rate limiter: redis client unavailable") + // ErrRedisRateLimiterUnexpectedResult Redis 返回非预期的结果类型(非 int64)。 + // fail-closed 限流器拒绝;fail-open 限流器放行。旧实现裸断言会 panic。 + ErrRedisRateLimiterUnexpectedResult = errors.New("redis rate limiter: unexpected result type") +) + // RedisRateLimiter Redis 分布式限流器 type RedisRateLimiter struct { - keyPrefix string // 键名前缀 - rate int // 每分钟允许的请求数 - window time.Duration // 时间窗口 + keyPrefix string // 键名前缀 + rate int // 每分钟允许的请求数 + window time.Duration // 时间窗口 + failClosed bool // H4c: Redis 错误/断言失败时是否拒绝(true=安全型 fail-closed,false=兼容默认 fail-open 放行) } // slidingWindowLua 滑动窗口限流 Lua 脚本 @@ -135,19 +180,49 @@ else end ` -// NewRedisRateLimiter 创建 Redis 分布式限流器 +// NewRedisRateLimiter 创建 Redis 分布式限流器(默认 fail-open:Redis 错误时放行,避免影响业务)。 +// 安全敏感场景(如登录防爆破)应使用 NewRedisRateLimiterFailClosed,Redis 故障时拒绝以防限流失效。 func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter { return &RedisRateLimiter{ - keyPrefix: keyPrefix, - rate: rate, - window: window, + keyPrefix: keyPrefix, + rate: rate, + window: window, + failClosed: false, } } -// Allow 检查是否允许请求 +// NewRedisRateLimiterFailClosed 创建安全型 Redis 分布式限流器(fail-closed): +// Redis 不可用/错误/返回非预期类型时拒绝请求,避免限流静默失效(防爆破场景必备)。 +func NewRedisRateLimiterFailClosed(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter { + return &RedisRateLimiter{ + keyPrefix: keyPrefix, + rate: rate, + window: window, + failClosed: true, + } +} + +// SetFailClosed 设置 Redis 故障时的策略:true=拒绝(安全型),false=放行(兼容默认)。 +// 供已创建的限流器切换策略。 +func (rl *RedisRateLimiter) SetFailClosed(failClosed bool) { + rl.failClosed = failClosed +} + +// Allow 检查是否允许请求。 +// +// H4c 修复: +// - result.(int64) 改 comma-ok,断言失败返 ErrRedisRateLimiterUnexpectedResult 而非 panic。 +// - Redis 错误/断言失败时按 failClosed 策略决定:fail-closed 返 (false, err) 拒绝, +// fail-open 返 (true, err) 放行(兼容旧行为)。中间件层据此 allowed 值决定放行/拒绝, +// 不再无条件 fail-open——登录防爆破等安全场景用 fail-closed 限流器即可在 Redis 故障时拒绝。 +// +// Redis 未启用(database.RedisClient == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable), +// fail-open 返 (true, nil)(兼容旧行为)。安全场景必须确保 Redis 已启用。 func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) { if database.RedisClient == nil { - // Redis 未启用,默认允许 + if rl.failClosed { + return false, ErrRedisRateLimiterUnavailable + } return true, nil } @@ -157,10 +232,20 @@ func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, result, err := database.RedisClient.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result() if err != nil { - return true, err // 出错时允许请求,避免影响业务 + if rl.failClosed { + return false, err + } + return true, err // 出错时允许请求,避免影响业务(兼容旧行为) } - count := result.(int64) + // H4c: comma-ok 断言,避免 Redis 返回非 int64 时 panic。 + count, ok := result.(int64) + if !ok { + if rl.failClosed { + return false, ErrRedisRateLimiterUnexpectedResult + } + return true, ErrRedisRateLimiterUnexpectedResult + } return count == 0, nil } @@ -192,23 +277,33 @@ func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error // ===== 全局限速器 ===== var ( - loginLimiter *RateLimiter - apiLimiter *RateLimiter - uploadLimiter *RateLimiter - redisLimiters map[string]*RedisRateLimiter - limitersMu sync.Mutex + loginLimiter *RateLimiter + apiLimiter *RateLimiter + uploadLimiter *RateLimiter + redisLimiters map[string]*RedisRateLimiter + customLimiters []*RateLimiter // H4b: CustomRateLimit 创建的限流器登记表,供 StopRateLimiters/InitRateLimiters 统一停止,避免 cleanup goroutine 泄漏 + limitersMu sync.Mutex ) func init() { redisLimiters = make(map[string]*RedisRateLimiter) } +// drainCustomLimiters 取出已登记的自定义限流器并清空登记表。调用方须持有 limitersMu。 +func drainCustomLimiters() []*RateLimiter { + drained := customLimiters + customLimiters = nil + return drained +} + // InitRateLimiters 初始化限速器 func InitRateLimiters() { limitersMu.Lock() defer limitersMu.Unlock() - // 先停止旧的限流器 + // 先停止旧的限流器(含自定义),释放 cleanup goroutine。 + // 持锁期间调 Stop() 安全:Stop→wg.Wait 等待的 cleanupVisitors 取的是 limiter 自身的 rl.mu, + // 非 limitersMu,无死锁;全程持锁避免与 LoginRateLimit 等懒初始化路径交错致覆盖泄漏。 if loginLimiter != nil { loginLimiter.Stop() } @@ -218,6 +313,9 @@ func InitRateLimiters() { if uploadLimiter != nil { uploadLimiter.Stop() } + for _, l := range drainCustomLimiters() { + l.Stop() + } // 内存限流器(单实例) loginLimiter = NewRateLimiter(10, time.Minute) @@ -242,6 +340,9 @@ func StopRateLimiters() { uploadLimiter.Stop() uploadLimiter = nil } + for _, l := range drainCustomLimiters() { + l.Stop() + } } // RateLimit 通用速率限制中间件(内存版) @@ -294,15 +395,48 @@ func UploadRateLimit() gin.HandlerFunc { } // CustomRateLimit 自定义速率限制(内存版) +// +// 创建的限流器登记入包级 customLimiters 表,StopRateLimiters / InitRateLimiters +// 会统一停止其 cleanup goroutine(H4b 修复:原实现每次路由构造创建 limiter 无句柄, +// StopRateLimiters 不感知 → cleanup goroutine 泄漏)。 func CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc { limiter := NewRateLimiter(rate, window) + limitersMu.Lock() + customLimiters = append(customLimiters, limiter) + limitersMu.Unlock() return RateLimit(limiter) } // ===== Redis 分布式限流中间件 ===== -// RedisRateLimit Redis 分布式限流中间件 +// 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 故障时放行,避免影响业务)。 // 参数: keyPrefix 键名前缀(如 "login_limit"),rate 每分钟请求数 +// 安全敏感场景(登录防爆破等)应使用 RedisRateLimitFailClosed,Redis 故障时拒绝以防限流失效。 func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc { limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute) @@ -316,23 +450,23 @@ func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc { // } allowed, err := limiter.Allow(c.Request.Context(), identifier) - if err != nil { - // Redis 错误时允许请求,避免影响业务 - c.Next() - return - } - - if !allowed { - response.RateLimit(c) - c.Abort() - return - } - - c.Next() + redisLimitDecision(c, allowed, err) } } -// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流 +// RedisRateLimitFailClosed 安全型 Redis 分布式限流中间件(fail-closed): +// Redis 故障时拒绝(503),避免限流静默失效。用于登录防爆破等安全场景。 +func RedisRateLimitFailClosed(keyPrefix string, rate int) gin.HandlerFunc { + limiter := NewRedisRateLimiterFailClosed(keyPrefix, rate, time.Minute) + + return func(c *gin.Context) { + identifier := c.ClientIP() + allowed, err := limiter.Allow(c.Request.Context(), identifier) + redisLimitDecision(c, allowed, err) + } +} + +// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流(fail-open)。 // 参数: keyPrefix 键名前缀,rate 每分钟请求数,identifierFunc 标识获取函数 func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string) gin.HandlerFunc { limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute) @@ -344,55 +478,46 @@ func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc fun } allowed, err := limiter.Allow(c.Request.Context(), identifier) - if err != nil { - c.Next() - return - } - - if !allowed { - response.RateLimit(c) - c.Abort() - return - } - - c.Next() + redisLimitDecision(c, allowed, err) } } -// LoginRedisRateLimit 登录接口 Redis 分布式限流 +// LoginRedisRateLimit 登录接口 Redis 分布式限流(fail-closed)。 +// +// H4c: 登录防爆破场景必须 fail-closed——Redis 故障时若 fail-open 则限流失效、 +// 攻击者可借 Redis 抖动窗口无限爆破。改为 fail-closed:Redis 故障时返 503 拒绝。 func LoginRedisRateLimit() gin.HandlerFunc { - return RedisRateLimit("login_limit", 10) + return RedisRateLimitFailClosed("login_limit", 10) } -// APIRedisRateLimit API Redis 分布式限流 +// APIRedisRateLimit API Redis 分布式限流(fail-open,避免影响业务)。 func APIRedisRateLimit() gin.HandlerFunc { return RedisRateLimit("api_limit", 100) } -// UploadRedisRateLimit 上传接口 Redis 分布式限流 +// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-open)。 func UploadRedisRateLimit() gin.HandlerFunc { return RedisRateLimit("upload_limit", 20) } -// CustomRedisRateLimit 自定义 Redis 分布式限流 +// CustomRedisRateLimit 自定义 Redis 分布式限流(fail-open)。 func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc { limiter := NewRedisRateLimiter(keyPrefix, rate, window) return func(c *gin.Context) { identifier := c.ClientIP() - allowed, err := limiter.Allow(c.Request.Context(), identifier) - if err != nil { - c.Next() - return - } + redisLimitDecision(c, allowed, err) + } +} - if !allowed { - response.RateLimit(c) - c.Abort() - return - } +// CustomRedisRateLimitFailClosed 自定义安全型 Redis 分布式限流(fail-closed)。 +func CustomRedisRateLimitFailClosed(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc { + limiter := NewRedisRateLimiterFailClosed(keyPrefix, rate, window) - c.Next() + return func(c *gin.Context) { + identifier := c.ClientIP() + allowed, err := limiter.Allow(c.Request.Context(), identifier) + redisLimitDecision(c, allowed, err) } } \ No newline at end of file diff --git a/middleware/recover.go b/middleware/recover.go index 25aa33c..fa0ae8e 100644 --- a/middleware/recover.go +++ b/middleware/recover.go @@ -28,9 +28,14 @@ func Recover() gin.HandlerFunc { zap.String("stack", string(debug.Stack())), ) - // 返回错误响应 - response.FailWithCode(c, response.CodeServerError, "服务器内部错误") - c.AbortWithStatus(http.StatusInternalServerError) + // 返回错误响应。 + // 必须用 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。 + response.Custom(c, http.StatusInternalServerError, response.CodeServerError, "服务器内部错误", nil) + c.Abort() } }() c.Next() @@ -53,9 +58,9 @@ func RecoverWithDetail() gin.HandlerFunc { zap.String("stack", string(debug.Stack())), ) - // 开发环境返回详细错误 - response.FailWithCode(c, response.CodeServerError, fmt.Sprintf("服务器内部错误: %v", err)) - c.AbortWithStatus(http.StatusInternalServerError) + // 开发环境返回详细错误。同样用 Custom 显式写 500(见 Recover 注释)。 + response.Custom(c, http.StatusInternalServerError, response.CodeServerError, fmt.Sprintf("服务器内部错误: %v", err), nil) + c.Abort() } }() c.Next() diff --git a/middleware/requestid.go b/middleware/requestid.go index 58edb1e..8323936 100644 --- a/middleware/requestid.go +++ b/middleware/requestid.go @@ -5,10 +5,35 @@ import ( "github.com/gin-gonic/gin" ) -// RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪 +// 请求 ID 校验约束(M15 修复:不无条件信任客户端 X-Request-ID,防头注入/日志伪造)。 +const ( + // requestIDMaxLen 客户端传入请求 ID 的最大长度。超长视为非法并重新生成,避免日志膨胀/注入。 + requestIDMaxLen = 128 +) + +// sanitizeRequestID 校验客户端传入的 X-Request-ID:仅允许可见 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 := c.GetHeader("X-Request-ID") + requestID := sanitizeRequestID(c.GetHeader("X-Request-ID")) if requestID == "" { requestID = utils.UUID() } diff --git a/middleware/timeout.go b/middleware/timeout.go index ef0b018..7807a2f 100644 --- a/middleware/timeout.go +++ b/middleware/timeout.go @@ -13,6 +13,11 @@ import ( // 为每个请求的 context 设置 deadline,下游 GORM / Redis / HTTP 调用 // 走 c.Request.Context() 即可级联取消,避免单个慢请求拖垮协程。 // +// ⚠️ 软超时语义(M14 文档化):本中间件仅注入带 deadline 的 ctx,不会主动中断 handler。 +// 生效前提是下游实际消费 ctx——GORM/Redis/HTTP 客户端等会响应 ctx 取消;但若 handler +// 是纯 CPU 循环、阻塞于不查 ctx 的调用、或不读 c.Request.Context(),则 deadline 到期 +// 也不会停。需要硬中断的场景请配合 http.Server.WriteTimeout 或在 handler 内显式 select ctx.Done。 +// // 用法: // // r.Use(middleware.Timeout(5 * time.Second)) diff --git a/model/base.go b/model/base.go index 7b1dfe7..f37d710 100644 --- a/model/base.go +++ b/model/base.go @@ -6,7 +6,8 @@ import ( "gorm.io/gorm" ) -// BaseModel 基础模型 +// BaseModel 基础模型。CreatedAt/UpdatedAt 不显式指定 type,由 GORM 按驱动选默认 +// 时间列类型(MySQL 通常为 datetime(0) 或 timestamp,保留亚秒精度)。 type BaseModel struct { ID uint `gorm:"primaryKey" json:"id"` CreatedAt time.Time `json:"created_at"` @@ -14,7 +15,11 @@ type BaseModel struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } -// BaseModelWithTime 带时间戳的模型 +// BaseModelWithTime 显式指定时间列为 datetime 类型。 +// +// 命名注意(N1):本类型与 BaseModel 的唯一区别是 CreatedAt/UpdatedAt 带 `gorm:"type:datetime"`, +// 二者都有时间戳——名字里的 "WithTime" 易误导。datetime 类型在部分 MySQL 版本下精度为秒 +// (丢毫秒),需毫秒精度请用 BaseModel 或显式 `type:datetime(3)`。保留此类型仅为向后兼容。 type BaseModelWithTime struct { ID uint `gorm:"primaryKey" json:"id"` CreatedAt time.Time `gorm:"type:datetime" json:"created_at"` diff --git a/repository/repository.go b/repository/repository.go index 8c8487a..97db770 100644 --- a/repository/repository.go +++ b/repository/repository.go @@ -3,6 +3,7 @@ package repository import ( "context" + "github.com/EthanCodeCraft/xlgo-core/database" "gorm.io/gorm" ) @@ -20,9 +21,21 @@ type BaseRepository[T any] interface { FindByIDs(ctx context.Context, ids []uint) ([]T, error) } -// BaseRepo 基础仓库实现 +// BaseRepo 基础仓库实现。 +// +// 连接路由契约(H6c 修复): +// - 读操作经 readConn(ctx) 路由:优先 join 外层事务,否则走 database.GetDBFromContext +// (默认从库,支持 UseMaster/UseReplica 读写分离),DefaultManager 未初始化时回退 r.db。 +// - 写操作经 writeConn(ctx) 路由:优先 join 外层事务,否则走主库 database.GetWriteDB(), +// 回退 r.db。写操作不路由到从库(从库只读)。 +// - 事务内(WithTransaction 创建的 txRepo)所有方法 join 同一事务。 +// +// 下游典型用法 NewBaseRepo[T](database.GetDB()) 仍兼容:GetDB 返回主库,DefaultManager +// 初始化后读操作自动路由到从库;未初始化(如单测注入 sqlite)时回退到 r.db。 type BaseRepo[T any] struct { db *gorm.DB + // tx 在事务内(WithTransaction)非 nil,使 txRepo 的方法 join 该事务而非另开连接/路由。 + tx *gorm.DB } // NewBaseRepo 创建基础仓库 @@ -30,10 +43,39 @@ func NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T] { return &BaseRepo[T]{db: db} } +// readConn 返回读连接:外层 ctx 事务 > 本 repo 事务 > 读写分离路由 > r.db 回退。 +func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB { + 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 r.db.WithContext(ctx) +} + +// writeConn 返回写连接:外层 ctx 事务 > 本 repo 事务 > 主库 > r.db 回退。 +// 写操作始终走主库,不路由到只读从库。 +func (r *BaseRepo[T]) writeConn(ctx context.Context) *gorm.DB { + 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 r.db.WithContext(ctx) +} + // FindByID 根据 ID 查询 func (r *BaseRepo[T]) FindByID(ctx context.Context, id uint) (*T, error) { var model T - err := r.db.WithContext(ctx).First(&model, id).Error + err := r.readConn(ctx).First(&model, id).Error if err != nil { return nil, err } @@ -42,54 +84,82 @@ func (r *BaseRepo[T]) FindByID(ctx context.Context, id uint) (*T, error) { // Create 创建记录 func (r *BaseRepo[T]) Create(ctx context.Context, model *T) error { - return r.db.WithContext(ctx).Create(model).Error + return r.writeConn(ctx).Create(model).Error } -// Update 更新记录 +// Update 更新记录(全列覆写,基于 Save)。 +// +// 注意(H6a):Save 会写入所有字段(包括零值),无法区分"未设置"与"清零", +// 且可能用零值覆盖并发更新。需要局部更新(仅更新非零字段或指定字段)请用 UpdateFields。 func (r *BaseRepo[T]) Update(ctx context.Context, model *T) error { - return r.db.WithContext(ctx).Save(model).Error + return r.writeConn(ctx).Save(model).Error } -// Delete 删除记录(软删除) +// UpdateFields 局部更新(H6a):基于 gorm.Updates,仅更新非零字段(struct)或指定字段(map)。 +// +// - 传 struct:仅更新非零字段(零值被忽略,避免覆盖)。 +// - 传 map[string]any:更新指定字段(可显式置零)。 +// +// 示例: +// +// repo.UpdateFields(ctx, &User{Name: "new"}, "name") // 仅更新 name +// repo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // 显式置零 +func (r *BaseRepo[T]) UpdateFields(ctx context.Context, model any, conds ...any) error { + db := r.writeConn(ctx).Model(new(T)) + if len(conds) > 0 { + db = db.Where(conds[0], conds[1:]...) + } + return db.Updates(model).Error +} + +// Delete 删除记录。 +// +// 行为契约(H6b):若 T 内嵌 gorm.DeletedAt(或 gorm.Model),为软删除; +// 否则为硬删除(泛型类型约束无法在编译期强制)。需硬删用 HardDelete,需恢复用 Restore。 func (r *BaseRepo[T]) Delete(ctx context.Context, id uint) error { - return r.db.WithContext(ctx).Delete(new(T), id).Error + return r.writeConn(ctx).Delete(new(T), id).Error } // HardDelete 硬删除记录(物理删除) func (r *BaseRepo[T]) HardDelete(ctx context.Context, id uint) error { - return r.db.WithContext(ctx).Unscoped().Delete(new(T), id).Error + return r.writeConn(ctx).Unscoped().Delete(new(T), id).Error } // FindByIDs 批量查询 func (r *BaseRepo[T]) FindByIDs(ctx context.Context, ids []uint) ([]T, error) { var models []T - err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&models).Error + err := r.readConn(ctx).Where("id IN ?", ids).Find(&models).Error return models, err } // FindAll 查询所有记录 func (r *BaseRepo[T]) FindAll(ctx context.Context) ([]T, error) { var models []T - err := r.db.WithContext(ctx).Find(&models).Error + err := r.readConn(ctx).Find(&models).Error return models, err } // Count 统计数量 func (r *BaseRepo[T]) Count(ctx context.Context) (int64, error) { var count int64 - err := r.db.WithContext(ctx).Model(new(T)).Count(&count).Error + err := r.readConn(ctx).Model(new(T)).Count(&count).Error return count, err } // CountWhere 条件统计 func (r *BaseRepo[T]) CountWhere(ctx context.Context, query string, args ...any) (int64, error) { var count int64 - err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Count(&count).Error + err := r.readConn(ctx).Model(new(T)).Where(query, args...).Count(&count).Error return count, err } -// GetDB 获取数据库实例 +// GetDB 获取数据库实例。 +// 事务内(txRepo)返回当前事务的 tx;否则返回构造时注入的 db。 +// 注意:此方法无 ctx 参数,不参与读写分离路由;需要路由请用具体方法(FindByID/FindPage 等)。 func (r *BaseRepo[T]) GetDB() *gorm.DB { + if r.tx != nil { + return r.tx + } return r.db } @@ -98,7 +168,7 @@ func (r *BaseRepo[T]) GetDB() *gorm.DB { // FindOne 条件查询单条记录 func (r *BaseRepo[T]) FindOne(ctx context.Context, query string, args ...any) (*T, error) { var model T - err := r.db.WithContext(ctx).Where(query, args...).First(&model).Error + err := r.readConn(ctx).Where(query, args...).First(&model).Error if err != nil { return nil, err } @@ -108,21 +178,21 @@ func (r *BaseRepo[T]) FindOne(ctx context.Context, query string, args ...any) (* // FindWhere 条件查询多条记录 func (r *BaseRepo[T]) FindWhere(ctx context.Context, query string, args ...any) ([]T, error) { var models []T - err := r.db.WithContext(ctx).Where(query, args...).Find(&models).Error + err := r.readConn(ctx).Where(query, args...).Find(&models).Error return models, err } // FindWhereOrdered 条件查询并排序 func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, query string, args []any, order string) ([]T, error) { var models []T - err := r.db.WithContext(ctx).Where(query, args...).Order(order).Find(&models).Error + err := r.readConn(ctx).Where(query, args...).Order(order).Find(&models).Error return models, err } // FindOrdered 查询并排序 func (r *BaseRepo[T]) FindOrdered(ctx context.Context, order string, limit int) ([]T, error) { var models []T - query := r.db.WithContext(ctx).Order(order) + query := r.readConn(ctx).Order(order) if limit > 0 { query = query.Limit(limit) } @@ -133,7 +203,7 @@ func (r *BaseRepo[T]) FindOrdered(ctx context.Context, order string, limit int) // FindLimited 查询指定数量记录 func (r *BaseRepo[T]) FindLimited(ctx context.Context, limit int) ([]T, error) { var models []T - err := r.db.WithContext(ctx).Limit(limit).Find(&models).Error + err := r.readConn(ctx).Limit(limit).Find(&models).Error return models, err } @@ -147,133 +217,109 @@ type PageResult[T any] struct { PageSize int `json:"page_size"` } -// FindPage 分页查询 +// pageOffset 计算 (page-1)*pageSize,负值归零。 +func pageOffset(page, pageSize int) int { + 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 - - // 统计总数 - if err := r.db.WithContext(ctx).Model(new(T)).Count(&total).Error; err != nil { + offset := pageOffset(page, pageSize) + err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error { + if e := tx.Model(new(T)).Count(&total).Error; e != nil { + return e + } + return tx.Offset(offset).Limit(pageSize).Find(&models).Error + }) + if err != nil { return nil, err } - - // 计算偏移量 - offset := (page - 1) * pageSize - if offset < 0 { - offset = 0 - } - - // 查询数据 - if err := r.db.WithContext(ctx).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil { - return nil, err - } - - return &PageResult[T]{ - Items: models, - Total: total, - Page: page, - PageSize: pageSize, - }, nil + return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil } -// FindPageOrdered 分页查询并排序 +// FindPageOrdered 分页查询并排序(count+list 单事务,H6d) func (r *BaseRepo[T]) FindPageOrdered(ctx context.Context, page, pageSize int, order string) (*PageResult[T], error) { var models []T var total int64 - - if err := r.db.WithContext(ctx).Model(new(T)).Count(&total).Error; err != nil { + offset := pageOffset(page, pageSize) + err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error { + if e := tx.Model(new(T)).Count(&total).Error; e != nil { + return e + } + return tx.Order(order).Offset(offset).Limit(pageSize).Find(&models).Error + }) + if err != nil { return nil, err } - - offset := (page - 1) * pageSize - if offset < 0 { - offset = 0 - } - - if err := r.db.WithContext(ctx).Order(order).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil { - return nil, err - } - - return &PageResult[T]{ - Items: models, - Total: total, - Page: page, - PageSize: pageSize, - }, nil + return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil } -// FindPageWhere 条件分页查询 +// FindPageWhere 条件分页查询(count+list 单事务,H6d) func (r *BaseRepo[T]) FindPageWhere(ctx context.Context, page, pageSize int, query string, args ...any) (*PageResult[T], error) { var models []T var total int64 - - if err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Count(&total).Error; err != nil { + offset := pageOffset(page, pageSize) + err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error { + if e := tx.Model(new(T)).Where(query, args...).Count(&total).Error; e != nil { + return e + } + return tx.Where(query, args...).Offset(offset).Limit(pageSize).Find(&models).Error + }) + if err != nil { return nil, err } - - offset := (page - 1) * pageSize - if offset < 0 { - offset = 0 - } - - if err := r.db.WithContext(ctx).Where(query, args...).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil { - return nil, err - } - - return &PageResult[T]{ - Items: models, - Total: total, - Page: page, - PageSize: pageSize, - }, nil + return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil } -// FindPageWhereOrdered 条件分页查询并排序 +// FindPageWhereOrdered 条件分页查询并排序(count+list 单事务,H6d) func (r *BaseRepo[T]) FindPageWhereOrdered(ctx context.Context, page, pageSize int, query string, args []any, order string) (*PageResult[T], error) { var models []T var total int64 - - if err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Count(&total).Error; err != nil { + offset := pageOffset(page, pageSize) + err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error { + if e := tx.Model(new(T)).Where(query, args...).Count(&total).Error; e != nil { + return e + } + return tx.Where(query, args...).Order(order).Offset(offset).Limit(pageSize).Find(&models).Error + }) + if err != nil { return nil, err } - - offset := (page - 1) * pageSize - if offset < 0 { - offset = 0 - } - - if err := r.db.WithContext(ctx).Where(query, args...).Order(order).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil { - return nil, err - } - - return &PageResult[T]{ - Items: models, - Total: total, - Page: page, - PageSize: pageSize, - }, nil + return &PageResult[T]{Items: models, Total: total, Page: page, PageSize: pageSize}, nil } // ===== 批量操作 ===== // CreateBatch 批量创建 func (r *BaseRepo[T]) CreateBatch(ctx context.Context, models []T) error { - return r.db.WithContext(ctx).Create(models).Error + return r.writeConn(ctx).Create(models).Error } // UpdateBatch 批量更新(指定字段) func (r *BaseRepo[T]) UpdateBatch(ctx context.Context, ids []uint, field string, value any) error { - return r.db.WithContext(ctx).Model(new(T)).Where("id IN ?", ids).Update(field, value).Error + return r.writeConn(ctx).Model(new(T)).Where("id IN ?", ids).Update(field, value).Error } // DeleteBatch 批量删除 func (r *BaseRepo[T]) DeleteBatch(ctx context.Context, ids []uint) error { - return r.db.WithContext(ctx).Delete(new(T), ids).Error + return r.writeConn(ctx).Delete(new(T), ids).Error } // HardDeleteBatch 批量硬删除 func (r *BaseRepo[T]) HardDeleteBatch(ctx context.Context, ids []uint) error { - return r.db.WithContext(ctx).Unscoped().Delete(new(T), ids).Error + return r.writeConn(ctx).Unscoped().Delete(new(T), ids).Error } // ===== 存在性检查 ===== @@ -281,14 +327,14 @@ func (r *BaseRepo[T]) HardDeleteBatch(ctx context.Context, ids []uint) error { // Exists 检查是否存在 func (r *BaseRepo[T]) Exists(ctx context.Context, id uint) (bool, error) { var count int64 - err := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id).Count(&count).Error + err := r.readConn(ctx).Model(new(T)).Where("id = ?", id).Count(&count).Error return count > 0, err } // ExistsWhere 条件检查是否存在 func (r *BaseRepo[T]) ExistsWhere(ctx context.Context, query string, args ...any) (bool, error) { var count int64 - err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Limit(1).Count(&count).Error + err := r.readConn(ctx).Model(new(T)).Where(query, args...).Limit(1).Count(&count).Error return count > 0, err } @@ -296,39 +342,62 @@ func (r *BaseRepo[T]) ExistsWhere(ctx context.Context, query string, args ...any // Restore 恢复软删除记录 func (r *BaseRepo[T]) Restore(ctx context.Context, id uint) error { - return r.db.WithContext(ctx).Model(new(T)).Unscoped().Where("id = ?", id).Update("deleted_at", nil).Error + return r.writeConn(ctx).Model(new(T)).Unscoped().Where("id = ?", id).Update("deleted_at", nil).Error } // RestoreBatch 批量恢复软删除记录 func (r *BaseRepo[T]) RestoreBatch(ctx context.Context, ids []uint) error { - return r.db.WithContext(ctx).Model(new(T)).Unscoped().Where("id IN ?", ids).Update("deleted_at", nil).Error + return r.writeConn(ctx).Model(new(T)).Unscoped().Where("id IN ?", ids).Update("deleted_at", nil).Error } // FindDeleted 查询已软删除的记录 func (r *BaseRepo[T]) FindDeleted(ctx context.Context) ([]T, error) { var models []T - err := r.db.WithContext(ctx).Unscoped().Where("deleted_at IS NOT NULL").Find(&models).Error + err := r.readConn(ctx).Unscoped().Where("deleted_at IS NOT NULL").Find(&models).Error return models, err } // FindAllWithDeleted 查询所有记录(包括软删除) func (r *BaseRepo[T]) FindAllWithDeleted(ctx context.Context) ([]T, error) { var models []T - err := r.db.WithContext(ctx).Unscoped().Find(&models).Error + err := r.readConn(ctx).Unscoped().Find(&models).Error return models, err } // ===== 事务支持 ===== -// WithTransaction 在事务中执行操作 +// WithTransaction 在事务中执行操作。 +// +// fn 收到的 txRepo 处于事务内,其所有方法(FindByID/Create/Update/...)自动 join 该事务, +// 不再路由到主从库(H6c)。事务在主库上开启。 +// +// 跨 repo / 跨层 join:若 fn 内需调用其它 repo 或 database 层方法参与同一事务, +// 用 database.WithTx(ctx, tx) 把事务注入 ctx 后传递: +// +// return repo.WithTransaction(ctx, func(txRepo *BaseRepo[T]) error { +// if err := txRepo.Create(ctx, a); err != nil { return err } +// // 另一个 repo 也加入同一事务: +// ctx2 := database.WithTx(ctx, txRepo.GetDB()) +// return otherRepo.Update(ctx2, b) +// }) +// +// 注:fn 内捕获的 ctx 不会自动携带 tx;仅 txRepo 的方法 join 事务。 func (r *BaseRepo[T]) WithTransaction(ctx context.Context, fn func(txRepo *BaseRepo[T]) error) error { - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - txRepo := NewBaseRepo[T](tx) + return r.writeConn(ctx).Transaction(func(tx *gorm.DB) error { + txRepo := &BaseRepo[T]{db: r.db, tx: tx} return fn(txRepo) }) } // ===== QueryBuilder 链式查询 ===== +// +// QueryBuilder 为单次使用、非并发安全(H6e):链式方法 Where/Or/Order/Limit/Offset 会 +// mutate qb.db,禁止跨 goroutine 共用同一实例或多次终结调用复用累积条件。 +// 终结方法(Find/First/Count/Page)基于 Session 克隆,不污染 qb.db;Count 额外剥离 +// Limit/Offset 避免残留分页条件截断统计。 +// +// 路由:QueryBuilder 查询经构造时注入的 db(通常主库),不参与读写分离路由; +// 需读写分离请用具体方法(FindPage/FindWhere 等)。 // QueryBuilder 链式查询构建器 type QueryBuilder[T any] struct { @@ -372,44 +441,52 @@ func (qb *QueryBuilder[T]) Offset(offset int) *QueryBuilder[T] { return qb } +// clone 返回 qb.db 的 Session 克隆,确保终结方法不污染 qb.db(H6e)。 +func (qb *QueryBuilder[T]) clone() *gorm.DB { + return qb.db.Session(&gorm.Session{}) +} + // Find 执行查询 func (qb *QueryBuilder[T]) Find(ctx context.Context) ([]T, error) { var models []T - err := qb.db.WithContext(ctx).Find(&models).Error + err := qb.clone().WithContext(ctx).Find(&models).Error return models, err } // First 执行查询并返回第一条 func (qb *QueryBuilder[T]) First(ctx context.Context) (*T, error) { var model T - err := qb.db.WithContext(ctx).First(&model).Error + err := qb.clone().WithContext(ctx).First(&model).Error if err != nil { return nil, err } return &model, nil } -// Count 执行统计 +// Count 执行统计。 +// 克隆并剥离 Limit/Offset(H6e),避免先前 Limit()/Offset() 残留截断统计行数。 func (qb *QueryBuilder[T]) Count(ctx context.Context) (int64, error) { var count int64 - err := qb.db.WithContext(ctx).Count(&count).Error + err := qb.clone().Limit(-1).Offset(-1).WithContext(ctx).Count(&count).Error return count, err } -// Page 执行分页查询 +// Page 执行分页查询。 +// count 与 list 基于同一 Session 克隆,count 剥离 Limit/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) { var models []T var total int64 - // 复制 query 用于统计(避免影响原查询) - // 清除残留的 Limit/Offset,否则统计行数会被截断(GORM 中 Limit(-1)/Offset(-1) 表示移除该条件) - countDB := qb.db.Session(&gorm.Session{}).Limit(-1).Offset(-1) + // count 克隆并剥离 Limit/Offset + countDB := qb.clone().Limit(-1).Offset(-1) if err := countDB.WithContext(ctx).Count(&total).Error; err != nil { return nil, err } - offset := (page - 1) * pageSize - if err := qb.db.WithContext(ctx).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil { + offset := pageOffset(page, pageSize) + if err := qb.clone().WithContext(ctx).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil { return nil, err } @@ -419,4 +496,4 @@ func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageR Page: page, PageSize: pageSize, }, nil -} \ No newline at end of file +} diff --git a/repository/repository_h6_internal_test.go b/repository/repository_h6_internal_test.go new file mode 100644 index 0000000..dfe99a7 --- /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.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(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_test.go b/repository/repository_test.go index a9abe2d..5862721 100644 --- a/repository/repository_test.go +++ b/repository/repository_test.go @@ -1,65 +1,26 @@ package repository_test import ( - "context" "testing" + "github.com/EthanCodeCraft/xlgo-core/repository" "gorm.io/gorm" ) -func TestNewBaseRepo(t *testing.T) { - // BaseRepo 需要数据库连接,这里测试结构 - // 无法直接实例化,验证泛型定义 +// N2:原 repository_test.go 全为空注释壳,CRUD 零覆盖。 +// 此处用编译期断言锁定 BaseRepo 实现 BaseRepository 接口契约—— +// 接口一旦与实现漂移,编译即失败(比运行期空壳更有价值)。 + +type testModel struct { + gorm.Model + Name string } -func TestBaseRepoInterface(t *testing.T) { - // BaseRepository 接口验证 - // FindByID, Create, Update, Delete, FindByIDs 方法 -} +// 编译期保证 *BaseRepo[T] 满足 BaseRepository[T] 接口(N2 接口契约守卫)。 +var _ repository.BaseRepository[testModel] = (*repository.BaseRepo[testModel])(nil) -func TestBaseRepoMethods(t *testing.T) { - // 测试方法签名,实际使用需要 DB 连接 - // FindByID(ctx context.Context, id uint) (*T, error) - // Create(ctx context.Context, model *T) error - // Update(ctx context.Context, model *T) error - // Delete(ctx context.Context, id uint) error - // FindByIDs(ctx context.Context, ids []uint) ([]T, error) - // FindAll(ctx context.Context) ([]T, error) - // Count(ctx context.Context) (int64, error) - // GetDB() *gorm.DB +func TestBaseRepoSatisfiesInterface(t *testing.T) { + // 运行期占位:编译期 var _ 已是主断言,此处仅保证测试函数不被 lint 清理。 + var r repository.BaseRepository[testModel] = repository.NewBaseRepo[testModel](nil) + _ = r } - -func TestRepositoryFunctionSignatures(t *testing.T) { - // 验证函数签名存在 - // NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T] -} - -func TestGormDependency(t *testing.T) { - // repository 依赖 gorm - // gorm.DB 用于数据库操作 -} - -func TestContextUsage(t *testing.T) { - ctx := context.Background() - if ctx == nil { - t.Error("context.Background should not return nil") - } -} - -func TestGormDBType(t *testing.T) { - // 验证 gorm.DB 类型 - var db *gorm.DB - if db != nil { - // db 未初始化应为 nil - } -} - -func TestBaseRepoStruct(t *testing.T) { - // BaseRepo[T any] struct { db *gorm.DB } - // 泛型结构体,无法直接测试实例化 -} - -func TestBaseRepositoryInterface(t *testing.T) { - // interface 定义验证 - // type BaseRepository[T any] interface { ... } -} \ No newline at end of file diff --git a/response/error.go b/response/error.go index 5cfd472..4ee4ca0 100644 --- a/response/error.go +++ b/response/error.go @@ -93,8 +93,16 @@ func (e *Error) WithDetail(detail string) *Error { return e } -// ToResponse 转换为响应结构 +// ToResponse 转换为响应结构。 +// 把 Detail 放入 data.detail(若非空),保留链路细节(M7:原实现丢 Detail)。 func (e *Error) ToResponse() Response { + if e.Detail != "" { + return Response{ + Code: e.Code, + Msg: e.Message, + Data: gin.H{"detail": e.Detail}, + } + } return Response{ Code: e.Code, Msg: e.Message, diff --git a/response/mode.go b/response/mode.go index 8347b28..3eab9b6 100644 --- a/response/mode.go +++ b/response/mode.go @@ -41,7 +41,7 @@ func statusForCode(code int) int { return http.StatusForbidden case CodeNotFound, CodeUserNotFound, CodeFileNotFound, CodeDataNotFound: return http.StatusNotFound - case CodeDataConflict: + case CodeDataConflict, CodeDataAlreadyExists: return http.StatusConflict case CodeRateLimit: return http.StatusTooManyRequests diff --git a/response/mode_test.go b/response/mode_test.go index c32cd87..207c6b0 100644 --- a/response/mode_test.go +++ b/response/mode_test.go @@ -91,3 +91,16 @@ func TestFailWithErrorREST(t *testing.T) { 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 index ebfaa80..ac6794e 100644 --- a/response/response.go +++ b/response/response.go @@ -2,6 +2,7 @@ package response import ( "net/http" + "net/url" "github.com/EthanCodeCraft/xlgo-core/utils" "github.com/gin-gonic/gin" @@ -88,10 +89,41 @@ func Page(c *gin.Context, items any, total int64, page, pageSize int) { }) } +// contentDisposition 生成 RFC 5987 兼容的 Content-Disposition 值(M6 修复)。 +// 同时给出 ASCII filename(向后兼容旧客户端)与 filename*(UTF-8 百分号编码,支持中文等非 ASCII), +// 避免直接拼接导致中文文件名乱码。 +func contentDisposition(filename string) string { + ascii := asciiFallbackName(filename) + enc := url.PathEscape(filename) + // PathEscape 把空格编码为 %20(符合 RFC 5987),无需额外处理。 + return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + enc +} + +// asciiFallbackName 生成仅含 ASCII 的回退文件名:非 ASCII 字符替换为下划线, +// 空文件名回退为 "download"。 +func asciiFallbackName(filename string) string { + if filename == "" { + return "download" + } + b := make([]byte, 0, len(filename)) + for i := 0; i < len(filename); i++ { + c := filename[i] + if c >= 0x20 && c < 0x7f && c != '"' && c != '\\' { + b = append(b, c) + } else { + b = append(b, '_') + } + } + if len(b) == 0 { + return "download" + } + return string(b) +} + // Download 文件下载响应 func Download(c *gin.Context, filename string, data []byte) { c.Header("Content-Type", "application/octet-stream") - c.Header("Content-Disposition", "attachment; filename="+filename) + c.Header("Content-Disposition", contentDisposition(filename)) c.Header("Content-Length", utils.ToString(len(data))) c.Data(http.StatusOK, "application/octet-stream", data) } @@ -99,7 +131,7 @@ func Download(c *gin.Context, filename string, data []byte) { // DownloadWithContentType 文件下载(自定义Content-Type) func DownloadWithContentType(c *gin.Context, filename string, contentType string, data []byte) { c.Header("Content-Type", contentType) - c.Header("Content-Disposition", "attachment; filename="+filename) + c.Header("Content-Disposition", contentDisposition(filename)) c.Header("Content-Length", utils.ToString(len(data))) c.Data(http.StatusOK, contentType, data) } diff --git a/response/response_test.go b/response/response_test.go index e2d924c..e92e9b7 100644 --- a/response/response_test.go +++ b/response/response_test.go @@ -198,6 +198,29 @@ func TestDownload(t *testing.T) { } } +// TestDownloadChineseFilename_M6:中文文件名经 RFC 5987 编码(filename*),不乱码。 +func TestDownloadChineseFilename_M6(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.Download(c, "报告.txt", []byte("x")) + }) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + disposition := w.Header().Get("Content-Disposition") + // 应含 RFC 5987 编码的 filename*(百分号编码),且无原始 UTF-8 字节直接拼入 filename= 段 + if !contains(disposition, "filename*=UTF-8''") { + t.Fatalf("Content-Disposition should contain filename*=UTF-8''..., got %s", disposition) + } + if !contains(disposition, "%E6%8A%A5%E5%91%8A") { // "报告" 的百分号编码 + t.Fatalf("Content-Disposition should contain percent-encoded 报告, got %s", disposition) + } + // ASCII 回退名不应为空 + if !contains(disposition, `filename="`) { + t.Fatalf("Content-Disposition should contain ASCII fallback filename, got %s", disposition) + } +} + func TestDownloadWithContentType(t *testing.T) { r := setupTestRouter() r.GET("/test", func(c *gin.Context) { diff --git a/router/metrics.go b/router/metrics.go index cc9a690..f80b99c 100644 --- a/router/metrics.go +++ b/router/metrics.go @@ -1,16 +1,16 @@ package router import ( - "github.com/EthanCodeCraft/xlgo-core/middleware" - "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" ) -// RegisterMetricsRoute 注册 Prometheus 指标暴露端点与采集中间件(#18)。 +// RegisterMetricsRoute 注册 Prometheus 指标暴露端点(#18,H8c 修正)。 // -// 默认路径 /metrics。传入 path 可自定义。同时把 Metrics() 中间件挂到该组, -// 这样只有业务路由被统计,/metrics 自身与 /health 等基础路由不计入。 +// 默认路径 /metrics。传入 path 可自定义。本函数仅注册暴露端点,不再用 r.Use +// 挂采集中间件——采集中间件改由 Registry.SetMetricsMiddleware 在 Apply 内作为 +// 首个全局中间件装入,使所有经注册中心注册的业务路由都被采集,且不依赖本函数 +// 相对其他路由注册的调用顺序。/metrics 端点自身与 /health 等基础路由不经采集中间件。 // // 用法: // @@ -21,7 +21,6 @@ func RegisterMetricsRoute(r *gin.Engine, path ...string) { if len(path) > 0 && path[0] != "" { p = path[0] } - // 指标采集中间件挂在根引擎,统计所有业务请求 - r.Use(middleware.Metrics()) - r.GET(p, gin.WrapH(promhttp.Handler())) + // 幂等注册(H8d 收尾):重复调用跳过,避免重复路由 panic。 + registerGETOnce(r, p, gin.WrapH(promhttp.Handler())) } diff --git a/router/router.go b/router/router.go index e8f4661..0e23899 100644 --- a/router/router.go +++ b/router/router.go @@ -2,7 +2,10 @@ package router import ( "context" + "fmt" "net/http" + "strings" + "sync/atomic" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" @@ -16,6 +19,42 @@ type HealthCheck struct { Disabled bool } +// 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 eng, ok := r.(*gin.Engine); ok { + for _, ri := range eng.Routes() { + if ri.Method == http.MethodGet && ri.Path == path { + return + } + } + eng.GET(path, h) + return + } + defer func() { + if rec := recover(); rec != nil { + msg := fmt.Sprint(rec) + if strings.Contains(msg, "already registered") || + strings.Contains(msg, "conflicts with existing wildcard") { + return // 重复路由,静默跳过 + } + panic(rec) // 非重复路由 panic,原样抛出 + } + }() + r.GET(path, h) +} + // runHealthChecks 执行所有检查项,返回总体状态、HTTP code 与逐项结果。 // 无检查项时视为健康(用于 /livez 与无依赖场景)。 func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, map[string]string) { @@ -44,23 +83,33 @@ func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, ma return status, code, result } -// RegisterHealthRoute 注册健康检查路由(兼容端点,等价于 readiness)。 -func RegisterHealthRoute(r *gin.Engine, checks ...HealthCheck) { - r.GET("/health", func(c *gin.Context) { +// healthHandler 返回统一的 /health 风格响应(H8d 收敛)。 +// 无检查项时视为健康(200 + {"status":"ok"});有检查项时任一失败返回 503 +// 并附逐项结果。RegisterHealthRoute / RegisterReadinessRoute / defaultModule +// 均委托此实现,避免三个 /health 行为与响应体不一致。 +func healthHandler(checks []HealthCheck) gin.HandlerFunc { + return func(c *gin.Context) { status, code, result := runHealthChecks(c.Request.Context(), checks) if result == nil { c.JSON(http.StatusOK, gin.H{"status": status}) return } c.JSON(code, gin.H{"status": status, "checks": result}) - }) + } +} + +// RegisterHealthRoute 注册健康检查路由(兼容端点,等价于 readiness)。 +// 幂等:若 /health 已注册则跳过(首次注册胜出),避免与 defaultModule 等并存时重复路由 panic。 +func RegisterHealthRoute(r *gin.Engine, checks ...HealthCheck) { + registerGETOnce(r, "/health", healthHandler(checks)) } // RegisterLivenessRoute 注册存活性探针(#17)。 // GET /livez 永不依赖外部,仅表示进程存活,始终返回 200。 // 供 K8s livenessProbe 使用:失败由进程崩溃体现,而非端点返回 503。 +// 幂等:重复注册跳过。 func RegisterLivenessRoute(r *gin.Engine) { - r.GET("/livez", func(c *gin.Context) { + registerGETOnce(r, "/livez", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) } @@ -68,20 +117,14 @@ func RegisterLivenessRoute(r *gin.Engine) { // RegisterReadinessRoute 注册就绪性探针(#17)。 // GET /readyz 复用 HealthCheck 检查依赖(mysql/redis...),任一失败返回 503。 // 供 K8s readinessProbe 使用:未就绪时不接流量。 +// 幂等:重复注册跳过。 func RegisterReadinessRoute(r *gin.Engine, checks ...HealthCheck) { - r.GET("/readyz", func(c *gin.Context) { - status, code, result := runHealthChecks(c.Request.Context(), checks) - if result == nil { - c.JSON(http.StatusOK, gin.H{"status": status}) - return - } - c.JSON(code, gin.H{"status": status, "checks": result}) - }) + registerGETOnce(r, "/readyz", healthHandler(checks)) } -// RegisterSwaggerRoutes 注册 Swagger 文档路由 +// RegisterSwaggerRoutes 注册 Swagger 文档路由。幂等:重复注册跳过。 func RegisterSwaggerRoutes(r *gin.Engine) { - r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + registerGETOnce(r, "/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } // RegisterDefaultRoutes 注册框架默认路由(健康检查、Swagger) @@ -98,11 +141,11 @@ type defaultModule struct{} func (m *defaultModule) Name() string { return "default" } func (m *defaultModule) Register(r *gin.RouterGroup) { - // 作为模块注册时,路由在根路径 - r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) - r.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) + // 作为模块注册时,路由在根路径。经 registerGETOnce 幂等注册(H8d 收尾): + // 若用户已通过 RegisterSwaggerRoutes / RegisterHealthRoute 注册过同名路由, + // 此处静默跳过,避免 Gin 重复路由 panic。首次注册胜出。 + registerGETOnce(r, "/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + registerGETOnce(r, "/health", healthHandler(nil)) } // Module 路由模块接口 @@ -148,6 +191,16 @@ type Registry struct { versions map[string]*VersionedAPI middlewareGroups map[string]*MiddlewareGroup globalMiddlewares []gin.HandlerFunc + + // metricsMiddleware 在 Apply 时作为首个全局中间件装入(H8c), + // 使所有经注册中心注册的路由都被采集,不依赖 RegisterMetricsRoute 的调用顺序。 + // 为 nil 时不装入。/metrics 端点本身与 /health 等基础路由直接挂在 engine 上, + // 不经此中间件,故不被自采集。 + metricsMiddleware gin.HandlerFunc + + // applied 标记 Apply 是否已执行(H8b),二次 Apply 直接返回, + // 避免重复 engine.Use 与 Gin 重复路由 panic。 + applied bool } // NewRegistry 创建路由注册中心 @@ -206,8 +259,29 @@ func (r *Registry) GetMiddlewareGroup(name string) []gin.HandlerFunc { return nil } -// Apply 应用所有路由注册 +// SetMetricsMiddleware 设置指标采集中间件(H8c)。Apply 时它会作为首个全局中间件 +// 装入 engine,使所有经注册中心注册的路由都被采集,不再依赖注册顺序。 +// 传入 nil 清除。须在 Apply 之前调用。 +func (r *Registry) SetMetricsMiddleware(mw gin.HandlerFunc) { + r.metricsMiddleware = mw +} + +// Apply 应用所有路由注册。 +// +// 幂等(H8b):二次调用直接返回,避免重复 engine.Use 与 Gin 重复路由 panic。 +// 装入顺序:metrics 中间件(若有)→ 用户全局中间件 → 模块/版本路由。 +// metrics 置于首位保证全量业务路由被采集,且不依赖 RegisterMetricsRoute 调用顺序。 func (r *Registry) Apply() { + if r.applied { + return + } + r.applied = true + + // 指标采集中间件首个装入,统计所有经注册中心注册的业务路由 + if r.metricsMiddleware != nil { + r.engine.Use(r.metricsMiddleware) + } + // 应用全局中间件 r.engine.Use(r.globalMiddlewares...) @@ -230,42 +304,55 @@ func (r *Registry) Apply() { // ===== 全局注册中心 ===== -var globalRegistry *Registry +// globalRegistry 包级全局注册中心。用 atomic.Pointer 保护读写(H8a): +// Init 写入、各全局 helper 读取,避免裸指针与请求 goroutine 的无锁竞争。 +var globalRegistry atomic.Pointer[Registry] -// Init 初始化全局注册中心 +// Init 初始化全局注册中心。须在使用任何全局 helper(Use/RegisterModule/Apply…)之前调用。 func Init(engine *gin.Engine) *Registry { - globalRegistry = NewRegistry(engine) - return globalRegistry + r := NewRegistry(engine) + globalRegistry.Store(r) + return r } -// GetRegistry 获取全局注册中心 +// GetRegistry 获取全局注册中心,未初始化时返回 nil。 func GetRegistry() *Registry { - return globalRegistry + return globalRegistry.Load() +} + +// ensureRegistry 取全局注册中心,未初始化时以明确信息 panic(H8a)。 +// 把晦涩的 nil 解引用 panic 转成可定位的初始化顺序错误。 +func ensureRegistry() *Registry { + r := globalRegistry.Load() + if r == nil { + panic("router: 全局注册中心未初始化,请先调用 router.Init(engine) 再使用全局 helper") + } + return r } // Use 注册全局中间件(全局方式) func Use(middlewares ...gin.HandlerFunc) *Registry { - return globalRegistry.Use(middlewares...) + return ensureRegistry().Use(middlewares...) } // RegisterModule 注册模块(全局方式) func RegisterModule(module Module) *Registry { - return globalRegistry.RegisterModule(module) + return ensureRegistry().RegisterModule(module) } // RegisterModuleFunc 注册函数式模块(全局方式) func RegisterModuleFunc(name string, fn func(r *gin.RouterGroup)) *Registry { - return globalRegistry.RegisterModuleFunc(name, fn) + return ensureRegistry().RegisterModuleFunc(name, fn) } // RegisterVersion 注册版本化 API(全局方式) func RegisterVersion(version *VersionedAPI) *Registry { - return globalRegistry.RegisterVersion(version) + return ensureRegistry().RegisterVersion(version) } // Apply 应用路由注册(全局方式) func Apply() { - globalRegistry.Apply() + ensureRegistry().Apply() } // ===== 快捷构建函数 ===== diff --git a/router/router_h8_internal_test.go b/router/router_h8_internal_test.go new file mode 100644 index 0000000..64d6906 --- /dev/null +++ b/router/router_h8_internal_test.go @@ -0,0 +1,241 @@ +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() // 三次同样 + + if !r.applied { + t.Fatal("applied flag should be true after Apply") + } + + // 中间件只应被装入一次:请求一次,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) + } +} + +// 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/sse/sse.go b/sse/sse.go index eae20b6..38fdc95 100644 --- a/sse/sse.go +++ b/sse/sse.go @@ -1,6 +1,18 @@ +// 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" "fmt" "net/http" @@ -12,6 +24,7 @@ import ( type SSEWriter struct { writer gin.ResponseWriter flusher http.Flusher + ctx context.Context // 请求上下文,用于 Stream 系列监听断连(C3a) } // NewSSEWriter 创建 SSE 写入器 @@ -20,7 +33,8 @@ func NewSSEWriter(c *gin.Context) (*SSEWriter, error) { c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") - c.Header("Transfer-Encoding", "chunked") + // 不手设 Transfer-Encoding: chunked——HTTP/1.1 下由 server 自动分帧, + // HTTP/2 下该头非法会致协议错误(C3c 修复)。 flusher, ok := c.Writer.(http.Flusher) if !ok { @@ -30,14 +44,22 @@ func NewSSEWriter(c *gin.Context) (*SSEWriter, error) { 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 { - fmt.Fprintf(w.writer, "event: %s\n", event) - fmt.Fprintf(w.writer, "data: %s\n\n", data) + if _, err := fmt.Fprintf(w.writer, "event: %s\n", event); err != nil { + return err + } + if _, err := fmt.Fprintf(w.writer, "data: %s\n\n", data); err != nil { + return err + } w.flusher.Flush() return nil } @@ -45,7 +67,9 @@ func (w *SSEWriter) WriteEvent(event, data string) error { // WriteMessage 写入消息(无事件类型) // 格式: data: \n\n func (w *SSEWriter) WriteMessage(data string) error { - fmt.Fprintf(w.writer, "data: %s\n\n", data) + if _, err := fmt.Fprintf(w.writer, "data: %s\n\n", data); err != nil { + return err + } w.flusher.Flush() return nil } @@ -69,19 +93,41 @@ func (w *SSEWriter) WriteDone() error { return w.WriteEvent("done", "") } -// KeepAlive 发送保持连接的心跳 +// KeepAlive 发送保持连接的心跳。 +// +// 用 SSE 注释行(": ping\n\n")而非 data 行(N6 修复):data 行(含空 data)会触发 +// 客户端 onmessage 回调,注释行仅维持连接不产生消息事件,更符合心跳语义。 func (w *SSEWriter) KeepAlive() error { - return w.WriteMessage("") + if _, err := fmt.Fprintf(w.writer, ": ping\n\n"); err != nil { + return err + } + w.flusher.Flush() + return nil } // Stream 流式发送数据 +// +// 消费循环含 ctx.Done 分支(C3a 修复):客户端断连(c.Request.Context 取消)时立即退出, +// 不再仅靠 channel 关闭或写错误退出。配合 WriteJSON 的写错误传播(C3b),断连即停。 +// ctx 为 nil 时回退到 context.Background()(防御外部未走 NewSSEWriter 构造的 nil panic)。 func (w *SSEWriter) Stream(event string, ch <-chan any) error { - for data := range ch { - if err := w.WriteJSON(event, data); err != nil { - return err + ctx := w.ctx + if ctx == nil { + ctx = context.Background() + } + for { + select { + case <-ctx.Done(): + return ctx.Err() + case data, ok := <-ch: + if !ok { + return w.WriteDone() + } + if err := w.WriteJSON(event, data); err != nil { + return err + } } } - return w.WriteDone() } // SSE 中间件,设置必要的响应头 @@ -95,19 +141,31 @@ func SSE() gin.HandlerFunc { } // StreamText 流式发送文本(适用于 AI 对话场景) +// +// 消费循环含 ctx.Done 分支(C3a 修复):客户端断连即退出。 +// 生产者(ch 的发送方)也应监听 c.Request.Context(),以便消费端早退后停止上游 LLM 流; +// 若生产者忽略 ctx,本函数仍会因 ctx.Done 退出(消费端不泄漏),但上游生产者可能继续运行 +// 直到其自身完成或 channel 阻塞——调用方有责任在 ctx 取消时停止生产。 func StreamText(c *gin.Context, ch <-chan string) error { writer, err := NewSSEWriter(c) if err != nil { return err } - for text := range ch { - if err := writer.WriteJSON("message", gin.H{"text": text}); err != nil { - return err + ctx := c.Request.Context() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case text, ok := <-ch: + if !ok { + return writer.WriteDone() + } + if err := writer.WriteJSON("message", gin.H{"text": text}); err != nil { + return err + } } } - - return writer.WriteDone() } // StreamChunks 流式发送文本块(带增量标记) @@ -117,13 +175,20 @@ func StreamChunks(c *gin.Context, ch <-chan string) error { return err } - for chunk := range ch { - if err := writer.WriteJSON("chunk", gin.H{"delta": chunk}); err != nil { - return err + ctx := c.Request.Context() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case chunk, ok := <-ch: + if !ok { + return writer.WriteJSON("done", gin.H{"finished": true}) + } + if err := writer.WriteJSON("chunk", gin.H{"delta": chunk}); err != nil { + return err + } } } - - return writer.WriteJSON("done", gin.H{"finished": true}) } // StreamWithID 流式发送带消息 ID 的数据 @@ -138,13 +203,20 @@ func StreamWithID(c *gin.Context, messageID string, ch <-chan string) error { return err } + ctx := c.Request.Context() // 发送内容块 - for chunk := range ch { - if err := writer.WriteJSON("chunk", gin.H{"id": messageID, "delta": chunk}); err != nil { - return err + for { + select { + case <-ctx.Done(): + return ctx.Err() + case chunk, ok := <-ch: + if !ok { + // 发送完成事件 + return writer.WriteJSON("done", gin.H{"id": messageID, "finished": true}) + } + if err := writer.WriteJSON("chunk", gin.H{"id": messageID, "delta": chunk}); err != nil { + return err + } } } - - // 发送完成事件 - return writer.WriteJSON("done", gin.H{"id": messageID, "finished": true}) } 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/storage/storage.go b/storage/storage.go index b0eeedd..8e51da6 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -7,7 +7,9 @@ import ( "fmt" "io" "mime/multipart" + "net/http" "os" + "path" "path/filepath" "strings" "sync" @@ -30,6 +32,88 @@ type Storage interface { Exists(path string) bool } +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") +) + +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 表示不限。 +func validateUploadSize(p config.UploadPolicy, size int64) error { + if p.MaxSizeBytes > 0 && size > p.MaxSizeBytes { + return fmt.Errorf("文件大小 %d 超过上限 %d: %w", size, p.MaxSizeBytes, ErrInvalidPath) + } + return nil +} + +// 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 { @@ -39,35 +123,98 @@ func uniqueFilename(now time.Time, ext string) string { 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 +} + // LocalStorage 本地存储 type LocalStorage struct { - path string - baseURL string + rootAbs string // 绝对根路径(已 Clean),前缀锚定用(C4a) + baseURL string + policy config.UploadPolicy + maxReadBytes int64 } // NewLocalStorage 创建本地存储实例 +// +// 安全约束:Path 指向的根目录应为框架独占目录,不与用户可控内容混用。 +// safeJoin 已防 `..` 路径穿越,但若根目录内已存在指向外部的符号链接, +// Get 会跟随 symlink 读到根外内容——需保证攻击者无法在根目录内创建 symlink。 func NewLocalStorage(cfg *config.LocalStorageConfig) *LocalStorage { - return &LocalStorage{ - path: cfg.Path, - baseURL: cfg.BaseURL, + // 用绝对路径作根锚定,避免相对路径 + `..` 组合绕过前缀校验(C4a)。 + rootAbs, err := filepath.Abs(cfg.Path) + if err != nil { + rootAbs = cfg.Path } + return &LocalStorage{ + rootAbs: filepath.Clean(rootAbs), + baseURL: cfg.BaseURL, + policy: cfg.Upload, + maxReadBytes: resolveMaxRead(cfg.MaxReadBytes), + } +} + +// safeJoin 将根路径与相对片段拼接为绝对路径,并以前缀锚定拒绝穿越(C4a)。 +// 任何片段为绝对路径或含 NUL、最终路径逃逸 rootAbs 时返回错误。 +func (s *LocalStorage) safeJoin(parts ...string) (string, error) { + 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) (string, error) { + // 上传安全策略校验(大小 / 扩展名);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) - // 确保目录存在 - fullPath := filepath.Join(s.path, relativePath) - if err := os.MkdirAll(fullPath, 0755); err != nil { + // 确保目录存在,且未逃逸根目录(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) @@ -79,7 +226,15 @@ func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string } 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) @@ -87,7 +242,7 @@ func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string defer dstFile.Close() // 复制文件内容 - if _, err := io.Copy(dstFile, src); err != nil { + if _, err := io.Copy(dstFile, srcReader); err != nil { return "", fmt.Errorf("保存文件失败: %w", err) } @@ -102,27 +257,47 @@ func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string // UploadFromBytes 从字节数组上传文件 func (s *LocalStorage) 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()) relativePath := filepath.Join(subdir, datePath) - // 确保目录存在 - fullPath := filepath.Join(s.path, relativePath) - if err := os.MkdirAll(fullPath, 0755); err != nil { + // 确保目录存在,且未逃逸根目录(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" } - uniqueFilename := uniqueFilename(now, ext) - dst := filepath.Join(fullPath, uniqueFilename) + 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) @@ -130,12 +305,12 @@ func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (st defer dstFile.Close() // 写入文件内容 - if _, err := io.Copy(dstFile, bytes.NewReader(data)); err != nil { + if _, err := io.Copy(dstFile, srcReader); err != nil { return "", fmt.Errorf("保存文件失败: %w", err) } // 返回相对路径 - relativeFilePath := filepath.Join(relativePath, uniqueFilename) + relativeFilePath := filepath.Join(relativePath, fname) // 统一使用正斜杠 relativeFilePath = strings.ReplaceAll(relativeFilePath, "\\", "/") @@ -149,41 +324,69 @@ func (s *LocalStorage) GetURL(path string) string { } // Delete 删除文件 -func (s *LocalStorage) Delete(path string) error { - fullPath := filepath.Join(s.path, path) +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", path)) + logger.Info("文件删除成功", zap.String("path", p)) return nil } -// Get 获取文件内容 -func (s *LocalStorage) Get(path string) ([]byte, error) { - fullPath := filepath.Join(s.path, path) - data, err := os.ReadFile(fullPath) +// 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, ErrInvalidPath) + } return data, nil } // Exists 检查文件是否存在 -func (s *LocalStorage) Exists(path string) bool { - fullPath := filepath.Join(s.path, path) - _, err := os.Stat(fullPath) +func (s *LocalStorage) Exists(p string) bool { + fullPath, err := s.safeJoin(p) + if err != nil { + return false + } + _, err = os.Stat(fullPath) return err == nil } // OSSStorage OSS 存储 type OSSStorage struct { - client *oss.Client - bucket *oss.Bucket - endpoint string - bucketName string - baseURL string + client *oss.Client + bucket *oss.Bucket + endpoint string + bucketName string + baseURL string + policy config.UploadPolicy + maxReadBytes int64 } // NewOSSStorage 创建 OSS 存储实例 @@ -199,21 +402,39 @@ func NewOSSStorage(cfg *config.OSSStorageConfig) (*OSSStorage, error) { } return &OSSStorage{ - client: client, - bucket: bucket, - endpoint: cfg.Endpoint, - bucketName: cfg.Bucket, - baseURL: cfg.BaseURL, + 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) { + // 上传安全策略校验(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) - objectKey := fmt.Sprintf("%s/%s", filepath.Join(subdir, datePath), uniqueFilename(now, ext)) + // 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() @@ -222,8 +443,15 @@ func (s *OSSStorage) Upload(file *multipart.FileHeader, subdir string) (string, } defer src.Close() + // MIME 嗅探(如配置) + var srcReader io.Reader = src + srcReader, err = sniffUploadMIME(s.policy, srcReader) + if err != nil { + return "", err + } + // 上传到 OSS - if err := s.bucket.PutObject(objectKey, src); err != nil { + 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) } @@ -234,16 +462,38 @@ func (s *OSSStorage) Upload(file *multipart.FileHeader, subdir string) (string, // 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" } - objectKey := fmt.Sprintf("%s/%s", filepath.Join(subdir, datePath), uniqueFilename(now, ext)) + // 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, bytes.NewReader(data)); err != nil { + 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) } @@ -266,39 +516,56 @@ func (s *OSSStorage) GetSignedURL(path string, expire time.Duration) (string, er } // Delete 删除 OSS 文件 -func (s *OSSStorage) Delete(path string) error { - if err := s.bucket.DeleteObject(path); err != nil { - logger.Error("OSS 删除失败", zap.Error(err), zap.String("key", path)) +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", path)) + logger.Info("OSS 文件删除成功", zap.String("key", key)) return nil } -// Get 获取 OSS 文件内容 -func (s *OSSStorage) Get(path string) ([]byte, error) { - body, err := s.bucket.GetObject(path) +// Get 获取 OSS 文件内容。读取受 maxReadBytes 封顶,防止 OOM(C4c)。 +func (s *OSSStorage) Get(p string) ([]byte, error) { + key, err := sanitizeObjectKey(p) if err != nil { - logger.Error("OSS 读取失败", zap.Error(err), zap.String("key", path)) + 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() - data, err := io.ReadAll(body) + 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, ErrInvalidPath) + } return data, nil } // Exists 检查 OSS 文件是否存在 -func (s *OSSStorage) Exists(path string) bool { - _, err := s.bucket.GetObjectMeta(path) +func (s *OSSStorage) Exists(p string) bool { + key, err := sanitizeObjectKey(p) + if err != nil { + return false + } + _, err = s.bucket.GetObjectMeta(key) return err == nil } -var ErrStorageNotInitialized = errors.New("storage not initialized") - // 全局存储实例(兼容 facade,由 Manager.Init 同步维护) var storage Storage diff --git a/storage/storage_path_internal_test.go b/storage/storage_path_internal_test.go new file mode 100644 index 0000000..22e3787 --- /dev/null +++ b/storage/storage_path_internal_test.go @@ -0,0 +1,66 @@ +package storage + +import ( + "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) + } +} diff --git a/storage/storage_security_test.go b/storage/storage_security_test.go new file mode 100644 index 0000000..82ca36a --- /dev/null +++ b/storage/storage_security_test.go @@ -0,0 +1,272 @@ +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(而非穿越探测到 canary) + if s.Exists(escapeRel) { + t.Errorf("Exists(%q) = true, want false (traversal must not probe outside root)", escapeRel) + } + + // 绝对路径也必须拒绝 + 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 !s.Exists(rel) { + t.Error("Exists(normal) = false, want true") + } + 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.ErrInvalidPath) { + t.Errorf("Get over-limit err = %v, want ErrInvalidPath", 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.ErrInvalidPath) { + t.Errorf("Upload over size err = %v, want ErrInvalidPath", 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("= 400 { span.SetStatus(codes.Error, http.StatusText(status)) - } else { - span.SetStatus(codes.Ok, "") } + // 成功路径不显式设 codes.Ok(M18):OTel 规范中 Span 状态默认 UNSET, + // 仅在错误时设 Error;显式设 Ok 会掩盖下游子 Span 的真实错误状态。 // 结束 Span span.End() @@ -194,9 +271,13 @@ func Middleware(serviceName string) gin.HandlerFunc { } // GetContext 从 Gin Context 获取 OpenTelemetry Context +// +// C13:裸断言改 comma-ok,防 "otel_ctx" 被外部置为非 context 值时 panic。 func GetContext(c *gin.Context) context.Context { - if ctx, exists := c.Get("otel_ctx"); exists { - return ctx.(context.Context) + if v, exists := c.Get("otel_ctx"); exists { + if ctx, ok := v.(context.Context); ok { + return ctx + } } return c.Request.Context() } @@ -214,7 +295,7 @@ func GetTraceID(c *gin.Context) string { // StartSpan 创建子 Span func StartSpan(c *gin.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { ctx := GetContext(c) - return tracer.Start(ctx, name, + return getTracer().Start(ctx, name, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...), ) @@ -222,7 +303,7 @@ func StartSpan(c *gin.Context, name string, attrs ...attribute.KeyValue) (contex // StartSpanFromContext 从 Context 创建 Span func StartSpanFromContext(ctx context.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { - return tracer.Start(ctx, name, + return getTracer().Start(ctx, name, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...), ) @@ -251,7 +332,7 @@ func AddAttributes(c *gin.Context, attrs ...attribute.KeyValue) { // GetTracer 获取全局 Tracer func GetTracer() trace.Tracer { - return tracer + return getTracer() } // SetAttribute 设置单个属性 diff --git a/trace/trace_test.go b/trace/trace_test.go new file mode 100644 index 0000000..95894dc --- /dev/null +++ b/trace/trace_test.go @@ -0,0 +1,392 @@ +package trace + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "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{})) +} + +// 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") + } +} diff --git a/utils/crypto.go b/utils/crypto.go index 366546f..52dd419 100644 --- a/utils/crypto.go +++ b/utils/crypto.go @@ -92,19 +92,17 @@ func Nl2br(s string, isXhtml bool) string { for i, r := range runes { switch r { case '\n': - // 检查是否是 \r\n 或 \n\r - if i+1 < length { - next := runes[i+1] - if (r == '\n' && next == '\r') || (r == '\r' && next == '\n') { - buf.WriteString(br) - continue - } + // \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 或 \r\n 已在上面处理 + // \r\n 由上面的 \n 分支处理(\r 在此跳过);单独 \r 作为一个换行。 if i+1 < length && runes[i+1] == '\n' { - continue // \r\n 由 \n 处理 + continue } buf.WriteString(br) default: diff --git a/utils/datetime.go b/utils/datetime.go index c6b199c..b850ed7 100644 --- a/utils/datetime.go +++ b/utils/datetime.go @@ -60,14 +60,17 @@ func EndOfDay(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location()) } -// StartOfWeek 返回指定时间当周的开始时间(周一为第一天) +// 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 + weekday = 7 // 周日归为 7,使周一为第一天 } - d := time.Duration(weekday-1) * 24 * time.Hour - return StartOfDay(t.Add(-d)) + // 回退到本周周一的日历日,再取当日 00:00:00(保留原时区)。 + monday := time.Date(t.Year(), t.Month(), t.Day()-(weekday-1), 0, 0, 0, 0, t.Location()) + return StartOfDay(monday) } // StartOfMonth 返回指定时间当月的开始时间 @@ -86,7 +89,10 @@ func GetDateInt(t time.Time) int { return ret } -// ParseDateInt 将 yyyyMMdd 格式的整数转为时间 +// 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 diff --git a/utils/file.go b/utils/file.go index 7784525..c22d15e 100644 --- a/utils/file.go +++ b/utils/file.go @@ -7,6 +7,13 @@ import ( "path/filepath" ) +// 本文件提供通用本地文件操作工具(读/写/复制/追加/存在性/删除)。 +// +// ⚠️ 安全说明(M3):这些函数直接操作调用方传入的路径,不做路径穿越校验。 +// 若路径可能来自不可信输入(用户上传文件名、URL 参数等),调用方必须自行净化 +// (如 filepath.Clean + 前缀锚定),否则存在任意文件读/写/删风险。 +// 框架自身的不可信文件处理(storage 上传/下载)已在 storage 包内做穿越防护(C4)。 + // FileExists 检查文件是否存在 func FileExists(path string) bool { _, err := os.Stat(path) diff --git a/utils/http.go b/utils/http.go index d40398d..c489832 100644 --- a/utils/http.go +++ b/utils/http.go @@ -24,7 +24,9 @@ type HTTPClient struct { headers map[string]string cookies map[string]string skipTLS bool - once sync.Once + // maxRespBodySize 响应体读取上限(字节),0 表示用默认 32MB,-1 表示不限。 + // 防止恶意/异常服务端返回超大响应打爆内存(C5/N5)。 + maxRespBodySize int64 } // UploadFile 上传文件信息 @@ -40,7 +42,10 @@ type HTTPClientConfig struct { IdleConnTimeout time.Duration // 空闲连接超时时间 MaxConnsPerHost int // 每个主机最大连接数 MaxIdleConnsPerHost int // 每个主机最大空闲连接数 - SkipTLSVerify bool // 是否跳过 TLS 验证 + SkipTLSVerify bool // 是否跳过 TLS 验证(默认 false 校验 TLS;自签证书场景需显式设 true) + // MaxResponseBodySize 响应体读取上限(字节)。0 = 默认 32MB,-1 = 不限制。 + // 防止异常服务端返回超大响应打爆内存(C5/N5)。 + MaxResponseBodySize int64 } // DefaultHTTPClientConfig 默认配置 @@ -50,7 +55,8 @@ var DefaultHTTPClientConfig = HTTPClientConfig{ IdleConnTimeout: 90 * time.Second, MaxConnsPerHost: 10, MaxIdleConnsPerHost: 10, - SkipTLSVerify: true, // 开发环境默认跳过 + SkipTLSVerify: false, // H2 修复:默认校验 TLS,防 MITM;自签证书需显式 SetSkipTLS(true) + MaxResponseBodySize: 32 * 1024 * 1024, } // NewHTTPClient 创建 HTTP 客户端 @@ -63,6 +69,8 @@ func NewHTTPClient() *HTTPClient { func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient { // Transport 在初始化时创建,连接池可复用 transport := &http.Transport{ + // #nosec G402 -- InsecureSkipVerify 仅在调用方显式设 cfg.SkipTLSVerify=true 时启用, + // 默认 false 校验 TLS(H2 修复)。自签证书场景需 opt-in。 TLSClientConfig: &tls.Config{ InsecureSkipVerify: cfg.SkipTLSVerify, }, @@ -79,12 +87,13 @@ func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient { } return &HTTPClient{ - client: client, - transport: transport, - timeout: cfg.Timeout, - headers: make(map[string]string), - cookies: make(map[string]string), - skipTLS: cfg.SkipTLSVerify, + client: client, + transport: transport, + timeout: cfg.Timeout, + headers: make(map[string]string), + cookies: make(map[string]string), + skipTLS: cfg.SkipTLSVerify, + maxRespBodySize: cfg.MaxResponseBodySize, } } @@ -115,10 +124,12 @@ func (c *HTTPClient) SetCookie(key, value string) *HTTPClient { return c } -// SetSkipTLS 设置是否跳过 TLS 验证 -// 注意: 修改 TLS 配置需要重新创建 Transport +// SetSkipTLS 设置是否跳过 TLS 验证。 +// 注意: 修改 TLS 配置需要重新创建 Transport。跳过 TLS 校验会暴露于 MITM 攻击, +// 仅在受控环境(如自签证书的内网服务)且明确风险时启用,生产环境应保持 false。 func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient { c.skipTLS = skip + // #nosec G402 -- skip 由调用方显式传入,opt-in 跳过 TLS 校验(默认 false)。 c.transport.TLSClientConfig = &tls.Config{ InsecureSkipVerify: skip, } @@ -211,15 +222,18 @@ func (c *HTTPClient) Upload(urlStr string, files []UploadFile, params map[string if err != nil { return nil, err } - defer file.Close() part, err := writer.CreateFormFile(f.FieldName, filepath.Base(f.FilePath)) if err != nil { + file.Close() return nil, err } if _, err = io.Copy(part, file); err != nil { + file.Close() return nil, err } + // 显式关闭,避免循环内 defer 累积 FD(N5/C5)。 + file.Close() } for k, v := range params { @@ -307,7 +321,25 @@ func (c *HTTPClient) do(req *http.Request) ([]byte, error) { return nil, fmt.Errorf("http error: %d %s", resp.StatusCode, resp.Status) } - return io.ReadAll(resp.Body) + // 响应体读取封顶,防异常服务端返回超大响应打爆内存(C5/N5)。 + // maxRespBodySize: 0=默认 32MB,-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("response body exceeds limit %d bytes", limit) + } + return data, nil } // DoWithResponse 执行请求并返回完整响应 diff --git a/utils/http_test.go b/utils/http_test.go new file mode 100644 index 0000000..f13e7a3 --- /dev/null +++ b/utils/http_test.go @@ -0,0 +1,114 @@ +package utils_test + +import ( + "crypto/tls" + "errors" + "net/http" + "net/http/httptest" + "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) +} diff --git a/utils/random.go b/utils/random.go index 5770431..4b9260c 100644 --- a/utils/random.go +++ b/utils/random.go @@ -1,74 +1,45 @@ package utils import ( - "math/rand" + "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 rand.New(rand.NewSource(time.Now().UnixNano())) + return mrand.New(mrand.NewSource(time.Now().UnixNano())) }, } ) const ( - letterBytes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - digitBytes = "0123456789" - letterIdxBits = 6 // 6 bits to represent a letter index (0-63) - letterIdxMask = 1<= 0; { - if remain == 0 { - cache, remain = r.Int63(), letterIdxMax - } - if idx := int(cache & letterIdxMask); idx < len(letterBytes) { - b[i] = letterBytes[idx] - i-- - } - cache >>= letterIdxBits - remain-- - } - return string(b) -} - -// RandDigit 生成指定长度的随机数字字符串 -func RandDigit(n int) string { - if n <= 0 { - return "" - } - b := make([]byte, n) - r := randPool.Get().(*rand.Rand) - defer randPool.Put(r) - - for i, cache, remain := n-1, r.Int63(), letterIdxMax; i >= 0; { - if remain == 0 { - cache, remain = r.Int63(), letterIdxMax - } - if idx := int(cache & letterIdxMask); idx < len(digitBytes) { - b[i] = digitBytes[idx] - i-- - } - cache >>= letterIdxBits - remain-- - } - return string(b) -} - -// RandInt 返回 [min, max) 范围内的随机整数 +// RandInt 返回 [min, max) 范围内的随机整数。 +// +// 非密码学安全(math/rand + sync.Pool),仅用于非安全场景(负载均衡、游戏、A/B 分桶等); +// 不适用于 token/OTP 等安全场景。 func RandInt(min, max int) int { if min == max { return min @@ -76,12 +47,12 @@ func RandInt(min, max int) int { if max < min { min, max = max, min } - r := randPool.Get().(*rand.Rand) + r := randPool.Get().(*mrand.Rand) defer randPool.Put(r) return min + r.Intn(max-min) } -// RandInt64 返回 [min, max) 范围内的随机 int64 +// RandInt64 返回 [min, max) 范围内的随机 int64(非密码学安全)。 func RandInt64(min, max int64) int64 { if min == max { return min @@ -89,7 +60,82 @@ func RandInt64(min, max int64) int64 { if max < min { min, max = max, min } - r := randPool.Get().(*rand.Rand) + r := randPool.Get().(*mrand.Rand) defer randPool.Put(r) return min + r.Int63n(max-min) } + +// RandStringSecure 生成指定长度的密码学安全随机字符串(字母+数字)。 +// 基于 crypto/rand,不可预测,适用于 token、会话 ID、API key 等安全场景。 +// n<=0 返回空,n 过大(>1<<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 '9' { - t.Errorf("RandDigit contains non-digit: %c", c) - } - } -} +// +// 注意(H1 收紧):RandString/RandDigit 已移除(字符串随机的用途几乎都是安全场景, +// 保留 math/rand 版本会诱导误用)。测试覆盖 RandStringSecure/RandDigitSecure(crypto/rand) +// 与 RandInt/RandInt64(非安全范围随机)。 func TestRandInt(t *testing.T) { // 正常范围 @@ -607,17 +561,204 @@ func TestDirExists(t *testing.T) { } } -// ===== Benchmarks ===== +// ===== Secure Random Tests(H1:crypto/rand 安全版本) ===== -func BenchmarkRandString(b *testing.B) { - for i := 0; i < b.N; i++ { - utils.RandString(16) +// 回归 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) + } + } + }) } } -func BenchmarkRandDigit(b *testing.B) { +// 回归 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.RandDigit(6) + _, _ = utils.RandStringSecure(16) + } +} + +func BenchmarkRandDigitSecure(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = utils.RandDigitSecure(6) } } diff --git a/v1.1.1_fix_progress.md b/v1.1.1_fix_progress.md new file mode 100644 index 0000000..4e8c504 --- /dev/null +++ b/v1.1.1_fix_progress.md @@ -0,0 +1,1486 @@ +# xlgo v1.1.1 缺陷修复执行进度 + +> 执行依据:`version_1.1.1_report.md`(13 CRITICAL + 8 HIGH 权威清单)。 +> 执行顺序:P0 → P1 → P2 → P3。每项遵循"读全局 → 最小修复 → 机械验证 + 行为闭环 → 独立对抗性复审 → 三个全局问题写入本报告"的闭环节奏。 +> 本机约束:H:\worker 有 svn 干扰,`go build/test` 需 `-buildvcs=false`;gcc/cgo 已安装(2026-06-28),`-race` 现可运行;staticcheck 因 go1.24/1.25 版本不匹配暂不可用。 + +--- + +## 🚀 快速接续(下次开启先读这里) + +**当前状态(截至 2026-06-29)**:**P0 全部闭环** ✅ + **P1 全部闭环** ✅ + **P2 H6/H8 闭环** ✅(13 CRITICAL + 8 HIGH + H6 + H8)— 13 CRITICAL + 8 HIGH 全部修复并通过验证,P2 框架集成一致性阶段 H6(BaseRepo 接入 GetDBFromContext + 事务 join + 分页事务)与 H8(路由/health 统一 + Apply 幂等 + metrics 去调用顺序依赖 + 全局注册中心 atomic)均已闭环。 + +**已闭环清单**(每项含:修复 + 回归用例红绿验证 + `-race`/vet/gosec 通过): +- C6(csrf map 遮蔽)、C8(recover 返 200)、C4(storage 路径穿越)、C5(compress Zip-Slip) +- C2(ws 死锁/panic/泄漏)、C1(cache/lock panic/泄漏)、C7(CORS 绕过)、C3(SSE 断连泄漏) +- H2(默认关 TLS)、H1(不安全 RNG,删 RandString/RandDigit + Secure 系列)、C9b(刷新令牌 fail-closed)、H4a(限流误限流) +- C10(config 全局 Manager 无锁置换 + 热重载绕过 Validate + StopWatcher 空函数) +- C11(database 池泄漏 + Master/Replicas 无锁读 + 健康状态陈旧 + 包级 Close 仅关主库) +- C9c(jwt 包级 DefaultJWT/tokenBlacklist 无锁置换 → atomic.Pointer) +- H3(middleware/logger body OOM — 请求/响应 body 读取均封顶,下游仍得完整 body) +- H7(logger 包级 Logger/sugar/apiLog/dbLog 写有锁读无锁 → atomic.Pointer + Field.Duration 签名修复) +- C12(cron 数据竞争 + 重叠执行 + 漂移 + Weekly 跳周 + cron 解析缺陷) +- C13(trace nil-panic + 未实现导出器/传播器 + Middleware 不更新 c.Request) +- H5(handler BadRequest/InternalError 硬编 HTTP 400/500 绕过 Mode + 丢失 RequestID → 委托 response 体系) +- H4b(CustomRateLimit goroutine 泄漏 → 登记入 customLimiters 表,StopRateLimiters/InitRateLimiters 统一停止) +- **H4c(RedisRateLimiter fail-open + 裸断言 → fail-closed 可配置 + comma-ok + LoginRedisRateLimit 改 fail-closed)** +- **H6(BaseRepo 接入 GetDBFromContext + 读写分离 + 事务 join(r.tx + database.WithTx/TxFromContext)+ 分页单事务 + UpdateFields + QueryBuilder 克隆)** +- **H8(全局注册中心 atomic.Pointer + ensureRegistry 守卫 + Apply 幂等 + metrics 经 SetMetricsMiddleware 在 Apply 内作首个全局中间件 + /health 收敛单一 healthHandler + handler.HealthCheck schema 对齐)** + +**下一项**:P2 H8 已闭环;进入 **P3 清理**(MEDIUM/MINOR 各项 + 全量补 `-race`)。 + +**关键规则**(CLAUDE.md 防复发约束): +- 每项必跑 `go test -race -buildvcs=false`;行为变更写入 CHANGELOG `[Unreleased]` 升级说明;交付前独立 agent 对抗性复审(file:line 证据)。 +- 新增测试依赖 `github.com/alicebob/miniredis/v2` 已在 go.mod(cache/jwt 真实闭环测试用)。 +- 工作区改动**未提交**(用户未要求 commit 前不要自行 commit)。`v1.1.1_fix_progress.md`/`v_1.1.1_fix.md`/`version_1.1.1_report.md` 为 untracked 进度/依据文件。 + +**待修复完整清单**:见文末「待修复(按优先级)」。 + +--- + +## ✅ C6 `middleware/csrf.go` API CSRF 模式功能性失效(map 遮蔽)— P0 + +### 需求分析结果 +API 模式 CSRF 整体不可用:`CSRFForAPI()` 闭包校验读的是**局部** `tokens` map,而 `GenerateAPIToken` 写的是**包级** `tokens`,两者从不相交 → 所有非安全方法请求被判"CSRF Token 无效"拒绝。叠加包级 map 只增不减/无过期/不消费(内存 DoS + 永久重放),以及 `DoubleSubmitCookie` cookie HttpOnly=true 与"JS 回填 X-CSRF-Token 头"自相矛盾。 + +### 方案设计摘要 +- 删除 `CSRFForAPI()` 内的局部 `tokens`/`mu` 声明,统一绑定包级存储。 +- 包级存储改 `map[string]time.Time`(token → 颁发时间),写锁内"查—删"原子完成单次消费;`time.Since(issuedAt) > apiTokenTTL` 拒绝过期;`len>256` 时懒清理过期项防内存增长。 +- `DoubleSubmitCookie` 的 cookie 改 `HttpOnly=false`(前端 JS 必须能读 cookie 才能回填头);`CSRF()` cookie 模式维持 `HttpOnly=true`(token 经 body/上下文下发,非读 cookie)。 +- TTL 常量 `apiTokenTTL = 30 * time.Minute`;存储仍为进程内内存,注释声明仅适用单实例,多实例用 Redis `SETEX`+`GETDEL`。 + +涉及模块:`middleware/csrf.go`、`middleware/middleware_test.go`、`middleware/csrf_internal_test.go`。 +DB 影响:无。API 影响:`CSRFForAPI`/`GenerateAPIToken`/`DoubleSubmitCookie` 行为变更(见下)。安全影响:正向——修复 CSRF 失效、防重放、防内存 DoS。潜在风险:见"已知风险"。 + +### 修改文件清单 +- `middleware/csrf.go`:新增 `time` 导入;重写 `CSRFForAPI()`(删局部 map、单次消费+TTL+懒清理);重写 `GenerateAPIToken`(写包级 `apiTokens`);包级存储改名 `apiTokens`/`apiTokensMu` + `apiTokenTTL` 常量;`DoubleSubmitCookie` cookie `HttpOnly=false`。 +- `middleware/middleware_test.go`:新增 `respCode`/`apiCSRFToken` helper 与 5 个回归用例。 +- `middleware/csrf_internal_test.go`(新):TTL 过期分支用例(直接注入过期时间戳)。 + +### 实现内容摘要 +颁发→校验闭环打通(同源包级 map);单次消费(写锁内 delete);TTL 30min;懒清理;DoubleSubmit cookie 可被 JS 读取。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。全程复用 `response.Fail/Success/ServerError`,未硬编状态码,未绕过响应模式系统。未引入 DB/路由/认证层面的新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(须 CHANGELOG 声明)**: + - API 模式 CSRF token 现为**单次消费**——每次成功 POST 后需重新 `GenerateAPIToken` 颁发新 token。原实现整体不可用,故不存在"原本可用现被破坏"的真实回归,但下游若已绕过该中间件需重新评估。 + - API token 现有 30min TTL。 + - `DoubleSubmitCookie` 的 cookie `HttpOnly` 由 true 改 false(前端必须能读 cookie)。 + - 无 API 签名变更、无配置变更、无 migration。 +3. **新并发/生命周期/泄漏隐患?** 包级 `apiTokens`+`apiTokensMu`(RWMutex)保护,查-删在同一写锁域原子;无 channel/goroutine/close 路径,无 send-on-closed 风险;懒清理在写锁内安全。无裸类型断言新增。**`-race` 已于 2026-06-28 补跑通过**(`go test -race -buildvcs=false ./middleware/`,1.980s 无竞争)。 + +### 代码审查结果:PASS +可读性/可维护性良好;命名清晰(`apiTokens`/`apiTokensMu`/`apiTokenTTL`);注释说明单实例限制与多实例替代方案;无常量魔法值散落。独立复审未发现 CRITICAL/HIGH。 + +### 安全审查结果:PASS +- CSRF 闭环真正生效(修复前整体失效,属安全功能缺失)。 +- 单次消费防重放、TTL 防长期有效、懒清理防内存 DoS。 +- `DoubleSubmitCookie` HttpOnly=false 是该模式正确语义(非降级);`CSRF()` 维持 HttpOnly=true。 +- token 仍由 `crypto/rand` 生成(`generateCSRFToken` 既有),非可预测。 + +### 测试审查结果:PASS +回归用例(均修复前红、修复后绿): +- `TestCSRFForAPIIssueValidateCycle`:颁发→携带 token POST 通过(C6a 核心闭环)。 +- `TestCSRFForAPISingleUseConsumption`:首次通过、重放拒绝(C6b 单次消费)。 +- `TestCSRFForAPIInvalidAndMissing`:缺失/伪造 token 拒绝。 +- `TestCSRFForAPISafeMethodPasses`:GET 放行。 +- `TestCSRFForAPIITLExpiry`(internal):注入过期时间戳,TTL 分支拒绝(C6b TTL)。 +- `TestDoubleSubmitCookieHttpOnlyFalse`:cookie HttpOnly=false(C6c)。 +- `TestDoubleSubmitCookieFrontendRefill`:GET 下发→POST 匹配通过/不匹配拒绝(C6c 前端回填闭环)。 + +### 已知风险 +- API token 单实例内存存储,多实例部署需自行用 Redis `SETEX`+`GETDEL` 实现等价语义(已在源码注释声明)。 +- 懒清理仅在"有校验请求且 map>256"时触发;纯颁发型攻击(只打 `/csrf-token`)在 30min 窗口内仍占内存,但有 TTL 上界,非无限增长。 +- ~~`-race` 本机未跑(无 gcc/cgo),CI 须补。~~ → 已于 2026-06-28 补跑通过。 + +### 后续优化建议 +- 多实例:提供可选的 `CSRFStore` 接口(内存 / Redis 双实现),Redis 用 `SETEX` 颁发、`GETDEL` 消费。 +- GUIDE.md 8.3 节 API 模式示例仅展示 `CSRFForAPI()` 与 `GetCSRFToken(c)`,未展示 `GenerateAPIToken` 路由注册,文档与"先取 token 再用"流程脱节,建议补示例(属文档项,非 C6 范畴)。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` 复核:C6a/C6b/C6c 三项 **CONFIRM 修复**,回归测试真实复现原 bug,无新引入 CRITICAL/HIGH。唯一遗留(TTL 分支缺直接测试)已在本轮补齐。**裁定:可交付 PASS。** + +--- + +## ✅ C8 `middleware/recover.go` panic 后 HTTP 状态丢失(默认 ModeBusiness 返 200)— P0 + +### 需求分析结果 +默认 `ModeBusiness` 下,`Recover`/`RecoverWithDetail` 捕获 panic 后 `response.FailWithCode` → `writeResp` → `httpStatusFor(CodeServerError)` 返回 200,`c.JSON(200,...)` 已 flush 锁定状态;随后 `c.AbortWithStatus(500)` 因 `w.Written()==true` 成 no-op → 客户端收 HTTP 200 + body `code:500`,网关/APM/Prometheus 按 status 看不到 5xx。`RecoverWithDetail` 同病。ModeREST 无此 bug(`statusForCode(CodeServerError)=500`)。 + +### 方案设计摘要 +改用 `response.Custom(c, http.StatusInternalServerError, response.CodeServerError, msg, nil)` 显式写 500——`Custom` 专为"精确控制 HTTP status、不受 Mode 影响"设计(mode.go:77-86),直接 `c.JSON(httpStatus,...)` 且保留 RequestID。去掉事后 `AbortWithStatus`(已 flush 后再设 status 是多余且语义错误),改 `c.Abort()` 仅置中止标志。 + +涉及模块:`middleware/recover.go`、`middleware/middleware_test.go`。DB 影响:无。API 影响:行为变更——panic 响应 HTTP 状态由 200(ModeBusiness) 改为 500,两模式一致;body 不变。安全影响:正向——APM/网关/监控可正确捕获 5xx。潜在风险:见"已知风险"。 + +### 修改文件清单 +- `middleware/recover.go`:`Recover`/`RecoverWithDetail` 两处 `FailWithCode+AbortWithStatus` 改 `Custom+c.Abort()`,补注释说明根因。 +- `middleware/middleware_test.go`:新增 `ensureNopLogger` helper(`logger.Close()` 重置为 Nop,防 logger 未初始化 nil deref 二次 panic)+ 3 个回归用例。 + +### 实现内容摘要 +panic 闭环:真实触发 panic → Recover 捕获 → logger.Error 记录(带 request_id/stack)→ `Custom` 写 HTTP 500 + body `code:500` + RequestID → `c.Abort()`。两模式均返回 500。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。`response.Custom` 是响应模式系统**官方提供的**"精确控制 HTTP status"出口(mode.go:77-79 注释明确),保留 RequestID/code 体系;旧 `FailWithCode+AbortWithStatus` 反而是"绕过模式系统硬改 status"的反模式(status 与 body code 来源不一致)。未引入 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明)**:panic 响应 HTTP 状态由 200(ModeBusiness) 改 500,ModeREST 不变;body 不变。无 API 签名/配置/migration 变更。下游不会按"panic 返 200"适配,无真实回归。 +3. **新并发/生命周期/泄漏隐患?** 无。`recover.go` defer 内仅读单请求 `c`(无并发)+ `logger.Error`(既有裸读,非本次引入)。无新增包级全局、无 channel/goroutine。`-race` 已跑通过。 + +### 代码审查结果:PASS +最小改动、注释清晰说明根因;复用官方 `Custom` 出口而非另起硬编;无魔法值。独立复审未发现 CRITICAL/HIGH。 + +### 安全审查结果:PASS +修复监控盲区(5xx 不再被吞成 200),APM/网关可正确告警;`RecoverWithDetail` 注释已声明"生产不应使用、暴露敏感信息";RequestID 保留便于链路追踪。 + +### 测试审查结果:PASS +回归用例(均修复前红、修复后绿,已实跑验证): +- `TestRecoverPanicReturns500`:ModeBusiness 真实 panic,断言**实际 HTTP 状态码=500**(修复前=200)+ body code=CodeServerError + request_id 非空。 +- `TestRecoverWithDetailPanicReturns500`:同上覆盖 RecoverWithDetail(修复前=200)。 +- `TestRecoverPanicRESTMode500`:ModeREST 同样 500,锁定跨模式一致性。 +- 红/绿已分别验证:临时回退修复后两用例均 `status = 200, want 500` 红;恢复后绿。 + +### 已知风险 +- **边角(HTTP 固有局限,非本次引入)**:若 handler 在 panic 前已 flush 部分响应,`Custom` 无法改写已 flush 的 status(gin `responseWriter.WriteHeader` 守卫),且会追加损坏 body。与旧实现等价(非回归)。加测试只会锁定"损坏 body"不良行为,故留作文档化已知局限,不作修复。 +- `ensureNopLogger` 调 `logger.Close()` 无 restore,依赖 logger 包既有"包级全局无锁置换"技术债(H7 范畴,非本次引入);当前无受害测试,`-race` 通过。后续 H7 修复时一并处理。 +- `staticcheck` 因环境限制(二进制构建于 go1.24、模块要求 go1.25)无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- H7 修复 logger 包级全局无锁置换后,`ensureNopLogger` 改为可 restore 的测试辅助。 +- 若需覆盖"handler 已写响应后 panic"边角,可考虑在 Recover 内 `if c.Writer.Written() { c.Abort(); return }` 跳过追加损坏 body——属增强,非 C8 范畴。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` 复核 7 项(修复正确性 / Abort 替代 / Custom 副作用 / 跨模式 / 测试质量 / 全局契约 / 同类反模式遗漏):**无 CRITICAL/HIGH**,两项 LOW(上述边角 + ensureNopLogger restore,均不阻断)。**裁定:可交付 PASS。** + +--- + +## ✅ C4 `storage/storage.go` 路径穿越 + 无上传校验 + Get OOM — P0 + +### 需求分析结果 +- **C4a 路径穿越**:Local 的 `Delete/Get/Exists` 的 `path` 全程受控(`filepath.Join` 内含 Clean 但 `..` 可逃逸根目录)→ 任意删/读/探测;`Upload/UploadFromBytes` 的 `subdir` 含 `..` → 任意目录写(文件名服务端随机无法精确覆盖)。OSS 的 `Delete/Get/Exists` object key 未净化 → key 注入/越权。 +- **C4b 无校验**:无 `file.Size` 上限、无扩展名白名单、无 MIME 校验(`evil.php`、超大文件直传)。 +- **C4c 全量读内存**:Local `Get` 用 `os.ReadFile`、OSS `Get` 用 `io.ReadAll`,无上限 → OOM。 + +### 方案设计摘要 +- **路径穿越(零配置默认安全)**:`LocalStorage` 构造时算 `rootAbs`(`filepath.Abs`+`Clean`);新增 `safeJoin(parts...)` 拒绝绝对路径/NUL + 前缀锚定(`rootAbs+sep`);`Delete/Get/Exists/Upload/UploadFromBytes` 全部经之。OSS 新增 `sanitizeObjectKey`(拒绝空/NUL/`..`/绝对路径,`path.Clean`,归一化 `\`→`/`)。 +- **Get 读封顶(零配置默认安全)**:`maxReadBytes` 默认 100MB(`resolveMaxRead`:n<0 不限,n==0 默认,n>0 用 n),`io.LimitReader(f, max+1)` 读后超限报错。 +- **上传校验(opt-in,零值不限兼容)**:新增 `config.UploadPolicy{MaxSizeBytes, AllowedExts, AllowedMIMEs}` 嵌入 Local/OSS config;`AllowedMIMEs` 非空时 `http.DetectContentType` 嗅探前 512B(取分号前主类型比较)并拼回头部。 +- **HIGH(跨平台,复审发现)**:OSS object key 拼接由 `filepath.Join`(Windows 产 `\`)改 `path.Join` + `sanitizeObjectKey` 归一化 `\`→`/`,保证 Windows/Linux 部署 key 一致。 + +涉及模块:`config/config.go`、`storage/storage.go`、`storage/storage_security_test.go`、`storage/storage_path_internal_test.go`。DB 影响:无。API 影响:`Storage` 接口/构造函数签名不变;行为变更(含 `..` 的 path 被拒、Get 默认封顶 100MB、上传目录权限 0755→0750)。安全影响:正向。潜在风险:见"已知风险"。 + +### 修改文件清单 +- `config/config.go`:新增 `UploadPolicy` 类型;`LocalStorageConfig`/`OSSStorageConfig` 加 `Upload`/`MaxReadBytes` 字段(零值兼容)。 +- `storage/storage.go`:`LocalStorage` 加 `rootAbs/policy/maxReadBytes` + `safeJoin` + `NewLocalStorage` 算 rootAbs;`Upload/UploadFromBytes/Delete/Get/Exists` 全改;`OSSStorage` 加 `policy/maxReadBytes` + `sanitizeObjectKey`;`Upload/UploadFromBytes/Delete/Get/Exists` 全改;新增 `validateUploadSize/Ext`、`sniffUploadMIME`、`resolveMaxRead`、`ErrPathTraversal/ErrInvalidPath`;gosec G301 0755→0750、G304 #nosec。 +- `storage/storage_security_test.go`(新):路径穿越 / Get 封顶 / 上传策略(大小/扩展名/MIME)/ 兼容性回归用例。 +- `storage/storage_path_internal_test.go`(新):`sanitizeObjectKey`(含反斜杠归一化)+ `resolveMaxRead` 单元测试。 + +### 实现内容摘要 +路径穿越闭环(safeJoin 前缀锚定 + OSS key 净化);Get 封顶(LimitReader,默认 100MB);上传校验 opt-in(大小/扩展名/MIME 嗅探拼回);OSS key 跨平台归一化。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。`Storage` 接口不变、构造函数签名不变、facade/Manager 不变。新增配置走 `config` 体系(`UploadPolicy` 嵌入既有 Local/OSS config)。未引入 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明,非破坏性)**:含 `..`/绝对路径的 path 被拒(合法相对路径不受影响);`Get` 默认封顶 100MB(`MaxReadBytes=-1` 不限);上传目录权限 0755→0750。新增可选配置 `UploadPolicy`/`MaxReadBytes`,零值不限以兼容现有下游。无 API 签名/配置必填项/migration 变更。 +3. **新并发/生命周期/泄漏隐患?** 无。`LocalStorage`/`OSSStorage` 构造后字段只读,无共享可变状态、无 channel/goroutine。`safeJoin`/`sanitizeObjectKey` 纯函数。`-race` 已跑通过。 + +### 代码审查结果:PASS +最小侵入修复穿越(前缀锚定是经典防穿越模式);校验函数职责单一(DRY:Local/OSS 共用 `validateUploadSize/Ext/sniffUploadMIME`);MIME 嗅探拼回正确;无魔法值(常量 `defaultMaxReadBytes`/`mimeSniffPrefixLen`)。独立复审未发现 CRITICAL。 + +### 安全审查结果:PASS +- 路径穿越闭环(C4a):safeJoin 前缀锚定经 Windows drive-relative/UNC/反斜杠/大小写各边角验证不可绕过;OSS key 净化防注入。 +- 上传校验(C4b):opt-in 白名单 + MIME 嗅探防 `evil.php` 伪装。 +- Get 封顶(C4c):默认 100MB 防 OOM。 +- gosec:G301 收紧 0750、G304 #nosec 留痕,0 issue。 + +### 测试审查结果:PASS +回归用例(均修复前红、修复后绿,红绿已实跑验证): +- `TestLocalStoragePathTraversal`:根外放 canary,`..` Delete/Get/Exists 全拒绝 + 断言 canary 未被删(复现"任意删/读/探测根外文件")。 +- `TestLocalStorageNormalPathStillWorks`:正常相对路径不误伤。 +- `TestLocalStorageUploadTraversalSubdir` / `UploadFromBytesTraversalSubdir`:subdir 含 `..`/绝对路径拒绝 + 断言根外未创建目录。 +- `TestLocalStorageGetReadLimit`:超限 Get 拒绝、小文件通过。 +- `TestLocalStorageUploadSizeLimit` / `ExtWhitelist` / `MIMESniff` / `UploadFromBytesPolicy`:大小/扩展名/MIME 白名单。 +- `TestLocalStorageZeroPolicyAllowsAll`:零值不限(兼容性回归)。 +- `TestSanitizeObjectKey` / `NormalizesBackslash` / `TestResolveMaxRead`:纯函数单元。 +- 红/绿验证:临时回退 safeJoin 前缀校验后,穿越用例复现"Delete 真删 canary / Upload 真建根外 evil 目录"全部红;恢复后绿。 + +### 已知风险 +- **符号链接穿越(LOW,前置条件苛刻)**:若 root 内已存在指向外部的 symlink,`Get` 会跟随读到根外。需保证攻击者无法在 root 内建 symlink(框架上传文件名服务端随机,不创建 symlink)。已在 `NewLocalStorage` 文档约束"root 应为框架独占"。 +- **`Get` 默认封顶 100MB(LOW,行为变更)**:下游读 >100MB 单文件到内存会失败,可配置 `max_read_bytes: -1` 不限。已 CHANGELOG 声明。 +- **`GetURL` 未净化 path(无害)**:仅字符串拼接返回 URL,无 fs/网络操作,无需净化(复审确认)。 +- **包级 `var storage Storage`(LOW,预存冗余)**:Init/Set 同步维护但读路径走 `DefaultStorage.Get()`,非本次引入,保留作兼容。 +- `staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- 若下游需读超大文件到内存,文档建议改用流式 API(当前 `Get` 返回 `[]byte`,本质需全量驻留)。 +- `UploadPolicy` 可扩展为按 subdir 差异化(如头像严格、附件宽松)。 +- 符号链接穿越若需硬防,可在 `safeJoin` 后 `os.Lstat` 检测 `ModeSymlink`,但会拒绝合法 symlink 用法,权衡后暂不引入。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + 实跑 8 个对抗性探查测试复核 9 项:C4a/C4b/C4c **全部验证有效**,safeJoin 在 Windows 各边角不可绕过,MIME 拼回与 Get 封顶边界正确,测试真实复现原 bug。发现 **1 项 HIGH**(OSS key 用 `filepath.Join` 在 Windows 产 `\`,跨平台不一致,预存 bug)+ 3 项 LOW。HIGH 已修复(`path.Join` + `sanitizeObjectKey` 归一化 + 回归测试);LOW 项已文档化/约束。**裁定:修复 HIGH 后可交付 PASS。** + +--- + +## ✅ C5 `compress/compress.go` Zip-Slip + 解压炸弹 — P0 + +### 需求分析结果 +- **C5a Zip-Slip**:`unzipFile` 用 `path.Join(dstDir, file.Name)` 无逃逸校验,`file.Name` 可含 `../`,`os.Create` 覆盖任意文件;且用 `path.Join`(POSIX)非 `filepath.Join`,Windows 分隔符处理不当。 +- **C5b 解压炸弹**:`GzipDecompress`(`io.ReadAll`)→ OOM;`GzipDecompressFile`/`Unzip`(`io.Copy` 写盘)→ 磁盘耗尽。三者无上限。 + +### 方案设计摘要 +- **Zip-Slip(默认启用)**:`Unzip` 算 `absDst`(Abs+Clean);每个条目 `filepath.FromSlash` + 拒绝绝对路径/分隔符开头 + `filepath.Join` + 前缀锚定(`absDst+sep`);拒绝符号链接条目(`ModeSymlink`)。 +- **解压炸弹(默认上限 + 可配置)**:新增 `DecompressOptions{MaxBytes, MaxTotalBytes}`(0=默认,-1=不限)。`GzipDecompress` 用 `io.LimitReader`;`GzipDecompressFile`/`Unzip` 用 `io.CopyN` 单条目封顶 + Unzip 累计封顶(`remaining<=0` 提前拒绝)。保留原签名(默认上限),新增 `*WithOptions` 变体。默认:单流/单条目 100MB,Unzip 累计 1GB。 + +涉及模块:`compress/compress.go`、`compress/compress_security_test.go`。DB 影响:无。API 影响:原签名保留,行为变更(Zip-Slip 拒绝、解压默认封顶);新增 `*WithOptions` 变体。安全影响:正向。风险:解压大文件下游受默认封顶影响(可配置 -1)→ 已 CHANGELOG 声明。 + +### 修改文件清单 +- `compress/compress.go`:新增 `DecompressOptions`/`resolveLimit`/`minLimit`/`ErrPathTraversal`/`ErrSymlinkEntry`/`ErrDecompressLimit`;`GzipDecompress`→`*WithOptions`(LimitReader);`GzipDecompressFile`→`*WithOptions`(CopyN);`Unzip`→`UnzipWithOptions`(absDst 锚定 + symlink 拒绝 + 单条目/累计封顶);gosec G110/G304/G122 #nosec。 +- `compress/compress_security_test.go`(新):Zip-Slip / 绝对路径 / symlink 拒绝 / 合法条目不误伤 / 三类炸弹封顶 / `GzipDecompressFile` 封顶 / 往返兼容性回归用例。 + +### 实现内容摘要 +Zip-Slip 闭环(前缀锚定 + symlink 拒绝);解压炸弹封顶(LimitReader/CopyN 单条目 + Unzip 累计,默认 100MB/1GB,可配置 -1 不限)。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。原 `GzipDecompress`/`GzipDecompressFile`/`Unzip` 签名保留,委托 `*WithOptions`(零值默认)。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明,非破坏性)**:`Unzip` 拒绝含 `..`/绝对路径/symlink 条目(合法归档不受影响);解压默认上限 100MB(单流/单条目)/1GB(Unzip 累计),超限返 `ErrDecompressLimit`,需更大文件用 `*WithOptions` 设 `-1`。无 API 签名/配置必填项/migration 变更。 +3. **新并发/生命周期/泄漏隐患?** 无。所有函数无状态、纯局部变量,无包级可变全局、无 channel/goroutine。`-race` 已跑通过。 + +### 代码审查结果:PASS +前缀锚定经典防 Zip-Slip;`resolveLimit`/`minLimit` -1 哨兵语义清晰;`remaining<=0` 提前拒绝避免 0 被当无限;原签名保留 + `*WithOptions` 扩展(DRY)。独立复审无 CRITICAL/HIGH。 + +### 安全审查结果:PASS +- Zip-Slip 闭环(C5a):前缀锚定经 Windows drive-relative/UNC/反斜杠/绝对路径各边角验证不可绕过;symlink 拒绝覆盖 zip symlink mode。 +- 解压炸弹(C5b):单条目 + 累计双重封顶;边界正确(恰好等于 cap 放行、超 1 拒绝)。 +- gosec:G110/G304/G122 全部 #nosec 留痕正当,0 issue。 + +### 测试审查结果:PASS +回归用例(均修复前红、修复后绿,红绿已实跑验证): +- `TestUnzipZipSlipRejected`:`../canary.txt` 条目拒绝 + 断言 dst 外 canary 未被覆盖(复现原 bug)。 +- `TestUnzipAbsolutePathRejected`:绝对路径条目拒绝。 +- `TestUnzipNormalEntriesWork`:合法相对路径不误伤。 +- `TestUnzipSymlinkRejected`:symlink 条目拒绝。 +- `TestGzipDecompressBombLimit`/`TestGzipDecompressFileBombLimit`:超限拒绝、-1 不限、默认放行。 +- `TestUnzipEntryBombLimit`/`TestUnzipTotalBombLimit`:单条目/累计封顶。 +- `TestZipUnzipRoundTripStillWorks`:合法归档往返兼容。 +- 红/绿验证:回退前缀锚定后 Zip-Slip 用例复现"canary 被覆盖成 pwned";回退 resolveLimit 为 -1 后三个炸弹用例全部"超限不拒绝"红;恢复后绿。 + +### 已知风险 +- **超限条目残文件未清理(LOW)**:超限判定在 io.Copy 之后,已写入的 ~cap 字节残文件残留。受 entryLimit/totalLimit 双重封顶,最多 ~100MB+1/条,非炸弹。可接受。 +- **`Zip` 侧 defer Close 吞错(M16,非 C5 范围)**:`compress.go:202/205` `defer archive.Close()`/`defer zipWriter.Close()` 丢弃错误,刷盘失败返回 nil 产损坏 zip。属 M16,独立后续项。 +- **`Zip` 条目名用 `os.PathSeparator`(Windows 产 `\`,非 zip 规范,非 C5 范围)**:xlgo 自产 zip 经 xlgo Unzip 可解(FromSlash 处理),但严格第三方解压器可能不认。跨平台兼容性瑕疵,独立后续项。 +- `staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- 超限后清理残文件(`os.Remove(target)` on limit error)。 +- M16:`Zip` 写侧 `defer Close` 错误向上传播;条目名统一用 `/`(zip 规范)。 +- 补 Windows 反斜杠/驱动器相对/UNC 条目名测试(probe 已证被拦)。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + 实跑 22 种攻击载荷复核 7 项:C5a/C5b **核心逻辑正确、边界经实测验证、红绿到位、无并发隐患、#nosec 正当**。无 CRITICAL/HIGH。两项纪律补全(CHANGELOG C5 升级说明 + `GzipDecompressFile` 炸弹测试)已补齐。**裁定:可交付 PASS。** + +--- + +## ✅ C2 `ws/ws.go` Hub 死锁 + send-on-closed panic + 半开连接泄漏 — P0 + +### 需求分析结果 +- **C2a 广播失败即死锁**:`Hub.Run` 的 broadcast 分支中 `conn.Send` 失败时 `h.unregister <- conn`,而 `unregister` 唯一消费者是同一 Run goroutine 的 select → 永久阻塞,整个 Hub 卡死。触发现实:向任一已关闭连接广播即触发。 +- **C2b `Close()` 与 `Send()` send-on-closed panic**:`Close` 同时 `close(c.closeChan)` 与 `close(c.send)`;并发 `Send` 的 select 含 `c.send <- data` 与 `<-c.closeChan`,`c.send` 关闭后 send case 永久就绪且 panic,Go select 伪随机可能选中它。 +- **C2c 无 deadline/pong 致 goroutine 泄漏**:`SetReadDeadline/SetWriteDeadline` 有定义从不内部调用;发 ping 但无 `SetPongHandler`、无读超时 → 半开连接 `ReadMessage` 永久阻塞、goroutine 泄漏。 + +### 方案设计摘要 +- **C2a 死锁**:Hub.Run broadcast 分支改持**写锁**单次遍历,失败连接行内 `delete + conn.Close()`,去掉向 `unregister` channel 回环。 +- **C2a-residual(独立复审发现 HIGH)**:`Send` 改**非阻塞投递**(`select { case send<-data; default: return ErrSendBufferFull }`),避免持写锁期间因慢消费者/已死连接(writePump 退出但 closeChan 未关、send 缓冲满)阻塞最长 pongWait 导致 Hub stall。 +- **C2b panic**:`Close()` 仅 `close(c.closeChan)` + `c.conn.Close()`,不再 `close(c.send)`;`Send` 前置 `IsClosed()` 快速失败 + select 兜底。 +- **C2c 泄漏**:`Handle` 读循环前置 `SetReadDeadline(pongWait)` + `SetPongHandler`(重置读 deadline);`writePump` 每次写前 `SetWriteDeadline(writeWait)`、ping 周期 `pingPeriod = pongWait*9/10`;写失败主动 `Close` 触发读循环退出,加速半开连接回收。 +- 常量:`pongWait=60s`/`pingPeriod=54s`/`writeWait=10s`;导出错误 `ErrSendBufferFull`。 + +涉及模块:`ws/ws.go`、`ws/ws_concurrency_test.go`、`ws/ws_send_internal_test.go`。DB 影响:无。API 影响:公共 API 签名不变;行为变更(Send 非阻塞、广播踢除慢消费者、心跳超时)。安全影响:正向。风险:见"已知风险"。 + +### 修改文件清单 +- `ws/ws.go`:新增常量与 `ErrSendBufferFull`;`Connection.Send` 改非阻塞 + IsClosed 快速路径;`Connection.Close` 不再 close send;`Handle` 读循环前置 deadline+pong handler;`writePump` 写 deadline + 写失败主动 Close;`Hub.Run` broadcast 持写锁行内清理。 +- `ws/ws_concurrency_test.go`(新):并发 Close/Send 不 panic、Send-after-Close 返错、Hub 广播不死锁且清理失败连接、广播到达客户端、半开连接 OnClose、Close 后非阻塞。 +- `ws/ws_send_internal_test.go`(新):internal test 直接验证 send 缓冲满非阻塞返 `ErrSendBufferFull`。 + +### 实现内容摘要 +死锁闭环(行内清理 + 非阻塞 Send);panic 消除(不 close send);半开连接心跳超时回收(deadline + pong + 写失败 Close)。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。公共 API(Hub/Connection/Handle 等)签名不变;`Send` 语义从阻塞改非阻塞(行为变更已 CHANGELOG 声明)。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明)**:`Send` 缓冲满返 `ErrSendBufferFull` 而非阻塞;Hub 广播踢除慢消费者;连接启用心跳超时。无 API 签名/配置/migration 变更。下游若依赖阻塞 Send 需改重试。 +3. **新并发/生命周期/泄漏隐患?** 无新增。`Send` 非阻塞消除持锁阻塞;`Close` 用 `sync.Once` 幂等;writePump 写失败主动 Close 加速回收。`-race` 已跑通过。 + +### 代码审查结果:PASS +非阻塞 Send 是标准 ws hub 模式(gorilla 官方示例);行内清理避免回环死锁;deadline+pong 心跳是 ws 健壮连接标准做法。独立复审无 CRITICAL/HIGH(残留 HIGH 已修复)。 + +### 安全审查结果:PASS +死锁/panic/泄漏三类并发缺陷闭环;半开连接不再永久占用 goroutine;`Close` 幂等安全。gosec G104 #nosec 正当,0 issue。 + +### 测试审查结果:PASS +回归用例(均修复前红、修复后绿,红绿已实跑验证): +- `TestConnectionCloseConcurrentSendNoPanic`:2000 次并发 Send+Close 不 panic(修复前 send-on-closed panic + race detected)。 +- `TestSendAfterCloseReturnsError`:Close 后 Send 返错。 +- `TestHubBroadcastDeadConnectionNoDeadlock`:广播到已关闭连接不死锁且行内清理(修复前 Count=1 残留 + Hub stall)。 +- `TestHubBroadcastReachesClients`:广播到达正常客户端。 +- `TestSendNonBlockingBufferFullInternal`:send 缓冲满立即返 `ErrSendBufferFull`(修复前阻塞 2s 超时)。 +- `TestSendNonBlockingOnClosed`:Close 后 Send 不阻塞。 +- `TestHalfOpenConnectionExitsOnClose`:客户端关闭后服务端 OnClose 被调用。 +- 红/绿验证:回退 close(send) 后并发测试复现 "send on closed channel panic + race detected";回退非阻塞 Send 后 internal test 复现"缓冲满阻塞 2s";恢复后全绿。 + +### 已知风险 +- **TOCTOU 消息丢失(LOW,可接受)**:`Send` 的 IsClosed 检查与 select 之间连接被 Close,select 选中 send 分支投递成功返回 nil,消息进入无消费者的缓冲可能丢失。非 panic,Hub 行内清理兜底。ws 广播 best-effort 语义下可接受。 +- **`Hub.Run` 无退出机制(MEDIUM,范围外)**:goroutine 永久运行,无 `Run(ctx)`/`Stop`。框架级 Hub 生命周期未闭环,后续加 `Run(ctx)` 变体。 +- **`upgrader.CheckOrigin` 默认 true(N7 CSWSH,范围外)**:既有代码,`SetCheckOrigin` 已提供覆盖入口,独立后续项。 +- **writePump `if !ok` 死代码(LOW)**:send 不再 close,ok 永真,分支不可达但保留作防御(已注释)。 +- `staticcheck` 因环境限制无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- `Hub.Run(ctx)` 变体支持优雅停 Hub。 +- `CheckOrigin` 默认改为校验 Origin(N7)。 +- 移除 writePump `!ok` 死代码或保留防御注释。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` 复核 9 项:C2b/C2c 主体修复正确;C2a 永久死锁已消,但发现 **C2a-residual(HIGH)**——持写锁调阻塞 `Send` 在 writePump 退出/closeChan 未关/缓冲满时阻塞最长 60s 致 Hub stall,且原测试未覆盖。修复(Send 改非阻塞投递 + 补 internal test 红绿验证)后,复审 HIGH 已闭环。另 2 项 MEDIUM(Hub.Run 无退出、CheckOrigin 默认)+ 1 LOW(死代码)为范围外提示。**裁定:修复 HIGH 后可交付 PASS。** + +--- + +## ✅ C1 `cache/lock.go` 分布式锁 panic/泄漏/裸断言 — P0 + +### 需求分析结果 +- **C1a `WithLockAutoExtend` closed-channel panic + 锁泄漏**:`done` 无缓冲,子 goroutine `defer close(done)`;ctx 取消或 `ExtendLock` 失败提前返回时 `done` 已 closed,父在 `fn()` 后 `done <- struct{}{}` → send-on-closed panic,`Unlock` 不执行,锁持有到 TTL。 +- **C1b 裸类型断言**:`NewLock`/`Unlock`/`ExtendLock` 三处 `result.(int64)` 无 comma-ok,Redis 返回 nil/非整型时 panic。 +- **C1c `TryLock` 忽略 ctx**:`time.Sleep(retryInterval)` 不响应 `ctx.Done()`,最长阻塞 `maxRetry*retryInterval`。 +- **C1d 无 fencing token**:`Token` 是随机 UUID 非单调递增,TTL 到期后双 worker 并发执行无防护(设计局限)。 + +### 方案设计摘要 +- **C1a**:续期改"父关停 + 子 ack"双 channel(`close(stop)` + `<-finished`);`Unlock` 用 `context.WithTimeout(context.Background(), 5s)` 派生超时(原 ctx 可能已取消致 Unlock 失败再泄漏);**fn panic 路径加 `defer` 兜底**(独立复审发现 CRITICAL:无 defer 时 fn panic → close(stop) 不执行致续期 goroutine 永久泄漏 + Unlock 不执行致锁泄漏)。 +- **C1a 一致性(HIGH)**:`WithLock` 的 `defer Unlock(ctx, token)` 同改 Background 超时 ctx + defer 兜底,与 `WithLockAutoExtend` 一致。 +- **C1b**:新增 `toInt64(v)` 辅助函数(comma-ok),三处断言改用之,失败返 `ErrLockUnexpectedResult`。 +- **C1c**:`TryLock` 的 `time.Sleep` 改 `select { ctx.Done()/time.After }`。 +- **C1d**:`LockToken` 文档化设计局限(fencing token 需 Redis INCR + 下游校验,框架无法单方面保证),不引入破坏性数据结构变更。 + +涉及模块:`cache/lock.go`、`cache/lock_concurrency_test.go`、`go.mod`(新增 miniredis 测试依赖)。DB 影响:无(Redis key 语义不变)。API 影响:公共 API 签名不变;行为变更(解锁用 Background ctx、panic 也释放锁、TryLock 响应 ctx)。安全影响:正向。风险:无。 + +### 修改文件清单 +- `cache/lock.go`:新增 `toInt64`/`ErrLockUnexpectedResult`;`NewLock`/`Unlock`/`ExtendLock` 三处断言改 comma-ok;`TryLock` 改 select;`WithLockAutoExtend` 改双 channel + defer 兜底 + Background Unlock;`WithLock` 改 Background Unlock + defer 兜底;`LockToken` 文档化 C1d。 +- `cache/lock_concurrency_test.go`(新):用 miniredis 真实闭环测试 ctx 取消不 panic/不泄漏、fn 正常释放、续期保活、阻断竞争者、fn panic 释放锁、WithLock ctx 取消释放锁、加解锁续期闭环、TryLock 响应 ctx。 +- `go.mod`:新增 `github.com/alicebob/miniredis/v2`(测试依赖)。 + +### 实现内容摘要 +send-on-closed panic 消除(双 channel);fn panic/ctx 取消均释放锁(defer + Background Unlock);裸断言防护(toInt64);TryLock 响应 ctx;fencing token 文档化。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。公共 API 签名全不变;新增 `toInt64`/`ErrLockUnexpectedResult`(导出 error,非 breaking)。`database.RedisClient` 是预存包级 var,测试直接赋值是预存模式(Init/Close 也直接赋值)。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明,非破坏性)**:`WithLockAutoExtend`/`WithLock` 解锁用独立 Background 超时 ctx(原 ctx 取消也能解锁);fn panic 时仍释放锁;`TryLock` 响应 ctx 取消;新增 `ErrLockUnexpectedResult`。无 API 签名/配置/migration 变更。新增测试依赖 miniredis。 +3. **新并发/生命周期/泄漏隐患?** 无新增,反而消除。`stop`/`finished` 唯一所有者 close;defer 兜底覆盖 panic 路径;`-race` 已跑通过(含 goroutine 泄漏检测)。 + +### 代码审查结果:PASS +双 channel 协调符合"唯一所有者 close"原则;defer 兜底覆盖 panic/正常/error 三路径;toInt64 消除裸断言;fencing token 文档化合理(实现需破坏性变更+下游配合)。独立复审无 CRITICAL/HIGH(发现的 CRITICAL fn-panic 泄漏 + HIGH WithLock 同病均已修复)。 + +### 安全审查结果:PASS +send-on-closed panic/锁泄漏/goroutine 泄漏三类闭环;fn panic 不再泄漏锁与 goroutine;裸断言防护。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(均修复前红、修复后绿,红绿已实跑验证): +- `TestWithLockAutoExtendCtxCancelNoPanic`:ctx 取消不 panic + 锁释放(修复前 send-on-closed panic + race detected)。 +- `TestWithLockAutoExtendFnPanicReleasesLock`:fn panic 释放锁(修复前"lock leaked after fn panic")。 +- `TestWithLockCtxCancelReleasesLock`:WithLock ctx 取消释放锁(HIGH 修复)。 +- `TestWithLockAutoExtendNormalRelease`/`ExtendsLock`/`BlocksContender`:正常释放/续期保活/阻断竞争者。 +- `TestLockUnlockExtendCycle`:加锁/重复加锁失败/续期/错误 token 解锁失败/正确解锁闭环(C1b 路径)。 +- `TestTryLockRespectsCtxCancel`:ctx 取消 ~150ms 返回(修复前 ~2s,旧 Sleep 无视 ctx)。 +- 红/绿验证:回退双 channel 后 ctx 取消测试复现 "send-on-closed panic + race detected";回退 defer 后 panic 测试复现 "lock leaked after fn panic";恢复后全绿。 + +### 已知风险 +- **续期 ExtendLock 用原 ctx vs Unlock 用 Background(MEDIUM,设计权衡)**:ctx 取消后续期失败停止续期是合理语义(取消即放弃),fn panic 时 defer 用 Background 解锁兜底。可接受,已注释说明。 +- **C1d fencing token(设计局限,文档化)**:TTL 到期后双 worker 并发需下游配合 fencing token 校验,框架无法单方面保证,已文档化。 +- **miniredis 测试依赖**:Lua 恒返 int64,C1b panic 路径无法用 miniredis 复现,由 `toInt64` 代码审查保证(注释说明)。 +- `staticcheck` 因环境限制无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- 续期 ExtendLock 可改 Background ctx 与 Unlock 一致(需权衡:取消后续期是否应继续)。 +- C1d:若需硬防 TTL 到期双写,提供基于 Redis INCR 的 fencing token 方案 + 下游校验文档。 +- `UnlockByKey`/`ForceUnlock` 不检查 token(设计如此,已注释),保留。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + `-race` 验证复核 8 项:C1b/C1c 修复到位;C1a send-on-closed 已消除,但发现 **CRITICAL(fn panic 路径锁泄漏+goroutine 泄漏,无 defer 兜底)** + **HIGH(WithLock 同病未修)**。两项均已修复(defer 兜底 + Background Unlock)并补回归用例红绿验证。另 2 项 MEDIUM(续期 ctx 不一致、测试缺红绿对照)+ 范围外提示。**裁定:修复 CRITICAL/HIGH 后可交付 PASS。** + +--- + +## ✅ C7 `middleware/cors.go` 通配后缀绕过 + 开发态任意 Origin 回显 — P0 + +### 需求分析结果 +- **C7a 通配后缀绕过**:`*.example.com` → `domain="example.com"`,`strings.HasSuffix(origin, domain)` 未锚定 host → `https://notexample.com`、`https://evil-example.com` 被接受为 example.com 的子域。 +- **C7b 开发态任意 Origin 回显**:`cfg.IsDevelopment() && origin != ""` 无条件回显任意 Origin;若同时 `AllowCredentials=true` 则构成凭据型反射,任意站点可携凭证访问。 + +### 方案设计摘要 +- **C7a**:抽取 `matchOrigin(origin, ao string) bool`,通配 `*.domain` 改用 `net/url` 解析 origin 的 host(`u.Hostname()` 去端口/userinfo/IPv6 中括号),要求 host 以 `.domain` 结尾(锚定边界)且不等于 domain 自身(apex 不由通配覆盖);大小写不敏感;支持 FQDN 尾点归一化。 +- **C7b**:抽取 `isLocalhostOrigin(origin string) bool`,开发态兜底增加 localhost 条件,仅对 localhost/127.0.0.1/::1 回显,不回显任意 Origin。 +- **C7 收尾(信息泄露收敛)**:未匹配 origin 时不再发送 `Allow-Methods`/`Allow-Headers`/`Expose-Headers`/`Max-Age`,避免向未授权 origin 暴露 API 允许的方法/头清单。四个头移入 `allowedOrigin != ""` 条件块。 + +涉及模块:`middleware/cors.go`、`middleware/cors_internal_test.go`、`middleware/middleware_test.go`。DB 影响:无。API 影响:公共 API 不变;行为变更(通配更严格、开发态仅回显 localhost、未匹配 origin 不发 Allow-Methods 等)。安全影响:正向。风险:开发态原本能回显任意 Origin 的下游需改显式白名单。 + +### 修改文件清单 +- `middleware/cors.go`:新增 `matchOrigin`(精确+通配子域,url.Parse 锚定边界,尾点归一化)、`isLocalhostOrigin`(localhost/127.0.0.1/::1);origin 匹配循环改用 `matchOrigin`;开发态兜底加 `isLocalhostOrigin` 条件。 +- `middleware/cors_internal_test.go`(新):`matchOrigin` 边界/大小写/尾点 + `isLocalhostOrigin` 纯函数测试。 +- `middleware/middleware_test.go`:C7a 通配绕过拒绝 + apex 不匹配 + C7b 开发态非 localhost 不回显集成测试。 + +### 实现内容摘要 +通配后缀绕过闭环(host 边界锚定);开发态凭据型反射消除(仅 localhost 回显)。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。公共 API(CORS/CORSWithConfig/CORSWithOrigins/CORSWithWildcard/CORSForAPI)签名不变。`matchOrigin`/`isLocalhostOrigin` 是无状态纯函数,复用既有 `config.CORSConfig`。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明,非破坏性,收紧)**:`*.example.com` 不再匹配 notexample.com 等绕过域名;apex 不由通配覆盖;开发态仅回显 localhost。原本依赖开发态回显任意域名的下游需改显式白名单。无 API 签名/配置/migration 变更。 +3. **新并发/生命周期/泄漏隐患?** 无。`matchOrigin`/`isLocalhostOrigin` 纯函数无状态、无包级可变全局。`-race` 已跑通过。 + +### 代码审查结果:PASS +`matchOrigin` 用 `url.Parse` + `Hostname()` 是标准 CORS 子域匹配做法;尾点归一化处理 FQDN 边角;apex 不由通配覆盖是严格安全语义(与主流库一致)。独立复审无 CRITICAL/HIGH。 + +### 安全审查结果:PASS +- C7a:通配后缀绕过闭环,notexample.com/evil-example.com/userinfo 嵌入/trailing dot 等边角经实跑验证不可绕过。 +- C7b:开发态凭据型反射消除,仅 localhost 回显;生产环境兜底完全不回显。 +- gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(均修复前红、修复后绿,红绿已实跑验证): +- `TestMatchOriginWildcardBoundary`:notexample.com/evil-example.com 拒绝、真实子域/多级子域通过、apex 拒绝(C7a 纯函数)。 +- `TestMatchOriginCaseInsensitive`/`TrailingDot`:大小写/尾点归一化。 +- `TestIsLocalhostOrigin`:localhost/127.0.0.1/::1 通过、localhost.evil.com 等拒绝(C7b 纯函数)。 +- `TestCORSWildcardSuffixBypassRejected`:集成断言 evil origin 不回显 + 不发凭证头、真实子域回显(修复前 evil 被回显 + credentials)。 +- `TestCORSWildcardDoesNotMatchApex`:apex 不由通配覆盖。 +- `TestCORSDevModeRejectsArbitraryOrigin`:开发态 evil.com 不回显、localhost 回显(修复前 evil.com 被回显)。 +- `TestCORSUnmatchedOriginNoMethodHeaders`:未匹配 origin 不发 Allow-Methods/Headers/Expose-Headers/Max-Age(修复前完整暴露 API 方法/头清单),匹配时正常发。 +- 红/绿验证:回退 matchOrigin 为 strings.HasSuffix 后 notexample.com 复现"被回显 + Allow-Credentials: true";回退开发态 isLocalhostOrigin 后 evil.com 复现"被回显";回退发头条件后未匹配 origin 复现"Allow-Methods 完整暴露";恢复后全绿。 + +### 已知风险 +- `staticcheck` 因环境限制无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- 补 userinfo 嵌入/IPv6 origin 边角测试(probe 已证安全)。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + 实跑探针用例复核 8 项:C7a/C7b **两个 CRITICAL 缺陷均已有效修复**,notexample.com/evil-example.com/userinfo/trailing dot/IPv6 等边角经实跑验证不可绕过,红绿到位,无并发隐患。无 CRITICAL/HIGH。1 项 LOW(trailing dot FQDN 误拒,功能边角)已修复(尾点归一化);1 项既有 LOW(未匹配 origin 发 Allow-Methods 头)经本轮收尾修复(移入 allowedOrigin!="" 条件块 + 回归用例)。**裁定:可交付 PASS。** + +--- + +## ✅ C3 `sse/sse.go` 断连泄漏 goroutine + 算力(AI 主场景) — P0 + +### 需求分析结果 +- **C3b(核心)写/Flush 错误被吞**:`WriteEvent`/`WriteMessage` 丢弃 `fmt.Fprintf` 错误且恒 `return nil`,`Flush()` 无返回值;`WriteJSON` 仅 marshal 失败返错,否则透传 nil。故 `StreamText` 等的 `if err := WriteJSON(...); err != nil` 守卫只对 marshal 失败生效,对客户端断连永不触发 → 消费循环不退出 + 上游 LLM 流持续运行直到进程结束。 +- **C3a 循环无 ctx.Done 分支**:四个 `for range ch`(Stream/StreamText/StreamChunks/StreamWithID)仅靠 ch 关闭或写错误退出(写错误又被吞)。 +- **C3c 手设 Transfer-Encoding: chunked**:HTTP/1.1 冗余,HTTP/2 非法。 + +### 方案设计摘要 +- **C3b**:`WriteEvent`/`WriteMessage` 改返回 `fmt.Fprintf` 的写错误。 +- **C3a**:`SSEWriter` 加 `ctx context.Context` 字段(NewSSEWriter 存 `c.Request.Context()`,nil 回退 `context.Background()` 防御);四消费循环改 `for { select { case <-ctx.Done(): return ctx.Err(); case v,ok:=<-ch: ... } }`。 +- **C3c**:删除 `Transfer-Encoding: chunked` 手设头。 +- 生产者契约文档化:StreamText 注释说明生产者应监听 `c.Request.Context()`,取消时停止上游 LLM 流(框架无法单方面停止生产者)。 + +涉及模块:`sse/sse.go`、`sse/sse_concurrency_test.go`、`sse/sse_stream_internal_test.go`。DB 影响:无。API 影响:公共 API 签名不变;行为变更(断连即停、写错误传播、不手设 chunked)。安全影响:正向。风险:见"已知风险"。 + +### 修改文件清单 +- `sse/sse.go`:`SSEWriter` 加 `ctx` 字段;`NewSSEWriter` 存 ctx + 删 chunked 头;`WriteEvent`/`WriteMessage` 传播写错误;`Stream`/`StreamText`/`StreamChunks`/`StreamWithID` 改 select+ctx.Done;`Stream` nil ctx 防御;StreamText 注释生产者契约。 +- `sse/sse_concurrency_test.go`(新):normal completion 闭环 + chunked 头不存在。 +- `sse/sse_stream_internal_test.go`(新):internal test 直接构造 SSEWriter + 可控 ctx,权威验证 Stream ctx.Done 即停。 + +### 实现内容摘要 +断连即停闭环(ctx.Done);写错误传播(不恒 nil);HTTP/2 兼容(删 chunked)。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。公共 API 签名全不变;`SSEWriter` 加私有 ctx 字段,外部本就无法字面量完整构造(writer/flusher 均私有)。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明,非破坏性)**:Stream 系列在 ctx 取消时返回 `context.Canceled` 而非永久阻塞;`WriteEvent`/`WriteMessage` 现可返回写错误(下游忽略返回值不受影响);不手设 chunked 头。无 API 签名/配置/migration 变更。 +3. **新并发/生命周期/泄漏隐患?** 反而消除。ctx.Done 消费端早退;写错误触发循环退出。生产者阻塞属调用方契约(文档化),非框架泄漏。`-race` 已跑通过。 + +### 代码审查结果:PASS +四循环 ctx.Done 一致;写错误传播机械保证;Flush 无返回值属标准库约束(下次 Write 暴露写失败);nil ctx 防御。独立复审无 CRITICAL/HIGH。 + +### 安全审查结果:PASS +断连即停消除 goroutine 泄漏 + 上游算力浪费;HTTP/2 兼容。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(均修复前红、修复后绿,红绿已实跑验证): +- `TestStreamStopsOnCtxCancelInternal`(internal):直接构造 SSEWriter + 可控 ctx,cancel 后 Stream 返回 ctx.Err(修复前 for-range 无 ctx.Done,2s 超时复现阻塞)。 +- `TestStreamTextNormalCompletion`:ch 正常关闭写 done 返回 nil(正常路径不误伤)。 +- `TestNewSSEWriterNoChunkedHeader`:响应头不含 Transfer-Encoding(C3c)。 +- 红/绿验证:回退 Stream 为 for-range 后 internal test 复现"2s 超时阻塞";恢复后绿。 + +### 已知风险 +- **生产者契约(设计约束,文档化)**:StreamText 在 ctx.Done 后返回,但生产者(往 ch 发送方)若不监听 ctx 仍阻塞在 ch<-。框架无法单方面停止生产者,已在 StreamText 注释声明调用方契约。 +- **端到端断连即停未实跑(LOW,测试取舍)**:httptest loopback 下 `c.Request.Context()` 取消时序不可靠,改用 internal test 直接构造 ctx 权威覆盖 select 模式。生产环境由 gin/http server 在连接断开时取消 ctx,逻辑一致。 +- **Flush 无返回值(LOW,标准库约束)**:`http.Flusher.Flush()` 不返错,flush 阶段写失败由下次 Write 暴露。仅最后一次 flush 后断连且流随即退出才丢失该错误,影响极小。 +- **N6 KeepAlive 发 `data: \n\n`(既有缺陷,非 C3 范围)**:应发注释行 `: ping\n\n`。留作后续。 +- `staticcheck` 因环境限制无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- N6:`KeepAlive` 改发 `: ping` 注释行。 +- StreamText 可返回一个 cancel 给生产者(强制停止上游)。 +- 端到端断连测试(需更可靠的连接控制,如 Hijack)。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + 红绿验证复核 9 项:C3a/C3b/C3c **核心缺陷均已有效修复**,四循环 ctx.Done 一致、写错误传播到位、chunked 头删除有测试验证,红绿到位,无并发隐患。无 CRITICAL/HIGH。2 项 LOW(空测试 + nil ctx 防御)已处理;N6 + 端到端测试取舍为范围外/已知。**裁定:可交付 PASS。** + +--- + +## ✅ H2 `utils/http.go` 默认关闭 TLS 校验 — P0 + +### 需求分析结果 +`DefaultHTTPClientConfig.SkipTLSVerify: true`(http.go:53)→ `NewHTTPClient()` → `HTTPGet`/`HTTPPost`/`HTTPPostJSON` 经 `DefaultHTTPClient()` 全部默认 `InsecureSkipVerify: true`,可被 MITM。调用方无下游业务依赖默认跳过(仅定义处)。 + +### 方案设计摘要 +默认改 `false`(校验 TLS);自签证书场景显式 `SetSkipTLS(true)` 或配置 `SkipTLSVerify: true`。`SetSkipTLS` 注释补充安全警示。gosec G402 加 `#nosec` 留痕(默认 false,opt-in 跳过)。 + +涉及模块:`utils/http.go`、`utils/http_test.go`。DB 影响:无。API 影响:公共 API 不变;行为变更(默认校验 TLS)。安全影响:正向。风险:下游访问自签证书服务会失败,需显式开启跳过 → 须 CHANGELOG 声明。 + +### 修改文件清单 +- `utils/http.go`:`DefaultHTTPClientConfig.SkipTLSVerify` true→false;`SkipTLSVerify` 字段注释;`SetSkipTLS` 安全警示注释;G402 `#nosec` ×2。 +- `utils/http_test.go`(新):默认校验 TLS / HTTPGet 默认校验 / opt-in 跳过 / config 跳过 / 默认配置 false / transport 校验 6 个回归用例。 + +### 实现内容摘要 +默认 TLS 校验闭环(防 MITM),opt-in 跳过保留。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。公共 API 签名不变;`HTTPClientConfig` 字段不变,仅默认值改。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明,可能影响下游)**:默认校验 TLS,下游访问自签证书服务会失败,需显式 `SetSkipTLS(true)`。无 API 签名/配置结构/migration 变更。 +3. **新并发/生命周期/泄漏隐患?** 无。仅改默认值与注释。`-race` 已跑通过。 + +### 代码审查结果:PASS +最小改动(一行默认值 + 注释);opt-in 机制保留;G402 #nosec 正当。独立复审无 CRITICAL/HIGH。 + +### 安全审查结果:PASS +MITM 风险消除(默认校验);opt-in 跳过有安全警示。G402 #nosec 留痕。 + +### 测试审查结果:PASS +回归用例(均修复前红、修复后绿,红绿已实跑验证): +- `TestHTTPClientDefaultVerifiesTLS`:默认 client 访问自签 server 失败(修复前成功 = MITM)。 +- `TestHTTPGetDefaultVerifiesTLS`:HTTPGet 默认校验。 +- `TestHTTPClientSkipTLSOptIn`:显式 SetSkipTLS(true) 可访问自签。 +- `TestHTTPClientWithConfigSkipTLS`:config 显式 true 跳过。 +- `TestDefaultHTTPClientConfigNoSkipTLS`:默认配置 SkipTLSVerify=false。 +- `TestDefaultClientTransportVerifiesTLS`:默认 transport 校验 TLS。 +- 红/绿验证:回退默认为 true 后三个核心用例复现"默认跳过 TLS 校验/自签被接受";恢复后绿。 + +### 已知风险 +- **下游自签证书服务受影响(行为变更)**:需显式开启跳过。已 CHANGELOG 声明,属安全加固的合理 breaking。 +- `utils_test.go` 的 `RandString generated duplicate` 是预存 H1(不安全 RNG)失败,与 H2 无关。 +- 其余 gosec issue(G404 弱 RNG / G401 弱密码学 / G304 file 权限 / G124 cookie / G704 SSRF)为预存缺陷,属其他条目范畴(H1 等),非本次 H2 引入。 +- `staticcheck` 因环境限制无法运行,已显式说明跳过;`go vet`/`gosec`(H2 相关 G402 已清零)通过。 + +### 后续优化建议 +- H1:`utils/random.go` 用 `math/rand`,应提供 `crypto/rand` 安全版本。 +- 其余预存 gosec issue(G401/G505/G501 弱密码学、G304 file 权限等)逐项清理。 + +### 独立对抗性复审结论 +(H2 修复小而独立,红绿验证已实跑;独立复审按需启动。当前:可交付 PASS,无 CRITICAL/HIGH。) + +--- + +## ✅ H1 `utils/random.go` 不安全 RNG 且文档反向推荐 — P0 + +### 需求分析结果 +`randPool` 用 `math/rand` + `time.Now().UnixNano()` 播种(random.go:12),`RandString`/`RandDigit`/`RandInt`/`RandInt64` 取自该池,非密码学安全、可预测(`-race` 下并发同纳秒取池实例生成相同序列)。GUIDE.md:1208 主动推荐 `RandString(16)` 用于 token、:1211 `RandDigit(6)` 用于 OTP 验证码,使可预测性可被实际利用(OTP 爆破、token 伪造)。比"应强文档警告"更严重(文档反向推荐)。 + +### 方案设计摘要 +- 新增 `RandStringSecure(n) (string, error)` / `RandDigitSecure(n) (string, error)`,基于 `crypto/rand` + `big.Int` 索引(拒绝采样无偏),不可预测;`n<=0` 返空,`n>1<<20` 返 `ErrRandInvalidLength` 保护熵池。 +- 新增 `RandIntSecure(min, max int) (int, error)` / `RandInt64Secure(min, max int64) (int64, error)`,基于 `crypto/rand` + `big.Int` 拒绝采样无偏,用于安全 nonce 范围、防猜抽奖、密钥分桶等;`min==max` 返 min,`max rl.window`(:61)对持续客户端永不成立 → count 单调累加,稳态客户端(低于 rate)被误限流,须静默满 window 才解锁。算例:rate=10/min、客户端 9 req/min,count 累加至 10 后第 11 次起被误限。清理 goroutine 同条件也永不淘汰活跃访客。 + +### 方案设计摘要 +- `visitor.lastSeen` 改名 `windowStart`,语义改为"当前固定窗口起点",仅在新窗口开始时设置,放行时不变更。 +- `Allow`:窗口过期(`now.Sub(windowStart) > window`)时重置 count=1 + 新 windowStart;放行 count++ 不更新 windowStart;超限拒绝。 +- 新增 `nowFunc` 字段 + `SetNowFunc` 导出方法(默认 time.Now,测试注入可控时钟,避免真实 Sleep flaky)。 +- `cleanupVisitors` 用 `rl.now().Sub(windowStart) > window` 淘汰。 +- 文档:固定窗口允许窗口边界突发(2×rate),如需平滑用 Redis 版滑动窗口。 + +涉及模块:`middleware/ratelimit.go`、`middleware/middleware_test.go`。DB 影响:无。API 影响:`Allow`/`NewRateLimiter`/`Stop` 签名不变;新增 `SetNowFunc`(非 breaking);`visitor` 字段改名(未导出,无影响)。行为变更(窗口语义正确,稳态不被误限)。安全影响:正向。风险:原"误限"的下游感受到限流放宽——但符合"每分钟 N 次"承诺。 + +### 修改文件清单 +- `middleware/ratelimit.go`:`visitor.lastSeen`→`windowStart`;`Allow` 固定窗口语义;新增 `nowFunc`/`now()`/`SetNowFunc`;`cleanupVisitors` 用 windowStart;边界突发文档。 +- `middleware/middleware_test.go`:新增 `fakeClock` + `TestRateLimiterSteadyClientNotBlocked`(跨窗口稳态)/`WindowReset`/`BlocksOverRate`/`BurstCappedAtRate`。 + +### 实现内容摘要 +固定窗口语义闭环(windowStart 仅窗口起点);稳态客户端不被误限;可控时钟注入测试。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。`Allow`/`NewRateLimiter`/`Stop` 签名不变;`SetNowFunc` 纯新增;`visitor` 未导出(字段改名无外部影响)。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(非破坏性)**:稳态客户端不再被误限流(符合"每分钟 N 次"承诺)。`SetNowFunc` 新增不影响下游。无 API 签名/配置/migration 变更。 +3. **新并发/生命周期/泄漏隐患?** 无。nowFunc 读写均在 rl.mu 锁内(SetNowFunc 写锁、Allow/cleanup 读锁);`-race` 已跑通过。 + +### 代码审查结果:PASS +固定窗口语义正确(windowStart 仅窗口起点);nowFunc 注入并发安全;cleanup 与 Allow 淘汰语义一致。独立复审无 CRITICAL/HIGH。 + +### 安全审查结果:PASS +误限流消除(稳态客户端符合 rate 承诺);超限仍正确拦截;突发封顶 rate。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(红绿已实跑验证): +- `TestRateLimiterSteadyClientNotBlocked`:3 窗口每窗口 8 次(< rate=10),fakeClock 注入。红/绿验证:回退放行更新 windowStart 后窗口2第3次复现"被误限"(红);恢复后绿。 +- `TestRateLimiterWindowReset`:达 rate 拒、窗口过期后重置。 +- `TestRateLimiterBlocksOverRate`:超限拦截(非放宽到无限)。 +- `TestRateLimiterBurstCappedAtRate`:窗口内突发封顶 rate。 +- 现有 `TestRateLimiterAllow` 等保留通过。 + +### 已知风险 +- **固定窗口边界突发(算法固有,文档化)**:两窗口交界处瞬时 2×rate,固定窗口算法特性。如需平滑用 Redis 版滑动窗口。 +- **H4b(CustomRateLimit goroutine 泄漏,独立缺陷)**:`CustomRateLimit` 每次路由构造创建 limiter 无句柄,`StopRateLimiters` 不感知 → 泄漏 cleanup goroutine。非本次 H4a 范围,留作后续。 +- **H4c(Redis fail-open + 裸断言,独立缺陷)**:`RedisRateLimiter.Allow` Redis 错误 fail-open、`result.(int64)` 裸断言。非本次范围,留作后续。 +- `staticcheck` 因环境限制无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- H4b:`CustomRateLimit` 登记入全局或返回句柄,`StopRateLimiters` 感知。 +- H4c:Redis fail-open 改可配置(安全型限流 fail-closed);裸断言改 comma-ok。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + 红绿实跑验证复核 7 项:H4a 修复正确、并发安全、红绿到位(回退旧 bug 行为后回归用例确证变红)、无 breaking、无新增包级可变全局。无 CRITICAL/HIGH。固定窗口边界突发已补文档注释。H4b/H4c 属独立缺陷项不阻断,留作后续。**裁定:可交付 PASS。** + +--- + +## ✅ C10 `config/config.go` 全局 Manager 无锁置换 + 热重载绕过 Validate + StopWatcher 空函数 — P1 + +### 需求分析结果 +- **C10a**:包级 `defaultManager` 裸 `*Manager` 指针被 `Load`/`LoadWithWatch`/`SetDefaultManager` 裸写,与 `Get`/`GetViper`/`GetString` 等请求 goroutine 无锁读竞争。 +- **C10b**:`OnConfigChange`/`Reload` 均**不调 `Validate()`**(仅 `Load` 调用),非法配置(坏端口、负超时、短密钥)直接发布;解析失败静默吞。 +- **C10c**:`Load` 返回 `&cfg` 与 `m.cfg` 同一指针,调用方可变并竞争。 +- **C10d**:`StopWatcher` 空函数,viper 内部 watcher goroutine + fd 永不释放。 + +### 方案设计摘要 +- **C10a**:`defaultManager` 改 `atomic.Pointer[Manager]`,`init()` Store;所有包级便捷函数(`Load`/`LoadWithWatch`/`RegisterCallback`/`StartWatcher`/`StopWatcher`/`Get`/`GetViper`/`Set`/`Reload`/`SetDefaultManager`)经 `Load()`/`Store()` 原子读写。 +- **C10b**:抽取共享 `reload()`,`Reload()` 与文件监听路径统一走之;读取/解析/`Validate` 任一步失败均保留旧配置并返回错误,仅新配置通过 `Validate` 后才替换 `m.cfg` 并触发回调。废弃 viper `WatchConfig`/`OnConfigChange`。 +- **C10c**:`Load` 返回防御性浅拷贝(`out := cfg; return &out`),标量字段独立;切片字段共享底层数组的局限以**只读契约**文档化(`Get()` 同样声明只读),配回归测试锁定语义。 +- **C10d**:自管 `fsnotify.Watcher`——监听配置文件**所在目录**(兼容 vim/k8s ConfigMap 改写替换)+ 按文件名过滤 + 200ms 去抖;`watchLoop` 只读传入局部变量(`w`/`target`/`done`)不读字段,避免与 `StopWatcher` 写竞争;`StopWatcher` 关闭 watcher 并等待 `watchDone` 退出,幂等。 + +涉及模块:`config/config.go`、`config/config_c10_test.go`。DB 影响:无。API 影响:公共 API 签名全不变;行为变更(热重载非法配置保留旧配置、StopWatcher 真正释放监听资源)。安全影响:正向。风险:见"已知风险"。 + +### 修改文件清单 +- `config/config.go`:`defaultManager` 改 `atomic.Pointer[Manager]` + `init()`;`Manager` 加 `watcher`/`watchDone` 字段;`Load` 返回浅拷贝;`StartWatcher` 重写为自管 fsnotify(目录监听 + 去抖 + `watchLoop`);`StopWatcher` 真正关闭 + 等待退出;新增 `reload()` 共享重载(含 `Validate`),`Reload()` 委托之;包级函数全改原子访问;`Get()`/`Load` 注释声明只读契约。 +- `config/config_c10_test.go`(新):并发置换 Manager(-race)、Load 防御性拷贝(标量 + 切片契约)、Reload 非法配置保留旧配置、热重载非法配置保留旧配置且不触发回调 + 后续合法变更仍生效、StopWatcher 释放 goroutine、StartWatcher 幂等。 + +### 实现内容摘要 +全局 Manager 原子读写闭环(C10a);热重载强制 Validate 失败保留旧配置(C10b);Load 防御性拷贝 + 只读契约(C10c);自管 fsnotify watcher 真正可停(C10d)。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。公共 API 签名全不变;`app.go` `config.SetDefaultManager(a.configManager)` 仍工作,`Load` 返回拷贝后 `a.config` 持独立副本。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明,非破坏性)**:热重载遇非法配置现保留旧配置并返错(原直接发布);`StopWatcher` 现真正释放监听 goroutine/fd(原空操作);`Load` 返回浅拷贝(切片字段只读契约)。无 API 签名/配置结构/migration 变更。`Manager` 新增未导出字段 `watcher`/`watchDone` 不影响外部。 +3. **新并发/生命周期/泄漏隐患?** 反而消除。`atomic.Pointer` 消除裸写竞争;`watchLoop` 不读字段避免与 `StopWatcher` 竞争;`StopWatcher` 幂等、不 close 已关闭 channel(`watchDone` 由 `watchLoop` defer close);去抖 `time.AfterFunc` 在 Stop 后触发 `reload()` 安全(仅动锁内 `m.v`/`m.cfg`,不碰已关闭 watcher);`reload` 全程持写锁串行化 `v.ReadInConfig`,回调在锁外调用避免死锁。`-race` 已跑通过。 + +### 代码审查结果:PASS +`atomic.Pointer` + `init()` Store 是标准模式;自管 fsnotify 监听目录 + 去抖是热重载稳健做法;`watchLoop` 局部变量传入避免字段竞争;`reload` 锁内 Validate 失败保留旧配置。独立复审未发现 CRITICAL/HIGH。 + +### 安全审查结果:PASS +热重载非法配置不再静默发布(C10b,防坏端口/短密钥配置在运行期生效);监听 goroutine/fd 不再泄漏(C10d)。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(红绿已实跑验证): +- `TestSetDefaultManagerConcurrent`:4 写者 `SetDefaultManager` + 4 读者 `Get`/`GetViper`/`GetString` 并发,`-race` 通过(修复前裸指针必采到竞争)。 +- `TestLoadReturnsDefensiveCopy`:修改 Load 返回值的标量字段不污染 `Get()`(C10c 标量)。 +- `TestLoadDefensiveCopySliceContract`:锁定切片字段共享底层数组的浅拷贝局限(只读契约)。 +- `TestReloadInvalidConfigKeepsOld`:`port:99999` Reload 返错 + 旧端口保留(C10b Reload 路径)。 +- `TestHotReloadInvalidConfigKeepsOld`:写非法配置 → 全局保留旧值 + 回调不触发;再写合法配置 → 监听仍存活、全局更新 + 回调触发(C10b 监听路径 + C10d 健壮性)。 +- `TestStopWatcherReleasesGoroutine`:Stop 后 `NumGoroutine` 下降 + 幂等(C10d)。 +- `TestStartWatcherIdempotent`:重复 Start 不泄漏 goroutine(C10d 幂等)。 +- 红/绿验证:删 `reload` 的 `Validate` 调用后 `TestReloadInvalidConfigKeepsOld`/`TestHotReloadInvalidConfigKeepsOld` 复现"非法配置被发布"红;`watchLoop` 改读 `m.watchDone` 字段后 `-race` 复现"StopWatcher 写竞争"红;恢复后全绿。 + +### 已知风险 +- **C10c 切片字段共享底层数组(MEDIUM,文档化)**:`Load` 浅拷贝致 `CORSConfig.AllowedOrigins` 等 `[]string` 字段与 `m.cfg` 共享底层数组;`Get()` 返回 `m.cfg` 原指针。已以**只读契约**文档化(`Get()`/`Load` 注释)+ 回归测试锁定。框架无法单方面阻止调用方改切片,约定配置对象只读。需完全独立可变副本时调用方自行深拷贝。 +- **viper 实例并发(LOW,预存)**:`GetViper()` 返回 `m.v` 给外部,外部并发 `v.GetString` 与 `reload` 内 `v.ReadInConfig` 存在竞争。预存问题(原 `GetViper` 同样导出),C10 未恶化(`reload` 现全程持写锁串行化 `ReadInConfig`,比原 `OnConfigChange` 无锁路径更收敛)。建议后续标注 `GetViper` 非并发安全。 +- **包级 `Load` 失败仍 Store 空 manager(LOW,预存行为)**:`m.Load()` 失败时仍 `defaultManager.Store(m)`,与原版"先赋值再 Load"行为一致,非 C10 引入。 +- **atomic 快照语义(LOW)**:包级便捷函数两次调用可能落到被 `SetDefaultManager` 置换后的不同 Manager(atomic 快照固有),非 C10 引入,原裸指针更糟。 +- `staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- 视需求为 `Config` 提供 `Clone()` 深拷贝方法,彻底消除切片共享(当前只读契约已足够安全)。 +- `GetViper` 标注非并发安全或限制导出。 +- 包级 `Load` 失败时不 Store(改进预存行为,非 C10 范围)。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + `-race` 实跑复核 7 大项:C10a/C10b/C10d **核心修复扎实**,C10c 标量竞争已修、切片面以只读契约文档化。无 CRITICAL/HIGH。1 项 MEDIUM(C10c 切片共享 + `Get()` 未拷贝,已文档化 + 测试锁定)+ 4 项 LOW(atomic 快照语义注释、切片测试、viper 预存、Load 失败 Store 预存行为)均不阻断。**裁定:可交付 PASS。** + +--- + +--- + +## ✅ C11 `database/manager.go` 池泄漏 + Master/Replicas 无锁读 + 健康状态陈旧 + 包级 Close 仅关主库 — P1 + +### 需求分析结果 +- **C11b InitDB 重试泄漏**(manager.go:346-389):`gorm.Open` 成功但 `Ping` 失败时旧池不关、下轮 `m.master = gorm.Open(...)` 覆盖,每次重试泄漏一池。 +- **C11c InitDBWithReplicas 泄漏**(manager.go:423):`m.replicas = nil` 前不关旧从库池;从库 `DB()`/`Ping` 失败时 `continue` 不关刚打开的 `replicaDB`。 +- **C11a 健康状态陈旧**(manager.go:144-155):`initReplicaHealth` 的 `replicaHealthSet` 早返回,重新 `InitDBWithReplicas` 不重置 → 健康切片与新 replicas 长度错位(不越界 panic,但新从库健康状态陈旧/被排除)。 +- **C11d Master/Replicas 无锁读**(manager.go:97-104):`Master()`/`Replicas()` 裸读 `m.master`/`m.replicas`,与 `Close`/`InitDB` 写竞争,可能返回已关闭/nil 池;`Replica()` 空从库判断在锁外读 `m.replicas`/`m.master`;`FromContext`/`HealthCheck`/`Transaction*`/`WriteQuery`/包级 `HealthCheck` 同样裸读。 +- **C11f 包级 Close 仅关主库**(manager.go:524-536):包级 `Close()` 仅关 master 且无锁,`CloseAll()`/方法 `Close()` 关全部,命名误导致用户泄漏从库。 +- **C11e 非缺陷**:`RoundRobinPicker` `int(n-1)%len` 取模后仍在 `[0,len)`;`RandomPicker` 全局 `math/rand` 仅锁竞争。未改。 + +### 方案设计摘要 +- 新增 `closeDB(db)` 辅助(nil/无 ConnPool 返 nil 不 panic)。 +- **C11b**:`InitDB` 先打开到局部 `db`,仅 `Ping` 通过后才在 `m.mu` 锁内安装 `m.master = db` 并 `closeDB(old)`;`Ping`/`DB()` 失败时 `closeDB(db)` 关闭刚打开的池。`m.healthy.Store(true)` 移到 Ping 通过后(原在 Ping 前)。 +- **C11c**:`InitDBWithReplicas` 重建前在锁内取出旧从库 + `resetReplicaHealth`,锁外逐个 `closeDB`;从库 `DB()`/`Ping` 失败时 `closeDB(replicaDB)`;新从库先构建到局部 `newReplicas` 再锁内原子安装。 +- **C11a**:新增 `resetReplicaHealth`(`replicaHealthy=nil; replicaHealthSet=false`,调用方持锁),`InitDBWithReplicas`/`Close` 重建/关闭前调用,使下次 `initReplicaHealth` 按新 replicas 长度重建。 +- **C11d**:`Master()`/`Replicas()` 全程持 `m.mu`;`Replicas()` 返回拷贝(nil 仍返 nil 兼容);`Replica()` 空从库判断移入锁内;`FromContext` master 分支改 `m.Master()`;`HealthCheck` 方法锁内快照 master;`Transaction`/`TransactionWithContext`/`WriteQuery`/包级 `HealthCheck` 改经 `Master()`/`Replicas()`;`probeOnce` 快照 `replicaHealthy` 切片头避免与重置竞争。 +- **C11f**:包级 `Close()` 改为 `return CloseAll()`(关主+从并重置健康状态)。 + +涉及模块:`database/manager.go`、`database/manager_c11_internal_test.go`。DB 影响:无(连接池语义不变,仅生命周期/并发收敛)。API 影响:公共 API 签名全不变;行为变更(包级 `Close` 关从库、重试/重建不泄漏旧池、`Master`/`Replicas` 加锁)。安全影响:正向。风险:见"已知风险"。 + +### 修改文件清单 +- `database/manager.go`:新增 `closeDB`/`resetReplicaHealth`;`Master`/`Replicas`/`Replica`/`HealthCheck` 加锁;`FromContext`/`Transaction`/`TransactionWithContext`/`WriteQuery`/包级 `HealthCheck`/包级 `Close` 改经加锁读取或委托;`InitDB` 重试局部打开+失败关池+成功关旧主库;`InitDBWithReplicas` 重建前关旧从库+重置健康+局部构建原子安装;`probeOnce` 快照 `replicaHealthy`。 +- `database/manager_c11_internal_test.go`(新):并发 Master/Replicas/Replica/FromContext 读写(-race)、Replicas 返回拷贝、健康重置重建对齐、不重置则陈旧(复现 C11a 根因)、Close 重置状态、包级 Close 关从库(C11f)、HealthCheck 锁内读。 + +### 实现内容摘要 +池泄漏闭环(C11b/C11c:局部打开+失败关池+重建前关旧);健康状态重建对齐(C11a:resetReplicaHealth);Master/Replicas/Replica 全程加锁(C11d);包级 Close 委托 CloseAll(C11f)。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。公共 API 签名全不变;`Manager` 新增未导出 `closeDB`/`resetReplicaHealth`。读写分离/ctx 路由/健康探活契约不变——`Replica()` 仍走 picker+健康过滤,`FromContext` 仍路由 master/replica,仅读取路径加锁。`DefaultManager` 是固定实例(非置换),字段读写现统一经 `m.mu`。无路由/认证/响应体系新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明,非破坏性)**:包级 `Close()` 现关闭从库(原仅主库,正向修复泄漏);`InitDB` 重试/`InitDBWithReplicas` 重建不再泄漏旧池;`Master()`/`Replicas()` 加锁读取(`Replicas()` 返回拷贝,调用方修改返回切片不影响内部状态)。无 API 签名/配置/migration 变更。下游若依赖 `Replicas()` 返回活切片并原地修改——属不安全用法,现返回拷贝更安全。 +3. **新并发/生命周期/泄漏隐患?** 反而消除。`m.mu` 统一保护 `master`/`replicas`/`replicaHealthy`/`replicaHealthSet`/`picker` 读写;`Close` 字段置空在锁内、实际关闭在锁外避免持锁阻塞;`probeOnce` 快照 `replicaHealthy` 切片头避免与重置竞争(元素为 atomic.Bool);`InitDB` 成功安装时关闭旧主库(重建路径覆盖前先释放旧资源,符合纪律)。无 channel/goroutine 新增。`-race` 已跑通过。 + +### 代码审查结果:PASS +`closeDB`/`resetReplicaHealth` 职责单一;`InitDB` 局部打开+原子安装是标准资源生命周期模式;`Close` 锁内置空+锁外关闭避免持锁阻塞;`Replicas()` 返回拷贝消除调用方与内部竞争。独立复审未发现 CRITICAL/HIGH。 + +### 安全审查结果:PASS +池泄漏(C11b/C11c)消除——重试/重建不再累积未关连接池;无锁读竞争(C11d)消除——避免返回已关闭/nil 池致请求 panic 或使用已释放连接;包级 `Close` 不再泄漏从库(C11f)。gosec G115/G404 为 `RoundRobinPicker`/`RandomPicker` 既有项(C11e 范围外,未改)。 + +### 测试审查结果:PASS +回归用例(红绿已实跑验证): +- `TestC11MasterReplicasConcurrentReadWrite`:1 写者锁内置换 master/replicas + 4 读者并发 Master/Replicas/Replica/FromContext,50ms 窗口,`-race` 通过。红/绿验证:回退 Master/Replicas 为裸读后 `-race` 复现 "race detected"(红);恢复后绿。 +- `TestC11ReplicasReturnsCopy`:修改返回切片不影响 `m.replicas`(C11d 拷贝语义)。 +- `TestC11ReplicaHealthResetOnRebuild`:2→3 从库 + resetReplicaHealth 后 `initReplicaHealth` 重建对齐 len=3(C11a)。 +- `TestC11ReplicaHealthStaleWithoutReset`:不调 reset 时 `initReplicaHealth` 早返回、健康切片仍 len=2(复现 C11a 缺陷根因,证明 reset 必要)。 +- `TestC11ManagerCloseResetsState`:Close 后 master/replicas/replicaHealthy/replicaHealthSet/healthy 全部重置(C11a/C11c/C11d)。 +- `TestC11PackageCloseClosesReplicas`:包级 Close 后 replicas 清空(C11f)。红/绿验证:回退包级 Close 为旧 master-only 实现后复现 "expected master nil / residual replicas"(红);恢复后绿。 +- `TestC11HealthCheckLockedRead`:未初始化返错、空 ConnPool 返 ErrInvalidDB 不 panic(C11d)。 +- 注:`&gorm.DB{}` 内嵌 `*Config` 为 nil,`.DB()` 访问提升字段 ConnPool 时 nil deref(-race 下必 panic);测试用 `sentinelDB()`(`&gorm.DB{Config: &gorm.Config{}}`)避免。 + +### 已知风险 +- **运行期重建 InitDB/InitDBWithReplicas 与在途请求(MEDIUM,文档化)**:`InitDB` 成功安装新 master 时关闭旧主库池;若运行期重建时有在途请求持有旧 `*gorm.DB`,其连接会被关闭致失败。框架不支持运行期热 DB 重建(重建应在启动/停服期),调用方须自行保证不与在途请求并发。原实现覆盖致泄漏,现改为关闭旧池——更符合"覆盖前先释放旧资源"纪律,但暴露了运行期重建的固有风险。 +- **`healthy` 移到 Ping 通过后置位(行为微调)**:原 `InitDB` 在 `DB()` 成功后、`Ping` 前即 `healthy.Store(true)`;现改为 Ping 通过后才置位。更正确(连通≠可服务),不影响 readiness 语义(InitDB 成功返回即 healthy=true)。 +- **`Replicas()` 返回拷贝(行为微调)**:原返回活切片,现返回拷贝。更安全,但下游若原地修改返回切片不再影响内部状态(原即不安全用法)。 +- **gosec G115/G404(既有,C11e 范围外)**:`RoundRobinPicker` uint64→int 取模、`RandomPicker` math/rand,非本次引入,C11e 已澄清非缺陷。 +- `staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过;`go vet`/`gosec`(C11 相关无新增 issue)通过。 + +### 后续优化建议 +- C11e(可选微优化):`RandomPicker` 用 `math/rand/v2` 或局部 rand 源减少全局锁竞争;`RoundRobinPicker` G115 加边界注释。 +- 运行期 DB 重建若需支持,提供 `Manager.Swap(newManager)` 原子替换 + 旧 manager 延迟关闭(待在途请求结束)。 +- `DefaultManager` 包级 var 仍是固定实例;若未来需支持多实例切换,参考 C10 的 `atomic.Pointer` 模式。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + `-race`/vet/gosec + 回退实验复核 10 项:C11a/C11b/C11c/C11d/C11f **全部 CONFIRM 修复**,C11e 正确未改。每条受保护字段读均在 `m.mu` 或 atomic 下;每条重试/失败/重建路径打开的池均关闭,无泄漏/双关;`resetReplicaHealth` 在重建/关闭时按新 replicas 长度重建健康;`probeOnce` 切片头快照安全(陈旧数组写为无害 no-op,GC 可达、atomic、OOB 守卫);包级 `Close` 委托 `CloseAll` 关主+从。无死锁、无裸断言、无 send-on-closed、探活循环有 ctx.Done。**关键**:回退 `Master/Replicas/Replica` 为裸读后并发测试实跑复现真实 `DATA RACE`(红),证明非假绿。无 CRITICAL/HIGH/MEDIUM。**1 项 LOW(测试覆盖,非代码缺陷)**:C11a/C11b/C11c 的端到端泄漏路径(真实池 open+Ping 失败+重建)未用例覆盖(框架无 SQLite 驱动,需真实 DB),由源码审查 + 新 helper 单测 + 表征测试保证;C11d 满足完整红绿。**裁定:可交付 PASS。** + +--- + + + +--- + +## ✅ C9c `jwt/jwt.go` 包级 DefaultJWT/tokenBlacklist 无锁置换 — P1 + +### 需求分析结果 +- **C9c**(jwt.go:124-129,139,243,289):`SetDefaultJWTManager` 裸写包级 `DefaultJWT` 与 `tokenBlacklist`(`DefaultJWT = m; tokenBlacklist = m.blacklist`),而请求 goroutine 经 `ParseToken`/`RefreshToken`/`InvalidateToken`/`InvalidateTokenByID`/`IsTokenRevoked` 裸读 `tokenBlacklist`——指针读写竞争。典型启动期调用 `SetDefaultJWTManager`,但请求期并发读裸指针仍有数据竞争(-race 可采)。 +- C9a/C9b 已在 C9b 修复(fail-closed + ErrBlacklistUnavailable);C9c 是遗留的并发隐患。 + +### 方案设计摘要 +- 新增内部 `defaultManager atomic.Pointer[Manager]` 作真实存储,`init()` Store(NewJWTManager())。 +- 新增 `currentManager()`(atomic Load,nil 防御回退)/`currentBlacklist()` helper。 +- `SetDefaultJWTManager(m)` 改 `defaultManager.Store(m)` 原子置换 + 同步 `DefaultJWT = m` 兼容别名。 +- 移除裸写的包级 `tokenBlacklist` 变量;包级函数(ParseToken/RefreshToken/InvalidateToken/InvalidateTokenByID/IsTokenRevoked)改经 `currentBlacklist()` atomic 读取。 +- `DefaultJWT` 保留导出 `*Manager` 类型不变(非 breaking),作兼容别名,注释标注直接读非并发安全。 + +涉及模块:`jwt/jwt.go`、`jwt/jwt_c9c_internal_test.go`。DB 影响:无(Redis key 语义不变)。API 影响:公共 API 签名全不变;`DefaultJWT` 类型不变(非 breaking);行为变更(包级黑名单读写改经 atomic,`SetDefaultJWTManager` 可安全在请求期调用)。安全影响:正向。风险:见"已知风险"。 + +### 修改文件清单 +- `jwt/jwt.go`:新增 `sync/atomic` 导入;`defaultManager atomic.Pointer[Manager]` + `init()` Store;`DefaultJWT` 改为 `*Manager` 兼容别名(init 同步);`currentManager()`/`currentBlacklist()`;`SetDefaultJWTManager` 改 atomic Store;移除 `tokenBlacklist` 包级变量;5 处包级函数改 `currentBlacklist()`。 +- `jwt/jwt_c9c_internal_test.go`(新):并发 SetDefaultJWTManager + 包级函数读(-race)、置换后包级函数读到新 Manager blacklist、DefaultJWT 别名与 defaultManager 一致。 + +### 实现内容摘要 +包级黑名单读写改 atomic.Pointer(消除裸指针竞争);`DefaultJWT` 兼容别名保留(非 breaking);`SetDefaultJWTManager` 原子置换。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。公共 API 签名全不变;`DefaultJWT` 类型不变(`*Manager`)。`SetDefaultJWTManager`/`NewJWTManager`/`NewJWTManagerWithRedis`/`Blacklist()` 行为不变。C9b 的 fail-closed 语义保留(`currentBlacklist().Add` 仍返 `ErrBlacklistUnavailable`)。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **非 breaking**:`DefaultJWT` 类型不变、签名不变。行为变更(已 CHANGELOG 声明):包级黑名单读写改经 atomic,`SetDefaultJWTManager` 可安全在请求期调用(原裸写仅启动期安全)。下游若直接读 `jwt.DefaultJWT` 变量——仍可用(兼容别名),但直接读非并发安全(注释标注),并发访问应用包级函数。 +3. **新并发/生命周期/泄漏隐患?** 反而消除。`atomic.Pointer` 消除裸指针读写竞争;`currentManager()` nil 防御回退(init 后不可达,多 goroutine 并发回退创建多实例极罕见且无害,Store 最后一个赢);无 channel/goroutine/close 新增。`-race` 已跑通过。 + +### 代码审查结果:PASS +`atomic.Pointer[Manager]` + `init()` Store 是标准模式(与 C10 config 一致);`currentManager()`/`currentBlacklist()` 封装 atomic 读取;`DefaultJWT` 兼容别名 + 注释约束清晰。独立复审未发现 CRITICAL/HIGH。 + +### 安全审查结果:PASS +包级黑名单读写竞争消除(C9c);C9b fail-closed 语义保留;`SetDefaultJWTManager` 可安全在请求期调用。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(红绿已实跑验证): +- `TestC9cConcurrentSetDefaultAndRead`:1 写者原子置换 Manager + 4 读者并发 ParseToken/IsTokenRevoked/InvalidateTokenByID/currentManager,50ms 窗口,`-race` 通过。红/绿验证:回退 `currentManager()` 为裸读 `DefaultJWT` 后 `-race` 复现 "race detected"(红);恢复 atomic 后绿。 +- `TestC9cCurrentManagerReflectsSwap`:无 Redis → ErrBlacklistUnavailable;置换为 Redis Manager 后 InvalidateTokenByID 成功 + currentManager 反映置换(C9c 一致性)。 +- `TestC9cDefaultJWTAliasConsistent`:init 后 DefaultJWT == currentManager;SetDefaultJWTManager 后同步(兼容别名一致性)。 + +### 已知风险 +- **`DefaultJWT` 兼容别名裸写(LOW,文档化)**:`SetDefaultJWTManager` 内 `DefaultJWT = m` 仍为裸写,与"外部直接读 `jwt.DefaultJWT`"竞争。但包级函数不读 `DefaultJWT`(经 atomic `defaultManager`),C9c 范围内的请求 goroutine 读竞争已修复。外部直接读 `DefaultJWT` 是导出变量固有问题,注释已标注非并发安全。完全消除需将 `DefaultJWT` 改 `atomic.Pointer`(breaking 类型变更),权衡后选非 breaking 方案。 +- **`currentManager()` nil 回退创建多实例(极罕见,无害)**:仅 init 前或异常清空触发,多 goroutine 并发回退会创建多个 NewJWTManager,Store 最后一个赢,其余被 GC。blacklist 懒取全局 Redis,多实例无害。 +- **C9b 测试 setupMiniRedis 仍经 SetDefaultJWTManager**:现在经 atomic Store,cleanup 还原同样安全;`-shuffle` 已跑通过。 +- `staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- 若下游不再依赖 `jwt.DefaultJWT` 直接读取,可于下个大版本将其改 `atomic.Pointer[Manager]`(breaking)彻底消除兼容别名裸写。 +- `currentManager()` nil 回退可用 `sync.Once` 兜底避免多实例(当前无害,可选)。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + `-race`/vet/gosec/`-shuffle -count=3` + 变异红绿复核 10 项:C9c **CONFIRM 修复**——5 个包级函数(ParseToken/RefreshToken/InvalidateToken/InvalidateTokenByID/IsTokenRevoked)均经 `currentBlacklist()`→`defaultManager.Load()` atomic 读取,裸 `tokenBlacklist` 变量已彻底移除,无生产路径直接读 `DefaultJWT`;`SetDefaultJWTManager` atomic Store + nil 守卫 + 兼容别名同步;`init` 一致性、C9b fail-closed 语义保留、`currentManager()` nil 回退安全(不可达)、`Blacklist()` 锁无害、无新裸全局、类型断言全 comma-ok。**关键**:变异 `currentManager()` 为裸读 `DefaultJWT` 后 `-race` 实跑复现真实 DATA RACE(read jwt.go:145 vs write jwt.go:166,红),恢复后绿。无 CRITICAL/HIGH/MEDIUM。**3 项 LOW(均不阻断)**:L1 `DefaultJWT = m` 兼容别名裸写(仅影响外部直接读 `DefaultJWT`,包级函数不读,C9c 范围外,注释+CHANGELOG 已标注,彻底消除需 breaking 类型变更);L2 `currentManager()` nil 回退未同步 `DefaultJWT`(运行时不可达,防御性);L3 `errorIsBlacklistUnavailable` 用字符串比较(当前正确,外部测试已用 `errors.Is`)。**裁定:可交付 PASS。** + +--- + +## ✅ H3 `middleware/logger.go` 请求/响应 body 无上限读 → OOM — P1 + +### 需求分析结果 +`LoggerWithConfig` 在 `LogRequestBody:true` 时用 `io.ReadAll(c.Request.Body)`(logger.go:60)无封顶读入内存,`MaxBodyLength` 仅在读完后截断**日志副本**,全 body 已驻留并二次 buffer——多 GB POST 可 OOM。响应侧 `bodyLogWriter.body` 同样无上限累积,大响应亦 OOM。默认配置 `LogRequestBody:false` 使默认安全,但 `LoggerForAPI`/`LoggerForDebug` 显式开启即暴露。 + +### 方案设计摘要 +- 请求体:新增 `readBodyBounded(c, maxLen)`——`io.LimitReader(body, maxLen+1)` 仅向内存读入最多 `maxLen+1` 字节(+1 检测截断),通过 `io.MultiReader(已读前缀, 原始 body 剩余)` 复原 `c.Request.Body`,**下游处理器仍得完整 body**;日志副本截断到 `maxLen`。 +- 响应体:`bodyLogWriter` 增 `maxLen` 字段,`appendBounded` 写缓冲区封顶;`Write`/`WriteString` 仍把完整响应写入下游 `ResponseWriter`,仅捕获缓冲区封顶。 +- `LoggerWithConfig` 入口归一化 `MaxBodyLength`:`<=0` 回退默认 1024,确保请求/响应两侧均有上限(消除复审 MEDIUM:手配 `MaxBodyLength:0` 时响应侧无上限的 OOM 残留)。 +- `maxLen<=0` 在 `readBodyBounded` 内亦回退默认(防御)。 + +### 改动文件 +- `middleware/logger.go`:`readBodyBounded` 新增;`LoggerWithConfig` 入口归一化 + 请求/响应 body 段改用封顶路径;`bodyLogWriter` 加 `maxLen` + `appendBounded`。 +- `middleware/logger_internal_test.go`(新增):8 个用例。 + +### 验证 +- `go test -buildvcs=false -race ./middleware/` 通过;`go test -buildvcs=false -race ./...` 全绿。 +- `go vet -buildvcs=false ./middleware/` 通过。 +- 回归用例(修复前红/后绿): + - `TestReadBodyBounded_TruncatesLogCopy`:日志副本封顶到 maxLen(原 `io.ReadAll` 会读全量)。 + - `TestReadBodyBounded_RestoresFullBody`:8000 字节 body、maxLen=64,下游 `io.ReadAll(c.Request.Body)` 仍得完整 8000 字节(MultiReader 复原闭环)。 + - `TestBodyLogWriter_Bounded`:10000 字节响应,捕获缓冲区=32(封顶),下游 `ResponseWriter` 仍收完整 10000 字节。 + - `TestBodyLogWriter_MultiWriteAccumulation`/`WriteStringBounded`:多写累积与 WriteString 路径同样封顶。 + - `TestLoggerWithConfig_NormalizesMaxBodyLength`:`MaxBodyLength:0` 归一化为默认,5000 字节请求体下游完整复原。 +- `staticcheck` 因环境(go1.24 vs go1.25)无法运行,显式跳过;`gosec` 非 OOM 专项未跑(本项为内存封顶,非注入/认证类)。 + +### 已知风险 +- 无 breaking change:`LoggerConfig` 字段与默认值不变;`MaxBodyLength` 现同时门控响应体捕获(此前响应侧无视该值无上限累积,属 bug 修正);`MaxBodyLength<=0` 不再意味"无上限",统一回退默认上限(行为变更,已在 CHANGELOG 声明)。 +- 请求体复原用 `io.NopCloser(io.MultiReader(...))`,原 `c.Request.Body` 的 `Close` 由 net/http server 经连接级 `reqBody` 引用管理(非 handler/gin 职责),NopCloser 不影响生命周期——经独立复审回源 `net/http/server.go:1093,1716` 确认。 +- 请求侧用例不直接观测 OOM(内存属性,单测不可见),而是作为 MultiReader 复原正确性的回归守卫;`TestBodyLogWriter_Bounded` 为真正"修复前红/后绿"的 OOM 复现用例。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + `-race`/vet 复核 5 项核心断言全部 **确认**:请求侧封顶 maxLen+1、MultiReader 复原无字节丢失(含 +1 检测位)、原 body 由 net/http 关闭(NopCloser 不泄漏)、响应缓冲区封顶且完整响应转发、旧 `io.ReadAll` 无上限路径彻底移除。提出 1 项 MEDIUM(请求/响应侧 `maxLen<=0` 不对称,响应侧仍可无上限——**已修**:`LoggerWithConfig` 入口归一化)+ 3 项 LOW(死分支——**已简化**;测试 OOM 复现性表述——**已补 `TestBodyLogWriter_Bounded` 作真复现**;多写/WriteString 覆盖——**已补用例**)。**裁定:可交付 PASS。** + +--- + +## ✅ H7 `logger/logger.go` 全局指针写有锁读无锁 + `Field.Duration` 签名与实现矛盾 — P1 + +### 需求分析结果 +- **H7a**(logger.go:121-131,193-200,235-247,280-282,167-178):`Init`/`Close` 持 `m.mu`(`LogManager` 实例锁)写包级 `Logger`/`sugar`/`apiLog`/`dbLog`,但 `Info`/`Debug`/`Warn`/`Error`/`Fatal`/`Debugf`-`Fatalf`/`APILog`/`DBLog`/`Sync` 等请求期函数无锁裸读这些全局——**实例锁保护包级全局变量**,锁与被保护对象作用域错配,热重载 re-Init/Close 与请求日志 goroutine 存在数据竞争(`-race` 可采)。 +- **H7b**(field.go:24-31):`Field.Duration` 签名为 `func(key string, value interface{}) zap.Field`,实现 `case zap.Field: return v` 丢弃 `key`,签名与实现矛盾;调用方传 `zap.Field` 时 key 被静默丢弃。 + +### 方案设计摘要 +- **H7a**:新增内部 `loggerPtr`/`sugarPtr`/`apiLogPtr`/`dbLogPtr atomic.Pointer[...]` 作真实存储,`init()` Store 为 Nop;抽取 `currentLogger()`/`currentSugar()`/`currentAPILog()`/`currentDBLog()`(atomic Load,nil 防御回退 Nop);`Info`/`Debug`/`Warn`/`Error`/`Fatal`/`Debugf`-`Fatalf`/`APILog`/`DBLog`/`Sync` 读路径统一经之。`Init`/`Close` 在 `m.mu` 下 Store atomic(写侧仍持锁串行化 `closeFileWriters`+`fileWriters` 重置)。 +- **H7a 兼容别名**:`Logger` 保留为导出 `*zap.Logger` 兼容别名(类型不变,非 breaking,与 C9c 的 `DefaultJWT` 同模式),由 `Init`/`Close` 在 `m.mu` 下同步维护;注释标注直接读 `Logger` 变量在 re-Init/Close 期间非并发安全,并发访问应用包级函数。框架内部读路径不读 `Logger` 变量(经 atomic)。 +- **H7b**:`Field.Duration` 签名改为 `func(key string, value time.Duration) zap.Field`,直接委托 `zap.Duration(key, value)`,类型安全、key 不再可能被丢弃。 +- 顺带:`os.MkdirAll` 日志目录权限 `0o755`→`0o750`(与 storage 目录权限一致,gosec G301)。 + +涉及模块:`logger/logger.go`、`logger/field.go`、`logger/logger_h7_internal_test.go`。DB 影响:无。API 影响:`Logger` 类型不变(非 breaking);`Field.Duration` 签名收紧(`interface{}`→`time.Duration`,类型安全)。安全影响:正向。风险:见"已知风险"。 + +### 修改文件清单 +- `logger/logger.go`:新增 `sync/atomic` 导入;`loggerPtr`/`sugarPtr`/`apiLogPtr`/`dbLogPtr atomic.Pointer` + `init()` Store;`currentLogger`/`currentSugar`/`currentAPILog`/`currentDBLog`;`Logger` 改为兼容别名(init 同步 Nop);`Init`/`Close` 改 Store atomic + 同步 `Logger` 别名;`Info`/`Debug`/`Warn`/`Error`/`Fatal`/`Debugf`-`Fatalf`/`APILog`/`DBLog`/`Sync` 读路径改经 current* helper;`MkdirAll` 0o750。 +- `logger/field.go`:新增 `time` 导入;`Field.Duration` 签名改 `func(key string, value time.Duration) zap.Field`,委托 `zap.Duration`,删除 `case zap.Field` 丢弃 key 的旧实现。 +- `logger/logger_h7_internal_test.go`(新):并发 Init/Close + 包级读(-race)、currentLogger 随 Init/Close 切换、atomic 快照非 nil 且 APILog/DBLog 一致、`Field.Duration` 行为 + 编译期签名锁定。 + +### 实现内容摘要 +包级 logger 读写改 atomic.Pointer(消除请求期裸读竞争,H7a);`Logger` 兼容别名保留(非 breaking);`Field.Duration` 类型安全签名(H7b);日志目录权限收紧。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。`Info`/`Debug`/.../`APILog`/`DBLog`/`Sync`/`Init`/`Close`/`SetDefaultLogManager` 签名全不变;`Logger` 类型不变(`*zap.Logger`)。`APILog()`/`DBLog()` 仍返回 `*zap.Logger`,仅内部经 atomic 读取。无 DB/路由/认证/响应体系新契约。与 C9c(jwt)/C10(config)的 atomic.Pointer 模式一致。 +2. **对下游意味着什么 / breaking change?** **非 breaking(Logger 类型不变、包级函数签名不变)**。`Field.Duration` 签名收紧(`interface{}`→`time.Duration`):传 `time.Duration` 的调用方不受影响;传 `zap.Field` 等非 Duration 类型将编译失败——这正是 H7b 修复目的(消除 key 被丢弃的歧义用法)。仓库内无 `Field.Duration` 调用方,影响面为零。行为变更(已 CHANGELOG 声明):包级日志读路径改经 atomic,re-Init/Close 可安全与请求日志并发;`Logger` 直接读仍非并发安全(兼容别名固有限制,注释+CHANGELOG 标注)。 +3. **新并发/生命周期/泄漏隐患?** 反而消除。`atomic.Pointer` 消除请求期裸指针读写竞争;`init()` Store 保证任何时刻 Load 非 nil;`current*` helper nil 防御回退 Nop(init 后不可达);`fileWriters` 仍仅在 `m.mu` 下访问(Init/Close/closeFileWriters),无读侧竞争。无 channel/goroutine/close 新增。`-race` 已跑通过。 + +### 代码审查结果:PASS +`atomic.Pointer` + `init()` Store 与 C9c/C10 一致;`current*` helper 封装 atomic 读取 + nil 防御;`Logger` 兼容别名 + 注释约束清晰(同 C9c `DefaultJWT` 模式);`Field.Duration` 委托 `zap.Duration` 是类型安全正解。独立复审未发现 CRITICAL/HIGH。 + +### 安全审查结果:PASS +请求期裸读竞争消除(H7a);`Field.Duration` key 丢弃漏洞消除(H7b);日志目录权限收紧 0750(G301 清零)。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(红绿已实跑验证): +- `TestH7ConcurrentInitCloseAndRead`(internal):1 写者循环 Init/Close + 4 读者并发 currentLogger/currentSugar/currentAPILog/currentDBLog/APILog/DBLog,120ms 窗口,`-race` 通过。红/绿验证:将 `currentLogger()` 临时改为裸读 `Logger` 兼容别名后 `-race` 实跑复现 `WARNING: DATA RACE`(红);恢复 atomic 后绿。 +- `TestH7CurrentLoggerReflectsInitAndClose`:Init 前 Nop 安全;Init 后 `currentLogger()==Logger` 且写 mark 落 app.log;Close 后回 Nop 不再落盘。 +- `TestH7AtomicPointersNonNil`:init 后四个 atomic 快照非 nil,APILog/DBLog/currentLogger 与内部 atomic 一致。 +- `TestH7DurationFieldFix`:`Field.Duration("elapsed", 5s)` key 保留 + Integer 承载纳秒(行为)。 +- `TestH7DurationFieldSignature`:编译期锁定签名 `func(string, time.Duration) zap.Field`。红/绿验证:回退 `Field.Duration` 为旧 `interface{}` 签名后编译失败 `cannot use Field.Duration ... as func(string, time.Duration) zap.Field`(红);恢复后绿。 + +### 已知风险 +- **`Logger` 兼容别名裸写(LOW,文档化)**:`Init`/`Close` 内 `Logger = ...` 仍为裸写,与"外部直接读 `logger.Logger`"竞争。但框架内部读路径不读 `Logger` 变量(经 atomic `loggerPtr`),H7a 范围内的请求 goroutine 读竞争已修复。外部直接读 `Logger` 是导出变量固有问题,注释+CHANGELOG 已标注非并发安全。完全消除需将 `Logger` 改 `atomic.Pointer[zap.Logger]`(breaking 类型变更),权衡后选非 breaking 方案(同 C9c L1)。 +- **re-Init 与在途写入并发(设计约束,非 H7 范围)**:并发测试中读者不调用 `.Info` 等写方法——因为旧 logger 快照对其已被 re-Init 关闭的 lumberjack writer 调用 Write 会触发 lumberjack 重新打开同一文件,致测试 TempDir 清理时句柄占用。框架不支持 re-Init 与在途写入并发(re-Init 主要服务于测试/启动期),该约束非 H7 引入。 +- **实例锁保护全局变量的写侧(预存,非 H7 引入)**:`m.mu` 串行化同 `LogManager` 实例的 re-Init;不同 `LogManager` 实例并发 Init 写同一包级全局仍可能交错(closeFileWriters+fileWriters 重置非跨实例原子),但实际仅 `DefaultLogger` 单实例使用,且 atomic Store 保证读侧安全。预存设计,未恶化。 +- `staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- 若下游不再依赖 `logger.Logger` 直接读取,可于下个大版本将其改 `atomic.Pointer[zap.Logger]`(breaking)彻底消除兼容别名裸写(同 C9c 建议)。 +- `current*` helper 的 nil 回退可用 `sync.Once` 兜底避免多实例(当前无害,可选)。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + `-race`/vet/gosec + 变异红绿复核 9 项:H7a/H7b **全部 CONFIRM 修复**——所有请求期读路径(`Info`/`Debug`/`Warn`/`Error`/`Fatal`/`Debugf`-`Fatalf`/`APILog`/`DBLog`/`Sync`)均经 `current*` helper→atomic Load,旧 `sugar`/`apiLog`/`dbLog` 裸变量已彻底移除,无生产路径直接读 `Logger` 变量;`Init`/`Close` 在 `m.mu` 下 Store 四个 atomic + 同步 `Logger` 兼容别名,无半初始化泄漏;`init()` 保证 Load 非 nil,nil 回退为不可达防御;`fileWriters` 仍仅锁内访问;`Field.Duration` 签名收紧 + 委托 `zap.Duration`,旧 `case zap.Field` 丢 key 分支已删。**关键**:变异 `currentLogger()` 为裸读 `Logger` 后 `-race` 实跑复现真实 `WARNING: DATA RACE`(read test:82 vs write logger.go:183/256,红),恢复后绿——证明非假绿。H7b 签名锁定为真编译期守卫。`go test -race`/`vet`/`gosec` 全清。无 CRITICAL/HIGH/MEDIUM。**1 项 LOW(预存,非 H7 引入)**:`SetDefaultLogManager` 裸写 `DefaultLogger`(同 C9c `DefaultJWT` 兼容别名模式),仅 `DefaultLogger` 单实例实际使用,H7 未触及也未恶化,四 atomic 仍为读侧唯一真源。**裁定:可交付 PASS。** + +--- + +## ✅ C12 `cron/cron.go` 数据竞争 + 重叠执行 + 漂移 + Weekly 跳周 + cron 解析缺陷 — P1 + +### 需求分析结果 +- **C12a**(cron.go:141-152,104-125):`runTask` 无锁写 `LastRun`/`RunCount`(:142-143),`NextRun` 部分在锁内;`GetTask`/`ListTasks` 返回 **live 指针**,请求/管理 goroutine 并发读 → data race。 +- **C12b**(cron.go:147-149,200-209):`NextRun` 在 handler 完成后才更新,`checkAndRun` 每秒 tick 反复 `go runTask`,长任务跨 tick 重叠执行。 +- **C12c**(cron.go:148,218-219):`NextRun` 以 handler 完成后 `time.Now()` 锚定,每周期累积 handler 时长 → 调度漂移。 +- **C12d**(cron.go:255-263):`WeeklySchedule.Next` `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻;当天目标未到点(周一 9:00 目标、周一 8:00 当前)被跳一周。 +- **C12e**(cron.go:310-434):`parseInt` 忽略非数字逐位累积 → `1-5,8` 先判 `-` 把整字段当范围、`parseInt("5,8")=58`;`garbage`→0 误触发;`*/garbage`→step=0→匹配全部;周日 `7` 不匹配。 + +### 方案设计摘要 +- **C12a**:`LastRun`/`RunCount`/`NextRun` 写入一律在 `s.mu` 写锁内;`GetTask`/`ListTasks` 返回 `cp := *task` 拷贝快照。 +- **C12b**:`Task` 新增未导出 `running *atomic.Bool`(`AddTask` 初始化);`checkAndRun` 与 `RunTask` 经 `CompareAndSwap(false,true)` 占用守卫,正在执行则跳过/返"任务正在执行中"错;spawn goroutine `defer running.Store(false)`。 +- **C12c**:`checkAndRun` spawn 前 `task.NextRun = task.Schedule.Next(task.NextRun)`(以上次 `NextRun` 锚定,非 `time.Now()`),`RunTask`/runTask 不再更新 `NextRun`。 +- **C12d**:`WeeklySchedule.Next` 重写为 `daysUntil=((day-now)+7)%7` 加天数后 `!next.After(now)` 才 +7。 +- **C12e**:`matchField` 重写——先按逗号拆列表,每项独立判 `*/n`/`a-b/n`/`a-b`/单值(列表分支独立于范围分支),全用 `strconv.Atoi` 返错;weekday `7→0`,范围 `lo>hi` 环绕;歧义范围 `0-7`/`7-0` 拒绝。新增 `ParseCronStrict` 严格校验;`ParseCron` 保留签名非法回退全 `*`。 + +涉及模块:`cron/cron.go`、`cron/cron_c12_test.go`、`cron/cron_c12_internal_test.go`。DB 影响:无。API 影响:`ParseCron` 仍返 `*FullCronSchedule`(非 breaking),新增 `ParseCronStrict`;`AddTask`/`RunTask`/`GetTask`/`ListTasks` 签名不变;`Task` 新增未导出 `running`。安全影响:正向。风险:见"已知风险"。 + +### 修改文件清单 +- `cron/cron.go`:`Task` 加 `running *atomic.Bool`;`AddTask` 初始化;`GetTask`/`ListTasks` 返回拷贝;`RunTask` 加 running 守卫 + 锁内写、不再更新 NextRun;删 `runTask`;`checkAndRun` 改写锁 + CAS 守卫 + spawn 前推进 NextRun(锚定 task.NextRun)+ goroutine 锁内写 LastRun/RunCount;`WeeklySchedule.Next` 重写;`matchField`/`matchCronItem`/`parseCronRange` 重写(strconv.Atoi + 列表独立 + 7→0 + 环绕 + 歧义拒绝);`splitPattern` 改 strconv.Atoi;删 `parseInt`/`split`;新增 `ParseCronStrict`/`validateCronField`/`validateCronItem`,`ParseCron` 委托 strict 失败回退。 +- `cron/cron_c12_test.go`(新):C12a 并发读写(-race)+ GetTask/ListTasks 拷贝;C12b 手动触发守卫返错;C12d Weekly 三场景;C12e 周日 7/范围环绕/严格解析/回退/简化 Cron garbage。 +- `cron/cron_c12_internal_test.go`(新):C12b 手动驱动 checkAndRun 防重叠;C12c NextRun 锚定(stepSchedule 确定性);C12e matchField 直接测试(1-5,8/garbage/*/garbage/7/范围步长列表)。 + +### 实现内容摘要 +数据竞争消除(C12a 锁内写 + 拷贝);重叠守卫(C12b atomic.Bool CAS);漂移消除(C12c 以上次 NextRun 锚定);Weekly 当天未到点不跳周(C12d);cron 解析类型安全 + 严格校验(C12e)。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。`AddTask`/`RunTask`/`GetTask`/`ListTasks`/`RemoveTask`/`EnableTask`/`DisableTask`/`Start`/`Stop`/`ParseCron`/`Every`/`Daily`/`Weekly`/`Cron` 签名全不变;`ParseCron` 仍返 `*FullCronSchedule`,非法回退全 `*`(保持原行为)。`Task` 新增未导出 `running`(外部不可构造/操作)。无 DB/路由/认证/响应体系新契约。拷贝快照模式同 C11 `Replicas()`/C10 `Load`;atomic 守卫同 C9c/C10/H7 模式。 +2. **对下游意味着什么 / breaking change?** **非 breaking**(公共 API 签名不变,`ParseCron` 行为兼容)。行为变更(已 CHANGELOG 声明):`GetTask`/`ListTasks` 返回拷贝(修改返回值不影响内部状态——原即不安全用法);`RunTask` 占用守卫期间再次调用返"任务正在执行中"错误;长任务不再重叠执行;调度不漂移;Weekly 当天未到点不再跳周;cron 解析拒绝非法表达式(`ParseCron` 非法回退默认全 `*`,新增 `ParseCronStrict` 严格校验)。新增 `ParseCronStrict`(非 breaking)。 +3. **新并发/生命周期/泄漏隐患?** 反而消除。`LastRun`/`RunCount`/`NextRun` 写入全在 `s.mu`;`running *atomic.Bool` 守卫每个 CAS-true 配 `defer Store(false)`(handler panic 亦触发 defer,不泄漏守卫);`checkAndRun` 持写锁推进 NextRun 后 spawn,goroutine 锁内写(不嵌套,loop 锁在 spawn 前释放);`Stop` 的 `wg.Wait` 与 `wg.Add` 经 `sync.WaitGroup` 文档语义安全(run loop 退出前 counter>0)。`-race` 已跑通过(含对抗性 Start/Stop 50 轮 + 20 并发)。 + +### 代码审查结果:PASS +`running *atomic.Bool` 指针避免 `cp := *task` 触发 copylocks(vet 清);`checkAndRun` spawn 前推进 NextRun + CAS 守卫双保险;`matchField` 列表独立分支 + strconv 类型安全;`parseCronRange` 歧义范围拒绝。独立复审未发现 CRITICAL/HIGH/MEDIUM。 + +### 安全审查结果:PASS +数据竞争消除(C12a);重叠执行消除(C12b,防资源竞争/重复副作用);cron 解析拒绝非法表达式(C12e,防误匹配致任务误触发/漏触发)。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(红绿已实跑验证,5 项变异均复现红): +- `TestC12aConcurrentReadWriteNoRace`:4 读者 GetTask/ListTasks 读字段 + 1 写者 RunTask,`-race` 通过。变异:RunTask 写去锁 → `WARNING: DATA RACE`(read ListTasks:132 vs write RunTask:164,红)。 +- `TestC12aGetTaskReturnsCopy`/`ListTasksReturnsCopies`:修改返回拷贝不污染内部。 +- `TestC12bNoOverlapManualDrive`(internal):手动驱动两轮 checkAndRun,handler 阻塞,断言 started=1。变异:去 CAS 守卫 → started=2(红)。 +- `TestC12bRunTaskConcurrentManualTriggerReturnsError`:手动 RunTask 占用守卫期间再次调用返错。 +- `TestC12cNextRunAnchoredOnPrevious`(internal):stepSchedule 确定性,NextRun 设过去锚点 T0,3 轮后断言 NextRun=T0+3*step。变异:锚定改 time.Now() → 超时明确失败(NextRun 跳未来不再到期,红)。 +- `TestC12dWeeklySameDayBeforeTarget`/`AfterTarget`/`CrossWeek`:当天未到点本周/已过下周/跨周。变异:旧 daysUntil<=0→+7 → 跳一周(红)。 +- `TestC12eMatchField*`(internal):1-5,8 仅匹配 1-5 与 8;garbage 不匹配;*/garbage 不匹配全部;weekday 7=周日;范围/步长/列表。变异:旧 parseInt → 1..58 全匹配/garbage 匹配 0/*/garbage 匹配 60/7 失败(红)。 +- `TestC12eWeekdaySundayAs7`/`RangeWraparound`:`0 0 * * 7` 落周日;`6-1` 环绕匹配周六周日周一、跳周二。 +- `TestC12eParseCronStrict`:合法/越界/歧义/step=0 校验。 +- `TestC12eParseCronFallback`:`ParseCron("invalid")` 回退全 `*`;合法不回退。 +- `TestC12eCronScheduleGarbageNoMatch`:简化 Cron garbage 不再 parseInt 为 0。 + +### 已知风险 +- **`Stop` 阻塞于忽略 ctx 的 handler(预存,非 C12 引入)**:`Stop` 调 `wg.Wait()` 等在途 handler 完成;若下游 handler 不响应 `s.ctx`(已取消)而无限阻塞,`Stop` 会挂起。框架已将取消的 `s.ctx` 传入 handler,契约要求 handler 响应 ctx。预存优雅关闭语义,C12 未引入也未恶化。 +- **`0-7`/`7-0` 歧义范围拒绝(行为变更)**:标准 cron 中 `0-7` 语义为整周,本实现因 0/7 均为周日会坍缩成仅周日,故拒绝(`ParseCronStrict` 返错、`ParseCron` 回退全 `*`)。下游用 `0-7` 需改 `0-6` 或 `*`。已 CHANGELOG 声明。 +- **`checkAndRun` 1s ticker 粒度(预存,非 C12 范围)**:调度精度受 1s ticker 限制,亚秒级调度实际按 1s 粒度触发。预存设计,C12 未触及。 +- `staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- `checkAndRun` ticker 可配置化(支持亚秒级调度精度)。 +- `Stop` 可加超时(避免忽略 ctx 的 handler 致关闭挂起)。 +- `0-7` 若需支持整周语义,可在 weekday 范围展开为集合而非区间(当前拒绝已足够安全)。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + `-race -count=3`/vet/gosec + 5 项变异红绿 + 对抗性 Start/Stop 复核 9 项:C12a/C12b/C12c/C12d/C12e **全部 CONFIRM 修复**——所有 task 字段写均在 `s.mu`、Getter 返回拷贝、running 守卫每 CAS-true 配 defer Store(false) 无泄漏、NextRun 锚定 task.NextRun 且 spawn 前推进、Weekly 三场景正确、matchField 列表独立 + strconv + 7→0 + 环绕 + 歧义拒绝、ParseCron 兼容回退、copylocks vet 清、无死锁/无 wg Add-Wait 竞争。**关键**:5 项变异(C12a 去锁写 / C12b 去守卫 / C12c 改 now 锚定 / C12d 旧跳周 / C12e 旧 parseInt)均实跑复现红(C12a DATA RACE / C12b started=2 / C12c 超时 / C12d 跳周 / C12e 5 用例全红),恢复后全绿——证明非假绿。无 CRITICAL/HIGH/MEDIUM。1 项 IMPRECISE(C12c 变异经超时而非断言失败——已改进测试加 `select+time.After` 超时明确失败)。**裁定:可交付 PASS。** + +--- + +## ✅ C13 `trace/trace.go` opt-in 即崩 + 未实现导出器/传播器 + Middleware 不更新 c.Request — P1 + +### 需求分析结果 +- **C13a**(trace.go:58,160,217,225):包级 `tracer`/`tracerProvider` 裸指针,未 `Init` 即 nil,`Middleware`/`StartSpan`/`StartSpanFromContext`/`GetTracer` 裸用 → 首个请求 panic(仅"从未 Init"才崩)。 +- **C13b**(trace.go:110-125,84-91):`createExporter` `default` 返 `nil, nil` 喂 `WithBatcher(nil)`;`stdout` 未实现。 +- **C13c**(trace.go:113-120):OTLP 默认 HTTPS+TLS,对 `localhost:4318` 明文 collector 握手失败,无 `WithInsecure`。 +- **C13d**(trace.go:172):`Middleware` 仅 `c.Set("otel_ctx", ctx)`,未 `c.Request = c.Request.WithContext(ctx)`,下游 `c.Request.Context()` 拿不到 span。 +- **C13e**(trace.go:128-138):`createPropagator` 仅 `w3c` + default 静默回落 W3C,`b3`/`jaeger` 未实现。 + +### 方案设计摘要 +- **C13a**:`tracer`/`tracerProvider` 改 `atomic.Pointer` + `init()` Store Noop 兜底;`getTracer()` 永不 nil;`Init` 原子替换、`Close` Shutdown 后 Store 回 Noop(防 Close 后再用 panic);`GetContext` 裸断言改 comma-ok。 +- **C13b**:新增 `case "stdout"`(官方 `stdouttrace` 包);`default` 返 `fmt.Errorf`(不再喂 nil)。 +- **C13c**:`Config.Insecure bool`(零值 false=TLS,opt-in 明文,安全默认),`Insecure` 时 otlp-http/grpc 追加 `WithInsecure()`。 +- **C13d**:`Middleware` 补 `c.Request = c.Request.WithContext(ctx)`(保留 `c.Set` 兼容)。 +- **C13e**:新增 `case "b3"`(contrib b3 propagator,单头+多头);`case "jaeger"` 映射 W3C(现代 Jaeger agent 透传 W3C,不引入不稳定的 jaegerremix);`default` 返错;`Init` 非法 propagator 时返错并回滚 provider。 +- 顺带:`resource.Merge` 改空 schema URL(`resource.Default()` 与 `semconv v1.24.0` schema 冲突致 Init 报错)。 + +涉及模块:`trace/trace.go`、`trace/trace_test.go`、`go.mod`/`go.sum`、`GUIDE.md`。DB 影响:无。API 影响:`Init`/`Middleware`/`StartSpan`/`GetTracer`/`Close` 签名不变;`Config` 新增 `Insecure`(零值兼容,非 breaking)。安全影响:正向(默认 TLS 安全)。风险:见"已知风险"。 + +### 修改文件清单 +- `trace/trace.go`:新增 `sync/atomic`/`os`/`b3`/`stdouttrace` 导入;`Config` 加 `Insecure`;`tracer`/`tracerProvider` 改 atomic + `init()` Store Noop;`getTracer()`/`TracerProvider()`;`Init` 改 atomic Store + Swap 旧 provider 关闭 + 非法 propagator 回滚;`createExporter` 加 stdout + default 返错 + Insecure option;`createPropagator` 加 b3/jaeger/default 返错;`Close` Swap 后 Store Noop 兜底;`Middleware` 改 getTracer + 更新 c.Request;`GetContext` comma-ok;`StartSpan`/`StartSpanFromContext`/`GetTracer` 改 getTracer;`resource.Merge` 空 schema URL。 +- `trace/trace_test.go`(新):`TestMain` 捕获 init() 快照;C13a init 不变式 + 未 Init 不 panic + Init(Enabled:false) + Close 后用;C13b stdout/未知返错/Init 失败;C13c Insecure 创建;C13d 下游 c.Request.Context() 含 span + 传播提取;C13e b3 注入/jaeger 映射 W3C/未知返错/Init 失败/w3c 默认;GetContext comma-ok。 +- `go.mod`/`go.sum`:新增 `stdouttrace v1.43.0`、`contrib/propagators/b3 v1.43.0`。 +- `GUIDE.md`:14.1 节补 Insecure/Propagator/导出器类型说明 + 未 Init 安全说明。 + +### 实现内容摘要 +nil-panic 消除(C13a atomic + Noop 兜底);stdout 导出器实现 + 未知返错(C13b);OTLP 明文 opt-in(C13c);下游 c.Request.Context() 含 span(C13d);b3 实现 + jaeger 映射 W3C + 未知返错(C13e);schema 冲突修复。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。`Init`/`Middleware`/`StartSpan`/`StartSpanFromContext`/`GetTracer`/`GetContext`/`GetTraceID`/`RecordError`/`SetAttribute`/`Close` 签名全不变;`Config` 新增 `Insecure`(零值兼容)。trace 是横切关注点,无 DB/路由/认证/响应体系契约改动。atomic 守护包级全局同 C9c/C10/H7 模式。 +2. **对下游意味着什么 / breaking change?** **非 breaking**(公共 API 签名不变,`Config.Insecure` 零值兼容)。行为变更(已 CHANGELOG/GUIDE 声明):未 Init 不 panic;未知导出器/传播器返错(不再静默 nil/回落 W3C);`Insecure` opt-in 明文(默认 TLS,与 H2 安全默认一致);Middleware 更新 c.Request(下游 c.Request.Context() 现含 span,原需经 GetContext(c));b3 实现、jaeger 映射 W3C;`Propagator` 空串按 w3c(兼容)。新增两个 OTel 同版本族依赖。 +3. **新并发/生命周期/泄漏隐患?** 反而消除。`atomic.Pointer` 消除裸指针读写竞争;`getTracer()` 返回接口值快照,请求 goroutine 持有自己的 tracer,即使并发 Init/Close Swap 也不读已释放;`Init` Swap 旧 provider 后 Shutdown(`WithBatcher` goroutine 由 provider.Shutdown 排空,无泄漏);`Close` Swap 后 Store Noop 兜底防 use-after-shutdown;`TestMain` 捕获 init() 快照锁定 Noop 不变式。`-race` 已跑通过(含对抗性并发 Init/Close/read 压测)。 + +### 代码审查结果:PASS +`atomic.Pointer` + `init()` Store Noop 同 C9c/C10/H7 模式;`getTracer()` 返回接口值快照避免读已释放;stdout 用官方包;b3 用 contrib 官方包;jaeger 映射 W3C 文档化(不引入不稳定模块);`Init` 非法 propagator 回滚 provider。独立复审未发现 CRITICAL/HIGH。 + +### 安全审查结果:PASS +opt-in 即崩消除(C13a);未知导出器/传播器 fail-fast 返错(C13b/C13e,防 nil 喂 WithBatcher/静默错误传播);默认 TLS(C13c,与 H2 一致)。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(红绿已实跑验证,4 项变异均复现红): +- `TestC13aInitNoopInvariant`:`TestMain` 捕获 init() 后 getTracer() 快照,断言非 nil。变异:init() 去 Noop Store → `getTracer()` nil deref panic(红)。 +- `TestC13aNoInitNoPanic`/`InitDisabledNoop`/`CloseThenUseNoPanic`:未 Init/Init(Enabled:false)/Close 后包级函数不 panic。 +- `TestC13bStdoutExporterWorks`/`UnknownExporterReturnsError`/`InitUnknownExporterFails`:stdout 可创建、未知返错、Init 失败。变异:default 返 nil,nil → 用例红。 +- `TestC13cInsecureExporterCreates`/`OtlpGrpcInsecureCreates`:Insecure 路径导出器可创建。 +- `TestC13dRequestContextContainsSpan`:下游 `c.Request.Context()` span TraceID 非空且与 X-Trace-ID 一致。变异:去 c.Request 更新 → 下游 TraceID 全 0(红)。 +- `TestC13dPropagatedTraceContextExtracted`:入站 W3C 头提取,下游 span 继承父 TraceID。 +- `TestC13eB3PropagatorImplemented`:b3 注入 b3/x-b3-traceid 头。变异:b3 未实现返 W3C → 无 b3 头(红)。 +- `TestC13eJaegerMapsToW3C`:jaeger 映射 W3C 注入 traceparent。 +- `TestC13eUnknownPropagatorReturnsError`/`InitUnknownPropagatorFails`/`W3CDefault`:未知返错/Init 失败/空串 w3c。变异:default 静默回落 → 未知不返错(红)。 +- `TestC13GetContextCommaOk`:otel_ctx 污染为非 context 不 panic、回退 c.Request.Context()。 + +### 已知风险 +- **jaeger 映射 W3C(文档化,非完整 Jaeger 协议)**:`jaeger` 传播器映射 W3C TraceContext(现代 Jaeger agent 透传 W3C);纯 Jaeger thrift 头协议需下游用 `b3`。不引入不稳定的 jaegerremix 模块。已 GUIDE 声明。 +- **`Init` 关闭旧 provider 时在途 span(MEDIUM,OTel 上游安全)**:`Init` Swap 旧 provider 后 Shutdown;在途 span 的 `OnEnd`/`End` 在 Shutdown 后为上游 no-op-safe 路径(OTel batch span processor 保证),对抗性压测无 crash。重新 Init 应在启动期/停服期,非运行期热切换。 +- **新增依赖体积(LOW)**:`stdouttrace` + `b3` 均为 OTel 官方同版本族,体积可控。 +- `staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- 若需纯 Jaeger thrift 头协议,待 contrib jaegerremix 稳定后接入或自实现 propagator。 +- `Init` 运行期热切换可加 `sync.RWMutex` 串行化 Init/Close(当前 atomic 已足够安全,热切换非框架支持场景)。 +- `semconv` 可升级至 v1.40.0 与 `resource.Default()` schema 对齐(当前空 schema URL 已规避冲突)。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + `-race -count=2`/vet/gosec + 4 项变异红绿 + 对抗性并发 Init/Close/read 压测 + 独立 `go run` no-Init panic 复核 10 项:C13a/C13b/C13c/C13d/C13e + schema 修复 + GetContext comma-ok **全部 CONFIRM 修复**——init() Store Noop 兜底(独立 go run 验证去 Store 必 panic)、getTracer() 经 atomic 永不 nil、所有读路径经 getTracer()、stdout/default 返错、Insecure opt-in、Middleware 更新 c.Request、b3 实现/jaeger 映射 W3C/default 返错、Init 非法 propagator 回滚、空 schema URL。**关键**:4 项变异(C13a 去 Noop Store / C13b default 返 nil,nil / C13d 去 c.Request 更新 / C13e 仅 w3c+静默回落)均实跑复现红(C13a nil deref panic / C13b 用例红 / C13d 下游 TraceID 全 0 / C13e b3+未知红),恢复后全绿——证明非假绿。无 CRITICAL/HIGH。1 项 MEDIUM(初始 `TestC13aNoInitNoPanic` 经 resetGlobal 掩盖 init() 路径——**已修**:新增 `TestMain` 捕获 init() 快照 + `TestC13aInitNoopInvariant` 锁定,变异 init() 去 Store 实跑复现 panic 红)。**裁定:可交付 PASS。** + +--- + +## ✅ H5 `handler` 业务码与 HTTP 状态混乱 + 丢失 RequestID — P1 + +### 需求分析结果 +- **H5b**(handler.go:157-170):`handler.BadRequest`/`InternalError` 直接 `c.JSON(http.StatusBadRequest/StatusInternalServerError, response.Response{...})`——硬编 HTTP 400/500 **绕过响应模式系统**(`response/mode.go` 的 `writeResp`→`httpStatusFor` 受 `Mode` 控制,ModeBusiness 下所有失败响应应 HTTP 200,错误经 body code 表达),且**不写 `RequestID`**(对比 `writeResp` 在 mode.go:73 写 `RequestID`)。与 `response` 体系不一致、丢链路追踪。正是 CLAUDE.md 反模式负面清单"handler 绕过 response 模式系统"。 +- **H5a**(response.go:33,43):`Success`/`SuccessWithMsg` 硬编 200——经核验为成功响应,HTTP 200 在两模式下均正确,非缺陷(报告 H5a 已澄清"ModeBusiness 默认全 200 成立",模式系统真实存在)。 + +### 方案设计摘要 +- `BadRequest(c, msg)` 委托 `response.FailWithCode(c, response.CodeFail, msg)`(= `response.Fail` 语义),`InternalError(c, msg)` 委托 `response.ServerError(c, msg)`——两者复用 `writeResp` 路径,遵循当前 `Mode` 并经 `getRequestID(c)` 写入 `RequestID`。 +- 移除 `net/http` 导入(仅这两处用到)。无新增 API/配置/契约。 + +涉及模块:`handler/handler.go`、`handler/handler_test.go`。DB 影响:无。API 影响:`BadRequest`/`InternalError` 签名不变;行为变更(HTTP 状态遵循 Mode、写入 RequestID)。安全影响:正向(链路追踪不丢、响应体系一致)。风险:见"已知风险"。 + +### 修改文件清单 +- `handler/handler.go`:删 `net/http` 导入;`BadRequest` 改委托 `response.FailWithCode`;`InternalError` 改委托 `response.ServerError`;补注释说明遵循 Mode + 写 RequestID。 +- `handler/handler_test.go`:新增 `withResponseMode`/`decodeBody` helper;改写 `TestBadRequest`/`TestInternalError` 为 ModeBusiness 语义(HTTP 200 + 业务码);新增 `TestBadRequestRESTMode`(CodeFail 不映射→200)、`TestInternalErrorRESTMode`(CodeServerError→500)、`TestBadRequestWritesRequestID`/`TestInternalErrorWritesRequestID`(RequestID 闭环)。 + +### 实现内容摘要 +响应体系一致闭环(委托 `writeResp`,遵循 Mode + 写 RequestID);消除 handler 绕过 response 模式系统的反模式。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。`BadRequest`/`InternalError` 签名不变;现复用 `response.FailWithCode`/`ServerError`→`writeResp` 路径,与 `response.Fail*`/`Unauthorized`/`NotFound` 等完全一致(此前是唯一绕过 Mode 的两个 handler helper)。`CodeFail`/`CodeServerError` 业务码不变。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG 声明,非破坏性)**:默认 `ModeBusiness` 下 `BadRequest`/`InternalError` 由恒 400/500 改为 HTTP 200(错误经 body code 表达,与所有 `response.Fail*` 一致);`ModeREST` 下 `InternalError`→500(`CodeServerError` 映射)、`BadRequest`→200(`CodeFail` 属业务失败不映射,与 `response.Fail` 一致)。两者现写入 `RequestID`。下游若依赖 `handler.BadRequest` 恒返 400,应改用 `response.Custom(c, 400, code, msg, nil)` 或业务自定义 4xxxx 错误码。无 API 签名/配置/migration 变更。 +3. **新并发/生命周期/泄漏隐患?** 无。改动为同步函数委托,无 channel/goroutine/包级可变全局/资源生命周期。`-race` 已跑通过。 + +### 代码审查结果:PASS +最小改动(两个函数体各改一行委托 + 删导入 + 注释);复用既有 `writeResp` 出口而非另起硬编;无魔法值。无 CRITICAL/HIGH。 + +### 安全审查结果:PASS +响应体系一致性修复(消除绕过 Mode 的反模式);`RequestID` 不再丢失,链路追踪完整;ModeREST 下 5xx 可被 APM/网关正确捕获(与 C8 同向)。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(红绿已实跑验证): +- `TestBadRequest`(ModeBusiness):HTTP 200 + code=CodeFail + msg。红/绿:回退 `BadRequest` 为旧 `c.JSON(400,...)` 后复现 "status = 400, want 200"(红);恢复后绿。 +- `TestInternalError`(ModeBusiness):HTTP 200 + code=CodeServerError。红/绿:回退为旧 `c.JSON(500,...)` 后复现 "status = 500, want 200"(红);恢复后绿。 +- `TestBadRequestRESTMode`(ModeREST):HTTP 200(CodeFail 不映射)+ code=CodeFail。回退后复现 "status = 400, want 200"(红)。 +- `TestInternalErrorRESTMode`(ModeREST):HTTP 500 + code=CodeServerError(锁定 REST 映射;旧实现恒 500 恰同,非红绿回归,作行为锁定)。 +- `TestBadRequestWritesRequestID`/`TestInternalErrorWritesRequestID`:上下文设 `request_id`,断言响应体含之。红/绿:回退后复现 "request_id = "", want ..."(红,复现 H5b 丢链路);恢复后绿。 +- `go test -buildvcs=false -race -count=1 ./handler/ ./response/` 全绿;`go vet` 通过。 + +### 已知风险 +- **`BadRequest` 在 ModeREST 下返 200(语义边角,文档化)**:`CodeFail` 是通用业务失败,按框架 `statusForCode` 规则不映射 HTTP 错误(与 `response.Fail` 在 ModeREST 下返 200 一致,mode_test.go:50 已锁定)。函数名"BadRequest"暗示 400,但与 `response.Unauthorized`(命名暗示 401,ModeBusiness 下返 200)同属框架既有"函数名暗示 HTTP 状态、ModeBusiness 下统一 200"模式。下游需 REST 模式下 400 应改用 `response.Custom` 或 4xxxx 业务码。 +- **`staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过**;`go vet`/`gosec` 通过。 +- 改动小而局部(仅 `handler` 包,无其他包引用 `handler.BadRequest`/`InternalError` 的旧 400/500 行为——经 `grep` 确认仅测试与文档引用),无全局影响,按用户指示跳过独立对抗性复审 agent;红绿 + `-race`/vet 已实跑。 + +### 后续优化建议 +- 视需求为 `response` 补 `BadRequest(c, msg)` 便捷函数(用 4xxxx 业务码或 `CodeFail`),统一"参数错误"出口;当前 `handler.BadRequest` 委托 `FailWithCode(CodeFail)` 已足够。 +- 文档(GUIDE)示例可补充"REST 模式下需 400 用 `response.Custom`"说明。 + +### 独立对抗性复审结论 +按用户指示(改动小而局部、无全局影响)跳过独立 agent 复审;以源码 `file:line` + 红绿变异 + `-race`/vet 实跑自验:H5b **CONFIRM 修复**——`BadRequest`/`InternalError` 均经 `response.FailWithCode`/`ServerError`→`writeResp`,遵循 Mode 并写 RequestID,旧硬编 `c.JSON(http.StatusBadRequest/StatusInternalServerError,...)` 路径彻底移除;回退变异实跑复现红(400/500≠200、RequestID 为空),恢复后全绿。无 CRITICAL/HIGH。**裁定:可交付 PASS。** + +--- + +## ✅ H4b `middleware/ratelimit.go` CustomRateLimit goroutine 泄漏 — P1 + +### 需求分析结果 +`CustomRateLimit(rate, window)`(ratelimit.go:329-332)每次调用 `NewRateLimiter` 创建一个 `RateLimiter`,其 `cleanupVisitors` goroutine 持续运行(每分钟 ticker 清理过期 visitor)。但创建的 limiter **无任何句柄**(仅作为闭包捕获返回),`StopRateLimiters` 只停止 `loginLimiter`/`apiLimiter`/`uploadLimiter` 三个命名限流器,**不感知自定义限流器** → cleanup goroutine 永久泄漏。应用每次路由构造调用 `CustomRateLimit` 即泄漏一个 goroutine,长期运行/反复 reload 路由会累积。`InitRateLimiters`(re-init 路径)同样不停止旧的自定义限流器。 + +### 方案设计摘要 +- 新增包级 `customLimiters []*RateLimiter` 登记表(受既有 `limitersMu` 保护)。 +- 新增 `drainCustomLimiters()` 辅助(锁内取出并清空,调用方持锁)。 +- `CustomRateLimit` 创建 limiter 后 `append` 登记入表。 +- `StopRateLimiters` / `InitRateLimiters` 经 `drainCustomLimiters()` 取出并逐个 `Stop()`,释放 cleanup goroutine。 +- **保持原"全程持 `limitersMu`"语义**(不引入锁外 Stop 窗口):持锁期间调 `Stop()`→`wg.Wait()` 安全——`cleanupVisitors` 取的是 limiter 自身的 `rl.mu`,非 `limitersMu`,无死锁;全程持锁避免与 `LoginRateLimit` 等懒初始化路径交错致覆盖泄漏。 + +涉及模块:`middleware/ratelimit.go`、`middleware/middleware_test.go`。DB 影响:无。API 影响:`CustomRateLimit`/`StopRateLimiters`/`InitRateLimiters` 签名不变;无行为变更(仅修复 goroutine 泄漏,限流语义不变)。安全影响:正向(资源泄漏消除)。风险:见"已知风险"。 + +### 修改文件清单 +- `middleware/ratelimit.go`:新增 `customLimiters` 字段 + `drainCustomLimiters`;`InitRateLimiters`/`StopRateLimiters` 增加 `for _, l := range drainCustomLimiters() { l.Stop() }`;`CustomRateLimit` 登记。 +- `middleware/middleware_test.go`:新增 `runtime` 导入 + `goroutineCount` helper + `TestCustomRateLimitNoGoroutineLeak`(Stop 释放)/`TestCustomRateLimitReinitStopsOldCustoms`(re-init 释放)。 + +### 实现内容摘要 +自定义限流器登记闭环(customLimiters 表);`StopRateLimiters`/`InitRateLimiters` 统一停止;cleanup goroutine 不再泄漏。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。`CustomRateLimit`/`StopRateLimiters`/`InitRateLimiters`/`RateLimit` 签名全不变;`customLimiters`/`drainCustomLimiters` 未导出。限流语义不变。复用既有 `limitersMu` 保护(与命名限流器同一锁域)。无 DB/路由/认证/响应体系新契约。 +2. **对下游意味着什么 / breaking change?** **非 breaking,无行为变更**。`CustomRateLimit` 现登记创建的限流器,`StopRateLimiters`/`InitRateLimiters` 现会停止它们(释放 cleanup goroutine)。下游无 API 签名/配置/migration 变更;下游若依赖"CustomRateLimit 的 limiter 永不被 Stop"——无此合理依赖(Stop 是资源清理,本就该停)。 +3. **新并发/生命周期/泄漏隐患?** 反而消除。`customLimiters` 读写均在 `limitersMu` 内(`CustomRateLimit` append 写锁、`drainCustomLimiters` 在 Stop/Init 持锁内读+清空);持锁期间调 `Stop()` 无死锁(不同 mutex);全程持锁避免懒初始化交错覆盖泄漏。无 channel/goroutine 新增(cleanup goroutine 本就存在,现可正确停止)。`-race` 已跑通过。 + +### 代码审查结果:PASS +登记表 + drain 模式是标准资源生命周期管理;复用既有 `limitersMu` 不引入新锁;持锁 Stop 无死锁论证清晰;保持原全程持锁语义避免引入交错窗口。独立复审(自验)未发现 CRITICAL/HIGH。 + +### 安全审查结果:PASS +goroutine 泄漏消除(应用长期运行/reload 路由不再累积 cleanup goroutine)。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(红绿已实跑验证): +- `TestCustomRateLimitNoGoroutineLeak`:创建 5 个 CustomRateLimit → goroutine 数明显增加;`StopRateLimiters` 后 goroutine 数回落到 before+2 内。红/绿:回退 `CustomRateLimit` 不登记后复现 "after StopRateLimiters: goroutines = 7, before = 2"(5 个泄漏,红);恢复后绿。 +- `TestCustomRateLimitReinitStopsOldCustoms`:基线 InitRateLimiters(3 命名)→ 创建 2 自定义 → 再 InitRateLimiters → goroutine 数回落到 baseline+1 内(自定义被 drain+Stop)。红/绿:回退不登记后复现 "goroutines = 7, baseline = 5"(2 个泄漏,红);恢复后绿。 +- `go test -buildvcs=false -race -count=1 ./middleware/` 全绿;`go vet` 通过。 + +### 已知风险 +- **goroutine 计数测试有调度噪声(LOW)**:`runtime.NumGoroutine` 读数受调度影响,测试用 GC+Gosched+轮询窗口(2s deadline)收敛。阈值留 +1/+2 容忍噪声,红绿变异均稳定复现。 +- **`StopRateLimiters` 持锁期间 Stop 阻塞(LOW,可接受)**:`Stop()`→`wg.Wait()` 等待 cleanup goroutine 退出(通常毫秒级)。shutdown 路径阻塞可接受;与原实现(同样持锁 Stop)一致。 +- **`staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过**;`go vet`/`gosec` 通过。 +- 改动小而局部(仅 `middleware` 包,无其他包引用 `CustomRateLimit` 的旧行为——经 `grep` 确认仅测试与文档引用),无全局影响,按用户指示跳过独立对抗性复审 agent;红绿 + `-race`/vet 已实跑。 + +### 后续优化建议 +- H4c:`RedisRateLimiter.Allow` Redis 错误 fail-open + `result.(int64)` 裸断言(独立缺陷,下一项)。 +- 若需自定义限流器独立生命周期管理,可提供 `CustomRateLimitWithHandle` 返回 `*RateLimiter` 句柄供调用方自行 Stop(当前登记表统一管理已足够)。 + +### 独立对抗性复审结论 +按用户指示(改动小而局部、无全局影响)跳过独立 agent 复审;以源码 `file:line` + 红绿变异 + `-race`/vet 实跑自验:H4b **CONFIRM 修复**——`CustomRateLimit` 创建的 limiter 经 `customLimiters` 登记表,`StopRateLimiters`/`InitRateLimiters` 经 `drainCustomLimiters()` 逐个 `Stop()`,cleanup goroutine 不再泄漏;回退变异实跑复现红(Stop 后 5 个泄漏 / re-init 后 2 个泄漏),恢复后全绿。无 CRITICAL/HIGH。**裁定:可交付 PASS。** + +--- + +## ✅ H4c `middleware/ratelimit.go` RedisRateLimiter fail-open + 裸断言 — P1 + +### 需求分析结果 +- **H4c-1 fail-open**(ratelimit.go:Allow + 中间件):`RedisRateLimiter.Allow` Redis 错误时 `return true, err`(放行),中间件层 `err != nil → c.Next()` 同样放行。**含登录防爆破场景静默失效**——Redis 抖动窗口限流失效,攻击者可借机无限爆破。无 fail-closed 选项。Redis 未启用时同样 fail-open(`return true, nil`)。 +- **H4c-2 裸断言**(ratelimit.go:Allow):`result.(int64)` 无 comma-ok,Redis 返回非 int64 时 panic。当前 Lua 脚本恒返整数不会触发,属脆弱性(脚本变更/Redis 异常返回即 panic)。 + +### 方案设计摘要 +- **H4c-1**:`RedisRateLimiter` 新增未导出 `failClosed bool` 字段(零值 false=兼容默认 fail-open)。`Allow` 在 Redis 未启用/错误时按策略决定:fail-closed 返 `(false, err)` 拒绝,fail-open 返 `(true, err)` 放行(兼容旧行为)。中间件层抽取 `redisLimitDecision(c, allowed, err)`——**不再无条件 fail-open**,按 `allowed` 值决定:`err!=nil && !allowed`(fail-closed 故障)返 **503**(`CodeServiceUnavailable`,区别于真实超限的 429),`!allowed`(真实超限)返 429,`allowed` 放行。 +- **H4c-2**:`result.(int64)` 改 comma-ok,断言失败返 `ErrRedisRateLimiterUnexpectedResult`(按 failClosed 策略拒绝/放行)而非 panic。 +- 新增构造函数 `NewRedisRateLimiterFailClosed`、切换方法 `SetFailClosed`、中间件 `RedisRateLimitFailClosed`/`CustomRedisRateLimitFailClosed`、导出错误 `ErrRedisRateLimiterUnavailable`/`ErrRedisRateLimiterUnexpectedResult`。 +- **`LoginRedisRateLimit` 改 fail-closed**(行为变更):登录防爆破 Redis 故障时拒绝(503),防限流静默失效。其余 Redis 限流中间件保持 fail-open(兼容默认)。 + +涉及模块:`middleware/ratelimit.go`、`middleware/middleware_test.go`、`GUIDE.md`、`CHANGELOG.md`。DB 影响:无(Redis key 语义不变)。API 影响:既有函数签名不变;`RedisRateLimiter` 新增未导出 `failClosed`;新增 fail-closed 变体 + 导出错误。行为变更(`LoginRedisRateLimit` 改 fail-closed)。安全影响:正向(防爆破 fail-closed + 断言不 panic)。风险:见"已知风险"。 + +### 修改文件清单 +- `middleware/ratelimit.go`:新增 `errors`/`net/http` 导入;`RedisRateLimiter` 加 `failClosed` 字段;新增 `ErrRedisRateLimiterUnavailable`/`ErrRedisRateLimiterUnexpectedResult`;`NewRedisRateLimiterFailClosed`/`SetFailClosed`;`Allow` 改按策略 + comma-ok;抽取 `redisLimitDecision`;新增 `RedisRateLimitFailClosed`/`CustomRedisRateLimitFailClosed`;`LoginRedisRateLimit` 改 fail-closed。 +- `middleware/middleware_test.go`:新增 `database`/`errors`/`net/http`/`miniredis`/`redis` 导入 + `setupMiddlewareMiniRedis`;改 `TestLoginRedisRateLimit` 为 fail-closed 语义(503);新增 8 个 H4c 回归用例。 +- `GUIDE.md`:Redis 限流示例标注 fail-open/fail-closed + 新增变体示例 + 策略说明。 + +### 实现内容摘要 +fail-closed 可配置闭环(H4c-1:`LoginRedisRateLimit` 改 fail-closed,新增变体);裸断言消除(H4c-2:comma-ok + 导出错误);中间件 503 区分故障与超限。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。既有 `RedisRateLimit`/`LoginRedisRateLimit`/`APIRedisRateLimit`/`UploadRedisRateLimit`/`CustomRedisRateLimit`/`RedisRateLimitWithIdentifier`/`NewRedisRateLimiter`/`Allow`/`GetCount`/`Reset` 签名全不变;`RedisRateLimiter` 新增未导出 `failClosed`(外部不可构造)。503 经 `response.Custom`(同 C8 模式)保留 RequestID/code 体系,未硬编状态码。无 DB/路由/认证新契约。 +2. **对下游意味着什么 / breaking change?** **行为变更(已 CHANGELOG/GUIDE 声明)**:`LoginRedisRateLimit` 改 fail-closed——Redis 故障/未启用时由放行改为拒绝(503)。登录防爆破场景安全加固,但下游登录接口须确保 Redis 可用(否则 Redis 故障时登录不可用,安全语义:宁拒勿放)。其余 Redis 限流中间件保持 fail-open(兼容默认)。新增 fail-closed 变体 + 导出错误 + `SetFailClosed`(非 breaking)。无既有 API 签名/配置/migration 变更。 +3. **新并发/生命周期/泄漏隐患?** 无。`failClosed` 字段在构造时设置(`NewRedisRateLimiter*`),`SetFailClosed` 供已创建限流器切换(限流器无并发写场景,中间件构造期调用)。`Allow` 无共享可变状态、无 channel/goroutine。`-race` 已跑通过(含 miniredis 真实闭环 + Redis 故障模拟)。 + +### 代码审查结果:PASS +fail-closed 可配置是限流安全标准做法;`redisLimitDecision` 抽取统一 err/allowed 组合语义清晰;503 区分故障与 429 超限语义正确;comma-ok 消除裸断言 panic。独立复审(自验)未发现 CRITICAL/HIGH。 + +### 安全审查结果:PASS +登录防爆破 fail-open 静默失效消除(H4c-1,`LoginRedisRateLimit` 改 fail-closed);裸断言 panic 消除(H4c-2);fail-closed 故障返 503 可被 APM/监控告警。gosec 0 issue。 + +### 测试审查结果:PASS +回归用例(红绿已实跑验证): +- `TestRedisRateLimiterFailOpenNoRedis`:无 Redis fail-open 放行(兼容)。 +- `TestRedisRateLimiterFailClosedNoRedis`:无 Redis fail-closed 拒绝 + `ErrRedisRateLimiterUnavailable`。 +- `TestRedisRateLimiterFailClosedOnRedisError`(miniredis):正常放行;关 miniredis 后 fail-open 放行、fail-closed 拒绝。红/绿:回退 `Allow` Redis 错误分支为旧 fail-open 后复现 "fail-closed on redis error should deny"(红);恢复后绿。 +- `TestRedisRateLimitFailClosedMiddlewareReturns503`:无 Redis 时 fail-closed 中间件返 503 + `CodeServiceUnavailable`。红/绿:回退无 Redis 分支为旧 fail-open 后复现 "status = 200, want 503"(红);恢复后绿。 +- `TestRedisRateLimitFailOpenMiddlewareAllowsOnError`:无 Redis 时 fail-open 中间件放行(兼容)。 +- `TestRedisRateLimiterRealRedisLimitCycle`(miniredis):前 3 次放行、第 4 次拒绝(真实 Lua + comma-ok int64 路径)。 +- `TestRedisRateLimiterCommaOkAssertion`(miniredis):返回字符串脚本验证裸断言 panic、comma-ok 不 panic(H4c-2 根因)。 +- `TestRedisRateLimiterSetFailClosed`:`SetFailClosed` 切换策略。 +- `TestLoginRedisRateLimit`:改 fail-closed 语义(503)。红/绿:回退后复现 "status = 200, want 503"(红);恢复后绿。 +- `go test -buildvcs=false -race -count=1 ./middleware/` 全绿;`go vet` 通过。 + +### 已知风险 +- **`LoginRedisRateLimit` 改 fail-closed 是行为变更(MEDIUM,已声明)**:下游登录接口须确保 Redis 可用,否则 Redis 故障时登录返 503 不可用。这是安全语义权衡(防爆破 fail-closed 必然代价),已 CHANGELOG/GUIDE 声明。下游若需登录 fail-open 可改用 `RedisRateLimit("login_limit", 10)`。 +- **`failClosed` 字段非原子读写(LOW,可接受)**:`SetFailClosed` 写、`Allow` 读,限流器无并发写场景(中间件构造期或启动期切换),无 `-race` 风险。若需运行期热切换可改 `atomic.Bool`(当前无此需求)。 +- **miniredis Lua 恒返 int64(LOW,与 C1b 同局限)**:H4c-2 panic 路径无法经 `Allow` 触发,由 `TestRedisRateLimiterCommaOkAssertion`(直接验证裸断言 panic + comma-ok 不 panic)+ 代码审查保证。 +- **`staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过**;`go vet`/`gosec` 通过。 +- 改动局部(仅 `middleware` 包 + 文档,无其他包引用 `LoginRedisRateLimit`/`NewRedisRateLimiter` 的旧 fail-open 行为——经 `grep` 确认仅测试与文档引用),无全局架构影响,按用户指示跳过独立对抗性复审 agent;红绿 + `-race`/vet 已实跑。 + +### 后续优化建议 +- `failClosed` 若需运行期热切换可改 `atomic.Bool`。 +- 视需求为 `RedisRateLimitWithIdentifier` 提供 fail-closed 变体。 + +### 独立对抗性复审结论 +按用户指示(改动局部、无全局架构影响)跳过独立 agent 复审;以源码 `file:line` + 红绿变异 + `-race`/vet 实跑自验:H4c-1/H4c-2 **CONFIRM 修复**——`Allow` 经 `failClosed` 策略决定 Redis 故障放行/拒绝,`result.(int64)` 改 comma-ok 返 `ErrRedisRateLimiterUnexpectedResult`,中间件 `redisLimitDecision` 按 allowed 决定(fail-closed 故障 503、超限 429、放行),`LoginRedisRateLimit` 改 fail-closed;回退变异实跑复现红(fail-closed 故障仍放行 / 200≠503),恢复后全绿;`TestRedisRateLimiterCommaOkAssertion` 证明裸断言 panic 根因。无 CRITICAL/HIGH。**裁定:可交付 PASS。** + +--- + +## ✅ H6 `repository/repository.go` BaseRepo 不接 GetDBFromContext + 读写分离失效 + 事务无法 join + 分页不一致 — P2 + +### 需求分析结果 +- **H6c(核心)**:`r.db` 构造时捕获,所有方法 `r.db.WithContext(ctx)` 从不调 `database.GetDBFromContext` → 读写分离形同虚设(读全走主库);`WithTransaction` 创建的 txRepo 仍用 `r.db`,fn 内方法拿不到事务(autocommit);外层 `database.TransactionWithContext` 的事务无法 join。 +- **H6a**:`Update` 用 `Save` 全列覆写,零值不可辨、可覆盖并发更新。 +- **H6b**:`Delete` 注释称软删,泛型 `T` 无 `gorm.DeletedAt` 时静默硬删(契约不可由类型强制)。 +- **H6d**:`FindPage` 的 count 与 list 是两条独立语句,高并发下 total/items 不一致。 +- **H6e**:`QueryBuilder` 链式 mutate `qb.db` 非并发安全;`Page` 的 count 已用 Session 克隆(未污染),但 `Count` 受残留 `Limit`/`Offset` 截断、Find 侧未克隆。 + +### 方案设计摘要 +- **H6c 路由**:新增 `readConn(ctx)`/`writeConn(ctx)`,优先级「外层 ctx 事务 > 本 repo 事务(`r.tx`)> 路由 db > `r.db` 回退」。读走 `database.GetDBFromContext`(默认从库,支持 `UseMaster`/`UseReplica`),写走 `database.GetWriteDB()`(主库,不路由只读从库)。`DefaultManager` 未初始化时回退 `r.db`,兼容下游 `NewBaseRepo[T](database.GetDB())` 与单测注入 sqlite。 +- **H6c 事务 join**:`BaseRepo` 新增未导出 `tx` 字段;`WithTransaction` 在 `writeConn(ctx).Transaction` 回调中创建 `txRepo = &BaseRepo{db: r.db, tx: tx}`,其方法经 `readConn`/`writeConn` 优先取 `r.tx` 自动 join。新增 `database.WithTx(ctx, tx)`/`TxFromContext(ctx)` 支持跨层/跨 repo join(外层事务经 `WithTx` 注入 ctx)。`WithTransaction` 签名不变。 +- **H6a**:新增 `UpdateFields(ctx, model, conds...)`(`gorm.Updates`,struct 仅非零字段 / map 可显式置零);`Update`(Save)保留并文档化。 +- **H6b**:`Delete` 文档化行为契约(T 含 `gorm.DeletedAt`/`gorm.Model` 软删,否则硬删)。 +- **H6d**:`FindPage`/`FindPageOrdered`/`FindPageWhere`/`FindPageWhereOrdered` 的 count+list 包进 `readConn(ctx).Transaction` 单事务。 +- **H6e**:`QueryBuilder` 终结方法(`Find`/`First`/`Count`/`Page`)基于 `Session(&gorm.Session{})` 克隆;`Count`/`Page` 的 count 额外 `Limit(-1).Offset(-1)` 剥离残留分页条件。文档标注单次使用、非并发安全。 + +涉及模块:`repository/repository.go`、`database/manager.go`(`WithTx`/`TxFromContext`/`txContextKey`)、`repository/repository_h6_internal_test.go`(新)、`go.mod`(新增 `github.com/glebarez/sqlite` 测试依赖)、`CHANGELOG.md`、`GUIDE.md`。DB 影响:无(连接池/路由语义不变,仅 repository 经既有路由)。API 影响:公共 API 签名全不变;新增 `database.WithTx`/`TxFromContext`/`repository.BaseRepo.UpdateFields`(additive,非 breaking)。行为变更(读默认走从库、写显式走主库、分页包单事务)已 CHANGELOG/GUIDE 声明。安全影响:正向。风险:见"已知风险"。 + +### 修改文件清单 +- `database/manager.go`:新增 `txContextKey`、`WithTx(ctx, tx)`、`TxFromContext(ctx)`。 +- `repository/repository.go`:`BaseRepo` 加 `tx` 字段;`readConn`/`writeConn`;全方法 `r.db.WithContext(ctx)` → `r.readConn(ctx)`/`r.writeConn(ctx)`(读/写分类);`GetDB()` 返回 `r.tx` 若在事务内;`WithTransaction` 注入 `tx` 字段;`UpdateFields`;`Delete` 文档;`FindPage*` 单事务;`QueryBuilder` 终结方法克隆 + `Count` 剥离 + 文档。 +- `repository/repository_h6_internal_test.go`(新):CRUD 闭环、`UpdateFields`(struct/map)、`WithTransaction` 回滚/提交/txRepo join、`database.WithTx` 跨层 join(回滚/提交)、`FindPage`/`FindPageWhere`、软删除闭环、`QueryBuilder.Count` 剥离 / `Find` 保留 Limit / `Page` 不累积、**路由读写分离**(DefaultManager 主从 sqlite:默认读从库、UseMaster 读主库、写落主库、UseReplica 下写仍落主库)。 +- `go.mod`/`go.sum`:新增 `github.com/glebarez/sqlite`(纯 Go sqlite,测试依赖)。 +- `CHANGELOG.md`/`GUIDE.md`:H6 Fixed 条目 + 升级说明 + 4.3 节路由/UpdateFields/跨层事务/QueryBuilder 单次使用说明。 + +### 实现内容摘要 +读写分离闭环(H6c:读走 GetDBFromContext、写走主库);事务 join 闭环(`r.tx` + `database.WithTx`/`TxFromContext`,WithTransaction 签名不变);局部更新(H6a `UpdateFields`);软删除契约文档(H6b);分页单事务(H6d);QueryBuilder 克隆 + Count 剥离(H6e)。 + +### 三个全局问题(强制回答) +1. **与既有体系契约一致?** 是。公共 API 签名全不变;`BaseRepo` 新增未导出 `tx` 字段。`readConn`/`writeConn` 复用既有 `database.GetDBFromContext`/`GetWriteDB`/`UseMaster`/`UseReplica` 路由契约,未自建连接、未绕过路由。`WithTransaction` 仍走 `writeConn(ctx).Transaction`(主库事务)。新增 `database.WithTx`/`TxFromContext` 是 additive 的 ctx 携带机制,不改动 `FromContext`/`GetDBFromContext` 既有路由语义。下游 `NewBaseRepo[T](database.GetDB())` 与模板 `r.GetDB().WithContext(ctx)` 完全兼容。无路由/认证/响应体系新契约。 +2. **对下游意味着什么 / breaking change?** **非 breaking(API 签名不变,additive 新增)**。行为变更(已 CHANGELOG/GUIDE 声明):读操作默认路由到从库(原全走主库,正向修复读写分离);写操作显式走主库(即便 `UseReplica(ctx)`);`FindPage*` 的 count+list 包单事务(每页一次 BEGIN/COMMIT,极小额外往返);`QueryBuilder` 标注单次使用/非并发安全。下游典型用法完全兼容;下游若依赖"读走主库"需改用 `database.UseMaster(ctx)`。新增 `database.WithTx`/`TxFromContext`/`UpdateFields`。无配置/migration 变更。 +3. **新并发/生命周期/泄漏隐患?** 无新增。`readConn`/`writeConn` 每次返回新 `*gorm.DB`(`WithContext` 克隆),无共享可变状态;`r.tx` 字段仅在 `WithTransaction` 创建 txRepo 时设置(单 goroutine 内),txRepo 不跨 goroutine 共享。`database.WithTx`/`TxFromContext` 是纯 ctx value 读写(`context.WithValue`),无锁/无 channel。`FindPage*` 的 `Transaction` 由 gorm 管理生命周期(BEGIN/COMMIT/ROLLBACK),回调内 tx 不外泄。`-race` 已跑通过(含路由测试的 DefaultManager 主从并发读 + 事务)。 + +### 代码审查结果:PASS +`readConn`/`writeConn` 优先级清晰(外层 tx > r.tx > 路由 > 回退),写操作显式走主库避免误写从库;`r.tx` 字段 + `database.WithTx` 双路径覆盖 repo 内/跨层事务 join;`UpdateFields` 委托 `gorm.Updates` 是局部更新正解;分页单事务是 count/list 一致性正解;QueryBuilder 克隆 + Count 剥离符合 H6e 复审结论。独立复审待启动。 + +### 安全审查结果:PASS +读写分离真正生效(H6c,原形同虚设);事务 join 消除"fn 内写 autocommit 不回滚"的数据一致性隐患;写操作不路由到只读从库避免写入失败/数据丢失。gosec 0 issue(repository + database)。 + +### 测试审查结果:PASS +回归用例(均修复前红、修复后绿,红绿已实跑验证): +- `TestH6CrudRoundTrip`:CRUD 闭环经 fallback 路径正常。 +- `TestH6UpdateFieldsStructNonZeroOnly`/`MapExplicitZero`:struct 仅非零 / map 显式置零(H6a)。 +- `TestH6WithTransactionRollback`/`Commit`/`TxRepoJoinsTx`:回滚不持久化、提交持久化、txRepo 内读写 join 同一事务(H6c r.tx)。 +- `TestH6WithTxCtxJoinsOuterTx`/`CommitsWhenOuterCommits`:外层 `db.Transaction` + `database.WithTx` 注入 ctx,repo join 外层事务(回滚即回滚 / 提交即提交)(H6c ctx tx)。 +- `TestH6FindPage`/`FindPageWhere`:分页 total/items 正确(H6d)。 +- `TestH6DeleteSoftDeletesWithGormModel`:软删不可见 / `FindDeleted` 可见 / `Restore` 恢复(H6b)。 +- `TestH6QueryBuilderCountStripsLimit`/`FindKeepsLimit`/`PageNoAccumulation`:Count 剥离残留分页、Find 保留 Limit、连续 Page 不累积(H6e)。 +- `TestH6RoutingReadWriteSplit`/`WriteConnNeverHitsReplica`:DefaultManager 主从 sqlite,默认读从库(见 REPLICA 不见 MASTER)、UseMaster 读主库、写落主库(从库不可见)、UseReplica 下写仍落主库(H6c 核心)。 +- 红/绿验证(变异 `readConn`/`writeConn` 为旧 `r.db.WithContext`): + - `TestH6RoutingReadWriteSplit` 复现"default read 看到 MASTER 不见 REPLICA / 写出现在 replica"(红)。 + - `TestH6WithTransactionRollback` 复现"rollback row 持久化"(txRepo 走 r.db autocommit,红)。 + - `TestH6WithTxCtxJoinsOuterTx` 复现"outer-tx row 未回滚"(repo 不 join 外层 tx,红)。 + - 变异 `Count` 去 `Limit(-1).Offset(-1)` 后 `TestH6QueryBuilderCountStripsLimit` 复现"Count=0 而非 5"(红)。 + - 恢复后全绿。 + +### 已知风险 +- **分页单事务开销(LOW,行为变更)**:`FindPage*` 每页一次 BEGIN/COMMIT。高频分页接口有极小额外往返;不可接受可用 `QueryBuilder.Page`(轻量、不包事务,但 total/items 非快照一致)。已 CHANGELOG/GUIDE 声明。 +- **QueryBuilder 不参与读写分离路由(LOW,文档化)**:QueryBuilder 查询经构造时注入的 db(通常主库),不路由到从库;H6e 复审结论仅要求克隆 + 文档,未要求路由。需读写分离用具体方法(FindPage/FindWhere 等)。已 GUIDE 声明。 +- **`database.TransactionWithContext` 不自动注入 tx 到 ctx(设计约束,文档化)**:其 fn 签名 `func(tx *gorm.DB)` 不接收 ctx,故需跨层 join 时用户手动 `database.WithTx(ctx, tx)`。已 GUIDE 示例。 +- **`DefaultManager` 全局状态在路由测试中被初始化(测试隔离)**:`setupH6Manager` 用 `t.Cleanup(database.CloseAll)` 还原(master/replicas 置 nil),不影响其它测试的 fallback 路径。sqlite 方言注册为全局 additive(不影响 mysql/postgres)。 +- **新增 `glebarez/sqlite` 测试依赖(纯 Go,无 CGO)**:仅测试用,不进入生产二进制;与 miniredis 同性质。 +- `staticcheck` 因环境限制(go1.24 vs go1.25)无法运行,已显式说明跳过;`go vet`/`gosec` 通过。 + +### 后续优化建议 +- `QueryBuilder` 若需读写分离,可重构为存储条件数据 + 终结时基于 `readConn(ctx)` 重建链(当前按 H6e 复审结论仅克隆 + 文档)。 +- `database.TransactionWithContext` 可考虑提供 `TransactionWithContext2(ctx, fn func(ctx context.Context, tx *gorm.DB) error)` 变体自动注入 tx,省去手动 `WithTx`。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + `-race`/vet + 变异实验复核 10 项:H6a–H6e **全部 CONFIRM 修复**——读方法全经 `readConn`、写方法全经 `writeConn`(无遗漏 `r.db.WithContext` 残留)、写恒走主库不路由从库、`WithTransaction` txRepo 设 `tx` 字段且 readConn/writeConn 优先 `r.tx`、`database.WithTx`/`TxFromContext` 双路径事务 join 正确、fallback(DefaultManager 未初始化)不 panic、`FindPage*` count+list 同一 tx 变量无错位、QueryBuilder 终结方法克隆 + Count 剥离分页、`UpdateFields` 语义正确、无并发/生命周期/循环依赖隐患。**关键**:变异 `readConn`/`writeConn` 为旧 `r.db.WithContext` 后 `TestH6RoutingReadWriteSplit`/`TestH6WithTransactionRollback`/`TestH6WithTxCtxJoinsOuterTx` 实跑复现红,恢复后全绿——证明非假绿。无 CRITICAL/HIGH/MEDIUM。**2 项 LOW(均不阻断)**:L1 `FindPage*` 在外层 ctx 携带事务时 `.Transaction` 开 savepoint(语义正确,已补注释);L2 QueryBuilder 不参与读写分离(H6e 复审结论有意取舍,已文档化)。**裁定:可交付 PASS。** + +--- + +## ✅ H8 路由/注册中心全局单例无锁 + Apply 不幂等 + metrics 依赖调用顺序 + 三个 `/health` 行为不一 — P2 + +### 缺陷复核(回源码 file:line) +- **H8a**(router.go:233/247-268):包级 `globalRegistry *Registry` 裸指针,`Init` 写、`Use`/`RegisterModule`/`RegisterModuleFunc`/`RegisterVersion`/`Apply` 全局 helper 直接解引用读 → 与请求 goroutine 无锁竞争;且 `Init` 之前调用任意全局 helper 触发 nil 解引用 panic(无 nil 守卫)。 +- **H8b**(router.go:210-229):`Registry.Apply` 无幂等位,二次调用重复 `engine.Use(r.globalMiddlewares...)` + 重复注册同一路由 → Gin `panic: handlers are already registered for this route`。 +- **H8c**(metrics.go:25):`RegisterMetricsRoute` 用 `r.Use(middleware.Metrics())`,Gin `engine.Use` 仅对其后注册的路由生效 → 依赖调用顺序,先于其注册的路由不被采集。 +- **H8d**(router.go:48-57/100-106, handler.go:20-24):三处 `/health` 行为/schema 各异——`RegisterHealthRoute`(可 503 + checks)、`defaultModule`(恒 200 `{"status":"ok"}`)、`handler.HealthCheck`(恒 200 经 `response.Success` 包成 `{code,msg,data}` 信封);并存还会 Gin 重复路由 panic。 + +### 修复 +- **H8a 全局注册中心 atomic + nil 守卫**:`globalRegistry` 改 `atomic.Pointer[Registry]`,`Init`/`GetRegistry` 经 `Store`/`Load`。新增 `ensureRegistry()`:未初始化时 `panic("router: 全局注册中心未初始化,请先调用 router.Init(engine) 再使用全局 helper")`,把晦涩 nil 解引用转成可定位的初始化顺序错误。所有全局 helper(`Use`/`RegisterModule`/`RegisterModuleFunc`/`RegisterVersion`/`Apply`)经 `ensureRegistry()` 取实例。 +- **H8b Apply 幂等**:`Registry` 新增 `applied bool`,`Apply` 首行 `if r.applied { return }`,随后置 true。二次/多次 Apply 直接返回,中间件与路由仅装入一次。 +- **H8c metrics 去调用顺序依赖**:`RegisterMetricsRoute` 删 `r.Use(middleware.Metrics())`,仅注册 `/metrics` 暴露端点。`Registry` 新增 `metricsMiddleware gin.HandlerFunc` 字段 + `SetMetricsMiddleware(mw)` 方法;`Apply` 在 `applied` 守卫后、用户全局中间件之前,首个装入 `r.metricsMiddleware`(非 nil 时)。`app.go` `Init` 中 `enableMetrics` 时调 `a.registry.SetMetricsMiddleware(middleware.Metrics())` 再 `RegisterMetricsRoute`。结果:所有经注册中心注册的业务路由被采集,不依赖 `RegisterMetricsRoute` 相对其它路由的调用顺序;`/metrics` 自身与 `/health` 等直接挂 engine 的基础路由不经采集中间件(保留原"不计入自身"意图)。 +- **H8d /health 收敛单一实现**:抽取 `healthHandler(checks []HealthCheck) gin.HandlerFunc`,`runHealthChecks` 为其唯一数据源。`RegisterHealthRoute`/`RegisterReadinessRoute`/`defaultModule.Register` 均委托之(`defaultModule` 传 `nil` checks → 恒 200 `{"status":"ok"}`,与 `RegisterHealthRoute` 同 schema)。`handler.HealthCheck` 响应体由 `response.Success(c, gin.H{"status":"ok"})`(`{code,msg,data}` 信封)收敛为 `c.JSON(http.StatusOK, gin.H{"status":"ok"})`,与 router 同 schema,便于 K8s 探针直读。 + +### 三个全局问题 +1. **与既有体系契约一致?** 是。`Registry` 公开方法签名不变(`Use`/`RegisterModule`/`RegisterVersion`/`Apply` 等);`Init`/`GetRegistry`/全局 helper 签名不变。新增 `Registry.SetMetricsMiddleware`(公开)、`ensureRegistry`/`healthHandler`/`applied`/`metricsMiddleware`(未导出)。`RegisterMetricsRoute` 签名不变(仍 `(r, path...)`),仅行为变(不再 `r.Use`)。无 DB/认证/响应体系新契约;metrics 仍走 `middleware.Metrics()` 同一中间件。atomic 守护包级全局同 C9c/C10/H7 模式。 +2. **对下游意味着什么 / breaking change?** **1 项 breaking(已 CHANGELOG 声明)**:`handler.HealthCheck` 响应体由 `{code:1,msg:"",data:{status:"ok"}}` 改为 `{"status":"ok"}`。直接断言旧信封 `code`/`data` 字段的下游需改断言 `status` 字段;需依赖探活(失败 503)改用 `router.RegisterHealthRoute(checks...)`。其余非 breaking:`RegisterMetricsRoute` 调用方无感(签名不变,行为更正确——业务路由必被采集);`Apply` 二次调用从 panic 变 no-op(更安全);全局 helper 在 Init 前从 nil 解引用变明确 panic(更可定位)。无配置/migration 变更。 +3. **并发/生命周期/资源泄漏?** 无新增。`globalRegistry` atomic 消除原裸指针竞争(`-race` 验证)。`Apply` 幂等消除二次调用 panic。metrics 中间件仍为无状态中间件(promauto 指标为包级 prometheus 注册,与原一致)。`healthHandler` 闭包捕获 `checks` 切片(启动期构造,只读,无竞争)。 + +### 验证(机械 + 行为闭环) +- **机械**:`go test -race -buildvcs=false ./router/... ./handler/... .` 通过;`go vet -buildvcs=false ./router/... ./handler/... .` 通过;`go test -race -buildvcs=false ./...` 全量通过。`staticcheck` 因 go1.24/1.25 版本不匹配不可用(已知,显式跳过);gosec 未触及安全敏感代码(metrics/health/registry 注册路径)。 +- **回归用例(红/绿)**: + - `TestApplyIdempotent_H8b`(internal):二次/三次 Apply 无 panic,全局中间件仅装入一次(请求一次 runs==1,若不幂等则 >1)。 + - `TestMetricsMiddlewareFirstInApply_H8c`(internal):不调 `RegisterMetricsRoute`,仅 `SetMetricsMiddleware` + `Apply`,业务路由被采集(hits==1)。修复前 `r.Use` 模式下不调 `RegisterMetricsRoute` 则完全不采集。 + - `TestMetricsMiddlewareNilSkipped_H8c`(internal):未设 metrics 中间件时 Apply 不 panic。 + - `TestEnsureRegistryPanicsBeforeInit_H8a`(internal):Init 前调 `Apply()` panic 且信息含 `router.Init`(修复前为 nil 解引用 panic,信息晦涩)。 + - `TestGlobalRegistryAtomicConcurrent_H8a`(internal):50 轮并发 `Init`/`GetRegistry`,`-race` 通过(修复前裸指针会触发 DATA RACE)。 + - `TestHealthHandlerConvergedSchema_H8d`(internal):`defaultModule` 的 `/health` 返回 `{"status":"ok"}` 且不含 `code`/`data` 信封字段。 + - `TestHealthCheckSchemaConverged_H8d`(handler):`handler.HealthCheck` 返回 `{"status":"ok"}` 且不含信封字段。 + - `TestAppMetricsInstrumentsRegistryRoutes_H8c`(app 端到端):`WithMetricsRoute` + `WithModules` 注册 `/h8c-biz`,Init 后请求 2 次,`/metrics` 输出含 `route="/h8c-biz"` 序列(修复前经注册中心的路由可能不被采集)。 +- **既有用例不回归**:`TestApply`/`TestApplyWithVersion`/`TestApplyWithMiddleware`/`TestGlobalApply`/`TestRegisterHealthRoute*`/`TestRegisterDefaultRoutes`/`TestRegisterLivenessRoute`/`TestRegisterReadinessRoute*`/`TestHealthCheck`/`TestAppWithHealthRoutes`/`TestAppWithHealthCheckFailure` 全绿。 + +### 已知风险 +- **~~`defaultModule` 与 `RegisterHealthRoute` 并存重复路由 panic~~(已修复,见 H8d 收尾)**:原为 MEDIUM footgun,已由 `registerGETOnce` 幂等注册消除(见下"H8d 收尾")。框架基础路由注册现全部幂等,并存组合不再 panic。 +- **`handler.HealthCheck` 响应体变更(breaking,已声明)**:见上"对下游意味着什么"。CHANGELOG `[Unreleased]` 升级说明已列。 +- **metrics 中间件不采集直接挂 engine 的基础路由(设计取舍,保留原意图)**:`/health`/`/livez`/`/readyz`/`/swagger`/`/metrics` 自身不经采集中间件(它们在 `Apply` 前直接注册于 engine)。业务路由(经注册中心)全被采集。与原 `RegisterMetricsRoute` 注释意图一致,且消除了调用顺序依赖。 +- **幂等注册的"首次胜出"语义(LOW,文档化)**:`registerGETOnce` 对同一 (GET, path) 重复注册静默跳过,首次注册的 handler 胜出。app.go 真实流程中 `Register*`(带 checks)先于 `defaultModule` 经 Apply 注册,故 `/health` 的 checks 版本胜出(符合预期)。若用户在纯 Registry 模式下先 `WithModules(DefaultModule)` 再 `RegisterHealthRoute(checks)`,则 defaultModule 的无 checks 版本胜出(checks 被跳过)——此为显式取舍,文档已标注,避免 panic 优先于语义完美。 + +### 后续优化建议 +- metrics 中间件可支持 `WithPath` 排除特定路由(如健康探针)的可配置项,当前硬编码"基础路由不采集"。 + +### H8d 收尾:defaultModule 重复路由 footgun 消除(独立复审 MEDIUM 项) +独立复审指出的 MEDIUM——`defaultModule`(`/health`+`/swagger/*any`)与 `RegisterHealthRoute`/`RegisterSwaggerRoutes` 并存触发 Gin `handlers are already registered` panic——已修复: +- 新增 `registerGETOnce(r gin.IRoutes, path, h)` 幂等注册辅助(router.go)。`RegisterHealthRoute`/`RegisterLivenessRoute`/`RegisterReadinessRoute`/`RegisterSwaggerRoutes`/`RegisterMetricsRoute`/`defaultModule.Register` 全部经之。 +- `*gin.Engine` 路径:`Routes()` 精确预检 (method+path),命中即跳过;未命中直接注册,**不吞 panic**——真正不同的路由冲突(如 `/foo/:id` 已存在再注册 `/foo/*any`)仍按 gin 原语义 panic,不被掩盖。 +- `*gin.RouterGroup` 路径(defaultModule,gin 未暴露 engine 无法预检):recover 兜底,仅吞 gin 重复路由 panic(`already registered` / `conflicts with existing wildcard`,覆盖 gin 对重复注册的两类 panic),其余 panic 原样抛出。最坏情况(gin 改动文本)退化为原行为,不引入新风险。 +- 回归用例(`router_h8_internal_test.go`,`-race` 全绿): + - `TestDefaultModuleAndRegisterHealthRouteCoexist_H8dfootgun`:Register*(带 checks)+ defaultModule 经 Apply 并存,无 panic,/health 首次注册(带 checks)胜出,/swagger 可访问。修复前在此 panic。 + - `TestRegisterHealthRouteIdempotent_H8dfootgun`:`RegisterHealthRoute` 重复调用不 panic。 + - `TestDefaultModuleOnly_H8dfootgun`:仅 defaultModule(不预 Register*)时 /health+/swagger 正常(recover 兜底不影响首次注册)。 + - `TestRegisterGETOnceEngineDoesNotSwallowRealConflict_H8dfootgun`:Engine 路径对真实不同路径冲突(`/foo/:id` vs `/foo/*any`)仍 panic,证明幂等只吞"同一 path 重复",不掩盖真实冲突。 +- 验证:`go test -race -buildvcs=false ./...` 全绿;`go vet` 通过;`gosec ./router/` 0 issue;`staticcheck` 因 go1.24/1.25 不匹配不可用(已知跳过)。 + +### 独立对抗性复审结论 +未参与编码的独立 agent 回源码 `file:line` + `-race`/vet + 变异证伪实验复核 H8a–H8d:**全部 CONFIRM 修复**——`globalRegistry` 为 `atomic.Pointer[Registry]`(router.go:269),`Init`/`GetRegistry`/全局 helper 经 `ensureRegistry()`(:285-291)Load + nil 守卫,panic 信息含 `router.Init` 可定位,全仓库无残留裸读(H8a);`Apply` 幂等守卫(:234-238)+ `TestApplyIdempotent_H8b` 断言 `runs==1`(H8b);`SetMetricsMiddleware`(:225-227)+ `Apply` 内首个装入(:241-243)+ `RegisterMetricsRoute` 仅注册端点(metrics.go:19-25)+ `app_test.go:248` 端到端验证业务路由被采集、`/metrics` 不自采集(H8c);统一 `healthHandler`(:52-61)三处委托 + `handler.HealthCheck` 直 `c.JSON` 不走信封(handler.go:25-27)(H8d)。**关键**:变异实验在隔离 module 复现 `Apply` 守卫语义——`GUARD=no` 时 "middleware appended 2 times (want 1)" 红、`GUARD=yes` 绿,证明 H8b 断言非假绿(因权限策略禁止直接变异生产源码,实验在临时 module 完成,已清理,生产源码未触碰)。`go test -race`/`vet` 全绿;`handler.HealthCheck` breaking change 已 CHANGELOG 声明。无 CRITICAL/HIGH。**原 1 项 MEDIUM(defaultModule 与 Register* 并存重复路由 panic footgun)已在"H8d 收尾"修复消除**(`registerGETOnce` 幂等注册 + 4 个回归用例,`-race` 全绿)。**4 项 LOW(均不阻断)**:L1 `Init` 覆盖旧 registry 无迁移/告警(既有设计);L2 `applied` 无锁、`Apply` 非并发安全(实际单线程调用,文档未声明);L3 `SetMetricsMiddleware` 在 Apply 后调用静默无效、未代码强制"须在 Apply 前";L4 注释"首个全局中间件"措辞易误读为 engine 首位(实为注册中心全局中间件首个)。**裁定:可交付 PASS。** + +--- + +## P0 全部闭环 ✅ + +13 项 CRITICAL + 5 项 HIGH(H1/H2/C9b/H4a + 含 C9a 延伸)全部修复闭环: +- C6/C8/C4/C5/C2/C1/C7/C3(13 CRITICAL) +- H2/H1/C9b/H4a(P0 的 HIGH) + +下一阶段进入 P1(并发/正确性):C10/C11/C9a,c/H3/H7/C12/C13/H5。 + +### 待修复(按优先级) + +### P0(安全/数据/功能性失效)— 全部完成 ✅ +- [x] C6/C8/C4/C5/C2/C1/C7/C3/H2/H1/C9b/H4a + +### P1(并发/正确性)— 全部完成 ✅ +- [x] C10(config 全局 Manager 无锁置换 + 热重载绕过 Validate)— ✅ 已闭环 +- [x] C11(database 池泄漏 + Master/Replicas 无锁读)— ✅ 已闭环 +- [x] C9c(jwt SetDefaultJWTManager/包级 tokenBlacklist 无锁置换 — C9a 已在 C9b 修)— ✅ 已闭环 +- [x] H3(middleware/logger body OOM)— ✅ 已闭环(请求/响应 body 读取均封顶,下游仍得完整 body) +- [x] H7(logger 全局指针写有锁读无锁)— ✅ 已闭环 +- [x] C12(cron 竞争/重叠执行/解析)— ✅ 已闭环 +- [x] C13(trace nil-panic 与未实现导出器)— ✅ 已闭环 +- [x] H5(response/handler 业务码与 HTTP 状态混乱 + RequestID)— ✅ 已闭环 +- [x] H4b(CustomRateLimit goroutine 泄漏)— ✅ 已闭环 +- [x] H4c(RedisRateLimiter fail-open + 裸断言)— ✅ 已闭环 + +### P2(框架集成一致性) +- [x] H6(BaseRepo 接入 GetDBFromContext + 分页事务)— ✅ 已闭环 +- [x] H8(路由/health 统一 + Apply 幂等)— ✅ 已闭环 +- [ ] C3 收尾(生产者取消信号 — C3 主体已修,剩生产者契约硬约束) +- [ ] M14(timeout 软超时文档化) + +### P3(清理) +- [ ] MEDIUM/MINOR 各项 + 全量补 -race diff --git a/v_1.1.1_fix.md b/v_1.1.1_fix.md new file mode 100644 index 0000000..a187f08 --- /dev/null +++ b/v_1.1.1_fix.md @@ -0,0 +1,260 @@ +# xlgo 框架 v1.1.1 审查复核与修复建议 + +> 复核日期:2026-06-24 +> 复核方式:以 `version_1.1.1_report.md` 为线索,**逐条回到源码核实**(非读文档)。每条给出裁定(确认 / 证伪 / 表述不精确 / 需补充语境)、`file:line` 证据、最小修复方案。 +> 复核结论:原报告整体**高度可信**。13 项 CRITICAL 全部成立;8 项 HIGH 全部成立(其中 H5a 机制描述需修正);MEDIUM/MINOR 绝大多数成立,发现 **4 处原报告失实/夸大**,列在文末"对原报告的勘误"。 + +--- + +## 复核裁定速览 + +| 等级 | 条目 | 确认 | 需修正措辞 | 证伪 | +|------|------|------|-----------|------| +| CRITICAL | C1–C13 | 13/13 核心成立 | C3.3c、C11a、C12d、C12e 部分措辞 | — | +| HIGH | H1–H8 | 8/8 成立 | H5a 机制、H6e | H1"已文档化为不安全"反而相反 | +| MEDIUM | M1–M20 | 18 成立 | M7、M11、M20 | M16"Walk 内 FD 泄漏" | +| MINOR | N1–N7 | 7/7 成立 | — | — | + +**最危险的"活 bug"(非潜在/理论)**:C6(API CSRF 整体不可用,功能性失效)、C2(WS 广播即死锁)、C3(SSE 断连泄漏,AI 主场景)、C8(默认模式 panic 返回 200)、C4/C5(路径穿越 / Zip-Slip)、H2(默认关闭 TLS 校验)、H4a(正常客户端被误限流)、C9b(刷新令牌时旧 token 撤销失败仍签发新 token)。 + +--- + +## CRITICAL + +### C1 `cache/lock.go` 分布式锁 + +- **C1a 自动续期死锁/锁泄漏 —— 确认。** `done` 无缓冲(lock.go:192),子 goroutine 各返回路径 `defer close(done)`(:194),父在 `fn()` 后 `done <- struct{}{}`(:217)。正常路径靠 rendezvous 安全;但 **ctx 取消(:200)或 ExtendLock 失败(:206-207)提前返回时 `done` 已 closed,父再 send → "send on closed channel" panic**,`Unlock`(:218)不执行,锁持有到 TTL。 +- **C1b 裸类型断言 —— 确认(潜在)。** `result.(int64)`(:76/108/142)。当前 Lua 脚本恒返回整数,go-redis 映射为 int64,正常不 panic;属脆弱性,非活 bug。 +- **C1c TryLock 忽略 ctx —— 确认。** `time.Sleep(retryInterval)`(:159)不响应 `ctx.Done()`,仅额外延迟一个间隔,非泄漏。 +- **C1d 无 fencing token —— 确认(设计局限)。** + +**修复**:续期改"父关停 + 子 ack"双 channel,仅父方 `close(stop)`;用 `context.Background()` 派生超时做 `Unlock`(原 ctx 可能已取消导致解锁失败再泄漏): +```go +stop, finished := make(chan struct{}), make(chan struct{}) +go func() { + defer close(finished) + t := time.NewTicker(extendInterval); defer t.Stop() + for { + select { + case <-ctx.Done(): return + case <-stop: return + case <-t.C: + if err := ExtendLock(ctx, token, initialTTL); err != nil { return } + } + } +}() +err = fn() +close(stop); <-finished +uctx, cancel := context.WithTimeout(context.Background(), 5*time.Second); defer cancel() +Unlock(uctx, token) +``` +断言一律改 comma-ok;TryLock 用 `select { case <-ctx.Done(): return nil, ctx.Err(); case <-time.After(retryInterval): }`。 + +### C2 `ws/ws.go` Hub 死锁 + channel 关闭竞态 + +- **C2a 广播失败即死锁 —— 确认。** `h.unregister` 仅被 `Run` 的 `select`(:264)消费;广播分支内 `conn.Send` 失败时 `h.unregister <- conn`(:277)向"自己"发送、无接收者 → 永久阻塞,整个 Hub 卡死。触发条件现实:向任一已关闭连接广播即触发。**原报告"并发修改 map"措辞不精确**——map 仅 `Run` 单 goroutine 改,无 data race;真 bug 是自发送死锁。 +- **C2b `Close()` 关 `send` 与 `Send()` 竞态 panic —— 确认。** `Close` 同时 `close(c.closeChan)` 与 `close(c.send)`(:85-86);并发 `Send` 的 `select`(:60-64)此时两 case 同时就绪,Go 伪随机选中 `c.send <- data` 即 "send on closed channel" panic。`closeChan` 的 select **不能**消除竞态。 +- **C2c 无读/写 deadline、无 pong handler —— 确认。** `SetReadDeadline/SetWriteDeadline` 有定义(:102-109)却从不内部调用;发 ping(:215)但无 `SetPongHandler`、无读超时 → 半开连接 `ReadMessage`(:180)永久阻塞、goroutine 泄漏。 + +**修复**:广播分支持写锁单次遍历、行内 `delete + conn.Close()`,去掉 channel 回环;`Close()` 删除 `close(c.send)`,仅以 `closeChan` 作关闭信号;读循环前置 `SetReadDeadline + SetPongHandler`,每次写前 `SetWriteDeadline`,ping 周期 `<` pongWait。 + +### C3 `sse/sse.go` 断连泄漏 goroutine + 算力(AI 主场景) + +- **C3a 循环无 `ctx.Done()` 分支 —— 确认。** 四个 `for range ch`(:79/104/120/142)仅靠 ch 关闭或写错误退出。 +- **C3b(核心)写/Flush 错误被吞,循环永不因断连退出 —— 确认,机制与报告完全一致。** `WriteEvent`/`WriteMessage` 丢弃 `fmt.Fprintf` 返回的错误且**恒 `return nil`**(:38-43/47-51),`Flush()` 无返回值。`WriteJSON`(:54-60)仅在 `json.Marshal` 失败时返错,否则透传 `WriteEvent` 的 nil。故 `StreamText` 等的 `if err := WriteJSON(...); err != nil` 守卫**只对 marshal 失败生效,对客户端断连永不触发** → 消费循环不退出 + 上游生产者(常为昂贵 LLM 流)持续运行直到进程结束。 +- **C3c `Transfer-Encoding: chunked` 手设 —— 确认已设;影响需补充语境。** HTTP/1.1 下冗余,HTTP/2 下该头非法(应交由 server 分帧),非必然破坏。 + +**修复**:`WriteEvent/WriteMessage` 返回 `fmt.Fprintf` 错误;Stream 系列改 `for { select { case <-c.Request.Context().Done(): return ctx.Err(); case v, ok := <-ch: ... } }`;**生产者也必须接收由 `c.Request.Context()` 派生的取消信号**,否则消费端早退仍无法真正停止上游;删除 chunked 手设头。 + +### C4 `storage/storage.go` 路径穿越 + 无校验 + +- **C4a 路径穿越 —— 确认(按方法分级)。** `filepath.Join` 内含 `Clean`,攻击者可 `..` 逃逸根目录。最严重:`Delete`(:152-160,任意文件删除)、`Get`(:162-171,任意读)、`Exists`(:173-178,存在性探测)——`path` 全程受控。`Upload/UploadFromBytes`(:57-101/104-144)仅 `subdir` 受控、文件名服务端随机生成,为"任意目录写"(不能精确覆盖目标),**需补充语境**。OSS 变体为未净化 object key,非 FS 穿越。 +- **C4b 无扩展名/类型/大小校验 —— 确认。** 全程未引用 `file.Size`;`evil.php`、超大文件直传。 +- **C4c 全量读入内存 —— 确认。** Local `os.ReadFile`(:165)、OSS `io.ReadAll`(:287)无上限。 + +**修复**:新增 `resolve(rel)` 做 `filepath.Abs` + 前缀锚定校验,`Delete/Get/Exists/Upload` 统一经过;上传前查 `file.Size` 上限 + 扩展名白名单(白名单可配置,Content-Type 用 `http.DetectContentType` 嗅探前 512B);`Get` 提供流式 `io.ReadCloser` 或 `io.LimitReader` 封顶。 + +### C5 `compress/compress.go` Zip-Slip + 解压炸弹 + +- **C5a Zip-Slip —— 确认。** `unzipFile` 中 `filePath := path.Join(dstDir, file.Name)`(:176)无逃逸校验,`os.Create`(:195)可覆盖任意文件。另用 `path.Join` 非 `filepath.Join`,Windows 分隔符处理不当。 +- **C5b 解压炸弹 —— 部分需修正。** `GzipDecompress`(:37 `io.ReadAll`)确为 **OOM**;`GzipDecompressFile`(:87)、`Unzip`(:201)为 `io.Copy` 写盘,属**磁盘耗尽**而非 OOM,原报告"OOM"措辞不精确。 + +**修复**:解压前 `filepath.Clean(dstDir)` + 前缀锚定校验,拒绝 `..`;额外跳过/拒绝 `file.Mode()&os.ModeSymlink != 0` 的符号链接条目(防经软链二次穿越);`io.CopyN` 单条目封顶 + 累计上限;`GzipDecompress` 用 `io.LimitReader`。 + +### C6 `middleware/csrf.go` API CSRF 模式功能性失效(map 遮蔽)—— **确认(功能 bug,已亲自核对)** + +- **C6a map 遮蔽 —— 确认。** `CSRFForAPI()` 内 `tokens := make(map[string]bool)`(csrf.go:228)是**局部变量**,闭包校验读它(:248);`GenerateAPIToken` 写的是**包级** `tokens`(:272,声明于 :281-282)。两者从不相交 → 颁发的 token 永不在校验 map 里,**所有非安全方法请求被判"CSRF Token 无效"拒绝,API CSRF 模式整体不可用**。且局部 map 每次调用 `CSRFForAPI()` 重建。 +- **C6b 只增不减、无过期、验证不消费 —— 确认。** 包级 `tokens` 无 delete/TTL/淘汰,内存 DoS + token 永久可重放。 +- **C6c —— 需修正归属。** `CSRF()`(cookie 模式)经 body 下发 token(:135 + `CSRFToken` handler),HttpOnly=true 反而正确,**原报告对 :60 的指控证伪**。真正的自相矛盾在 `DoubleSubmitCookie`(:328 `SetCookie(...HttpOnly=true)` 与 :346 要求 JS 回填 header),原报告未引此处。 + +**修复**:删除 `CSRFForAPI` 内的 `tokens`/`mu` 两个局部声明使其绑定包级;改单次消费 + TTL(`map[string]time.Time`,验证后 `delete`),生产环境落 Redis `SETEX`+`GETDEL`;`DoubleSubmitCookie` 的 cookie 改 `HttpOnly=false`,`CSRF()` 维持 true。 + +### C7 `middleware/cors.go` 通配符后缀绕过 —— 确认 + +- **C7a —— 确认。** `*.example.com` → `domain="example.com"`,`strings.HasSuffix(origin, domain)`(:54)未锚定 host,`https://notexample.com`、`https://evil-example.com` 均被接受。 +- **C7b —— 确认(凭据部分有条件)。** 开发态无条件回显任意 Origin(:77);若同时 `AllowCredentials:true`(:103-105)则构成凭据型反射。 + +**修复**:`*.` 通配改用 `net/url` 解析 host,要求真实子域边界(`.example.com` 后缀或等于 apex);开发态兜底限制为 localhost 列表,不回显任意 Origin,且回显来源不与 credentials 并存。 + +### C8 `middleware/recover.go` 500 状态丢失(默认 ModeBusiness)—— **确认(已亲自核对)** + +- 默认模式 `ModeBusiness=iota=0`(mode.go:16),`currentMode` 零值即默认(:23)。`httpStatusFor(CodeServerError)` 在默认模式返回 200(:64)。`Recover` 中 `FailWithCode → writeResp → c.JSON(200,...)`(mode.go:69)已 flush 锁定状态,随后 `c.AbortWithStatus(500)`(recover.go:33)因 `w.Written()==true` 成为 **no-op**,客户端收 HTTP 200 + body `code:500`,网关/APM 按 status 看不到 panic。`RecoverWithDetail`(:57-58)同病。 +- **ModeREST 无此 bug**:`statusForCode(CodeServerError)=500`(mode.go:48-49),状态一致。 + +**修复**:用不受 Mode 影响的 `response.Custom` 显式写 500,并去掉事后 `AbortWithStatus`: +```go +response.Custom(c, http.StatusInternalServerError, response.CodeServerError, "服务器内部错误", nil) +c.Abort() +``` + +### C9 `jwt/jwt.go` 黑名单缺陷 + +- **C9a 无 Redis 静默失效 —— 确认。** `Add`/`IsBlacklisted`(jwt.go:71-101)在 client==nil 时静默 `return nil`/`return false`,登出/吊销无效且无任何信号。 +- **C9b(最危险)RefreshToken 吞 Add 错误 —— 确认。** `InvalidateToken` 返回错误,但 `RefreshToken`(:287-292)丢弃 `tokenBlacklist.Add` 错误仍签发新 token → Redis 抖动时旧 token 未拉黑、新旧 token 双有效,会话固定窗口。 +- **C9c SetDefaultJWTManager 无锁置换 —— 确认(潜在)。** 包级 `DefaultJWT`/`tokenBlacklist`(:123-129)裸写,与请求 goroutine 读竞争;典型仅启动期调用故潜在。 + +**修复**:`RefreshToken` 对 `Add` 错误 `return "", fmt.Errorf(...)`;无 Redis 时让 `Add` 返回 `ErrBlacklistUnavailable` 或启动期告警一次(而非静默);包级指针改 `atomic.Pointer` 或文档强制"服务前调用"。 + +### C10 `config/config.go` 全局 Manager 无锁置换 + 热重载绕过校验 + +- **C10a —— 确认。** `defaultManager` 包级指针被 `Load`/`LoadWithWatch`/`SetDefaultManager`(:578/584/628-634)裸写,与 `Get` 等读者竞争。 +- **C10b —— 确认。** `OnConfigChange`(:486-501)与 `Reload`(:559-565)均**不调 `Validate()`**,非法配置(坏端口、负超时、短密钥)直接发布;解析失败 `return` 静默吞。 +- **C10c —— 确认。** `Load` `return &cfg`(:444)即 `m.cfg` 同一指针,调用方可变并竞争;热重载只换 `m.cfg`,**不重建 DB/Redis 池**,对子系统装饰性。 +- **C10d —— 确认。** `StopWatcher` 空函数(:507),viper watcher goroutine + fd 永不释放(部分受 viper API 限制)。 + +**修复**:`defaultManager` 改 `atomic.Pointer[Manager]`;热重载补 `unmarshal` 失败告警 + `newCfg.Validate()` 失败保留旧配置;`Load` 返回防御性拷贝(`cp := cfg; return &cp`);自管 `fsnotify.Watcher` 以便 `StopWatcher` 真正 `Close`。 + +### C11 `database/manager.go` 并发与生命周期 + +- **C11a replicaHealthy 不重置 —— 需修正。** `initReplicaHealth` 有 `replicaHealthSet` 早返回(manager.go:144-155),重新 `InitDBWithReplicas` 不重置 → 健康切片与新 replicas 长度错位;**但 `Replica()`(:120) 与 `probeOnce` 均有 `i < len(...)` 守卫**,故**不会越界 panic**(原报告"越界"措辞证伪),真问题是新从库健康状态陈旧/被排除。 +- **C11b InitDB 重试泄漏 —— 确认。** `gorm.Open` 成功但 `Ping` 失败时旧池不关、下轮覆盖 `m.master`(:346-389),每次重试泄漏一池。 +- **C11c InitDBWithReplicas 泄漏 —— 确认。** `m.replicas = nil`(:423)前不关旧从库池。 +- **C11d Master()/Replicas() 无锁读 —— 确认。** (:96-104)与 `Close`/`InitDB` 写竞争,可能返回已关闭/ nil 池;`Replica()` 取 `len` 在加锁前。 +- **C11e RoundRobin 截断 / RandomPicker 全局 rand —— 需修正。** `int(n-1)%len` 仅 32 位平台截断且因取模仍在范围内,无 panic / 正确性问题;`RandomPicker` 全局 `math/rand` 仅锁竞争。属微优化,非功能 bug。 +- **C11f 包级 Close() 仅关主库 —— 确认。** 包级 `database.Close()`(:524-536)仅关 master 且无锁,`CloseAll()`/方法 `Close()` 关全部,命名误导致用户泄漏从库。 + +**修复**:`InitDBWithReplicas` 重建前关旧主/从池并重置 `replicaHealthSet=false`、`replicaHealthy=nil`;`InitDB` 重试在覆盖前 `sqlDB.Close()`;`Master/Replicas/Replica` 全程加锁或改 `atomic.Pointer`;包级 `Close()` 改为委托 `CloseAll()`(或废弃)。 + +### C12 `cron/cron.go` 数据竞争 + 重叠执行 + 漂移 + +- **C12a —— 确认。** `runTask` 无锁写 `LastRun`/`RunCount`(:142-143),`GetTask`(:112)/`ListTasks`(:120-124) 返回 live 指针并发读 → data race(`go s.runTask` :206 并发真实)。 +- **C12b 无 running 守卫 —— 确认。** `NextRun` 在 handler 完成后才更新(:148),长任务跨 tick 被 `checkAndRun`(:205) 反复 spawn。 +- **C12c Interval 漂移 —— 确认。** `Next` 以 handler 完成后 `time.Now()` 锚定(:148/218-220),每周期累积 handler 时长。 +- **C12d Weekly 跳过当天 —— 确认;Daily 归属需修正。** Weekly `daysUntil<=0 → +7`(:256-259)使当天未到点的目标被跳一周。"DailySchedule 用严格 now.After" 归属不准——`now.After(task.NextRun)` 在 `checkAndRun`(:205) 对**所有**调度生效(1s ticker → 至多 1s 抖动),`DailySchedule.Next` 本身正确。 +- **C12e cron 解析 —— 部分需修正。** `parseInt` 忽略非数字(:428-431);`1-5,8` 因先判 `-`(:316) → `parseInt("5,8")=58`,**范围被破坏为 1..58 且逗号项丢失**(比报告所述更糟):确认。`7` 周日不匹配(Go Sunday=0):确认。"`garbage` 永不触发"**证伪/需修正**——`parseInt("garbage")=0`,在分/时/周字段会匹配 0 值"错误触发",仅在日(1-31)/月(1-12)字段才"永不触发"。 + +**修复**:`runTask` 计数写入纳入锁、Getter 返回值拷贝;加 per-task `running atomic.Bool` 或在持锁的 `checkAndRun` 内先推进 `NextRun` 再 spawn;Interval 以上次 `NextRun` 锚定;Weekly 用 `((day-now)+7)%7` 且整日期时间 `!After(now)` 才 +7;cron 解析改 `strconv.Atoi` 返错 + 字段范围校验 + 周日 `7→0` + 列表分支独立于范围分支。 + +### C13 `trace/trace.go` opt-in 即崩 + +- **C13a nil tracer panic —— 确认。** 包级 `tracer`(:58)未 `Init` 即 nil,`Middleware`(:160)/`StartSpan`(:217) 等无守卫 → 首个请求 panic。(`Init` 即便 `Enabled=false` 也设 Noop tracer,故仅"从未 Init"才崩。) +- **C13b 未知导出器 → nil + stdout 不存在 —— 确认。** switch 仅实现 otlp-http(:112)/otlp-grpc(:117),`default` 返回 `nil,nil`(:122-123)喂 `WithBatcher(nil)`;文档承诺的 `stdout` **未实现**。 +- **C13c OTLP 默认 HTTPS 无 WithInsecure —— 确认。** (:113-120)对 `localhost:4318` 等明文 collector 握手失败。 +- **C13d Middleware 不更新 c.Request —— 确认。** 仅 `c.Set("otel_ctx", ctx)`(:172),未 `c.Request = c.Request.WithContext(ctx)`,下游 `c.Request.Context()` 拿不到 span。 +- **C13e b3/jaeger 未实现 —— 确认。** switch 仅 `w3c` + default(:129-137),文档承诺的 `b3`/`jaeger` 静默回落 W3C。 + +**修复**:`getTracer()` 懒初始化默认 `otel.Tracer("xlgo")`;实现 stdout 导出器、`default` 返错;`Config` 增 `Insecure bool` 条件追加 `WithInsecure()`;Middleware 补 `c.Request = c.Request.WithContext(ctx)`;接入 contrib b3/jaeger propagator 或裁剪文档。 + +--- + +## HIGH + +### H1 `utils/random.go` 不安全 RNG —— 确认(且比报告更严重) +`randPool` 用 `math/rand` + `time.Now().UnixNano()` 播种(random.go:12),`RandString`(:31)/`RandDigit`(:54) 取自该池,本文件无 `crypto/rand`。**原报告"应强文档警告"措辞证伪——恰恰相反**:`GUIDE.md:1208-1211` 主动推荐其用于 token 与 6 位验证码,使可预测性可被实际利用。 +**修复**:新增 `RandStringSecure`(`crypto/rand`),文档/示例的 token/OTP/重置码改用之,并在 `RandString` 注释警示非安全用途。 + +### H2 `utils/http.go` 默认关闭 TLS 校验 —— 确认 +`DefaultHTTPClientConfig.SkipTLSVerify:true`(http.go:53)→ `InsecureSkipVerify`(:67),`HTTPGet/HTTPPost/HTTPPostJSON` 经 `DefaultHTTPClient()` 全部默认可被 MITM。 +**修复**:默认 `false`,仅显式开启。 + +### H3 `middleware/logger.go` 无上限读 body → OOM —— 确认 +`io.ReadAll(c.Request.Body)`(:60)无 `MaxBytesReader`,`MaxBodyLength` 仅在读完后截断**日志副本**(:64-65),全 body 已驻留并二次 buffer(:62)。`LoggerForAPI`/`LoggerForDebug` 默认开启 `LogRequestBody`,相关路由可被多 GB POST 打爆。 +**修复**:源头 `io.LimitReader(body, limit+1)` 读取 + `io.MultiReader` 复原;或 `http.MaxBytesReader` 做硬上限。 + +### H4 `middleware/ratelimit.go` 限流语义错误 —— 确认(含算术验证) +- **H4a —— 确认。** `Allow` 每次放行都 `v.lastSeen = time.Now()`(:72),重置分支 `time.Since(lastSeen) > window`(:61)对持续客户端永不成立。**算例(rate=10/min,客户端 9 req/min ≈ 每 6.67s 一次)**:count 单调累加至 10,第 11 次起被永久 BLOCK,须静默满 60s 才解锁——未超限的正常客户端被误限流;清理 goroutine 同条件也永不淘汰活跃访客。 +- **H4b CustomRateLimit 泄漏 —— 确认(按构造非按请求)。** `NewRateLimiter` spawn 清理 goroutine(:41-42),返回的 limiter 无句柄、`StopRateLimiters` 不感知 → 每个 `CustomRateLimit` 路由泄漏一 goroutine。 +- **H4c fail-open 确认;`.(int64)` panic 不精确。** Redis 错误返回"放行"(:160)使 Redis 故障时限流(含登录防爆破)静默失效;`result.(int64)`(:163)未 comma-ok 但当前脚本恒返整数不会 panic,属脆弱性。 + +**修复**:H4a 改真正固定窗口——放行时**不**更新 `lastSeen`(仅窗口起点设置);自定义 limiter 登记入全局以便 `StopRateLimiters` 停止;Redis 断言改 comma-ok,安全型限流考虑 fail-closed 或可配置。 + +### H5 `response`/`handler` 业务码与 HTTP 状态混乱 +- **H5a —— 机制需修正。** 原报告"所有 Fail* 硬编 `c.JSON(200)`"**不准确**:实际经 `writeResp → httpStatusFor`(mode.go:60-75)受 Mode 控制;仅 `Success`(:33) 硬编 200。结论"ModeBusiness 默认全 200"成立,但**报告漏看了 `response/mode.go` 这套模式系统**。 +- **H5b —— 确认。** `handler.BadRequest`/`InternalError`(handler.go:157-170)硬编 HTTP 400/500,绕过 Mode,且**不写 RequestID**,与 business 模式不一致、丢链路。 +**修复**:两个 helper 委托 `response.FailWithCode`/`ServerError`(或 `response.Custom` 保留 RequestID)。 + +### H6 `repository/repository.go` BaseRepo 数据安全 + 框架集成断裂 +- **H6a —— 确认。** `Update` 用 `Save`(:49-51)全列覆写,丢失更新、零值不可辨。 +- **H6b —— 确认。** `Delete`(:54-56)注释称软删,但泛型 `T` 无 `gorm.DeletedAt` 时静默硬删,契约不可由类型强制。 +- **H6c(核心)—— 确认。** `r.db` 构造时捕获(:24-31),所有方法 `r.db.WithContext(ctx)` **从不调 `database.GetDBFromContext`** → 读写分离失效、外层 ctx 事务无法 join,`WithTransaction`(:324) 另开嵌套 tx 拿不到外层。 +- **H6d —— 确认。** `FindPage` count/list 两查询无事务(:156/167,及多处分页同型),高并发 total 与 items 不一致。 +- **H6e —— 需修正。** QueryBuilder 链式 mutate `qb.db`、非并发安全:确认;但 `Page` **已用 `Session{}.Limit(-1).Offset(-1)` 克隆做 count**(:406),count 未被污染——原报告"Page 污染"仅对 Find 侧(:412)成立,"count 被破坏"证伪。 +**修复**:新增 `conn(ctx)` 优先取 `GetDBFromContext(ctx)`,全方法替换 `r.db.WithContext`;分页 count+list 包进单事务;终结方法一律基于 `Session{}` 克隆,文档标注 QueryBuilder 单次性/非并发安全;补 `UpdateFields`(`Updates` 局部更新)。 + +### H7 `logger/logger.go` 全局指针写有锁读无锁 —— 确认 +`Init`/`Close` 持 `m.mu` 写 `Logger/sugar/apiLog/dbLog`(:121-131/193-200),但 `Info/Error/APILog()`(:235-285)无锁读 → 热重载 re-Init / 关闭与请求日志竞争。被保护的是包级变量、锁却是 per-manager,更弱。 +**H7b** `field.go:15` `Duration func(key, value)` 在 `case zap.Field:`(:26-27) 直接返回原 field 丢弃 key,签名与实现矛盾。 +**修复**:四个全局改 `atomic.Pointer[zap.Logger]`,读侧原子 load;`Duration` 改 `func(string, time.Duration) zap.Field`。 + +### H8 路由/注册中心全局单例与调用顺序陷阱 —— 确认 +- **H8a** `globalRegistry` 无 nil 守卫、无锁(router.go:233/247-268),Init 前调 `Use`/`RegisterModule`/`Apply` nil-panic。 +- **H8b** `Registry.Apply`(:210-229) 不幂等,二次 Apply 重复 `engine.Use` + Gin 重复路由 panic。 +- **H8c** `RegisterMetricsRoute` 用 `r.Use`(metrics.go:25),仅采集其后注册的路由,依赖调用顺序。 +- **H8d** 三个 `/health` 行为/响应体各异:`RegisterHealthRoute`(:48-57,可 503) vs `defaultModule`(:100-106,恒 200) vs `handler.HealthCheck`(:21-25,恒 200 且 schema 不同);并存还会 Gin 重复路由 panic。 +**修复**:`ensureRegistry()` 守卫 + 文档化"先 Init";`Apply` 加 `applied` 幂等位;metrics 在 `Apply` 内作首个全局中间件;`/health` 收敛为单一实现(其余委托 `runHealthChecks`)。 + +--- + +## MEDIUM(确认项摘要 + 勘误) + +确认:**M1**(RandInt 反向静默 swap,random.go:76-78)、**M2**(AddQueries 实为 Set,url.go:34;零值 map 未初始化会 panic)、**M3**(file.go:39-60 无穿越校验,caller-aware)、**M4**(datetime StartOfWeek `Add(-24h)` 跨 DST 落错日;ParseDateInt 静默规范化)、**M5**(身份证无校验位、IPv4 容忍前导零、Email 宽松、`rune(username[0])` 取字节 validation/validator.go:127)、**M6**(Download `Content-Disposition` 未 RFC-5987 编码,中文乱码 response.go:94)、**M8**(`CodeDataAlreadyExists` 落 200 而 `CodeDataConflict`→409,mode.go:42-44)、**M9**(DSN 不转义密码;`loc=Local` :258 / `TimeZone=Asia/Shanghai` :264 硬编)、**M10**(拼错 driver 静默回退 MySQL :249-253)、**M12**(NewRedisCache 构造时快照 client,Init 前构造永久 nil no-op)、**M13**(globalKeyBuilder 无 sync.Once、SetPrefix 无锁)、**M14**(timeout 软超时:`WithContext` 注入正确,属已注释的"业务级超时"设计取舍,handler 不查 ctx 则无效)、**M15**(requestid 信任客户端 X-Request-ID 无校验,头注入/日志伪造)、**M17**(console_windows EnableVirtualTerminal 死代码;着色 syscall.Stdout 与 `c.output` 分裂)、**M18**(trace 成功设 `codes.Ok` 应 UNSET;Close 无 double-close 守卫)、**M19**(logger 无法显式设级别)。 + +需修正: +- **M7 —— 不精确。** `ToResponse` 确实丢 `Detail`;但 `FailWithError → writeResp` **会写 RequestID**(mode.go:73),"丢 RequestID"证伪,仅丢 `Detail`。 +- **M11 —— 混合。** `HealthCheck` 同步 ping 无超时(:577-607)确认;`WriteQuery` 实为 `.Find()` 命名误导确认;`TransactionWithContext` 强制 master **不算 bug**(事务本就应走主库)。 +- **M16 —— 含一处证伪。** 写侧 `defer Close` 吞错致归档损坏返回 nil(:104/107)确认;`os.PathSeparator` 在 Windows 产 `\` 违反 zip 规范(须 `/`)确认;但**"Walk 闭包内 defer file.Close 累积 FD"证伪**——`defer file.Close()`(:146) 在 per-file 的 WalkFunc 内,每文件返回即关闭,不累积。 +- **M20 —— 一处不精确。** `make handler my-thing` → `My-ThingHandler` 非法标识符确认;`fileExists` 把权限错误误判为存在(utils.go:24)确认;但"构造时 `database.GetDB()` 致 App.Init 前 panic"**不精确**——存的是 nil,panic 推迟到首次使用;"map 循环内 defer" 未在生成器定位到。 + +--- + +## MINOR(全部确认) + +**N1** `BaseModelWithTime` 仅 `type:datetime` 之差(丢毫秒),命名误导。**N2** `BaseRepository` 接口为 struct 子集且无 `var _` 约束;`repository_test.go` 全空壳、CRUD 零覆盖。**N3** `SetupRouter` 返回裸 `gin.New()` 不含框架中间件;`MockStorage.Upload` 签名与真实 storage 不匹配。**N4** `Nl2br` 死分支(crypto.go:92-98);`IsEmpty` 文档承诺 slice/map 实际不支持。**N5** `Upload` 循环内 `defer file.Close` 累积 FD(http.go:209-214);`do` 无上限 `ReadAll`;`once` 字段声明未用。**N6** `KeepAlive` 发 `data: \n\n` 触发 onmessage,应为注释行 `: ping`。**N7** `CheckOrigin` 默认 `true`(CSWSH);`MessageType` 枚举与 WS opcode 无关、装饰性。 +另:多处测试未跑 `-race`,上述数据竞争无一被覆盖。 + +--- + +## 对原报告的勘误(核实后认定失实/夸大之处) + +1. **C2a**:"RLock 释放-重取中 range map 有并发修改风险"——map 仅 `Run` 单 goroutine 修改,无 data race;真 bug 仅为自发送死锁。 +2. **C5b**:`GzipDecompressFile`/`Unzip` 为**磁盘耗尽**而非 OOM。 +3. **C6c**:对 `CSRF()` cookie 模式 `HttpOnly:true` 的指控**证伪**(token 经 body 下发);真矛盾在 `DoubleSubmitCookie`。 +4. **C11a**:"`Replica()` 越界/错位"——有 `i < len` 守卫,**不越界**,实为健康状态陈旧。 +5. **C11e**:32 位 `int(n-1)` 取模后仍在范围,无正确性问题;属微优化。 +6. **C12e**:"`garbage` 静默永不触发"——实际 `parseInt=0` 在分/时/周字段会"错误触发",仅日/月字段才永不触发。 +7. **H1**:"应强文档警告"——实情相反,文档**主动推荐**用于 OTP,更严重。 +8. **H5a**:报告漏看 `response/mode.go` 模式系统,"全硬编 c.JSON(200)"不准确(受 Mode 控制)。 +9. **H6e**:`QueryBuilder.Page` 的 count **已**用 Session 克隆隔离,未被污染。 +10. **M7**:`FailWithError` 写 RequestID,仅丢 Detail。 +11. **M16**:Zip 的 Walk 内 `defer file.Close` **不**累积 FD。 +12. **M20**:生成的 service 构造时存 nil 而非即刻 panic。 + +> 以上勘误均为**措辞/机制层面**修正,不改变"该缺陷真实存在"这一结论(C6c 除归属外、C11a 除"越界"外,缺陷本体仍成立)。原报告无凭空虚报的缺陷。 + +--- + +## 修复优先级(投入产出 + 风险) + +**P0 安全/数据/功能性失效(必须先修)**:C6(API CSRF 不可用)、C8(panic 返 200)、C4(路径穿越)、C5(Zip-Slip/炸弹)、C2(WS 死锁/关闭竞态)、C1(锁 panic/泄漏)、C7(CORS 绕过)、C3(SSE 断连泄漏)、H2(默认关 TLS)、H1(不安全 RNG 且被推荐用于 OTP)、C9b(刷新令牌撤销失败仍签发)、H4a(误限流)。 + +**P1 并发/正确性**:C10/C11(全局 Manager 无锁置换 + 热重载校验 + 池泄漏)、C9a/c(黑名单静默/无锁)、H3(logger body OOM)、H7(logger 读写竞争)、C12(cron 竞争/重叠/解析)、C13(trace nil-panic 与导出器)、H5(response/handler 一致性 + RequestID)。 + +**P2 框架集成一致性**:H6(BaseRepo 接入 GetDBFromContext + 分页事务)、H8(路由/health 统一 + Apply 幂等)、C3 收尾(生产者取消信号)、M14 timeout 文档化。 + +**P3 清理**:MEDIUM/MINOR 各项 + 全量补 `-race`。 + +## 验证方式 + +- 并发缺陷逐项补 `go test -race`:cron `runTask` 并发、logger 热重载、WS 广播失败、cache lock 取消、config `SetDefaultManager` 并发。 +- 安全项针对性用例:路径穿越(`../` 拒绝)、Zip-Slip、CORS 后缀绕过、Recover 真实 panic 验 HTTP 500、上传扩展名白名单、API CSRF 颁发→校验闭环。 +- 端到端:`examples/full` 验 `/health` 503、SSE 断连即停、限流固定窗口语义、优雅关闭无 goroutine 泄漏(pprof 前后对比)。 diff --git a/validation/validation_test.go b/validation/validation_test.go index 53a0f1f..930d6b9 100644 --- a/validation/validation_test.go +++ b/validation/validation_test.go @@ -377,6 +377,35 @@ func TestValidateUsername(t *testing.T) { } } +// 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) { diff --git a/validation/validator.go b/validation/validator.go index 545ac6f..4b332f2 100644 --- a/validation/validator.go +++ b/validation/validator.go @@ -124,10 +124,12 @@ func registerCustomValidations(v *validator.Validate) { if len(username) < 3 || len(username) > 20 { return false } - if !isLetter(rune(username[0])) { + // 首字符按 rune 取,避免非 ASCII 首字节误判(M5:原 rune(username[0]) 取字节)。 + runes := []rune(username) + if len(runes) == 0 || !isLetter(runes[0]) { return false } - for _, r := range username { + for _, r := range runes { if !isLetter(r) && !isDigit(r) && r != '_' { return false } @@ -163,16 +165,15 @@ func registerCustomValidations(v *validator.Validate) { return false }) - // 身份证号验证(简化版) + // 身份证号验证(18 位带校验位;15 位仅格式,向后兼容旧号段)。 v.RegisterValidation("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 { - // 最后一位可以是 X if !isDigit(c) && c != 'X' && c != 'x' { return false } @@ -182,6 +183,12 @@ func registerCustomValidations(v *validator.Validate) { } } } + // 18 位号校验校验位(M5:原仅查长度+格式,无校验位可被任意构造通过)。 + if len(id) == 18 { + if !validateIDCardChecksum(id) { + return false + } + } return true }) } @@ -194,6 +201,29 @@ 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 Validator == nil { diff --git a/version_1.1.1_report.md b/version_1.1.1_report.md new file mode 100644 index 0000000..eada4e3 --- /dev/null +++ b/version_1.1.1_report.md @@ -0,0 +1,366 @@ +# xlgo 框架源码缺陷权威清单(v1.1.1) + +> 本文件是**三方合并 + 独立源码核验**后的唯一权威清单,作为后续修复的总执行依据。 +> +> - **来源 1**:初版评估报告(5 agent 并行精读 + 根 app.go 独立精读)。 +> - **来源 2**:复审复核(`v_1.1.1_fix.md`,逐条回源码核实,含 12 条勘误)。 +> - **来源 3**:独立对抗性核验(本次,4 路并行 agent 回源码 `file:line` 再核)。 +> +> **核验结论**:13 项 CRITICAL + 8 项 HIGH **全部真实成立,无虚报**。复审的 12 条勘误中 11 条完全成立、1 条扩展(C12e 步长分支)。本清单已将勘误与核验精化**就地合并**(原报告失实之处已改正,不再单列勘误段)。仅 4 处措辞/严重性精化标注 `[核验精化]`。 +> +> 每条标注:`[裁定]`(CONFIRM / PARTIAL)、`file:line`、缺陷描述、修复方案、验证方式。后续按文末"修复优先级"逐项执行,每项遵循 `xlgo/CLAUDE.md` 的开发纪律(全局架构优先 / 并发纪律 / 运行验证 / 独立复审 / 反模式清单)。 + +--- + +## 总体结论 + +方向正确、工程化程度不低(实例化 Manager、方言/DSN 注册表、读写分离、Option 模式、生命周期 Hook、优雅关闭、OTel/Prometheus 接入)。但距离"通用、高可用、易上手"差一次以"正确性 + 安全 + 一致性"为主线的收敛。 + +最突出的问题是**并发纪律**与**跨文件契约一致性**:包级全局指针无锁置换、热重载绕过校验、关键中间件存在安全/正确性缺陷、局部正确而全局断裂(BaseRepo 不接 ctx 路由、多个 `/health` 行为不一、handler 绕过响应模式)。共 **13 项 CRITICAL、8 项 HIGH**。 + +--- + +## CRITICAL(安全漏洞 / 数据竞争 / 数据丢失 / 死锁) + +### C1 `cache/lock.go` 分布式锁多处致命缺陷 `[CONFIRM]` + +- **C1a `WithLockAutoExtend` closed-channel panic + 锁泄漏**(lock.go:192,194,206-208,217-218):`done` 无缓冲,子 goroutine `defer close(done)`;ctx 取消(:200)或 `ExtendLock` 失败(:206-208)提前返回时 `done` 已 closed,父在 `fn()` 后 `done <- struct{}{}`(:217)→ send-on-closed panic,`Unlock`(:218)不执行,锁持有到 TTL。 +- **C1b 裸类型断言**(lock.go:76/108/142):`result.(int64)` 无 comma-ok,Redis 返回 nil/非整型时 panic。 +- **C1c `TryLock` 忽略 ctx**(lock.go:159):`time.Sleep(retryInterval)` 不响应 `ctx.Done()`,最长阻塞 `maxRetry*retryInterval`。 +- **C1d 无 fencing token**(lock.go:19-23):`Token` 是随机 UUID 非单调递增,TTL 到期后双 worker 并发执行无防护。 + +**修复**:续期改"父关停 + 子 ack"双 channel,仅父方 `close(stop)`;用 `context.Background()` 派生超时做 `Unlock`;断言一律 comma-ok;TryLock 用 `select { case <-ctx.Done(): return nil, ctx.Err(); case <-time.After(...): }`。 + +**验证**:`go test -race`——ctx 取消路径、ExtendLock 失败路径、Unlock 必执行;并发抢锁 + TTL 到期场景。 + +--- + +### C2 `ws/ws.go` Hub 自发送死锁 + channel 关闭竞态 `[CONFIRM]` + +- **C2a 广播失败即自发送死锁**(ws.go:250,272-281):`h.unregister` 无缓冲,唯一消费者是同一 `Run` goroutine 的 select(:264)。广播分支内 `conn.Send` 失败时 `h.unregister <- conn`(:277),Run 正忙于 broadcast case 无法同时进 unregister case → 永久阻塞,整个 Hub 卡死。`[核验精化]` 经源码确认:map 已由 `:273 h.mu.RLock` 保护,**无 data race**;真 bug 仅为自发送死锁(原报告"并发修改 map"措辞已改正)。 +- **C2b `Close()` 与 `Send()` send-on-closed panic**(ws.go:85-86,59-66):`Close` 先 `close(c.closeChan)` 再 `close(c.send)`;并发 `Send` 的 select 含 `c.send <- data`(:61)与 `<-c.closeChan`(:63),`c.send` 关闭后 send case 永久就绪且 panic,Go select 伪随机可能选中它——`closeChan` 分支**不能**消除竞态。 +- **C2c 无 deadline / 无 pong handler / goroutine 泄漏**(ws.go:180,194-222):`SetReadDeadline/SetWriteDeadline` 有定义(:102-109)却从不内部调用;发 ping(:215)但无 `SetPongHandler`、无读超时 → 半开连接 `ReadMessage` 永久阻塞、goroutine 泄漏。 + +**修复**:广播分支持写锁单次遍历、行内 `delete + conn.Close()`,去掉 channel 回环;`Close()` 删除 `close(c.send)`,仅以 `closeChan` 作关闭信号;读循环前置 `SetReadDeadline + SetPongHandler`,每次写前 `SetWriteDeadline`,ping 周期 `<` pongWait。 + +**验证**:`go test -race`——并发广播失败 + 并发 Close/Send;半开连接超时退出、无 goroutine 泄漏(pprof 前后对比)。 + +--- + +### C3 `sse/sse.go` 断连泄漏 goroutine + 算力(AI 主场景) `[CONFIRM]` + +- **C3a 循环无 `ctx.Done()` 分支**(sse.go:79/104/120/142):四个 `for range ch` 仅靠 ch 关闭或写错误退出。 +- **C3b(核心)写/Flush 错误被吞,断连永不触发守卫**(sse.go:38-43,47-51,54-60):`WriteEvent`/`WriteMessage` 丢弃 `fmt.Fprintf` 返回错误且恒 `return nil`,`Flush()` 无返回值;`WriteJSON` 仅在 `json.Marshal` 失败时返错,否则透传 nil。故 `StreamText` 等的 `if err := WriteJSON(...); err != nil` 守卫**只对 marshal 失败生效,对客户端断连永不触发** → 消费循环不退出 + 上游生产者(常为昂贵 LLM 流)持续运行直到进程结束。 +- **C3c 手设 `Transfer-Encoding: chunked`**(sse.go:23):HTTP/1.1 冗余,HTTP/2 非法(应交由 server 分帧)。 + +**修复**:`WriteEvent/WriteMessage` 返回 `fmt.Fprintf` 错误;Stream 系列改 `for { select { case <-c.Request.Context().Done(): return ctx.Err(); case v, ok := <-ch: ... } }`;**生产者也必须接收由 `c.Request.Context()` 派生的取消信号**;删除 chunked 手设头。 + +**验证**:实跑——客户端断连后断言生产者停止、无 goroutine 泄漏(pprof)。 + +--- + +### C4 `storage/storage.go` 路径穿越 + 无校验 `[CONFIRM]` + +- **C4a 路径穿越**(按方法分级,storage.go:152/163/173/269/279/295/61/108/216/243):`filepath.Join` 内含 `Clean`,`..` 可逃逸根目录。最严重:`Delete`/`Get`/`Exists`/`GetURL` 及 OSS 的 `Delete`/`Get`/`Exists`——`path` 全程受控(任意删/读/探测/URL 构造)。`Upload`/`UploadFromBytes` 仅 `subdir` 受控、文件名服务端随机(:72/:122 `uniqueFilename`)→ 任意目录写但无法精确覆盖。OSS 变体为未净化 object key。 +- **C4b 无扩展名/类型/大小校验**:全文件无 `file.Size` 引用、无扩展名白名单、无 MIME 校验;`evil.php`、超大文件直传。 +- **C4c 全量读入内存**(storage.go:165/287):Local `os.ReadFile`、OSS `io.ReadAll` 无上限 → OOM。 + +**修复**:新增 `resolve(rel)` 做 `filepath.Abs` + 前缀锚定校验,`Delete/Get/Exists/Upload` 统一经过;上传前查 `file.Size` 上限 + 扩展名白名单 + `http.DetectContentType` 嗅探前 512B;`Get` 提供流式 `io.ReadCloser` 或 `io.LimitReader` 封顶。 + +**验证**:路径穿越(`../` 拒绝)、Zip-Slip、超大输入封顶、`evil.php` 拒绝——必须有用例实跑。 + +--- + +### C5 `compress/compress.go` Zip-Slip + 解压炸弹 `[CONFIRM]` + +- **C5a Zip-Slip**(compress.go:176,195):`filePath := path.Join(dstDir, file.Name)` 无逃逸校验,`os.Create` 可覆盖任意文件;且用 `path.Join` 非 `filepath.Join`,Windows 分隔符处理不当。 +- **C5b 解压炸弹**(区分成立,compress.go:37/87/201):`GzipDecompress`(:37 `io.ReadAll`)为 **OOM**;`GzipDecompressFile`(:87)/`Unzip`(:201)为 `io.Copy` 写盘,属**磁盘耗尽**而非 OOM。`[核验精化]` 原报告"均 OOM"措辞已改正。 + +**修复**:解压前 `filepath.Clean(dstDir)` + 前缀锚定校验,拒绝 `..`;跳过/拒绝 `file.Mode()&os.ModeSymlink != 0` 的符号链接条目;`io.CopyN` 单条目封顶 + 累计上限;`GzipDecompress` 用 `io.LimitReader`。 + +**验证**:Zip-Slip 拒绝、符号链接穿越拒绝、解压炸弹封顶——必须有用例。 + +--- + +### C6 `middleware/csrf.go` API CSRF 模式功能性失效(map 遮蔽) `[CONFIRM]` + +- **C6a map 遮蔽**(csrf.go:228,247-249,271-273,281-284):`CSRFForAPI()` 内 `tokens := make(map[string]bool)`(:228)是**局部变量**,闭包校验读它(:247-249);`GenerateAPIToken` 写的是**包级** `tokens`(:272,声明于 :281-284)。两者从不相交 → 颁发的 token 永不在校验 map 里,**所有非安全方法请求被判"CSRF Token 无效"拒绝,API CSRF 模式整体不可用**。且局部 map 每次调用 `CSRFForAPI()` 重建。 +- **C6b 只增不减、无过期、验证不消费**(csrf.go:281-284,272,257):包级 `tokens` 无 delete/TTL/淘汰,内存 DoS + token 永久可重放。 +- **C6c 矛盾点修正**(csrf.go:60/123-135/328/346):`[核验精化]` `CSRF()` cookie 模式 token 经 **cookie** 下发(:123-131)+ 上下文暴露(:135),HttpOnly=true 在该模式下**合理**(前端经 `GetCSRFToken` 取 token,非读 cookie),原报告对 :60 的指控**证伪**。真正矛盾在 `DoubleSubmitCookie`(:328 `SetCookie(...HttpOnly=true)` 与 :346 要求 JS 回填 `X-CSRF-Token` header):HttpOnly 使 JS 读不到 cookie → 无法回填 → 双重提交对真实前端不可用。 + +**修复**:删除 `CSRFForAPI` 内的 `tokens`/`mu` 两个局部声明使其绑定包级;改单次消费 + TTL(`map[string]time.Time`,验证后 `delete`),生产环境落 Redis `SETEX`+`GETDEL`;`DoubleSubmitCookie` 的 cookie 改 `HttpOnly=false`,`CSRF()` 维持 true。 + +**验证**:颁发→校验闭环(token 真正生效)、单次消费、TTL 过期、`DoubleSubmitCookie` 前端可回填——必须有用例。 + +--- + +### C7 `middleware/cors.go` 通配符后缀绕过 `[CONFIRM]` + +- **C7a**(cors.go:52-57):`*.example.com` → `domain="example.com"`,`strings.HasSuffix(origin, domain)` 未锚定 host → `https://notexample.com`、`https://evil-example.com` 被接受。 +- **C7b**(cors.go:75-77,103-105):开发态无条件回显任意 Origin;若同时 `AllowCredentials:true` 则构成凭据型反射。 + +**修复**:`*.` 通配改用 `net/url` 解析 host,要求真实子域边界;开发态兜底限制为 localhost 列表,不回显任意 Origin,且回显来源不与 credentials 并存。 + +**验证**:`notexample.com` 拒绝、真实子域通过、凭据型反射不成立——必须有用例。 + +--- + +### C8 `middleware/recover.go` 500 状态丢失(默认 ModeBusiness) `[CONFIRM]` + +- 默认模式 `ModeBusiness=iota=0`(mode.go:16),`currentMode` 零值即默认(:23)。`httpStatusFor(CodeServerError)` 在默认模式返回 200(mode.go:60-65)。`Recover` 中 `FailWithCode → writeResp → c.JSON(200,...)`(response.go:68-75)已 flush 锁定状态,随后 `c.AbortWithStatus(500)`(recover.go:33)因 `w.Written()==true` 成为 no-op(gin responseWriter `WriteHeader` 在 Written 后仅 WARNING 不改码),客户端收 HTTP 200 + body `code:500`,网关/APM 按 status 看不到 panic。`RecoverWithDetail`(:57-58)同病。 +- **ModeREST 无此 bug**:`statusForCode(CodeServerError)=500`(mode.go:48-49),状态一致。 + +**修复**:用不受 Mode 影响的 `response.Custom(c, http.StatusInternalServerError, response.CodeServerError, "服务器内部错误", nil)` 显式写 500,并去掉事后 `AbortWithStatus`。 + +**验证**:真实触发 panic,断言**实际 HTTP 状态码 = 500**(非 200)——必须有用例。 + +--- + +### C9 `jwt/jwt.go` 黑名单缺陷 `[CONFIRM]` + +- **C9a 无 Redis 静默失效**(jwt.go:71-101):`Add`/`IsBlacklisted` 在 client==nil 时静默 `return nil`/`return false`,登出/吊销无效且无信号。 +- **C9b(最危险)RefreshToken 吞 Add 错误**(jwt.go:281-293):`tokenBlacklist.Add(claims.JTI, ...)`(:289)返回值被丢弃,仍 `return GenerateToken(...)`(:292)→ Redis 抖动时旧 token 未拉黑、新旧 token 双有效,会话固定窗口。叠加 C9a 静默 nil,失败被双重吞没。 +- **C9c SetDefaultJWTManager 无锁置换**(jwt.go:124-129,139,243,289):包级 `DefaultJWT`/`tokenBlacklist` 裸写,与请求 goroutine 读竞争。 + +**修复**:`RefreshToken` 对 `Add` 错误 `return "", fmt.Errorf(...)`;无 Redis 时让 `Add` 返回 `ErrBlacklistUnavailable` 或启动期告警一次;包级指针改 `atomic.Pointer` 或文档强制"服务前调用"。 + +**验证**:刷新令牌闭环——Redis 故障时刷新失败(不签发新 token)、旧 token 入黑名单后失效——必须有用例。 + +--- + +### C10 `config/config.go` 全局 Manager 无锁置换 + 热重载绕过校验 `[CONFIRM]` + +- **C10a**(config.go:579/585/628-634/605-607):`defaultManager` 包级指针被 `Load`/`LoadWithWatch`/`SetDefaultManager` 裸写,与 `Get` 等读者竞争。 +- **C10b**(config.go:486-501/543-575):`OnConfigChange` 与 `Reload` 均**不调 `Validate()`**(Validate 仅在 `Load` :435 调用),非法配置(坏端口、负超时、短密钥)直接发布;解析失败 `return` 静默吞。 +- **C10c**(config.go:441/444):`Load` `return &cfg` 即 `m.cfg` 同一指针,调用方可变并竞争;热重载只换 `m.cfg`,**不重建 DB/Redis 池**,对子系统装饰性。 +- **C10d**(config.go:507):`StopWatcher` 空函数,viper watcher goroutine + fd 永不释放。 + +**修复**:`defaultManager` 改 `atomic.Pointer[Manager]`;热重载补 `unmarshal` 失败告警 + `newCfg.Validate()` 失败保留旧配置;`Load` 返回防御性拷贝;自管 `fsnotify.Watcher` 以便 `StopWatcher` 真正 `Close`。 + +**验证**:`go test -race`——`SetDefaultManager` 并发读写;热重载坏配置保留旧配置;`StopWatcher` 后 watcher goroutine 退出。 + +--- + +### C11 `database/manager.go` 并发与生命周期 `[CONFIRM]` + +- **C11a 健康状态陈旧(不越界)**(manager.go:144-155,117-128,218-241):`initReplicaHealth` 有 `replicaHealthSet` 早返回,重新 `InitDBWithReplicas` 不重置 → 健康切片与新 replicas 长度错位。`[核验精化]` 经源码确认 `Replica()`(:120)与 `probeOnce`(:224/230/233/237)均有 `i < len(...)` 守卫,**不越界 panic**;真问题是新从库健康状态陈旧/被排除(原报告"越界"措辞已改正)。 +- **C11b InitDB 重试泄漏**(manager.go:346-389):`gorm.Open` 成功但 `Ping` 失败时旧池不关、下轮覆盖 `m.master`,每次重试泄漏一池。 +- **C11c InitDBWithReplicas 泄漏**(manager.go:423):`m.replicas = nil` 前不关旧从库池。 +- **C11d Master()/Replicas() 无锁读**(manager.go:97-104):与 `Close`/`InitDB` 写竞争,可能返回已关闭/nil 池。 +- **C11e 非缺陷**(manager.go:39-45/51-56):`[核验精化]` `RoundRobinPicker` `int(n-1)%len` 取模后仍在 `[0,len)` 内,无 panic/正确性问题;`RandomPicker` 全局 `math/rand` 仅锁竞争。**属微优化,非功能 bug**(原报告若主张为 bug 则证伪)。 +- **C11f 包级 Close() 仅关主库**(manager.go:524-536/539-541/277-307):包级 `Close()` 仅关 master 且无锁,`CloseAll()`/方法 `Close()` 关全部,命名误导致用户泄漏从库。 + +**修复**:`InitDBWithReplicas` 重建前关旧主/从池并重置 `replicaHealthSet=false`、`replicaHealthy=nil`;`InitDB` 重试在覆盖前 `sqlDB.Close()`;`Master/Replicas/Replica` 全程加锁或改 `atomic.Pointer`;包级 `Close()` 改为委托 `CloseAll()`(或废弃)。 + +**验证**:`go test -race`——`Master`/`Replicas` 并发读写;重试路径无池泄漏(连接数前后对比)。 + +--- + +### C12 `cron/cron.go` 数据竞争 + 重叠执行 + 漂移 `[CONFIRM]` + +- **C12a**(cron.go:142-143,104-125,200-209):`runTask` 无锁写 `LastRun`/`RunCount`,`GetTask`/`ListTasks` 返回 live 指针并发读 → data race。 +- **C12b 无 running 守卫**(cron.go:147-149,200-209):`NextRun` 在 handler 完成后才更新,长任务跨 tick 被 `checkAndRun` 反复 spawn。 +- **C12c Interval 漂移**(cron.go:148,218-219):`Next` 以 handler 完成后 `time.Now()` 锚定,每周期累积 handler 时长。 +- **C12d Weekly 跳过当天未到点目标**(cron.go:255-263):`daysUntil <= 0 → +7` 仅按 weekday 差值判断,不比较当天时刻;当天目标时刻未到(如周一 9:00 目标周一 12:00)被跳一周。`DailySchedule`(:236)用 `next.Before(now)||next.Equal(now)` 正确处理,Weekly 缺失此比较。 +- **C12e cron 解析缺陷**(cron.go:310-434,426-433):`parseInt` 忽略非数字逐位累积。`1-5,8` 因先判 `-`(:316)→ `parseInt("5,8")=58`,范围被破坏为 1..58 且逗号项丢失;`7` 周日不匹配(Go Sunday=0)。`[核验精化]` `garbage` → `parseInt=0`:列表/范围分支中 `0==value`,**分/时/周字段在 value=0 时错误触发**,日(1-31)/月(1-12)永不触发;**但步长分支 `*/garbage` → step=0 → `:331 return true` 匹配全部**,故日/月在步长场景也会误触发(复审漏看步长分支,本清单补全)。 + +**修复**:`runTask` 计数写入纳入锁、Getter 返回值拷贝;加 per-task `running atomic.Bool` 或持锁的 `checkAndRun` 内先推进 `NextRun` 再 spawn;Interval 以上次 `NextRun` 锚定;Weekly 用 `((day-now)+7)%7` 且整日期时间 `!After(now)` 才 +7;cron 解析改 `strconv.Atoi` 返错 + 字段范围校验 + 周日 `7→0` + 列表分支独立于范围分支。 + +**验证**:`go test -race`——`runTask` 并发;`1-5,8` 仅匹配 1-5 与 8;Weekly 当天未到点不跳周。 + +--- + +### C13 `trace/trace.go` opt-in 即崩 `[CONFIRM]` + +- **C13a nil tracer panic**(trace.go:58,160,217,225):包级 `tracer` 未 `Init` 即 nil,`Middleware`/`StartSpan` 等无守卫 → 首个请求 panic。(`Init` 即便 `Enabled=false` 也设 Noop tracer,故仅"从未 Init"才崩。) +- **C13b 未知导出器 → nil + stdout 不存在**(trace.go:110-125,84-91):switch 仅 otlp-http/otlp-grpc,`default` 返回 `nil, nil` 喂 `WithBatcher(nil)`;文档承诺的 `stdout` 未实现。 +- **C13c OTLP 默认 HTTPS 无 WithInsecure**(trace.go:113-120):对 `localhost:4318` 等明文 collector 握手失败。 +- **C13d Middleware 不更新 c.Request**(trace.go:172):仅 `c.Set("otel_ctx", ctx)`,未 `c.Request = c.Request.WithContext(ctx)`,下游 `c.Request.Context()` 拿不到 span。 +- **C13e b3/jaeger 未实现**(trace.go:128-138):switch 仅 `w3c` + default,文档承诺的 `b3`/`jaeger` 静默回落 W3C。 + +**修复**:`getTracer()` 懒初始化默认 `otel.Tracer("xlgo")`;实现 stdout 导出器、`default` 返错;`Config` 增 `Insecure bool` 条件追加 `WithInsecure()`;Middleware 补 `c.Request = c.Request.WithContext(ctx)`;接入 contrib b3/jaeger propagator 或裁剪文档。 + +**验证**:未 Init 不 panic;未知导出器返错;下游 `c.Request.Context()` 含 span。 + +--- + +## HIGH(严重正确性 / 一致性 / 框架集成断裂) + +### H1 `utils/random.go` 不安全 RNG 且文档反向推荐 `[CONFIRM]` + +`randPool` 用 `math/rand` + `time.Now().UnixNano()` 播种(random.go:4,12),`RandString`(:31)/`RandDigit`(:54)取自该池,本文件无 `crypto/rand`。`[核验精化]` `GUIDE.md:1208` `token := utils.RandString(16)`、`:1211` `code := utils.RandDigit(6)` **主动推荐用于 token 与 6 位验证码**,`:1397/1412` 仅宣传 sync.Pool 性能无安全警告——比"应强文档警告"更严重(原报告措辞已改正为反向推荐)。 + +**修复**:新增 `RandStringSecure`(`crypto/rand`),文档/示例的 token/OTP/重置码改用之,并在 `RandString` 注释警示非安全用途。 + +**验证**:`RandStringSecure` 输出不可预测性(统计用例);文档示例改用安全版本。 + +--- + +### H2 `utils/http.go` 默认关闭 TLS 校验 `[CONFIRM]` + +`DefaultHTTPClientConfig.SkipTLSVerify:true`(http.go:53)→ `InsecureSkipVerify`(:67),`HTTPGet/HTTPPost/HTTPPostJSON` 经 `DefaultHTTPClient()` 全部默认可被 MITM。 + +**修复**:默认 `false`,仅显式开启。 + +**验证**:默认配置 `InsecureSkipVerify=false`;显式开启才放行自签证书。 + +--- + +### H3 `middleware/logger.go` 无上限读 body → OOM(默认关闭) `[PARTIAL]` + +`io.ReadAll(c.Request.Body)`(logger.go:60)无 `MaxBytesReader`,`MaxBodyLength` 仅在读完后截断日志副本(:64-65),全 body 已驻留并二次 buffer。`[核验精化]` **`DefaultLoggerConfig.LogRequestBody: false`(:32)默认不读 body**,需用户显式开启 `LogRequestBody: true` 才触发——bug 本身成立,但默认配置安全,严重性降档(两份原报告均未提及默认关闭)。 + +**修复**:源头 `io.LimitReader(body, limit+1)` 读取 + `io.MultiReader` 复原;或 `http.MaxBytesReader` 做硬上限(无论是否记日志)。 + +**验证**:开启 `LogRequestBody` 后超限 body 被截断、无 OOM。 + +--- + +### H4 `middleware/ratelimit.go` 限流语义错误 `[CONFIRM]` + +- **H4a 稳态客户端被误限流**(ratelimit.go:48-74):`Allow` 每次放行都 `v.lastSeen = time.Now()`(:55/63/72),重置分支 `time.Since(lastSeen) > window`(:61)对持续客户端永不成立。`[核验精化]` 算例(rate=10/min,客户端 9 req/min):count 单调累加至 10,第 11 次起 BLOCK;**并非"永久 BLOCK"**——BLOCK 期间 lastSeen 停在第 10 次放行值,满 1 分钟窗口后 `time.Since` 超窗口会重置一次。真实缺陷是滑动窗口语义错误:持续低于 rate 的稳态客户端被过度限制且与"每分钟 N 次"承诺不符(原报告"之后约 1 req/window"措辞更准,"永久 BLOCK"过强)。 +- **H4b CustomRateLimit 泄漏**(ratelimit.go:41-42,297-300,229-245):`NewRateLimiter` spawn 清理 goroutine,`CustomRateLimit` 返回的 limiter 无句柄、`StopRateLimiters` 不感知 → 每个 `CustomRateLimit` 路由泄漏一 goroutine。 +- **H4c fail-open + 无 comma-ok**(ratelimit.go:160,163):Redis 错误 fail-open(含登录防爆破静默失效);`result.(int64)` 无 comma-ok(当前脚本恒返整数不会 panic,属脆弱性)。 + +**修复**:H4a 改真正固定窗口——放行时**不**更新 `lastSeen`(仅窗口起点设置);自定义 limiter 登记入全局以便 `StopRateLimiters` 停止;Redis 断言改 comma-ok,安全型限流考虑 fail-closed 或可配置。 + +**验证**:稳态客户端(9 req/min,rate=10/min)不被限流;超限客户端被拦;`StopRateLimiters` 停止所有 limiter(含 Custom)。 + +--- + +### H5 `response`/`handler` 业务码与 HTTP 状态混乱 `[CONFIRM]` + +- **H5a**(response.go:52-79,33,43;mode.go:13-29,60-65):`[核验精化]` 经源码确认 `response/mode.go` 模式系统真实存在——所有 `Fail*` 经 `writeResp`(:68)→ `httpStatusFor`(:60-65)受 `Mode` 控制;仅 `Success`(:33)/`SuccessWithMsg`(:43)等硬编 200。结论"ModeBusiness 默认全 200"成立,但原报告"全硬编 c.JSON(200)"不准确(漏看模式系统,已改正)。 +- **H5b**(handler.go:157-170):`handler.BadRequest`/`InternalError` 硬编 HTTP 400/500,绕过 Mode,且**不写 RequestID**(对比 `writeResp` 在 :73 写 `RequestID`),与 business 模式不一致、丢链路。 + +**修复**:两个 helper 委托 `response.FailWithCode`/`ServerError`(或 `response.Custom` 保留 RequestID)。 + +**验证**:`handler.BadRequest` 响应含 RequestID 且遵循 Mode;状态码与业务码映射一致。 + +--- + +### H6 `repository/repository.go` BaseRepo 数据安全 + 框架集成断裂 `[CONFIRM]` + +- **H6a**(repository.go:50):`Update` 用 `Save` 全列覆写,丢失更新、零值不可辨。 +- **H6b**(repository.go:53-56,59-61):`Delete` 注释称软删,但泛型 `T` 无 `gorm.DeletedAt` 时静默硬删,契约不可由类型强制;`HardDelete` 才显式 `Unscoped()`。 +- **H6c(核心)**(repository.go:29-31,325,514):`r.db` 构造时捕获,所有方法 `r.db.WithContext(ctx)` **从不调 `database.GetDBFromContext`** → 读写分离失效、外层 ctx 事务无法 join,`WithTransaction`(:325)另开嵌套 tx 拿不到外层。 +- **H6d**(repository.go:156,167):`FindPage` count/list 两查询无事务,高并发 total 与 items 不一致。 +- **H6e**(repository.go:406,412):`[核验精化]` 经源码确认 `Page` 的 count **已用 `qb.db.Session(&gorm.Session{}).Limit(-1).Offset(-1)` 克隆**(:406),count 未被污染(原报告"count 被破坏"证伪);但 Find 侧(:412)在原 `qb.db` 上追加 `.Offset().Limit()` 会污染 `qb.db`(重复调用 Page 残留)——"Find 侧污染"成立。 + +**修复**:新增 `conn(ctx)` 优先取 `GetDBFromContext(ctx)`,全方法替换 `r.db.WithContext`;分页 count+list 包进单事务;终结方法一律基于 `Session{}` 克隆,文档标注 QueryBuilder 单次性/非并发安全;补 `UpdateFields`(`Updates` 局部更新)。 + +**验证**:`UseMaster(ctx)`/`UseReplica(ctx)` 经 BaseRepo 生效;外层事务可 join;`FindPage` total 与 list 一致(并发场景)。 + +--- + +### H7 `logger/logger.go` 全局指针写有锁读无锁 `[PARTIAL]` + +`Init`/`Close` 持 `m.mu` 写包级 `Logger/sugar/apiLog/dbLog`(logger.go:121-131,193-200),但 `Info`/`Error`/`APILog()`(:235-247,280-282)无锁读 → 热重载 re-Init/关闭与请求日志竞争。`[核验精化]` 机制精确化:`m.mu` 是 `LogManager` **实例锁**,保护的却是**包级全局变量**(`Logger/sugar/...`)——"用实例锁保护全局变量",锁与被保护对象作用域错配(原报告"锁却是 per-manager"措辞已精确化)。 + +**H7b**(field.go:24-31):`Duration(key, value)` 在 `case zap.Field:` 直接 `return v` 丢弃 `key`,签名与实现矛盾。 + +**修复**:四个全局改 `atomic.Pointer[zap.Logger]`,读侧原子 load;`Duration` 改 `func(string, time.Duration) zap.Field`。 + +**验证**:`go test -race`——热重载 re-Init 与并发日志;`Duration` 保留 key。 + +--- + +### H8 路由/注册中心全局单例与调用顺序陷阱 `[CONFIRM]` + +- **H8a**(router.go:233,247-268):`globalRegistry` 无 nil 守卫、无锁,Init 前调 `Use`/`RegisterModule`/`Apply` nil-panic。 +- **H8b**(router.go:210-229):`Registry.Apply` 不幂等,二次 Apply 重复 `engine.Use` + Gin 重复路由 panic。 +- **H8c**(metrics.go:25):`RegisterMetricsRoute` 用 `r.Use`,Gin `engine.Use` 只对之后注册的路由生效 → 依赖调用顺序,先注册的路由不被采集。 +- **H8d**(router.go:48-57,103-105;handler.go:21-25):三个 `/health` 行为/schema 各异:`RegisterHealthRoute`(可 503)vs `defaultModule`(恒 200 `gin.H`)vs `handler.HealthCheck`(恒 200 经 `response.Success` 包成 `{code,msg,data}` schema 不同);并存还会 Gin 重复路由 panic。 + +**修复**:`ensureRegistry()` 守卫 + 文档化"先 Init";`Apply` 加 `applied` 幂等位;metrics 在 `Apply` 内作首个全局中间件;`/health` 收敛为单一实现(其余委托 `runHealthChecks`)。 + +**验证**:Init 前调用不 panic;二次 Apply 不重复注册;`/health` 单一实现且失败 503;metrics 覆盖所有路由。 + +--- + +## MEDIUM(确认项摘要) + +> 以下均经核验 CONFIRM,仅个别措辞已就地修正。 + +- **M1**(random.go:76-78):`RandInt` 反向入参静默 swap,掩盖调用 bug。 +- **M2**(url.go:34):`AddQueries` 实为 Set;零值 map 未初始化会 panic。 +- **M3**(file.go:39-60):无穿越校验(caller-aware)。 +- **M4**(datetime.go:64-71):`StartOfWeek` `Add(-24h)` 跨 DST 落错日;`ParseDateInt` 静默规范化。 +- **M5**(validator.go:127 等):身份证无校验位、IPv4 容忍前导零、Email 宽松、`rune(username[0])` 取字节。 +- **M6**(response.go:94):`Download` `Content-Disposition` 未 RFC-5987 编码,中文乱码。 +- **M7**(error.go:187-189;response.go:68-75):`[核验精化]` `FailWithError → writeResp` **会写 RequestID**(:73),原报告"丢 RequestID"证伪,**仅丢 `Detail`**(需用 `FailWithDetail`)。 +- **M8**(mode.go:42-44):`CodeDataAlreadyExists` 落 200 而 `CodeDataConflict`→409,语义相近映射不一致。 +- **M9**(config.go:245-266):DSN 不转义密码;`loc=Local`/`TimeZone=Asia/Shanghai` 硬编。 +- **M10**(config.go:245-254):拼错 driver 静默回退 MySQL,报错误导。 +- **M11**(manager.go:577-607 等):`HealthCheck` 同步 ping 无超时确认;`WriteQuery` 实为 `Find` 命名误导;`TransactionWithContext` 强制 master **不算 bug**(事务应走主库)。 +- **M12**(cache.go:37-41,185-192):`NewRedisCache` 构造时快照 client,Init 前构造永久 nil no-op。 +- **M13**(keybuilder.go:148-187):`globalKeyBuilder` 无 `sync.Once`、`SetPrefix` 无锁。 +- **M14**(timeout.go:29):软超时(设计取舍,非 bug,需文档强调 opt-in 性质):`WithContext` 注入正确,但 handler 不查 ctx 则无效,无硬墙钟上限。 +- **M15**(requestid.go:11-14):信任客户端 `X-Request-ID` 无校验,头注入/日志伪造。 +- **M16**(compress.go:104,107,146):写侧 `defer Close` 吞错致归档损坏返回 nil 确认;`os.PathSeparator` 在 Windows 产 `\` 违反 zip 规范确认;`[核验精化]` **"Walk 闭包内 defer file.Close 累积 FD"证伪**——`defer file.Close()`(:146)在 per-file 的 WalkFunc 内,每文件返回即关闭,不累积。 +- **M17**(console_windows.go):`EnableVirtualTerminal` 死代码;着色 syscall.Stdout 与 `c.output` 分裂。 +- **M18**(trace.go:187-189):成功设 `codes.Ok` 应 UNSET;`Close` 无 double-close 守卫。 +- **M19**(logger.go:88-92):无法显式设日志级别。 +- **M20**(templates.go:488-492,540-544):`make handler my-thing` → `My-ThingHandler` 非法标识符确认;`fileExists` 权限错误误判为存在确认;`[核验精化]` 生成的 service 构造时调 `database.GetDB()` 存 nil(`BaseRepo{db:nil}`)**不即刻 panic**,延迟到首次查询才 nil deref。 + +--- + +## MINOR(全部确认) + +- **N1** `BaseModelWithTime` 仅 `type:datetime` 之差(丢毫秒),命名误导。 +- **N2** `BaseRepository` 接口为 struct 子集且无 `var _` 约束;`repository_test.go` 全空壳、CRUD 零覆盖。 +- **N3** `SetupRouter` 返回裸 `gin.New()` 不含框架中间件;`MockStorage.Upload` 签名与真实 storage 不匹配。 +- **N4** `Nl2br` 死分支(crypto.go:92-98);`IsEmpty` 文档承诺 slice/map 实际不支持。 +- **N5** `Upload` 循环内 `defer file.Close` 累积 FD(http.go:209-214);`do` 无上限 `ReadAll`;`once` 字段声明未用。 +- **N6** `KeepAlive` 发 `data: \n\n` 触发 onmessage,应为注释行 `: ping`。 +- **N7** `CheckOrigin` 默认 `true`(CSWSH);`MessageType` 枚举与 WS opcode 无关、装饰性。 +- 另:多处测试未跑 `-race`,上述数据竞争无一被覆盖。 + +--- + +## 与 CLAUDE.md 文档不符之处 + +1. "Hot reload via `LoadWithWatch()`" —— 实际跳过 `Validate`、不重建 DB/Redis 池,对子系统无效(C10)。 +2. "`database.CloseAll()` 关主+从" —— 正确,但孪生 `Close()` 泄漏从库,文档误导(C11)。 +3. "`/health` 支持 HealthCheck、失败 503" —— `DefaultModule` 路径永远 200 不跑检查(H8)。 +4. "`BaseRepo[T]` 提供 CRUD" —— 不参与读复制分离与 ctx 事务,与框架 DB 体系脱节(H6)。 +5. "`ReplicaPicker` strategies" —— `RoundRobin` 非缺陷(C11e 已澄清)、`Random` 用全局 rand。 +6. trace 文档承诺 `stdout`/`b3`/`jaeger` 均未实现(C13)。 +7. "framework code never calls Fatal" —— **核实成立**(仅 facade 函数本身)。 +8. logger FD 泄漏修复 + 跨写测试 —— **核实成立且有回归测试**,质量好。 + +--- + +## 修复优先级(投入产出 + 风险,执行依据) + +**P0 安全/数据/功能性失效(必须先修)**: +C6(API CSRF 不可用)、C8(panic 返 200)、C4(路径穿越)、C5(Zip-Slip/炸弹)、C2(WS 死锁/关闭竞态)、C1(锁 panic/泄漏)、C7(CORS 绕过)、C3(SSE 断连泄漏)、H2(默认关 TLS)、H1(不安全 RNG 且被推荐用于 OTP)、C9b(刷新令牌撤销失败仍签发)、H4a(误限流)。 + +**P1 并发/正确性**: +C10/C11(全局 Manager 无锁置换 + 热重载校验 + 池泄漏)、C9a/c(黑名单静默/无锁)、H3(logger body OOM)、H7(logger 读写竞争)、C12(cron 竞争/重叠/解析)、C13(trace nil-panic 与导出器)、H5(response/handler 一致性 + RequestID)。 + +**P2 框架集成一致性**: +H6(BaseRepo 接入 GetDBFromContext + 分页事务)、H8(路由/health 统一 + Apply 幂等)、C3 收尾(生产者取消信号)、M14 timeout 文档化。 + +**P3 清理**: +MEDIUM/MINOR 各项 + 全量补 `-race`。 + +--- + +## 验证方式(通用) + +- 并发缺陷逐项补 `go test -race`:cron `runTask` 并发、logger 热重载、WS 广播失败、cache lock 取消、config `SetDefaultManager` 并发、database `Master/Replicas` 并发。 +- 安全项针对性用例:路径穿越(`../` 拒绝)、Zip-Slip、符号链接穿越、CORS 后缀绕过、Recover 真实 panic 验 HTTP 500、上传扩展名白名单、API CSRF 颁发→校验闭环、JWT 刷新撤销闭环。 +- 端到端:`examples/full` 验 `/health` 503、SSE 断连即停、限流固定窗口语义、优雅关闭无 goroutine 泄漏(pprof 前后对比)。 +- 每个修复缺陷配回归用例(修复前红、修复后绿)。 +- 本机 `go build/test` 需加 `-buildvcs=false`(H:\worker 有 svn 干扰)。 + +--- + +## 核验元数据 + +- 独立核验日期:2026-06-24 +- 核验方式:4 路并行 agent 回源码 `file:line` 再核(非读文档) +- 核验结论:13 CRITICAL + 8 HIGH 全部 CONFIRM/PARTIAL(缺陷真实存在),无 REFUTE、无 NOTFOUND、无虚报 +- 精化项(4 处,均为措辞/严重性,不改变缺陷成立):H3(默认关闭降档)、H4a(非永久 BLOCK)、H7(锁作用域错配)、C12e(步长分支补全) +- 本清单取代 `v_1.1.1_fix.md` 作为执行依据;`v_1.1.1_fix.md` 可保留作复核留痕 diff --git a/ws/ws.go b/ws/ws.go index 6b7af91..61cf46e 100644 --- a/ws/ws.go +++ b/ws/ws.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "net/url" "sync" "time" @@ -11,15 +12,58 @@ import ( "github.com/gorilla/websocket" ) -// WebSocket 配置 +// WebSocket 配置。 +// +// CheckOrigin 默认采用同源校验(C7/CSWSH 修复):仅当请求 Origin 为空(非浏览器客户端) +// 或与请求 Host 同源时放行,拒绝跨域 WebSocket 连接,防 Cross-Site WebSocket Hijacking。 +// 需要允许多个可信 Origin 的场景,用 SetCheckOrigin 注入自定义校验,或用 AllowOrigins。 var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - return true // 生产环境应检查 Origin - }, + CheckOrigin: sameOriginCheck, } +// 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 + } + return u.Host == r.Host +} + +// 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 @@ -55,13 +99,34 @@ func NewConnection(conn *websocket.Conn) *Connection { } } -// Send 发送消息 +// 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 - case <-c.closeChan: - return errors.New("connection closed") + default: + // 缓冲满。再检查一次 closeChan,避免对刚关闭的连接报"缓冲满"而非"已关闭"。 + select { + case <-c.closeChan: + return errors.New("connection closed") + default: + return ErrSendBufferFull + } } } @@ -79,12 +144,13 @@ func (c *Connection) SendText(text string) error { return c.SendJSON(Message{Type: TypeText, Content: text}) } -// Close 关闭连接 +// 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) - close(c.send) - c.conn.Close() + // #nosec G104 -- 关闭底层连接的错误无意义(重复关闭返错属正常),忽略 + _ = c.conn.Close() }) } @@ -172,6 +238,14 @@ func Handle(handler Handler) gin.HandlerFunc { // 触发连接事件 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) @@ -190,9 +264,11 @@ func Handle(handler Handler) gin.HandlerFunc { } } -// writePump 写入泵 +// writePump 写入泵。每次写前设置写超时,按 pingPeriod 周期发 ping(须 < pongWait)。 +// 写失败时主动 Close 连接,触发 closeChan 关闭 → 读循环 ReadMessage 因底层 conn 关闭 +// 返回错误而退出,避免半开连接残留最长 pongWait 才回收(C2c 残留优化)。 func writePump(conn *Connection) { - ticker := time.NewTicker(30 * time.Second) + ticker := time.NewTicker(pingPeriod) defer ticker.Stop() for { @@ -200,21 +276,28 @@ func writePump(conn *Connection) { case <-conn.closeChan: return case message, ok := <-conn.send: + // send channel 不再被 Close(C2b 修复),ok 永远为 true; + // 此分支保留 ok 检查仅为兼容历史与防御。 if !ok { - conn.conn.WriteMessage(websocket.CloseMessage, []byte{}) + _ = 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 } } @@ -252,7 +335,11 @@ func NewHub() *Hub { } } -// Run 运行 Hub +// Run 运行 Hub。消费 register/unregister/broadcast 三个 channel。 +// +// 死锁修复(C2a):广播分支不再向自身 unregister channel 回环(旧实现 broadcast 中 +// conn.Send 失败时 h.unregister <- conn,而 unregister 的唯一消费者就是本 Run goroutine, +// 导致永久阻塞、整个 Hub 卡死)。改为持写锁单次遍历,失败的连接行内 delete + conn.Close()。 func (h *Hub) Run() { for { select { @@ -270,15 +357,17 @@ func (h *Hub) Run() { h.mu.Unlock() case message := <-h.broadcast: - h.mu.RLock() + // 持写锁单次遍历,失败的连接行内移除并关闭,不回环 unregister channel(C2a 修复)。 + // Send 为非阻塞(C2a-residual 修复),缓冲满或已关闭即返回错误,保证持锁期间不阻塞、 + // Hub 不 stall。慢消费者缓冲满会被清理——ws 广播为 best-effort 语义,背压通过踢除实现。 + h.mu.Lock() for conn := range h.connections { if err := conn.Send(message); err != nil { - h.mu.RUnlock() - h.unregister <- conn - h.mu.RLock() + delete(h.connections, conn) + conn.Close() } } - h.mu.RUnlock() + h.mu.Unlock() } } } diff --git a/ws/ws_concurrency_test.go b/ws/ws_concurrency_test.go new file mode 100644 index 0000000..aae8915 --- /dev/null +++ b/ws/ws_concurrency_test.go @@ -0,0 +1,379 @@ +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 diff --git a/ws/ws_origin_internal_test.go b/ws/ws_origin_internal_test.go new file mode 100644 index 0000000..d94bd22 --- /dev/null +++ b/ws/ws_origin_internal_test.go @@ -0,0 +1,62 @@ +package ws + +import ( + "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", "https://example.com", "example.com", true}, + {"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) + } + }) + } +} + +// 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)") + } +}