Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86e126c42b | |||
| 6595e94677 | |||
| a74ea0c2f4 | |||
| 0f13292a46 |
@@ -7,3 +7,11 @@ gitHub_release_*.md
|
||||
# 构建产物
|
||||
*.exe
|
||||
bin/
|
||||
claude_check_report_framework.md
|
||||
claude_check_report_module.md
|
||||
cross_review_assessment.md
|
||||
deepseek_check_report_framework.md
|
||||
deepseek_check_report_module.md
|
||||
glm_check_report_framework.md
|
||||
glm_check_report_modelue.md
|
||||
last_report.md
|
||||
|
||||
+396
-1
@@ -14,6 +14,400 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_暂无未发布变更。_
|
||||
|
||||
## [1.2.0] - 2026-07-04
|
||||
|
||||
> 框架整体评估后的结构性修复:4 轮对抗性评审(deepseek / GLM / Claude / 终审)收口的全部 CRITICAL/HIGH/MEDIUM 问题 + 主线A 包级可变全局并发治理统一。
|
||||
> 项目处于初级阶段,无下游用户,放心引入破坏性变更。`go vet` + `go build` + `go test -race ./...` 全绿。
|
||||
|
||||
### Breaking ⚠️
|
||||
|
||||
- **`database.RedisClient` 不再可外部访问**:包级变量改为 unexported。所有消费者必须通过 `database.GetRedis()` 获取客户端。测试注入使用 `database.SetTestRedisClient(c)` 替代直接赋值。
|
||||
- **`xlgo.WithConfig(cfg)` 不再调用 `config.Set(cfg)`**:配置不再写入全局状态。依赖 `config.Get()` 获取注入配置的下游代码需改用 `WithConfigPath`。
|
||||
- **`App.Init()` 内部改为 `sync.Once`**:多次调用返回首次执行的结果(含错误),不再是之前的"第二次直接返回 nil"。
|
||||
- **`database.DefaultRedis` 类型变更**(database/redis.go):由 `*RedisManager` 改为 `atomic.Pointer[RedisManager]`,消除裸指针无锁置换的数据竞争(C-1)。下游若直接调用 `DefaultRedis.Init(...)` 等方法需改用 `InitRedis(...)` facade,或 `DefaultRedis.Load().Init(...)`。
|
||||
- **`storage.DefaultStorage` 类型变更**(storage/storage.go):由 `*StorageManager` 改为 `atomic.Pointer[StorageManager]`,与 `config.defaultManager`/`database.DefaultRedis` 并发保护对齐。下游直接调用方法需改用 facade 或 `.Load()`。
|
||||
- **`validation.Validator` 类型变更**(validation/validator.go):由 `*validator.Validate` 改为 `atomic.Pointer[validator.Validate]`,消除无锁读写竞态(H-13)。下游直接读 `validation.Validator.Struct(...)` 需改用 `ValidateStruct(...)`,或 `validation.Validator.Load().Struct(...)`。
|
||||
- **`repository.FindWhereOrdered` / `FindPageWhereOrdered` 签名变更**(repository/repository.go):`args []any` 改为 `args ...any`,与同类方法统一;因 Go 变长参数须为末尾参数,`order` 前置于 `query`。新签名:`FindWhereOrdered(ctx, order, query string, args ...any)`、`FindPageWhereOrdered(ctx, page, pageSize int, order, query string, args ...any)`(H-15)。
|
||||
- **`response.Response` 的 `Data`/`RequestID` 去掉 `omitempty`**(response/response.go):`data` 与 `request_id` 字段在所有响应中恒存在(失败时 `data:null`、未装 RequestID 中间件时 `request_id:""`),下游严格按 schema 解析不再缺字段(M-38/M-39)。
|
||||
- **`cache.IsLocked`/`GetLockTTL`/`ForceUnlock` Redis 不可用时改返 `ErrRedisNotReady`**(cache/lock.go,M-E):原返 `(false/0, nil)` 与"锁确实未占用/已过期"不可区分,调用方可能误以为可获取锁而进入临界区。现统一为:锁操作(正确性相关)Redis 不可用返 `ErrRedisNotReady`,调用方 `errors.Is(err, ErrRedisNotReady)` 区分;cache 数据操作(Get/Set/Incr,性能层)保持 best-effort 静默。
|
||||
- **`config.Load()` 返回深拷贝**(config/config.go,M-G):新增 `(*Config).Clone()` 深拷贝所有切片字段(CORS/Upload/Storage 白名单),`Load()` 与 reload 回调改返 Clone。原"防御性拷贝"为浅拷贝,切片字段与内部 `m.cfg` 共享底层数组,调用方 append/sort/改元素会污染全局。`Get()` 仍返回内部只读指针(热路径零分配),需可变副本用 `Clone()`。
|
||||
- **`handler.GetPage` 加 page 上限 10000**(handler/handler.go,M-D):防 `?page=999999999` 产生超大 OFFSET 拖垮 DB(深分页 DoS)。超过上限钳制到 `MaxPage`,需更深遍历应改游标/keyset 分页。
|
||||
- **`utils.EqualsIgnoreCase` 改 `strings.EqualFold`**(utils/strings.go,L-C):原仅 ASCII 字节折叠,非 ASCII(如 `É`/`é`)误判为不等。现 Unicode 大小写折叠,行为更正确。
|
||||
- **`utils.ReadFile` 去 `FileExists` 前置检查**(utils/file.go,M-F):消除 TOCTOU 竞态,直接 `os.ReadFile`。文件不存在返 `*os.PathError`,调用方改用 `errors.Is(err, os.ErrNotExist)` 判断(原字符串 `"file not found"` 不再返回)。
|
||||
- **`database.DefaultManager` 类型变更**(database/manager.go,主线A 收口):由 `*Manager` 改为 `atomic.Pointer[Manager]`,消除原裸指针(外部直接 `database.DefaultManager = ...` 赋值与请求 goroutine 经 facade 读取)的数据竞争,与 `database.DefaultRedis`/`storage.DefaultStorage`/`cache.defaultCachePtr`/`jwt.defaultManager`/`config.defaultManager` 对齐——框架内包级可变全局一律 atomic.Pointer。新增 `database.SetDefaultManager(m)`(atomic.Store,并发安全)与 `database.GetDefaultManager()`(atomic 读取)。下游若直接调用 `DefaultManager.Init/Master` 等方法需改用 `InitDB`/`GetDB` 等 facade,或 `DefaultManager.Load().Init(...)`,或经 `GetDefaultManager()` 取实例;原 `database.DefaultManager = myDB` 直接赋值改为 `database.SetDefaultManager(myDB)`。
|
||||
|
||||
### Fixed 🐛
|
||||
|
||||
#### 第一轮:CRITICAL + HIGH 基金会战
|
||||
|
||||
- **U1 UUIDShort 生成错误**(utils/uuid.go):`uuid.New().String()[:32]` 产生的 32 字符保留了 4 个破折号(UUID 格式为 36 字符含 4 个 `-`,`[:32]` 截断末尾 4 个 hex 字符但保留破折号)。改为 `strings.ReplaceAll(uuid.New().String(), "-", "")`,现在正确生成 32 位纯十六进制字符串。
|
||||
- **M1 filterSensitiveFields 假过滤**(middleware/logger.go):旧实现用 `strings.ReplaceAll` 在 key 后面追加 `"[FILTERED]"` 而不删除原始值,导致 `{"password":"mypass"}` → `{"password":"[FILTERED]""mypass"}` 密码仍然可见。改为编译期正则 `sensitiveFieldsRE` 匹配 `"key":"value"` 整体并替换 value 部分为 `[FILTERED]`。
|
||||
- **A1 App.Init 并发竞态**(app.go):`a.initialized` 是普通 bool,并发 `Init()` 调用会同时通过检查导致双重初始化。改为 `sync.Once`,`doInit()` 提取为内部方法,错误通过 `a.initErr` 保存。
|
||||
- **D3/CK1 Redis 客户端访问竞态与不一致**(database/redis.go, cache/lock.go):`database.RedisClient` 全局变量在 `Init`/`Close` 中有锁写入但可被外部无锁读取(数据竞态);`cache/lock.go` 直接引用 `RedisClient` 而非 `GetRedis()`(绕过 RedisManager 抽象)。修复方案:`RedisClient` → unexported `redisClient`;`GetRedis()` 加入回退逻辑(优先 `DefaultRedis.Client()`,回退内部 `redisClient`);lock.go 全部改为 `rdb := database.GetRedis()` 单次获取 + nil 检查。
|
||||
- **M2 Metrics in-flight gauge 泄漏**(middleware/metrics.go):`httpRequestsInFlight.Inc()` 后无 `defer Dec()`,handler panic 时 gauge 永久膨胀。改为 `Inc()` 后立即 `defer Dec()`,不依赖 Recover 中间件顺序。
|
||||
- **R1 FailWithError 丢弃 Detail**(response/error.go):`FailWithError` 直接 `writeResp(c, err.Code, err.Message, nil)` 丢弃了 `err.Detail`。改为 `resp := err.ToResponse(); writeResp(c, resp.Code, resp.Msg, resp.Data)`。
|
||||
|
||||
#### 第二轮:并发纪律红线 + 生命周期/泄漏修复
|
||||
|
||||
> 来源:glm_check_report_framework.md 结构性评估。聚焦"包级可变全局统一治理"与"重建路径资源释放"两条主线。
|
||||
|
||||
- **C-1/H-4 `database.DefaultRedis` 并发竞态 + `redisClient` 双源**(database/redis.go):`DefaultRedis` 改 `atomic.Pointer[RedisManager]`(init Store 兜底),所有 facade 经 `Load()`;废弃 `redisClient` 包级变量,`GetRedis` 单源化;`SetTestRedisClient` 改为在当前 manager 上持锁 `setClientForTest` 替换 client,消除双源真相与无锁写竞态。
|
||||
- **H-13 `validation.Validator` 无锁读写**(validation/validator.go):改 `atomic.Pointer[validator.Validate]`;`InitValidator` 先注册全部自定义规则再 Store,确保 Load 得到的 validator 永远完整。
|
||||
- **H-11 + 主线A `storage` 死代码全局 + `DefaultStorage` 裸指针**(storage/storage.go):删除从未被读取的死代码 `var storage Storage`;`DefaultStorage` 改 `atomic.Pointer[StorageManager]`。
|
||||
- **H-10 `response.Error.WithDetail` 并发不安全**(response/error.go):原实现 `e.Detail = detail` mutate 共享的预定义 `Err*`(并发写竞争 + 全局污染)。改为返回新 `*Error` 拷贝。
|
||||
- **H-6 `middleware.RedisRateLimiter.failClosed` 数据竞争**(middleware/ratelimit.go):改 `atomic.Bool`,`SetFailClosed`/`Allow` 全用 atomic,支持运行期并发切换策略。
|
||||
- **H-7 `middleware.GetCSRFToken` 裸断言 + 非恒定时间比较**(middleware/csrf.go):`token.(string)` 改 comma-ok;CSRF token 比较改 `subtle.ConstantTimeCompare`(两处),防时序侧信道。
|
||||
- **H-14/M-64/M-65 `trace.Close` sync.Once 泄漏 + Init 回滚不全**(trace/trace.go):`Close` 去 `sync.Once` 改 Swap+Shutdown,支持 Close→Init→Close 多轮生命周期(原第二次 Close no-op 致 exporter 泄漏);`Init` `!Enabled` 分支 Swap+Shutdown 旧 provider;传播器失败时不切 otel 全局(移到成功路径末尾),避免指向已 Shutdown provider。
|
||||
- **H-8/H-9 `ws.Hub.Stop` double-close panic + WaitGroup Add/Wait 竞态**(ws/ws.go):`Stop` 用 `sync.Once` 包 `close(stop)`(删除"stopped 标志"虚假注释);用 `runDone chan` + `runStarted atomic.Bool` 替代 WaitGroup,消除 `wg.Add(0→1)` 与 `wg.Wait` 的 happens-before 竞态,Stop 先于 Run 调用也不泄漏。
|
||||
- **H-1 `app.OnReady` 失败资源泄漏**(app.go):失败时走 `Shutdown()` 释放 HTTP 端口/后台 goroutine/资源,不再直接 return 致监听 goroutine 永久阻塞。
|
||||
- **H-2 `app.Go` + `Init` 失败 goroutine 泄漏**(app.go):`Init` 失败时 cancel rootCtx + 等 wg(10s 超时兜底),通知已通过 `App.Go` 启动的后台 goroutine 退出。
|
||||
- **H-12 `utils.HTTPClient.SetSkipTLS` 数据竞争**(utils/http.go):加 `sync.RWMutex`;`SetSkipTLS`/`SetTimeout` 写锁下重建 client/transport 并释放旧 transport 空闲连接;`do`/`DoWithResponse`/`Close` 读锁快照后无锁调 `Do`。修复注释自承"需重建 Transport"却未重建的不一致。
|
||||
|
||||
#### 第三轮:P0 安全阻断 + P1 并发/资源收口(Claude 终审)
|
||||
|
||||
> 来源:claude_check_report_framework.md / claude_check_report_module.md。在 deepseek/GLM 修复后的版本上补审,闭合两者共同漏掉的 JWT 算法混淆等安全默认值问题。
|
||||
|
||||
- **#1 JWT 算法混淆(alg confusion)**(jwt/jwt.go):`ParseWithClaims` 统一传 `jwt.WithValidMethods(["HS256","HS384","HS512"])`,keyfunc 内断言 `*jwt.SigningMethodHMAC`,拒绝 alg=none 及非 HMAC 算法(防公钥当 HMAC 密钥伪造 token)。回归 `TestParseTokenRejectsAlgNone`。
|
||||
- **#2 JWT 空密钥 fail-closed**(jwt/jwt.go):新增 `secretKey()`,`GenerateToken*`/`ParseToken*` 空 secret 返 `ErrEmptySecret`。回归 `TestEmptySecretFailsClosed`。
|
||||
- **#3 JWT 不支持算法拒绝**(jwt/jwt.go):`signingMethod` 改返 `(SigningMethod, error)`,RS256 等返 `ErrUnsupportedAlgorithm`,不再静默回退 HS256。回归 `TestUnsupportedAlgorithmFailsClosed`。
|
||||
- **#4 上传大小实测封顶**(storage/storage.go):新增 `enforceUploadSize`/`enforceMaxReader`,本地与 OSS 上传拷贝阶段按实际字节封顶,超限返 `ErrUploadTooLarge` 并清理部分落盘文件。修复原信任客户端 `file.Size` 的绕过。
|
||||
- **#5 HTTP header/cookie map 竞态**(utils/http.go):`SetHeader/SetHeaders/SetCookie` 加写锁,`do/DoWithResponse` 经 `snapshotHeadersCookies()` 读锁快照。
|
||||
- **#6 HTTP SSRF 防护**(utils/http.go):新增 `BlockPrivateNetworks` + `NewSSRFSafeHTTPClient()` + `SetBlockPrivateNetworks()`,经 `net.Dialer.Control` 在连接建立时拦截回环/私有/链路本地/元数据 IP,覆盖重定向每一跳。
|
||||
- **#7–#21 P1 并发/资源/泄露收口**:console 写锁(`writeMu`);config 包级 `Load/LoadWithWatch` `pkgLoadMu` 串行化 + debounce timer Stop;CSRF `ShouldBindBodyWith`(body 可重放);cron `StopWithTimeout`/`WithCron()` 接入 App 生命周期;database `m.cfg` 经 `getCfg/setCfg` 锁内读写;`isTransientDBError` 移除过宽 `"database"` 子串;router `applyOnce sync.Once` + 版本 map 排序;logger `filterSensitiveQuery` 脱敏;response `SetExposeDetail`(生产隐藏 Detail);validation 密码 MaxLength=72 + phone 全数字 + `RegisterValidation` `must` 检查;trace noop 包 + `defer span.End()` + `[unmatched]` 低基数 span 名;storage `NewLocalStorage` Abs fail-closed;cmd/xlgo 项目名/模块路径校验 + `os.Exit(1)` + 失败回滚。
|
||||
|
||||
#### 第四轮:终审剩余项收口(last_report.md)
|
||||
|
||||
> 来源:last_report.md 终审。在第三轮基础上逐项回读核实后修复,均经 `go test -race` 验证。
|
||||
|
||||
- **H-A 从库连接池上限截断**(database/manager.go):`MaxOpenConns/2` 在配置为 1 时得 0(`database/sql` 视 0 为无限制),与"从库减少"意图相反、资源失控。新增 `replicaMaxOpenConns()`:`>0` 取 `max(1, /2)`,`<=0` 与主库一致无限。
|
||||
- **H-B `router.GroupWithMiddlewareGroup` nil panic**(router/router.go):改走 `ensureRegistry()`(与其他全局 helper 一致),把未初始化 nil 解引用 panic 转成可定位错误。
|
||||
- **M-A JWT 黑名单无超时 + 吞错**(jwt/jwt.go):`Add`/`IsBlacklisted` 改用 `context.WithTimeout(1s)`(`blacklistCtx`),把鉴权路径阻塞上限收敛到 1s;`IsBlacklisted` 用 `.Result()` 替代 `.Val()` 显式处理错误(fail-open 保留但记录告警)。
|
||||
- **M-B `Recover` 响应已写出时无效写 500**(middleware/recover.go):加 `c.Writer.Written()` 守卫,已写出时仅 Abort + 记录,避免流式/部分写场景下 `response.Custom` 沦为 no-op 致客户端收到截断响应。
|
||||
- **M-C `RedisRateLimiter` 多次 `GetRedis()` nil-deref 窗口**(middleware/ratelimit.go):`Allow`/`GetCount`/`Reset` 改为取一次 `rdb` 复用,消除两次调用间 `CloseRedis` 致第二次 nil-deref 的窗口。
|
||||
- **M-D `handler.GetPage` 深分页 DoS**(handler/handler.go):加 `page` 上限 `MaxPage=10000`(见 Breaking)。
|
||||
- **M-E Redis 不可用失败语义统一**(cache/lock.go,见 Breaking):锁操作返 `ErrRedisNotReady`;`IsLocked` 用 `.Result()` 返错(合并 L-G)。
|
||||
- **M-F `HashFile`/`ReadFile` OOM 与 TOCTOU**(utils/crypto.go, utils/file.go):`HashFile` 改流式 `io.Copy(h, f)`(恒定内存,可哈希任意大文件);`ReadFile` 去 `FileExists` 前置检查(见 Breaking)。
|
||||
- **M-G `config.Get()` 返回内部指针切片别名**(config/config.go,见 Breaking):新增 `Clone()`,`Load()` 与 reload 回调返深拷贝;`Get()` 保持热路径零分配返只读指针。
|
||||
- **M-H `cron.checkAndRun` 持锁 spawn 阻塞管理 API**(cron/cron.go):锁内只收集到期任务(CAS + 推进 NextRun + `wg.Add`),锁外 spawn。`wg.Add` 必须在锁内以保证 `Stop` 的 `wg.Wait` 等到本批(否则 Stop 可能在任务间提前返回、后启动 goroutine `wg.Done` 下溢 panic)。
|
||||
- **L-A compress 解压残留文件**(compress/compress.go):`GzipDecompressFileWithOptions`/`unzipFile` 失败时(超限/拷贝错误)`os.Remove` 部分落盘文件,避免被拒炸弹遗留残片。
|
||||
- **L-B utils 正则重编译**(utils/validator.go):`IsPhone`/`IsEmail`/`IsIPv4`/`IsIDCard` 正则提为包级 `MustCompile`,避免每次调用重编译。
|
||||
- **L-C `EqualsIgnoreCase` 非 ASCII 误判**(utils/strings.go,见 Breaking):改 `strings.EqualFold`。
|
||||
- **L-D `redisLimiters` 死代码**(middleware/ratelimit.go):删除从未被读写的包级 `redisLimiters` map 与其 `init()`。
|
||||
- **L-E `logger.Logger` 导出变量**(logger/logger.go):标 `Deprecated:`,引导用包级 `Info/Debug/...` 函数(atomic 读取,并发安全)。
|
||||
- **L-H `ws.SetCheckOrigin` 无锁写**(ws/ws.go):文档明确"仅启动前调用",运行期切换 Origin 用自有 upgrader 实例。
|
||||
- **L-J `cron.RunTask` 用 `s.ctx`**(cron/cron.go):文档说明 Stop 后 handler 收到 canceled ctx 的预期行为。
|
||||
|
||||
> **未修(评估后决定)**:L-K `model` 时间戳 `omitempty`——`encoding/json` 对 `time.Time`(结构体)的 `omitempty` 是 no-op,报告建议的修复无效;真正的修复需改 `*time.Time`(破坏性 + nil 安全隐患),收益与成本不匹配,暂不做。L-I `response.writeResp` nil-c 守卫——nil `*gin.Context` 是程序员错误,panic 是恰当反馈,加静默守卫反而掩盖 bug。L-F/L-M/L-N/L-O 为文档/命名类,已就地注释说明或属设计取舍。
|
||||
|
||||
### Changed 🔄
|
||||
|
||||
- **`database.GetRedis()` 加入回退逻辑**:优先返回 `DefaultRedis.Client()`,若未初始化则回退到内部 `redisClient`(测试注入路径)。确保 `SetTestRedisClient` + `GetRedis()` 闭环。
|
||||
- **`database.SetTestRedisClient(c)` 新增**:供测试注入 mock Redis 客户端,返回旧值便于清理恢复。
|
||||
|
||||
---
|
||||
|
||||
## [v1.1.1] - 2026-06-28
|
||||
|
||||
> v1.1.1 后的安全/正确性补丁,依据 `version_1.1.1_report.md` 权威缺陷清单逐项修复(13 CRITICAL + 8 HIGH)。
|
||||
|
||||
### Fixed 🐛
|
||||
|
||||
#### P3 清理第三批(MEDIUM/MINOR 收尾)
|
||||
|
||||
> 续前两批。本批为校验收紧、全局状态并发、Windows 控制台、trace 遗留、logger 级别、测试质量。`go test -race` + `go vet` + `gosec` 通过。
|
||||
|
||||
- **M5 身份证无校验位 + 用户名首字节**(validation/validator.go):18 位身份证补 GB 11643-1999 校验位验证(`validateIDCardChecksum`),原仅查长度+格式可被任意构造通过;`username` 验证首字符改按 `[]rune` 取,避免非 ASCII 首字节误判(原 `rune(username[0])` 取字节)。
|
||||
- **M13 cache globalKeyBuilder 无锁 + 无 sync.Once**(cache/keybuilder.go):`globalKeyBuilder` 加 `sync.RWMutex` 读写保护,`GetKeyBuilder` 用 `sync.Once` 保证自动初始化只执行一次,消除 check-then-init 竞态。`SetPrefix` 补实例非并发安全注释。
|
||||
- **M12 NewRedisCache 构造时快照 client**(cache/cache.go):`redisCache` 不再构造时 `database.GetRedis()` 快照(Init Redis 之前构造则永久 nil no-op),改每次操作实时取 client,使"先构造后 Init Redis"顺序也能正确工作。
|
||||
- **M18 trace 显式设 codes.Ok + Close 无 double-close 守卫**(trace/trace.go):成功路径不再 `SetStatus(codes.Ok, "")`(OTel 规范默认 UNSET,显式 Ok 会掩盖子 Span 错误状态);`Close` 用 `sync.Once` 保证只 Shutdown 一次,重复调用安全。
|
||||
- **M19 logger 无法显式设级别**(logger/logger.go):三个 core(app/api/db/console)改共享 `zap.AtomicLevel`,新增 `LogManager.SetLevel`/`GetLevel` + 包级 `SetLevel`/`GetLevel`,支持运行期热切换日志级别。
|
||||
- **M17 console_windows 死代码 + 着色句柄分裂**(console/console_windows.go):移除从未被调用的 `EnableVirtualTerminal`;`printColor` 原对 `syscall.Stdout` 设置颜色、文本却写 `c.output`(非 stdout 时二者分裂),改为按 `c.output` 实际类型取句柄(`*os.File` 用 Fd,否则退化为纯文本)。
|
||||
- **N2 repository_test 空壳**(repository/repository_test.go):原全为注释空壳、CRUD 零覆盖,改为编译期断言 `var _ BaseRepository[T] = (*BaseRepo[T])(nil)` 锁定接口契约(实现与接口漂移即编译失败)。
|
||||
- **N3 test MockStorage 签名不符 + SetupRouter 文档**(test/test.go):`MockStorage.Upload` 签名对齐真实 `storage.Upload(file *multipart.FileHeader, subdir)`,新增 `UploadFromBytes` 对齐 `storage.UploadFromBytes`;`SetupRouter` 补文档说明刻意返回裸 `gin.New()`(不含框架中间件,由测试方控制)。
|
||||
- **Added**:`logger.LogManager.SetLevel`/`GetLevel`、`logger.SetLevel`/`GetLevel`、`validation.validateIDCardChecksum`(未导出)。无 breaking(新 API;身份证校验位收紧可能拒绝先前"格式正确但校验位错"的输入——这正是修复目的)。
|
||||
|
||||
#### P3 清理第二批(MEDIUM/MINOR 文档/正确性/校验)
|
||||
|
||||
> 续上一批。本批以正确性修复 + 文档/命名澄清为主,风险分级处理,`go test -race` + `go vet` + `gosec` 通过。
|
||||
|
||||
- **M4 datetime StartOfWeek DST 落错日**(utils/datetime.go):原用 `t.Add(-N*24h)` 回退到周一,DST 切换日 24h ≠ 1 个日历日会落错日。改为按日历日 `time.Date(..., Day-(weekday-1), ...)` 计算,保留原时区。`ParseDateInt` 补注释说明非法输入会被 time.Date 静默规范化、调用方须校验。
|
||||
- **M11 HealthCheck 同步 ping 无超时 + WriteQuery 命名误导**(database/manager.go):包级 `HealthCheck()` 的 `sqlDB.Ping()` 改 `pingWithTimeout`(3s 超时,尊重调用方 ctx deadline),避免探针被慢/挂起的 DB 长期阻塞。`WriteQuery` 补注释说明其命名沿用历史、实际为读取语义(强制主库 read-your-writes)。
|
||||
- **M9 DSN 密码不转义 + 时区硬编**(config/config.go):MySQL DSN 密码改 `url.QueryEscape`,Postgres DSN 密码改单引号包裹+内嵌单引号翻倍,避免含 `@`/`:`/空格/引号 的密码破坏 DSN。新增 `DatabaseConfig.Timezone` 字段(MySQL loc / Postgres TimeZone 可配,空则保持原默认 `Local`/`Asia/Shanghai` 向后兼容)。
|
||||
- **M7 ToResponse 丢 Detail**(response/error.go):`Error.ToResponse()` 把 `Detail` 放入 `data.detail`(非空时),不再丢失细节信息。
|
||||
- **M14 timeout 软超时文档化**(middleware/timeout.go):注释明确软超时语义——仅注入带 deadline 的 ctx,不主动中断 handler;纯 CPU/不查 ctx 的 handler 不生效,硬中断需配合 `http.Server.WriteTimeout` 或 handler 内 `select ctx.Done`。
|
||||
- **C3 收尾:生产者取消信号契约**(sse/sse.go):包文档化断连契约——框架消费循环已监听 `c.Request.Context()` 断连即退,但生产者(LLM 流)必须自行监听同一 ctx 在取消时停止,否则上游持续运行浪费算力。`StreamText` 注释已有,补包级 doc。
|
||||
- **M20 生成器非法标识符 + fileExists 权限误判**(cmd/xlgo):`make handler my-thing` 原 `cases.Title` 得 `My-ThingHandler`(非法标识符);新增 `sanitizeIdent` 把非字母数字转下划线再 Title,得 `MyThingHandler`。`fileExists` 注释澄清权限错误的判定语义。
|
||||
- **N1 BaseModelWithTime 命名误导**(model/base.go):补注释说明与 BaseModel 唯一区别是 `type:datetime`(部分 MySQL 丢毫秒),名字 "WithTime" 易误导,保留仅为兼容。
|
||||
- **N4 Nl2br 死分支 + IsEmpty 文档不符**(utils/):`Nl2br` 的 `case '\n'` 内 `r == '\r'` 半恒假(r 恒为 '\n')已清理;`IsEmpty` 文档原称支持 slice/map 但实现仅支持 string/[]byte/nil,文档修正为实际行为。
|
||||
- **N6 sse KeepAlive 触发 onmessage**(sse/sse.go):心跳由 `data: \n\n`(触发客户端 onmessage)改 SSE 注释行 `: ping\n\n`(不产生消息事件,更符合心跳语义)。
|
||||
- **Added**:`config.DatabaseConfig.Timezone`。无 breaking(Timezone 零值保持原默认时区);DSN 密码转义对合法密码无影响(无特殊字符的密码转义后不变)。Postgres DSN 格式变更(password 加单引号)对下游 GORM 透明。
|
||||
|
||||
#### P3 安全/正确性轻量清理(一批 MEDIUM/MINOR)
|
||||
|
||||
> 风险分级处理:安全与逻辑正确性项实际修复 + 针对性用例;纯文档/感知项仅加注释。全部经 `go test -race` + `go vet` + `gosec` 验证。
|
||||
|
||||
- **M15 requestid 头注入**(middleware/requestid.go):原无条件信任客户端 `X-Request-ID`,可注入 CRLF 伪造响应头/日志。新增 `sanitizeRequestID`:仅接受可见 ASCII(0x20-0x7e)、无换行、长度 ≤128,非法则忽略并重新生成。合法 ASCII ID 仍沿用客户端值(向后兼容)。
|
||||
- **C7/N7 ws CheckOrigin 默认 true(CSWSH)**(ws/ws.go):默认 `CheckOrigin` 由恒 `true` 改为同源校验(空 Origin 放行非浏览器客户端、否则要求 Origin host 与请求 Host 一致),防 Cross-Site WebSocket Hijacking。新增 `AllowOrigins(origins...)` 辅助多可信域名场景。**Breaking ⚠️**:原默认放行所有跨域 WS 连接,现拒绝跨域;依赖跨域 WS 的下游需用 `ws.SetCheckOrigin` 或 `ws.AllowOrigins(...)` 显式放行。
|
||||
- **C5/N5 HTTPClient Upload FD 累积 + 响应体无上限**(utils/http.go):`Upload` 循环内 `defer file.Close` 改为显式关闭,避免大批量上传累积 FD;`do` 的 `io.ReadAll(resp.Body)` 改 `io.LimitReader` 封顶(默认 32MB,可经 `HTTPClientConfig.MaxResponseBodySize` 配置,-1 不限),防异常服务端返回超大响应打爆内存。
|
||||
- **M16/B18 压缩写侧 defer Close 吞错**(compress/compress.go):`GzipCompressFile`/`Zip` 的 `defer gz.Close()`/`defer zipWriter.Close()`/`defer archive.Close()` 改为显式关闭并向上传播错误——flush 失败(归档损坏)不再被吞成成功返回。
|
||||
- **M6 Download 中文文件名乱码**(response/response.go):`Content-Disposition` 由直接拼接 `filename=` 改为 RFC 5987:同时给 ASCII 回退 `filename="..."` 与 UTF-8 百分号编码 `filename*=UTF-8''...`,中文等非 ASCII 文件名不再乱码。
|
||||
- **M8 CodeDataAlreadyExists 状态不一致**(response/mode.go):`CodeDataAlreadyExists` 在 ModeREST 下原落 200,与同语义的 `CodeDataConflict`(409) 不一致。映射到 409 Conflict。
|
||||
- **M10 driver 拼写错误静默回退 MySQL**(database/dialect.go):未注册驱动回退 MySQL 时新增 `logger.Warnf` 告警(含已注册驱动列表),避免拼错 driver 名(如 `postgrs`)静默回退导致连接错误难排查。
|
||||
- **M2 AddQueries 实为 Set**(utils/url.go):`AddQueries` 原用 `query.Set`(覆盖同名),与 `AddQuery`(追加)语义不一致。改为 `query.Add`,同 key 多值共存。
|
||||
- **M3 file.go 路径穿越感知**(utils/file.go):文件工具函数加包级文档警告——直接操作调用方路径不做穿越校验,不可信输入须调用方自行净化(框架 storage 包已做防护)。
|
||||
- **Added**:`ws.AllowOrigins`、`utils.HTTPClientConfig.MaxResponseBodySize`。**Breaking**:`ws` 默认 CheckOrigin 收紧为同源(见 C7)。无配置/migration 变更(MaxResponseBodySize 零值默认 32MB)。
|
||||
|
||||
#### H6:`repository/repository.go` BaseRepo 不接 GetDBFromContext + 读写分离失效 + 事务无法 join + 分页不一致(repository/repository.go, database/manager.go)
|
||||
|
||||
`BaseRepo` 构造时捕获 `r.db`,所有方法 `r.db.WithContext(ctx)` 从不调 `database.GetDBFromContext` → 读写分离形同虚设(读全走主库)、外层 ctx 事务无法 join、`WithTransaction` 内方法走 `r.db` 拿不到事务。叠加 `Update` 用 `Save` 全列覆写(H6a)、`FindPage` 的 count+list 为两条独立语句高并发下 total/items 不一致(H6d)、`QueryBuilder` 终结方法未克隆且 `Count` 受残留 Limit/Offset 截断(H6e)。修复:
|
||||
|
||||
- **H6c 连接路由**:新增 `readConn(ctx)`/`writeConn(ctx)`,优先级为「外层 ctx 事务(`database.TxFromContext`)> 本 repo 事务(`r.tx`)> 路由 db > `r.db` 回退」。读走 `database.GetDBFromContext`(默认从库,支持 `UseMaster`/`UseReplica`),写走 `database.GetWriteDB()`(主库,不路由到只读从库)。`DefaultManager` 未初始化(如单测注入 sqlite)时回退 `r.db`,兼容下游 `NewBaseRepo[T](database.GetDB())`。
|
||||
- **H6c 事务 join**:`BaseRepo` 新增未导出 `tx` 字段;`WithTransaction` 创建 `txRepo` 时注入 `tx`,其方法自动 join 事务。新增 `database.WithTx(ctx, tx)`/`TxFromContext(ctx)` 支持跨层/跨 repo join(外层 `database.TransactionWithContext` 拿到的 tx 经 `WithTx` 注入 ctx 后传给 repo 方法即可参与同一事务)。`WithTransaction` 签名**不变**。
|
||||
- **H6a 局部更新**:新增 `UpdateFields(ctx, model, conds...)` 基于 `gorm.Updates`(struct 仅更新非零字段、map 可显式置零),避免 `Save` 全列覆写丢失更新/零值不可辨。`Update`(Save)保留并文档化其全列覆写语义。
|
||||
- **H6b 软删除契约**:`Delete` 文档化行为契约——`T` 内嵌 `gorm.DeletedAt`/`gorm.Model` 时软删除,否则硬删除(泛型类型约束无法编译期强制)。
|
||||
- **H6d 分页一致性**:`FindPage`/`FindPageOrdered`/`FindPageWhere`/`FindPageWhereOrdered` 的 count+list 包进单事务(同一快照),消除高并发下 total/items 不一致。
|
||||
- **H6e QueryBuilder 克隆**:终结方法(`Find`/`First`/`Count`/`Page`)基于 `Session(&gorm.Session{})` 克隆,不污染 `qb.db`;`Count`/`Page` 的 count 额外 `Limit(-1).Offset(-1)` 剥离残留分页条件。文档标注 QueryBuilder 单次使用、非并发安全。
|
||||
- **Added**:`database.WithTx`/`database.TxFromContext`、`repository.BaseRepo.UpdateFields`。无既有 API 签名/配置/migration 变更(非 breaking)。行为变更:读操作默认路由到从库(原全走主库)、写操作显式走主库、分页查询包单事务(每页一次 BEGIN/COMMIT,见下)。
|
||||
|
||||
#### C10:`config/config.go` 全局 Manager 无锁置换 + 热重载绕过 Validate + StopWatcher 空函数(config/config.go)
|
||||
|
||||
- **C10a 全局 Manager 无锁置换**:包级 `defaultManager` 原为裸 `*Manager` 指针,`Load`/`LoadWithWatch`/`SetDefaultManager` 直接赋值,与 `Get`/`GetViper`/`GetString` 等请求 goroutine 的无锁读存在数据竞争。改为 `atomic.Pointer[Manager]`,所有包级便捷函数经 `Load()`/`Store()` 原子读写。
|
||||
- **C10b 热重载绕过 Validate**:`OnConfigChange` 与 `Reload` 原均不调 `Validate()`(仅 `Load` 调用),非法配置(坏端口、负超时、短密钥)直接发布。`Reload` 与文件监听路径统一走 `reload()`:读取/解析/校验任一步失败均保留旧配置并返回错误,仅新配置通过 `Validate` 后才替换 `m.cfg` 并触发回调。
|
||||
- **C10c Load 返回可变指针**:`Load` 原返回 `&cfg` 与 `m.cfg` 同一指针,调用方可变并竞争。改为返回防御性浅拷贝,调用方修改返回值不污染全局读取路径。
|
||||
- **C10d StopWatcher 空函数**:原 `StopWatcher()` 为空,viper 内部 watcher goroutine + fd 永不释放。改为自管 `fsnotify.Watcher`(监听配置文件所在目录以兼容编辑器改写/k8s ConfigMap 原子替换,按文件名过滤 + 200ms 去抖),`StopWatcher` 关闭 watcher 并等待监听 goroutine 退出(`watchDone`),幂等。废弃 viper `WatchConfig`/`OnConfigChange`。
|
||||
- 无 API 签名/配置结构变更;行为变更(热重载非法配置保留旧配置而非发布、StopWatcher 真正释放监听资源)。
|
||||
|
||||
#### C11:`database/manager.go` 池泄漏 + Master/Replicas 无锁读 + 健康状态陈旧(database/manager.go)
|
||||
|
||||
- **C11b InitDB 重试泄漏**:`InitDB` 原直接 `m.master = gorm.Open(...)`,`gorm.Open` 成功但 `Ping` 失败时旧池不关、下轮覆盖 `m.master`,每次重试泄漏一池。改为先打开到局部变量,仅 `Ping` 通过后才在锁内安装为 `m.master` 并关闭旧主库池;`Ping`/`DB()` 失败时关闭刚打开的池。
|
||||
- **C11c InitDBWithReplicas 泄漏**:原 `m.replicas = nil` 前不关旧从库池,且从库 `DB()`/`Ping` 失败时 `continue` 不关刚打开的池。改为重建前在锁内取出旧从库、重置健康状态后逐个 `closeDB`;从库构建失败时关闭刚打开的池;新从库先构建到局部切片再原子安装。
|
||||
- **C11a 健康状态陈旧**:`initReplicaHealth` 的 `replicaHealthSet` 早返回使重新 `InitDBWithReplicas` 后健康切片与新 replicas 长度错位。新增 `resetReplicaHealth`,`InitDBWithReplicas`/`Close` 重建/关闭前调用,使下次 `initReplicaHealth` 按新 replicas 长度重建。
|
||||
- **C11d Master/Replicas 无锁读**:`Master()`/`Replicas()` 原裸读 `m.master`/`m.replicas`,与 `Close`/`InitDB` 写竞争。改为全程持 `m.mu` 锁;`Replicas()` 返回拷贝;`Replica()` 的空从库判断移入锁内;`FromContext`/`HealthCheck`/`Transaction`/`TransactionWithContext`/`WriteQuery`/包级 `HealthCheck` 改经 `Master()`/`Replicas()` 读取;`probeOnce` 快照 `replicaHealthy` 切片头避免与重置竞争。
|
||||
- **C11f 包级 Close 仅关主库**:包级 `Close()` 原仅关 master 且无锁,命名误导致从库泄漏。改为委托 `CloseAll()`(关主+从并重置健康状态)。
|
||||
- **C11e(非缺陷)**:`RoundRobinPicker` `int(n-1)%len` 取模后仍在 `[0,len)` 内,无 panic/正确性问题;`RandomPicker` 全局 `math/rand` 仅锁竞争。属微优化,非功能 bug,未改。
|
||||
- 无 API 签名/配置结构变更;行为变更(包级 `Close` 现关闭从库、`InitDB` 重试/重建不再泄漏旧池、`Master`/`Replicas` 加锁读取)。gosec G115/G404 为 `RoundRobinPicker`/`RandomPicker` 既有项(C11e 范围外)。
|
||||
|
||||
#### C9c:`jwt/jwt.go` 包级 `DefaultJWT`/`tokenBlacklist` 无锁置换(jwt/jwt.go)
|
||||
|
||||
`SetDefaultJWTManager` 原裸写包级 `DefaultJWT`/`tokenBlacklist`,与请求 goroutine(`ParseToken`/`RefreshToken`/`InvalidateToken`/`InvalidateTokenByID`/`IsTokenRevoked` 读 `tokenBlacklist`)存在数据竞争(C9c,C9a/b 已在 C9b 修复 fail-closed,此项是遗留并发隐患)。修复:
|
||||
- 新增内部 `defaultManager atomic.Pointer[Manager]` 作真实存储,`init()` Store;包级函数经 `currentManager()`/`currentBlacklist()`(atomic 读取)访问,消除裸指针读写竞争。
|
||||
- `SetDefaultJWTManager` 改用 `defaultManager.Store(m)` 原子置换;移除裸写的包级 `tokenBlacklist` 变量。
|
||||
- `DefaultJWT` 保留为导出 `*Manager` 兼容别名(类型不变,非 breaking),由 `SetDefaultJWTManager` 同步维护;注释标注直接读 `DefaultJWT` 非并发安全,并发访问应用包级函数或 `SetDefaultJWTManager`。
|
||||
- 无 API 签名变更;`DefaultJWT` 类型不变(非 breaking)。行为变更:包级黑名单读写改经 atomic,`SetDefaultJWTManager` 可安全在请求期调用。
|
||||
|
||||
#### H3:`middleware/logger.go` 请求/响应 body 无上限读 → OOM(middleware/logger.go)
|
||||
|
||||
`LoggerWithConfig` 在 `LogRequestBody:true` 时用 `io.ReadAll(c.Request.Body)` 无封顶读入内存,`MaxBodyLength` 仅在读完后截断**日志副本**,全 body 已驻留并二次 buffer——多 GB POST 可 OOM;响应侧 `bodyLogWriter.body` 同样无上限累积。默认 `LogRequestBody:false` 使默认安全,但 `LoggerForAPI`/`LoggerForDebug` 显式开启即暴露。修复:
|
||||
- 请求体新增 `readBodyBounded(c, maxLen)`:`io.LimitReader(body, maxLen+1)` 仅向内存读入最多 `maxLen+1` 字节(+1 检测截断),通过 `io.MultiReader(已读前缀, 原始 body 剩余)` 复原 `c.Request.Body`——**下游处理器仍得完整请求体**;日志副本截断到 `maxLen`。
|
||||
- 响应体 `bodyLogWriter` 增 `maxLen` 字段,捕获缓冲区封顶;`Write`/`WriteString` 仍把完整响应写入下游 `ResponseWriter`,仅捕获缓冲区封顶。
|
||||
- `LoggerWithConfig` 入口归一化 `MaxBodyLength`:`<=0` 时回退默认值(1024),确保请求/响应两侧捕获均有上限,消除手配 `MaxBodyLength:0` 时响应侧无上限的 OOM 残留路径。
|
||||
- 无 API 签名/配置结构变更;行为变更:`MaxBodyLength` 现同时门控响应体捕获(此前响应侧无视该值无上限累积,属 bug 修正);`MaxBodyLength<=0` 不再意味"无上限",统一回退默认上限。
|
||||
|
||||
#### H7:`logger/logger.go` 全局指针写有锁读无锁 + `Field.Duration` 签名与实现矛盾(logger/logger.go, logger/field.go)
|
||||
|
||||
`Init`/`Close` 持 `m.mu`(实例锁)写包级 `Logger`/`sugar`/`apiLog`/`dbLog`,但 `Info`/`Error`/`APILog()`/`DBLog()`/`Sync` 等请求期函数无锁裸读——锁与被保护对象作用域错配(实例锁保护包级全局变量),热重载 re-Init/Close 与请求日志存在数据竞争(H7a)。另 `Field.Duration` 签名为 `func(key string, value interface{})`,`case zap.Field` 分支 `return v` 丢弃 `key`,签名与实现矛盾(H7b)。修复:
|
||||
- **H7a**:新增内部 `loggerPtr`/`sugarPtr`/`apiLogPtr`/`dbLogPtr atomic.Pointer[...]` 作真实存储,`init()` Store 为 Nop;`Info`/`Debug`/`Warn`/`Error`/`Fatal`/`Debugf`-`Fatalf`/`APILog`/`DBLog`/`Sync` 读路径统一经 `currentLogger()`/`currentSugar()`/`currentAPILog()`/`currentDBLog()`(atomic Load,nil 防御回退 Nop),消除请求期裸读竞争。`Init`/`Close` 在 `m.mu` 下 Store atomic。
|
||||
- **H7a 兼容别名**:`Logger` 保留为导出 `*zap.Logger` 兼容别名(类型不变,非 breaking),由 `Init`/`Close` 在 `m.mu` 下同步维护;注释标注直接读 `Logger` 变量在 re-Init/Close 期间非并发安全,并发访问应用包级函数。
|
||||
- **H7b**:`Field.Duration` 签名改为 `func(key string, value time.Duration) zap.Field`,直接委托 `zap.Duration(key, value)`,类型安全、key 不再可能被丢弃。
|
||||
- 顺带收紧 `os.MkdirAll` 日志目录权限 `0o755`→`0o750`(与 storage 目录权限一致,gosec G301)。
|
||||
- 无 API 签名/配置结构变更(`Logger` 类型不变);行为变更:包级日志读路径改经 atomic,re-Init/Close 可安全与请求日志并发。`Field.Duration` 签名变更为**类型收紧**(`interface{}`→`time.Duration`),旧调用方传 `time.Duration` 不受影响,传 `zap.Field` 等非 Duration 类型将编译失败(属修复目的)。
|
||||
|
||||
#### C12:`cron/cron.go` 数据竞争 + 重叠执行 + 漂移 + Weekly 跳周 + cron 解析缺陷(cron/cron.go)
|
||||
|
||||
`cron/cron.go` 存在 5 子项缺陷(C12a–C12e)。修复:
|
||||
- **C12a 数据竞争**:`runTask` 原无锁写 `LastRun`/`RunCount`,`GetTask`/`ListTasks` 返回 live 指针并发读 → data race。改为 `LastRun`/`RunCount`/`NextRun` 写入一律在 `s.mu` 写锁内;`GetTask`/`ListTasks` 返回拷贝快照(`cp := *task`)。
|
||||
- **C12b 无重叠守卫**:`checkAndRun` 每秒 tick,长任务跨 tick 被反复 spawn 同一任务并发执行。新增 per-task `running *atomic.Bool` 守卫,`checkAndRun` 与 `RunTask` 均经 `CompareAndSwap(false,true)` 占用,正在执行则跳过/返错。
|
||||
- **C12c Interval 漂移**:`NextRun` 原在 handler 完成后以 `time.Now()` 锚定,每周期累积 handler 时长。改为 `checkAndRun` spawn 前 `task.NextRun = task.Schedule.Next(task.NextRun)`(以上次 `NextRun` 锚定),`runTask`/`RunTask` 不再更新 `NextRun`。
|
||||
- **C12d Weekly 跳周**:原 `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻,当天目标未到点被跳一周。重写为 `((day-now)+7)%7` 加天数后 `!next.After(now)` 才 +7,当天未到点返回本周、已过返回下周。
|
||||
- **C12e cron 解析缺陷**:`parseInt` 忽略非数字逐位累积,`1-5,8` 因先判 `-` 被当范围(`parseInt("5,8")=58`)、`garbage`→0 误触发、`*/garbage`→step=0 匹配全部、周日 `7` 不匹配。重写 `matchField`:列表分支独立于范围分支(先按逗号拆,每项判 `*/n`/`a-b/n`/`a-b`/单值),全用 `strconv.Atoi` 返错;weekday `7→0`,范围 `lo>hi` 环绕;歧义范围 `0-7`/`7-0` 拒绝。新增 `ParseCronStrict(expr) (*FullCronSchedule, error)` 严格校验;`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 过时/错误描述。
|
||||
@@ -574,7 +968,8 @@ v1.0.2 引入可插拔方言注册表后,`gorm.io/driver/postgres` 成为直
|
||||
- 基础框架功能
|
||||
- 完整示例代码
|
||||
|
||||
[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.1.1...HEAD
|
||||
[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.2.0...HEAD
|
||||
[1.2.0]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.1.1...v1.2.0
|
||||
[1.1.1]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.1.1
|
||||
[1.1.0]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.1.0
|
||||
[1.0.4]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.4
|
||||
|
||||
@@ -111,7 +111,6 @@ app:
|
||||
env: "dev" # dev/test/prod
|
||||
debug: true
|
||||
base_url: "http://localhost:8080"
|
||||
token_expire: 86400 # Token过期时间(秒)
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
@@ -134,7 +133,7 @@ redis:
|
||||
|
||||
jwt:
|
||||
secret: your_jwt_secret_key
|
||||
expire: 86400
|
||||
expire: "24h" # time.Duration,如 "24h"/"30m"
|
||||
|
||||
log:
|
||||
dir: ./logs
|
||||
@@ -288,7 +287,7 @@ xlgo 基于 GORM,驱动由配置 `database.driver` 决定。v1.0.2 起 GORM
|
||||
```go
|
||||
import "github.com/EthanCodeCraft/xlgo-core/database"
|
||||
|
||||
// 初始化数据库(驱动由配置决定,等价于 database.DefaultManager.InitDB(cfg))
|
||||
// 初始化数据库(驱动由配置决定,等价于 database.DefaultManager.Load().InitDB(cfg))
|
||||
database.InitDB(cfg)
|
||||
|
||||
// 关闭全部连接(含从库)
|
||||
@@ -393,13 +392,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)
|
||||
@@ -421,7 +430,7 @@ result, err := userRepo.FindPageOrdered(ctx, 1, 20, "created_at DESC")
|
||||
// 条件查询
|
||||
user, err := userRepo.FindOne(ctx, "email = ?", "test@example.com")
|
||||
users, err := userRepo.FindWhere(ctx, "status = ?", 1)
|
||||
users, err := userRepo.FindWhereOrdered(ctx, "status = ?", 1, []any{}, "created_at DESC")
|
||||
users, err := userRepo.FindWhereOrdered(ctx, "created_at DESC", "status = ?", 1)
|
||||
|
||||
// 排序查询
|
||||
users, err := userRepo.FindOrdered(ctx, "created_at DESC", 10)
|
||||
@@ -442,7 +451,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 +474,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 基础模型
|
||||
@@ -577,11 +595,14 @@ err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func()
|
||||
// 续期锁(手动续期)
|
||||
cache.ExtendLock(ctx, token, 30*time.Second)
|
||||
|
||||
// 检查锁是否被占用
|
||||
// 检查锁是否被占用(Redis 不可用时返回 ErrRedisNotReady,可经
|
||||
// errors.Is(err, cache.ErrRedisNotReady) 与"锁未占用"区分)
|
||||
locked, err := cache.IsLocked(ctx, key)
|
||||
|
||||
// 强制释放锁(管理场景)
|
||||
cache.ForceUnlock(ctx, key)
|
||||
// 强制释放锁(管理场景;Redis 不可用时返回 ErrRedisNotReady)
|
||||
if err := cache.ForceUnlock(ctx, key); err != nil {
|
||||
// 处理 Redis 不可用
|
||||
}
|
||||
```
|
||||
|
||||
**安全特性:**
|
||||
@@ -679,7 +700,9 @@ if !ok {
|
||||
| `max=20` | 最大长度 |
|
||||
| `email` | 邮箱格式 |
|
||||
| `phone` | 手机号格式 |
|
||||
| `password` | 密码强度(至少 8 位,包含字母和数字) |
|
||||
| `phone_strict` | 手机号严格校验(验证运营商号段) |
|
||||
| `password` | 密码强度(8-72 字节,需含大写+小写+数字) |
|
||||
| `username` | 字母开头,3-20 位字母/数字/下划线 |
|
||||
| `url` | URL 格式 |
|
||||
| `ip` | IP 地址格式 |
|
||||
|
||||
@@ -886,13 +909,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 +927,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 限流:多实例共享,滑动窗口算法,分布式场景必需
|
||||
@@ -1042,7 +1071,8 @@ router.RegisterVersion(router.NewVersion("v1", "/api/v1"))
|
||||
// 应用路由
|
||||
router.Apply()
|
||||
|
||||
xlgo.StartServer(engine, 8080)
|
||||
// 启动服务(如需优雅关闭与生命周期管理,改用 app := xlgo.New(...); app.Run())
|
||||
engine.Run(":8080")
|
||||
```
|
||||
|
||||
#### 8.4.7 默认路由
|
||||
@@ -1171,7 +1201,9 @@ storage:
|
||||
import "github.com/EthanCodeCraft/xlgo-core/storage"
|
||||
|
||||
// 初始化
|
||||
storage.Init(&cfg.Storage)
|
||||
if err := storage.Init(&cfg.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 上传文件(从请求中获取)
|
||||
file, _ := c.FormFile("file")
|
||||
@@ -1201,19 +1233,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 +1439,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 +1455,7 @@ if utils.UUIDValid(uuid) { }
|
||||
|
||||
| 改进 | 说明 |
|
||||
|------|------|
|
||||
| 性能优化 | `RandString/RandDigit` 使用 sync.Pool 复用随机源 |
|
||||
| 性能优化 | 非安全随机用 `RandInt/RandInt64`(math/rand,sync.Pool 复用源);安全场景用 `RandStringSecure/RandDigitSecure/RandIntSecure`(crypto/rand + big.Int 拒绝采样无偏) |
|
||||
| 类型安全 | 移除使用反射的函数,保持类型安全 |
|
||||
| 式调用 | `HTTPClient` 和 `URLBuilder` 支持链式调用 |
|
||||
| 零依赖 | 仅依赖 `google/uuid`,其余使用标准库 |
|
||||
@@ -1527,11 +1573,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
|
||||
@@ -1750,13 +1803,17 @@ func Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 生成Token
|
||||
token, _ := jwt.GenerateToken(user.ID, user.Username, "admin", "admin")
|
||||
token, err := jwt.GenerateToken(user.ID, user.Username, "admin", "admin")
|
||||
if err != nil {
|
||||
response.FailWithError(c, response.ErrServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存用户信息
|
||||
cache.GetCache().Set(c.Request.Context(),
|
||||
cache.KSession(token),
|
||||
user,
|
||||
time.Duration(cfg.JWT.Expire)*time.Second)
|
||||
cfg.JWT.Expire)
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"token": token,
|
||||
@@ -1768,5 +1825,5 @@ func Login(c *gin.Context) {
|
||||
|
||||
---
|
||||
|
||||
_文档版本: v1.0.2_
|
||||
_最后更新: 2026-06-20_
|
||||
_文档版本: v1.2.0_
|
||||
_最后更新: 2026-07-04_
|
||||
|
||||
@@ -20,7 +20,7 @@ db := database.GetDB()
|
||||
// 同一份代码,也能注入实例 / 跑多套 / 塞 mock
|
||||
myDB := database.NewManager(cfg)
|
||||
myDB.Open(ctx) // 独立实例,不受全局影响
|
||||
database.SetDefaultManager(myDB) // 或提升为全局默认
|
||||
database.SetDefaultManager(myDB) // 或提升为全局默认(并发安全)
|
||||
mockCache := &fakeCacheSvc{}
|
||||
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入
|
||||
```
|
||||
@@ -246,7 +246,8 @@ database.InitDBWithReplicas(cfg, []string{
|
||||
})
|
||||
|
||||
// 读操作自动路由到从库
|
||||
users := database.GetReadDB().Find(&users)
|
||||
var users []model.User
|
||||
database.GetReadDB().Find(&users)
|
||||
|
||||
// 强制使用主库
|
||||
ctx := database.UseMaster(context.Background())
|
||||
@@ -792,7 +793,7 @@ xlgo/
|
||||
### Docker 部署
|
||||
|
||||
```dockerfile
|
||||
FROM golang:1.22-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
@@ -819,6 +820,22 @@ docker run -d -p 8080:8080 xlgo-app:latest
|
||||
|
||||
> 完整变更历史见 [CHANGELOG.md](./CHANGELOG.md)。
|
||||
|
||||
### v1.2.0 (2026-07-04)
|
||||
|
||||
> **破坏性版本**:4 轮对抗性评审(deepseek / GLM / Claude / 终审)收口的全部 CRITICAL/HIGH/MEDIUM 修复 + 主线A 包级可变全局并发治理统一。`go vet` + `go build` + `go test -race ./...` 全绿。完整变更见 [CHANGELOG.md](./CHANGELOG.md)。
|
||||
|
||||
主要破坏性变更(升级前必读):
|
||||
|
||||
- **包级可变全局一律 `atomic.Pointer`**(主线A):`database.DefaultRedis`/`database.DefaultManager`/`storage.DefaultStorage`/`validation.Validator` 由裸指针改为 `atomic.Pointer[T]`。直接读字段调方法需改用 facade(`InitRedis`/`GetDB`/`ValidateStruct` 等)或 `.Load()`;`database.DefaultManager = x` 改 `database.SetDefaultManager(x)`。
|
||||
- **`jwt.DefaultJWT` 包级变量删除** → `GetDefaultJWT()` / `SetDefaultJWTManager()`。
|
||||
- **`repository.FindWhereOrdered`/`FindPageWhereOrdered` 签名**:`args []any` → `args ...any`,`order` 前置于 `query`。
|
||||
- **`response.Response` 的 `Data`/`RequestID` 去 `omitempty`**:两字段在所有响应中恒存在。
|
||||
- **`cache.IsLocked`/`GetLockTTL`/`ForceUnlock`** Redis 不可用改返 `ErrRedisNotReady`(锁操作正确性相关)。
|
||||
- **`config.Load()` 返回深拷贝**:新增 `(*Config).Clone()`;`Get()` 仍返只读指针(热路径零分配)。
|
||||
- **`handler.GetPage` 加 `page` 上限 10000**;`utils.EqualsIgnoreCase` 改 `strings.EqualFold`;`utils.ReadFile` 去 TOCTOU 前置检查(错误改 `errors.Is(err, os.ErrNotExist)`)。
|
||||
|
||||
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.2.0`(⚠️ 含破坏性变更,详见 CHANGELOG 迁移说明)
|
||||
|
||||
### v1.1.1 (2026-06-23)
|
||||
|
||||
> v1.1.0 的补丁发布:补 `ServerConfig.Host` 绑定地址、面向用户文案统一中文、修正 README 过时/错误描述(含会致启动失败的配置示例)。
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/cache"
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/cron"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
@@ -27,7 +28,7 @@ import (
|
||||
|
||||
// Version 框架版本号。发版时只改这一处,避免版本字面量散落各处。
|
||||
// CLI(xlgo version)、脚手架生成的 go.mod 等均引用此常量。
|
||||
const Version = "1.1.1"
|
||||
const Version = "1.2.0"
|
||||
|
||||
// HealthCheckFunc 健康检查函数
|
||||
type HealthCheckFunc func(context.Context) error
|
||||
@@ -41,12 +42,13 @@ type Migrator func(*gorm.DB) error
|
||||
// - OnReady: 端口就绪后(已开始接受连接)
|
||||
// - OnStop: Shutdown() 开头,关 HTTP 之前
|
||||
//
|
||||
// OnInit/OnStart/OnStop 返回 error 会中断流程并向上返回。
|
||||
// OnInit/OnStart/OnReady/OnStop 返回 error 会中断流程并向上返回。
|
||||
// A2 修复:OnReady 改为返回 error,失败时触发 shutdown。
|
||||
type Hook struct {
|
||||
Name string
|
||||
OnInit func(*App) error
|
||||
OnStart func(*App) error
|
||||
OnReady func(*App)
|
||||
OnReady func(*App) error
|
||||
OnStop func(*App) error
|
||||
}
|
||||
|
||||
@@ -74,13 +76,17 @@ type App struct {
|
||||
enableLiveness bool
|
||||
enableReadiness bool
|
||||
enableMetrics bool
|
||||
enableCron bool
|
||||
metricsPath string
|
||||
|
||||
staticRoutes []staticRoute
|
||||
migrators []Migrator
|
||||
healthChecks []router.HealthCheck
|
||||
hooks []Hook
|
||||
initialized bool
|
||||
|
||||
// 初始化同步守卫(A1 修复:sync.Once 替代裸 bool,防止并发 Init 竞态)
|
||||
initOnce sync.Once
|
||||
initErr error
|
||||
|
||||
// 请求级超时(#19),<=0 表示不启用
|
||||
requestTimeout time.Duration
|
||||
@@ -102,11 +108,12 @@ func WithConfigPath(path string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfig 设置配置对象
|
||||
// WithConfig 设置配置对象。
|
||||
// A3 修复:不再调用 config.Set(cfg),配置仅保留在 App 实例内,不污染全局状态。
|
||||
// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath。
|
||||
func WithConfig(cfg *config.Config) Option {
|
||||
return func(a *App) {
|
||||
a.config = cfg
|
||||
config.Set(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +281,14 @@ func WithRequestTimeout(d time.Duration) Option {
|
||||
return func(a *App) { a.requestTimeout = d }
|
||||
}
|
||||
|
||||
// WithCron 将全局 cron 调度器纳入 App 生命周期(P1 #10)。
|
||||
// 启用后:Init 末尾 cron.Start() 启动调度循环;Shutdown 时在 ShutdownTimeout 约束内
|
||||
// 停止全局调度器并等待在跑任务退出(cron.StopGlobalWithTimeout),避免调度 goroutine
|
||||
// 与在跑任务在优雅关闭后残留。任务仍通过 cron.AddTask(...) 在 Run 前注册。
|
||||
func WithCron() Option {
|
||||
return func(a *App) { a.enableCron = true }
|
||||
}
|
||||
|
||||
// WithModels 注册 GORM 自动迁移模型(自动启用 AutoMigrate)
|
||||
func WithModels(models ...any) Option {
|
||||
return WithMigrator(func(db *gorm.DB) error {
|
||||
@@ -350,12 +365,32 @@ func RunFullStack(opts ...Option) error {
|
||||
return NewFullStack(opts...).Run()
|
||||
}
|
||||
|
||||
// Init 初始化应用,不启动 HTTP 监听
|
||||
// Init 初始化应用,不启动 HTTP 监听(A1 修复:sync.Once 保证单次执行,并发安全)。
|
||||
// 多次调用返回首次执行的结果。
|
||||
func (a *App) Init() error {
|
||||
if a.initialized {
|
||||
return nil
|
||||
}
|
||||
a.initOnce.Do(func() {
|
||||
a.initErr = a.doInit()
|
||||
if a.initErr != nil {
|
||||
// H-2 修复:Init 失败时 cancel rootCtx 并等待已通过 App.Go 启动的后台 goroutine 退出。
|
||||
// App.Go 不要求 Init 先成功(注释 app.go:337),若不清理,调用方在 Init 失败后
|
||||
// 不调 Run/Shutdown,则 rootCtx 永不 cancel、wg 永不归零,后台 goroutine 永久泄漏。
|
||||
if a.cancel != nil {
|
||||
a.cancel()
|
||||
}
|
||||
waitDone := make(chan struct{})
|
||||
go func() { a.wg.Wait(); close(waitDone) }()
|
||||
select {
|
||||
case <-waitDone:
|
||||
case <-time.After(10 * time.Second):
|
||||
logger.Warnf("Init 失败后等待后台 goroutine 退出超时(10s),可能存在未响应 ctx.Done 的 goroutine")
|
||||
}
|
||||
}
|
||||
})
|
||||
return a.initErr
|
||||
}
|
||||
|
||||
// doInit 执行实际初始化流程。仅在 sync.Once 中调用,不可直接调用。
|
||||
func (a *App) doInit() error {
|
||||
cfg, err := a.resolveConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -445,6 +480,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 {
|
||||
@@ -464,6 +502,10 @@ func (a *App) Init() error {
|
||||
}
|
||||
}
|
||||
|
||||
// 错误 Detail 暴露策略(P1 #15):仅开发环境向客户端输出 Error.Detail,
|
||||
// 生产环境隐藏,避免内部错误细节(SQL/堆栈等)经 Detail 泄露给客户端。
|
||||
response.SetExposeDetail(cfg.IsDevelopment())
|
||||
|
||||
// in-flight goroutine 根 ctx 在 New() 时已初始化(#22)
|
||||
|
||||
// 启动主库/从库探活后台循环(#21),ctx 在 Shutdown 时取消
|
||||
@@ -471,7 +513,10 @@ func (a *App) Init() error {
|
||||
a.Go(database.StartDBProbing)
|
||||
}
|
||||
|
||||
a.initialized = true
|
||||
// 启动 cron 全局调度器(P1 #10),Shutdown 时统一停止。任务须在此前经 cron.AddTask 注册。
|
||||
if a.enableCron {
|
||||
cron.Start()
|
||||
}
|
||||
|
||||
// OnInit hooks:组件初始化完成后触发(#12)
|
||||
for _, h := range a.hooks {
|
||||
@@ -573,10 +618,20 @@ func (a *App) StartServer() error {
|
||||
serverErr <- nil
|
||||
}()
|
||||
|
||||
// OnReady hooks:端口就绪后
|
||||
// OnReady hooks:端口就绪后(A2 修复:失败时触发 shutdown)
|
||||
for _, h := range a.hooks {
|
||||
if h.OnReady != nil {
|
||||
h.OnReady(a)
|
||||
if err := h.OnReady(a); err != nil {
|
||||
logger.Errorf("OnReady hook %q 失败: %v", h.Name, err)
|
||||
// H-1 修复:失败时走 Shutdown 释放 HTTP 端口、后台 goroutine 与已初始化资源,
|
||||
// 避免监听 goroutine 永久阻塞在 ListenAndServe 致端口/goroutine 泄漏。
|
||||
// 不再向 serverErr 写入——监听 goroutine 在 Shutdown 后会写 nil 到 serverErr(cap=1),
|
||||
// 若此处也写则第二次写阻塞致 goroutine 泄漏。
|
||||
if shutErr := a.Shutdown(); shutErr != nil {
|
||||
return fmt.Errorf("OnReady hook %q 失败: %w (关闭错误: %v)", h.Name, err, shutErr)
|
||||
}
|
||||
return fmt.Errorf("OnReady hook %q 失败: %w", h.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +699,20 @@ func (a *App) Shutdown() error {
|
||||
logger.Warnf("等待后台 goroutine 退出超时")
|
||||
}
|
||||
|
||||
// 停止 cron 全局调度器(P1 #10),在剩余 shutdown 预算内等待在跑任务退出。
|
||||
if a.enableCron {
|
||||
logger.Info("停止 cron 调度器...")
|
||||
remaining := time.Second
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
if r := time.Until(dl); r > 0 {
|
||||
remaining = r
|
||||
}
|
||||
}
|
||||
if !cron.StopGlobalWithTimeout(remaining) {
|
||||
logger.Warnf("cron 调度器停止超时,可能有任务未响应 ctx 取消")
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("停止限流器...")
|
||||
middleware.StopRateLimiters()
|
||||
|
||||
|
||||
+118
@@ -3,6 +3,7 @@ package xlgo_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
@@ -241,3 +243,119 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// freePort 返回一个当前空闲的 TCP 端口。
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("freePort: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// TestAppOnReadyFailureReleasesPort H-1 回归:OnReady 失败时 Run 应返回错误,
|
||||
// 且 HTTP 端口被释放(监听 goroutine 不泄漏)。
|
||||
// 修复前 OnReady 失败直接 return,监听 goroutine 仍阻塞在 ListenAndServe,
|
||||
// 端口不释放、goroutine 泄漏。
|
||||
func TestAppOnReadyFailureReleasesPort(t *testing.T) {
|
||||
port := freePort(t)
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(port)),
|
||||
xlgo.WithHook(xlgo.Hook{
|
||||
Name: "fail-on-ready",
|
||||
OnReady: func(*xlgo.App) error { return errors.New("boom") },
|
||||
}),
|
||||
)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- app.Run() }()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("expected OnReady failure error, got %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Run did not return after OnReady failure (server goroutine leaked?)")
|
||||
}
|
||||
|
||||
// 端口应已释放:能重新 bind。容忍 TIME_WAIT 短暂残留。
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
|
||||
if err == nil {
|
||||
l.Close()
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("port %d not released 3s after Run returned: %v (server goroutine leaked)", port, err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppInitFailureCancelsGoGoroutine H-2 回归:App.Go 启动的 goroutine 在 Init 失败后
|
||||
// 应因 rootCtx cancel 而退出,不泄漏。
|
||||
// 修复前 Init 失败不 cancel rootCtx,goroutine 永久阻塞在 ctx.Done()。
|
||||
func TestAppInitFailureCancelsGoGoroutine(t *testing.T) {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18087)),
|
||||
// WithMigrator 无 WithMySQL → Init 确定性失败("未启用 MySQL")
|
||||
xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }),
|
||||
)
|
||||
|
||||
ctxDone := make(chan struct{})
|
||||
app.Go(func(ctx context.Context) {
|
||||
<-ctx.Done()
|
||||
close(ctxDone)
|
||||
})
|
||||
|
||||
if err := app.Init(); err == nil {
|
||||
t.Fatal("expected Init error")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctxDone:
|
||||
// 好:goroutine 在 Init 失败后退出
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("App.Go goroutine did not exit after Init failure (rootCtx not cancelled, leaked)")
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+44
-24
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
@@ -28,25 +29,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 +71,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 +82,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 +92,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 +107,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 +117,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 +125,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 +150,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 模式:
|
||||
@@ -154,16 +165,25 @@ type CacheManager struct {
|
||||
svc CacheService
|
||||
}
|
||||
|
||||
// DefaultCache 默认缓存管理器,包级 facade 代理到它。
|
||||
var DefaultCache = NewCacheManager()
|
||||
// defaultCachePtr 全局默认缓存管理器(CK3 修复:atomic.Pointer 替代裸指针)。
|
||||
var defaultCachePtr atomic.Pointer[CacheManager]
|
||||
|
||||
func init() {
|
||||
defaultCachePtr.Store(NewCacheManager())
|
||||
}
|
||||
|
||||
// NewCacheManager 创建缓存管理器实例。
|
||||
func NewCacheManager() *CacheManager { return &CacheManager{} }
|
||||
|
||||
// SetDefaultCacheManager 提升指定 CacheManager 为全局默认。
|
||||
// GetDefaultCache 返回全局默认缓存管理器(并发安全,CK3 修复)。
|
||||
func GetDefaultCache() *CacheManager {
|
||||
return defaultCachePtr.Load()
|
||||
}
|
||||
|
||||
// SetDefaultCacheManager 提升指定 CacheManager 为全局默认(atomic 置换,并发安全)。
|
||||
func SetDefaultCacheManager(m *CacheManager) {
|
||||
if m != nil {
|
||||
DefaultCache = m
|
||||
defaultCachePtr.Store(m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,14 +211,14 @@ func (m *CacheManager) Get() CacheService {
|
||||
return m.svc
|
||||
}
|
||||
|
||||
// --- 包级 facade(代理到 DefaultCache,兼容存量) ---
|
||||
// --- 包级 facade(代理到 GetDefaultCache(),CK3 修复) ---
|
||||
|
||||
// Init 初始化全局缓存实例
|
||||
func Init() {
|
||||
DefaultCache.Init()
|
||||
GetDefaultCache().Init()
|
||||
}
|
||||
|
||||
// GetCache 获取全局缓存实例
|
||||
func GetCache() CacheService {
|
||||
return DefaultCache.Get()
|
||||
return GetDefaultCache().Get()
|
||||
}
|
||||
|
||||
Vendored
+50
-17
@@ -3,14 +3,16 @@ package cache
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// KeyBuilder 缓存键名构建器
|
||||
// KeyBuilder 缓存键名构建器(CK2 修复:mu 保护并发访问)。
|
||||
// 使用场景: 多个小项目共用一台 Redis 服务器,每个项目设置不同前缀
|
||||
type KeyBuilder struct {
|
||||
mu sync.Mutex
|
||||
prefix string // 项目/站点前缀,如 "site_a"
|
||||
_separator string // 分隔符,默认 ":"
|
||||
_cacheType string // 缓存类型标识,如 "cache"
|
||||
@@ -56,10 +58,12 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
|
||||
return kb
|
||||
}
|
||||
|
||||
// Build 构建完整键名
|
||||
// Build 构建完整键名(CK2 修复:mu 读保护)。
|
||||
// 格式: {cacheType}{separator}{prefix}{separator}{key}
|
||||
// 示例: kb.Build("user:1") -> "cache_site_a_user:1"
|
||||
func (kb *KeyBuilder) Build(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{}
|
||||
if kb._cacheType != "" {
|
||||
parts = append(parts, kb._cacheType)
|
||||
@@ -71,9 +75,11 @@ func (kb *KeyBuilder) Build(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildTemp 构建临时缓存键名(带过期时间)
|
||||
// BuildTemp 构建临时缓存键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "temp{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildTemp(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"temp"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -82,9 +88,11 @@ func (kb *KeyBuilder) BuildTemp(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildPerm 构建永久缓存键名(不带过期时间)
|
||||
// BuildPerm 构建永久缓存键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "perm{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildPerm(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"perm"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -93,9 +101,11 @@ func (kb *KeyBuilder) BuildPerm(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildLock 构建分布式锁键名
|
||||
// BuildLock 构建分布式锁键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "lock{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildLock(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"lock"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -104,9 +114,11 @@ func (kb *KeyBuilder) BuildLock(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildCounter 构建计数器键名
|
||||
// BuildCounter 构建计数器键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "counter{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildCounter(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"counter"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -115,9 +127,11 @@ func (kb *KeyBuilder) BuildCounter(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildSession 构建会话键名
|
||||
// BuildSession 构建会话键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "session{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildSession(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"session"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -126,26 +140,35 @@ func (kb *KeyBuilder) BuildSession(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildPattern 构建匹配模式(用于 SCAN/Keys)
|
||||
// BuildPattern 构建匹配模式(用于 SCAN/Keys)(CK2 修复:mu 读保护)。
|
||||
// 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*"
|
||||
func (kb *KeyBuilder) BuildPattern(pattern string) string {
|
||||
return kb.Build(pattern)
|
||||
}
|
||||
|
||||
// GetPrefix 获取当前前缀
|
||||
// GetPrefix 获取当前前缀(CK2 修复:mu 读保护)。
|
||||
func (kb *KeyBuilder) GetPrefix() string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
return kb.prefix
|
||||
}
|
||||
|
||||
// SetPrefix 动态设置前缀
|
||||
// SetPrefix 动态设置前缀(CK2 修复:mu 写保护,并发安全)。
|
||||
func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
kb.prefix = prefix
|
||||
return kb
|
||||
}
|
||||
|
||||
// ===== 全局键名构建器 =====
|
||||
|
||||
var globalKeyBuilder *KeyBuilder
|
||||
// globalKeyBuilder 全局构建器,受 globalKBMu 保护;globalKBOnce 保证自动初始化只执行一次(M13)。
|
||||
var (
|
||||
globalKeyBuilder *KeyBuilder
|
||||
globalKBMu sync.RWMutex
|
||||
globalKBOnce sync.Once
|
||||
)
|
||||
|
||||
// InitKeyBuilder 初始化全局键名构建器
|
||||
// 参数: prefix 站点别名,如果为空则自动从配置读取
|
||||
@@ -164,7 +187,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 +203,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
|
||||
}
|
||||
|
||||
|
||||
+31
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+148
-57
@@ -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)
|
||||
@@ -61,19 +82,24 @@ end
|
||||
// 参数: key 锁名称,ttl 锁定时长
|
||||
// 返回: LockToken 用于后续解锁或续期
|
||||
func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil, ErrRedisNotReady
|
||||
}
|
||||
|
||||
token := utils.UUID()
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
|
||||
result, err := rdb.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -92,7 +118,8 @@ func Lock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
|
||||
// Unlock 安全释放锁
|
||||
func Unlock(ctx context.Context, token *LockToken) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
@@ -100,12 +127,16 @@ func Unlock(ctx context.Context, token *LockToken) error {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
|
||||
result, err := rdb.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.(int64) == 0 {
|
||||
n, err := toInt64(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
@@ -115,16 +146,18 @@ func Unlock(ctx context.Context, token *LockToken) error {
|
||||
// UnlockByKey 按键名释放锁(不安全,仅用于旧代码兼容)
|
||||
// 注意: 此函数不检查 Token,任何客户端都能释放锁
|
||||
func UnlockByKey(ctx context.Context, key string) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
return rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ExtendLock 续期锁
|
||||
// 参数: token 锁令牌,ttl 新的过期时间
|
||||
func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
@@ -134,19 +167,23 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
|
||||
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
|
||||
result, err := rdb.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
|
||||
if err != nil {
|
||||
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 +193,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 +217,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 +243,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,106 +256,140 @@ func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duratio
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-done:
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 续期锁(每次续期为 initialTTL)
|
||||
// 续期锁(每次续期为 initialTTL)。续期失败则停止续期,fn 应尽快结束。
|
||||
if err := ExtendLock(ctx, token, initialTTL); err != nil {
|
||||
return // 续期失败,停止续期
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 执行业务函数
|
||||
err = fn()
|
||||
// defer 兜底:fn panic 时也要停止续期 goroutine 并释放锁(C1a panic 路径修复)。
|
||||
// 无 defer 时 fn panic 会导致 close(stop) 不执行 → 续期 goroutine 永久泄漏,且 Unlock 不执行 → 锁泄漏到 TTL。
|
||||
defer func() {
|
||||
close(stop)
|
||||
<-finished
|
||||
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = Unlock(unlockCtx, token)
|
||||
}()
|
||||
|
||||
// 停止续期并释放锁
|
||||
done <- struct{}{}
|
||||
Unlock(ctx, token)
|
||||
// 执行业务函数。
|
||||
err = fn()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// IsLocked 检查锁是否被占用(不获取锁)
|
||||
// IsLocked 检查锁是否被占用(不获取锁)。
|
||||
//
|
||||
// M-E 修复(失败语义统一):Redis 未初始化时返回 (false, ErrRedisNotReady) 而非 (false, nil)——
|
||||
// 后者与"锁确实未被占用"不可区分,调用方可能误以为可获取锁而进入临界区(正确性 bug)。
|
||||
// 调用方应 errors.Is(err, ErrRedisNotReady) 区分"Redis 不可用"与"锁未占用"。
|
||||
// L-G 修复:用 .Result() 显式返回 Redis 错误(原 .Val() 吞错致故障被当"未占用")。
|
||||
//
|
||||
// 契约说明:锁操作有正确性影响(无锁进入临界区 = bug),故 Redis 不可用时显式返错;
|
||||
// 与 cache.Get/Set 等数据操作(性能层、best-effort 静默 no-op)区分。
|
||||
func IsLocked(ctx context.Context, key string) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
return false, nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.Exists(ctx, key).Val() > 0, nil
|
||||
n, err := rdb.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// GetLockTTL 获取锁的剩余过期时间
|
||||
// GetLockTTL 获取锁的剩余过期时间。
|
||||
//
|
||||
// M-E 修复:Redis 未初始化时返回 (0, ErrRedisNotReady) 而非 (0, nil),调用方可区分
|
||||
// "Redis 不可用"与"锁不存在/已过期"(后者 TTL=-1/-2)。
|
||||
func GetLockTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.TTL(ctx, key).Result()
|
||||
return rdb.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// ForceUnlock 强制释放锁(危险操作,仅用于管理场景)
|
||||
// 注意: 此函数不检查 Token,强制删除锁
|
||||
//
|
||||
// M-E 修复:Redis 未初始化时返回 ErrRedisNotReady 而非 nil——原 nil 让调用方误以为
|
||||
// 已解锁成功、实则从未操作。管理脚本应据此重试或告警,而非假设成功。
|
||||
func ForceUnlock(ctx context.Context, key string) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
return rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ===== 计数器操作 =====
|
||||
|
||||
// Incr 自增计数器
|
||||
func Incr(ctx context.Context, key string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.Incr(ctx, key).Result()
|
||||
return rdb.Incr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// IncrBy 指定增量自增
|
||||
func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.IncrBy(ctx, key, value).Result()
|
||||
return rdb.IncrBy(ctx, key, value).Result()
|
||||
}
|
||||
|
||||
// Decr 自减计数器
|
||||
func Decr(ctx context.Context, key string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.Decr(ctx, key).Result()
|
||||
return rdb.Decr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// GetTTL 获取键的剩余过期时间
|
||||
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.TTL(ctx, key).Result()
|
||||
return rdb.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetExpire 设置键的过期时间
|
||||
func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return false, nil
|
||||
}
|
||||
return database.RedisClient.Expire(ctx, key, ttl).Result()
|
||||
return rdb.Expire(ctx, key, ttl).Result()
|
||||
}
|
||||
|
||||
// GetRaw 获取原始字符串值(不反序列化)
|
||||
func GetRaw(ctx context.Context, key string) (string, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return "", nil
|
||||
}
|
||||
return database.RedisClient.Get(ctx, key).Result()
|
||||
return rdb.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetRaw 设置原始值(不序列化)
|
||||
func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Set(ctx, key, value, ttl).Err()
|
||||
return rdb.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
Vendored
+301
@@ -0,0 +1,301 @@
|
||||
package cache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/cache"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// setupMiniRedis 启动一个 miniredis 实例并把它注入 database 内部,返回清理函数。
|
||||
func setupMiniRedis(t *testing.T) *miniredis.Miniredis {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
// 通过 SetTestRedisClient 注入 miniredis 客户端到 database 包的内部 redisClient
|
||||
database.SetTestRedisClient(client)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
return mr
|
||||
}
|
||||
|
||||
// ===== C1a:WithLockAutoExtend 不 panic / 不泄漏锁 =====
|
||||
|
||||
// 回归 C1a:ctx 取消时 WithLockAutoExtend 不 send-on-closed panic,且锁被释放。
|
||||
// 旧实现:ctx 取消时子 goroutine defer close(done),父 fn() 后 done<-struct{}{} 即 panic,
|
||||
// Unlock 不执行,锁泄漏到 TTL。
|
||||
func TestWithLockAutoExtendCtxCancelNoPanic(t *testing.T) {
|
||||
mr := setupMiniRedis(t)
|
||||
_ = mr
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
key := "c1a:cancel"
|
||||
|
||||
ran := make(chan struct{})
|
||||
var panicked any
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { panicked = recover() }()
|
||||
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func() 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)
|
||||
}
|
||||
}
|
||||
Vendored
+8
-6
@@ -2,6 +2,7 @@ package cache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -32,19 +33,20 @@ func TestLockErrors(t *testing.T) {
|
||||
t.Errorf("Unlock with nil token should return ErrLockNotHeld or ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
|
||||
// Test IsLocked without Redis (should return false)
|
||||
// Test IsLocked without Redis — M-E:应返回 (false, ErrRedisNotReady),
|
||||
// 让调用方可区分"Redis 不可用"与"锁未占用"(原 (false,nil) 与"未占用"不可区分)。
|
||||
locked, err := cache.IsLocked(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("IsLocked error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("IsLocked without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if locked {
|
||||
t.Error("IsLocked should return false without Redis")
|
||||
}
|
||||
|
||||
// Test GetLockTTL without Redis
|
||||
// Test GetLockTTL without Redis — M-E:应返回 (0, ErrRedisNotReady)。
|
||||
ttl, err := cache.GetLockTTL(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("GetLockTTL error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("GetLockTTL without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if ttl != 0 {
|
||||
t.Error("GetLockTTL should return 0 without Redis")
|
||||
|
||||
+162
-54
@@ -13,10 +13,13 @@ import (
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
)
|
||||
|
||||
func createProject(name string) {
|
||||
func createProject(name string) error {
|
||||
// P1 #21:校验项目名,拒绝路径穿越与非法 Go 包名。
|
||||
if err := validateProjectName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(name); !os.IsNotExist(err) {
|
||||
fmt.Printf("目录 %s 已存在\n", name)
|
||||
return
|
||||
return fmt.Errorf("目录 %s 已存在", name)
|
||||
}
|
||||
|
||||
// 解析 --template 与 --module 参数(默认 template=api)
|
||||
@@ -26,25 +29,34 @@ func createProject(name string) {
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--template", "-t":
|
||||
if i+1 < len(args) {
|
||||
tmplName = args[i+1]
|
||||
i++
|
||||
if i+1 >= len(args) {
|
||||
return fmt.Errorf("%s 缺少参数值", args[i])
|
||||
}
|
||||
tmplName = args[i+1]
|
||||
i++
|
||||
case "--module", "-m":
|
||||
if i+1 < len(args) {
|
||||
module = args[i+1]
|
||||
i++
|
||||
if i+1 >= len(args) {
|
||||
return fmt.Errorf("%s 缺少参数值", args[i])
|
||||
}
|
||||
module = args[i+1]
|
||||
i++
|
||||
default:
|
||||
// P1 #21:未知参数显式报错,不再静默忽略。
|
||||
return fmt.Errorf("未知参数: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
// P1 #21:校验 module 路径,避免模板元字符 {{ }} 经 Sprintf 进 go.mod 后触发 Parse 报错。
|
||||
if err := validateModulePath(module); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 校验模板名
|
||||
switch tmplName {
|
||||
case "minimal", "api", "fullstack":
|
||||
// ok
|
||||
default:
|
||||
fmt.Printf("未知模板: %s(可选: minimal / api / fullstack)\n", tmplName)
|
||||
return
|
||||
return fmt.Errorf("未知模板: %s(可选: minimal / api / fullstack)", tmplName)
|
||||
}
|
||||
|
||||
// minimal 模板目录结构最小化;api/fullstack 含完整分层目录
|
||||
@@ -63,8 +75,8 @@ func createProject(name string) {
|
||||
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fmt.Printf("创建目录失败: %s\n", err)
|
||||
return
|
||||
_ = os.RemoveAll(name) // P1 #21:清理半成品
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,22 +114,9 @@ func createProject(name string) {
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
tmpl, err := template.New(path).Parse(content)
|
||||
if err != nil {
|
||||
fmt.Printf("解析模板失败: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
fmt.Printf("创建文件失败: %s\n", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := tmpl.Execute(file, data); err != nil {
|
||||
fmt.Printf("写入文件失败: %s\n", err)
|
||||
return
|
||||
if err := renderTemplateFile(path, content, data); err != nil {
|
||||
_ = os.RemoveAll(name) // P1 #21:部分失败回滚,避免留下半成品项目
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,33 +125,133 @@ func createProject(name string) {
|
||||
fmt.Printf(" cd %s\n", name)
|
||||
fmt.Println(" go mod tidy")
|
||||
fmt.Println(" go run main.go")
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeFile(fileType, name string) {
|
||||
// renderTemplateFile 解析并渲染单个模板文件,每次调用显式关闭句柄
|
||||
// (P1 #21:不在循环内 defer 累积句柄,且 Close 错误纳入返回)。
|
||||
func renderTemplateFile(path, content string, data TemplateData) (retErr error) {
|
||||
tmpl, err := template.New(path).Parse(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析模板 %s 失败: %w", path, err)
|
||||
}
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件 %s 失败: %w", path, err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := file.Close(); cerr != nil && retErr == nil {
|
||||
retErr = fmt.Errorf("关闭文件 %s 失败: %w", path, cerr)
|
||||
}
|
||||
}()
|
||||
if err := tmpl.Execute(file, data); err != nil {
|
||||
return fmt.Errorf("写入文件 %s 失败: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateProjectName 校验项目名(P1 #21):非空、无路径分隔符/.. /前导点,
|
||||
// 且可派生为合法 Go 包名(须以字母开头,仅含字母/数字/下划线)。
|
||||
func validateProjectName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("项目名不能为空")
|
||||
}
|
||||
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return fmt.Errorf("项目名不能包含路径分隔符或 ..(防止在预期目录外创建文件): %q", name)
|
||||
}
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return fmt.Errorf("项目名不能以 . 开头: %q", name)
|
||||
}
|
||||
if !isValidGoIdentifier(name) {
|
||||
return fmt.Errorf("项目名 %q 无法生成合法 Go 包名:须以字母开头,仅含字母、数字、下划线", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidGoIdentifier 判断 s 是否为合法 Go 标识符(ASCII 范围)。
|
||||
func isValidGoIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_'
|
||||
isNum := r >= '0' && r <= '9'
|
||||
if i == 0 {
|
||||
if !isLetter {
|
||||
return false
|
||||
}
|
||||
} else if !isLetter && !isNum {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validateModulePath 校验 --module(P1 #21):非空、不含模板元字符与空白。
|
||||
func validateModulePath(module string) error {
|
||||
if module == "" {
|
||||
return fmt.Errorf("模块路径不能为空")
|
||||
}
|
||||
if strings.Contains(module, "{{") || strings.Contains(module, "}}") {
|
||||
return fmt.Errorf("模块路径不能包含模板元字符 {{ }}: %q", module)
|
||||
}
|
||||
if strings.ContainsAny(module, " \t\r\n") {
|
||||
return fmt.Errorf("模块路径不能包含空白字符: %q", module)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeFile(fileType, name string) error {
|
||||
// 文件名小写,但保留原分隔用于多词;标识符须为合法 Go 标识符(仅字母数字下划线)。
|
||||
// 将连字符/空格等转为下划线后再 Title,避免 "my-thing" → "My-Thing" 生成非法标识符(M20)。
|
||||
name = strings.ToLower(name)
|
||||
// P1 #21:拒绝含路径分隔符的名称,避免生成到目标目录之外。
|
||||
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name)
|
||||
}
|
||||
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":
|
||||
createHandler(name, nameTitle)
|
||||
return createHandler(name, nameTitle)
|
||||
case "repository":
|
||||
createRepository(name, nameTitle)
|
||||
return createRepository(name, nameTitle)
|
||||
case "model":
|
||||
createModel(name, nameTitle)
|
||||
return createModel(name, nameTitle)
|
||||
case "service":
|
||||
createService(name, nameTitle)
|
||||
return createService(name, nameTitle)
|
||||
default:
|
||||
fmt.Printf("未知类型: %s\n", fileType)
|
||||
fmt.Println("可用类型: handler, repository, model, service")
|
||||
return fmt.Errorf("未知类型: %s(可用类型: handler, repository, model, service)", fileType)
|
||||
}
|
||||
}
|
||||
|
||||
func createHandler(name, nameTitle string) {
|
||||
// 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) error {
|
||||
path := fmt.Sprintf("handler/%s.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
return fmt.Errorf("文件 %s 已存在", path)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.HandlerMake,
|
||||
@@ -166,48 +265,54 @@ func createHandler(name, nameTitle string) {
|
||||
)
|
||||
content = replaceModuleImports(content)
|
||||
|
||||
writeFile(path, content)
|
||||
if err := writeFile(path, content); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ 创建处理器: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createRepository(name, nameTitle string) {
|
||||
func createRepository(name, nameTitle string) error {
|
||||
path := fmt.Sprintf("repository/%s_repository.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
return fmt.Errorf("文件 %s 已存在", path)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.RepositoryMake,
|
||||
nameTitle, name, nameTitle, nameTitle,
|
||||
nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle,
|
||||
nameTitle, nameTitle, nameTitle,
|
||||
nameTitle, nameTitle,
|
||||
)
|
||||
content = replaceModuleImports(content)
|
||||
|
||||
writeFile(path, content)
|
||||
if err := writeFile(path, content); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ 创建仓库: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createModel(name, nameTitle string) {
|
||||
func createModel(name, nameTitle string) error {
|
||||
path := fmt.Sprintf("model/%s.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
return fmt.Errorf("文件 %s 已存在", path)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.ModelMake,
|
||||
nameTitle, name, nameTitle, nameTitle, name,
|
||||
)
|
||||
|
||||
writeFile(path, content)
|
||||
if err := writeFile(path, content); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ 创建模型: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createService(name, nameTitle string) {
|
||||
func createService(name, nameTitle string) error {
|
||||
path := fmt.Sprintf("service/%s_service.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
return fmt.Errorf("文件 %s 已存在", path)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.ServiceMake,
|
||||
@@ -220,6 +325,9 @@ func createService(name, nameTitle string) {
|
||||
)
|
||||
content = replaceModuleImports(content)
|
||||
|
||||
writeFile(path, content)
|
||||
if err := writeFile(path, content); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ 创建服务: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
+14
-7
@@ -39,21 +39,28 @@ func main() {
|
||||
|
||||
command := os.Args[1]
|
||||
|
||||
// P1 #21:命令执行失败时以非零码退出,便于 `xlgo new x && cd x` 等脚本/CI 正确中断。
|
||||
switch command {
|
||||
case "new":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("用法: xlgo new <项目名>")
|
||||
return
|
||||
fmt.Fprintln(os.Stderr, "用法: xlgo new <项目名>")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := createProject(os.Args[2]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
createProject(os.Args[2])
|
||||
|
||||
case "make":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("用法: xlgo make <类型> <名称>")
|
||||
fmt.Println("类型: handler, repository, model, service")
|
||||
return
|
||||
fmt.Fprintln(os.Stderr, "用法: xlgo make <类型> <名称>")
|
||||
fmt.Fprintln(os.Stderr, "类型: handler, repository, model, service")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := makeFile(os.Args[2], os.Args[3]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
makeFile(os.Args[2], os.Args[3])
|
||||
|
||||
case "version":
|
||||
fmt.Printf("xlgo v%s\n", xlgo.Version)
|
||||
|
||||
@@ -491,14 +491,13 @@ func New%sRepository() *%sRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// FindByName 根据名称查询
|
||||
// FindByName 根据名称查询。
|
||||
//
|
||||
// 走 BaseRepo.FindOne → readConn 读连接:默认路由到从库(读写分离,H6c),
|
||||
// 需读主库用 database.UseMaster(ctx),事务内自动 join 外层事务。
|
||||
// 不要用 r.GetDB() 自行查询——它返回注入的主库且不参与路由(M-35 footgun)。
|
||||
func (r *%sRepository) FindByName(ctx context.Context, name string) (*model.%s, error) {
|
||||
var m model.%s
|
||||
err := r.GetDB().WithContext(ctx).Where("name = ?", name).First(&m).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
return r.FindOne(ctx, "name = ?", name)
|
||||
}
|
||||
`,
|
||||
|
||||
|
||||
+10
-4
@@ -7,20 +7,26 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func writeFile(path, content string) {
|
||||
func writeFile(path, content string) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fmt.Printf("创建目录失败: %s\n", err)
|
||||
return
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
fmt.Printf("写入文件失败: %s\n", err)
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
// 仅当"不存在"才返回 false;权限错误等其它错误视为"可能存在/不可覆写",
|
||||
// 避免把无权限访问的路径误判为可创建(M20:原 !os.IsNotExist 把权限错误当存在,
|
||||
// 反而安全;但语义模糊,显式区分更清晰)。
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
|
||||
+265
-69
@@ -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) (err error) {
|
||||
// #nosec G304 -- src 为调用方提供的本地文件路径,解压 API 固有语义,非不可信输入
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -78,126 +168,232 @@ 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()
|
||||
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 dst,避免被拒炸弹遗留残片;
|
||||
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
|
||||
defer func() {
|
||||
dstFile.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(dst)
|
||||
}
|
||||
}()
|
||||
|
||||
_, 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) (written int64, err error) {
|
||||
// 拒绝符号链接条目,防经软链二次穿越(C5a)。
|
||||
if file.Mode()&os.ModeSymlink != 0 {
|
||||
return 0, fmt.Errorf("条目 %s 为符号链接: %w", file.Name, ErrSymlinkEntry)
|
||||
}
|
||||
|
||||
// zip 条目名规范用正斜杠;转成当前平台分隔符后再 Join,并以前缀锚定拒绝 `..` 逃逸(C5a)。
|
||||
name := filepath.FromSlash(file.Name)
|
||||
// 拒绝绝对路径与以分隔符开头的条目(非标准、可疑,避免平台语义差异)。
|
||||
// filepath.IsAbs 在 Windows 不认 "/x"(无盘符)为绝对路径,故补充分隔符前缀检查。
|
||||
if filepath.IsAbs(name) || strings.HasPrefix(name, string(os.PathSeparator)) || strings.HasPrefix(file.Name, "/") {
|
||||
return 0, fmt.Errorf("条目 %s 为绝对路径: %w", file.Name, ErrPathTraversal)
|
||||
}
|
||||
target := filepath.Join(absDst, name)
|
||||
if target == absDst || !strings.HasPrefix(target, absDst+string(os.PathSeparator)) {
|
||||
return 0, fmt.Errorf("条目 %s 逃逸目标目录: %w", file.Name, ErrPathTraversal)
|
||||
}
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
return os.MkdirAll(filePath, 0755)
|
||||
if err := os.MkdirAll(target, 0750); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 创建父目录
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||
return err
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 打开 zip 中的文件
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// 创建目标文件
|
||||
dstFile, err := os.Create(filePath)
|
||||
// #nosec G304 -- target 经前缀锚定校验(absDst+sep),已防 Zip-Slip 逃逸
|
||||
dstFile, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 target,避免被拒炸弹遗留残片;
|
||||
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
|
||||
defer func() {
|
||||
dstFile.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(target)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(dstFile, rc)
|
||||
return err
|
||||
// 计算本次拷贝上限:单条目上限与累计剩余上限中较小者(-1 视为无限)。
|
||||
// remaining: 累计剩余(-1 表示累计不限);totalLimit>0 时若已无剩余,直接判超限。
|
||||
remaining := int64(-1)
|
||||
if totalLimit > 0 {
|
||||
remaining = totalLimit - accrued
|
||||
if remaining <= 0 {
|
||||
return 0, fmt.Errorf("累计解压超过上限 %d 字节: %w", totalLimit, ErrDecompressLimit)
|
||||
}
|
||||
}
|
||||
cap := minLimit(entryLimit, remaining)
|
||||
// cap 为 -1(两者皆不限)或 >0(有限上限,剩余已保证 >0),不会是 0。
|
||||
|
||||
if cap > 0 {
|
||||
written, err = io.CopyN(dstFile, rc, cap+1)
|
||||
} else {
|
||||
// #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹
|
||||
written, err = io.Copy(dstFile, rc)
|
||||
}
|
||||
if err != nil && err != io.EOF {
|
||||
return written, err
|
||||
}
|
||||
if cap > 0 && written > cap {
|
||||
return written, fmt.Errorf("条目 %s 超过解压上限 %d 字节: %w", file.Name, cap, ErrDecompressLimit)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+280
-68
@@ -2,8 +2,11 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
@@ -18,16 +21,55 @@ var (
|
||||
|
||||
// Config 全局配置结构体
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
SMS SMSConfig `mapstructure:"sms"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Upload UploadConfig `mapstructure:"upload"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
SMS SMSConfig `mapstructure:"sms"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Upload UploadConfig `mapstructure:"upload"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
}
|
||||
|
||||
// Clone 返回 Config 的深拷贝(M-G 修复)。
|
||||
//
|
||||
// 标量字段与子结构体经结构体值拷贝独立;所有切片字段(CORS 的 4 个列表、Upload 的 2 个
|
||||
// 类型白名单、Storage.Local/OSS 上传策略的扩展名/MIME 白名单)深拷贝底层数组,使返回值
|
||||
// 可被调用方安全修改(含 append/sort/改元素)而不污染框架内部配置、不与其他读者竞态。
|
||||
//
|
||||
// 用于需要可变配置副本的场景。热路径的 Get() 为零分配仍返回内部只读指针——需要改配置时
|
||||
// 用 Clone() 或 Load()(Load 内部已返回 Clone)。
|
||||
func (c *Config) Clone() *Config {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *c // 浅拷贝:标量与子结构体独立,切片字段仍共享底层数组(下方逐个深拷贝)
|
||||
// CORS
|
||||
cp.CORS.AllowedOrigins = cloneStrings(c.CORS.AllowedOrigins)
|
||||
cp.CORS.AllowedMethods = cloneStrings(c.CORS.AllowedMethods)
|
||||
cp.CORS.AllowedHeaders = cloneStrings(c.CORS.AllowedHeaders)
|
||||
cp.CORS.ExposedHeaders = cloneStrings(c.CORS.ExposedHeaders)
|
||||
// Upload
|
||||
cp.Upload.AllowedImageTypes = cloneStrings(c.Upload.AllowedImageTypes)
|
||||
cp.Upload.AllowedVideoTypes = cloneStrings(c.Upload.AllowedVideoTypes)
|
||||
// Storage.Local.Upload / Storage.OSS.Upload
|
||||
cp.Storage.Local.Upload.AllowedExts = cloneStrings(c.Storage.Local.Upload.AllowedExts)
|
||||
cp.Storage.Local.Upload.AllowedMIMEs = cloneStrings(c.Storage.Local.Upload.AllowedMIMEs)
|
||||
cp.Storage.OSS.Upload.AllowedExts = cloneStrings(c.Storage.OSS.Upload.AllowedExts)
|
||||
cp.Storage.OSS.Upload.AllowedMIMEs = cloneStrings(c.Storage.OSS.Upload.AllowedMIMEs)
|
||||
return &cp
|
||||
}
|
||||
|
||||
// cloneStrings 返回字符串切片的深拷贝(nil 保持 nil 语义,避免把 nil 变成空切片)。
|
||||
func cloneStrings(s []string) []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(s))
|
||||
copy(out, s)
|
||||
return out
|
||||
}
|
||||
|
||||
// AppConfig 应用配置
|
||||
@@ -225,6 +267,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 +298,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 配置
|
||||
@@ -284,7 +341,7 @@ type JWTConfig struct {
|
||||
Expire time.Duration `mapstructure:"expire"` // 过期时间,如 "24h"(time.Duration)
|
||||
RefreshExpire time.Duration `mapstructure:"refresh_expire"` // 刷新 token 过期时间,如 "168h"
|
||||
Issuer string `mapstructure:"issuer"` // 签发者
|
||||
Algorithm string `mapstructure:"algorithm"` // 签名算法:HS256(默认)/RS256
|
||||
Algorithm string `mapstructure:"algorithm"` // 签名算法:HS256(默认)/HS384/HS512;非 HMAC 算法(如 RS256)会被 jwt.signingMethod 拒绝(ErrUnsupportedAlgorithm)
|
||||
}
|
||||
|
||||
// SMSConfig 短信配置
|
||||
@@ -304,10 +361,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 +390,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 +471,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 +529,11 @@ func (m *Manager) Load() (*Config, error) {
|
||||
m.cfg = &cfg
|
||||
m.mu.Unlock()
|
||||
|
||||
return &cfg, nil
|
||||
// M-G 修复:返回深拷贝(Clone),标量与切片字段均独立,调用方可安全修改。
|
||||
// 原"防御性拷贝"为浅拷贝(out := cfg)——切片字段(CORS.AllowedOrigins 等)仍与
|
||||
// 内部 m.cfg 共享底层数组,调用方 append/sort/改元素会污染全局并与其他读者竞态。
|
||||
// 内部 m.cfg 保留独立的 &cfg,不受返回值修改影响。
|
||||
return cfg.Clone(), nil
|
||||
}
|
||||
|
||||
// LoadWithWatch 加载配置文件并启用热更新
|
||||
@@ -469,44 +561,123 @@ 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
|
||||
// P1 #16:退出时停掉未触发的去抖 timer,避免 StopWatcher 之后 AfterFunc 仍
|
||||
// reload() 一个调用方认为已停止的 manager。
|
||||
defer func() {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}()
|
||||
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 竞态。需要可变副本时
|
||||
// 用 Clone()(返回深拷贝)或 Load()(重载并返回深拷贝)。
|
||||
//
|
||||
// M-G:热路径(jwt 鉴权等每请求调用)保持返回内部指针以零分配,可变副本走 Clone()。
|
||||
// 框架自身的 reload 创建新 Config 并替换 m.cfg 指针,不改写旧 Config 对象,故 Get()
|
||||
// 返回的旧指针在 reload 后仍指向一致的(旧)快照,reload 不引入对旧快照的竞态。
|
||||
func (m *Manager) Get() *Config {
|
||||
if m == nil {
|
||||
return nil
|
||||
@@ -539,98 +710,139 @@ 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)
|
||||
m.mu.Unlock()
|
||||
|
||||
// M-G:回调传入 newCfg 的深拷贝,避免回调修改切片字段与 Get() 读者(持有 &newCfg)竞态。
|
||||
// 回调为 onChange 通知语义,应观察而非改写配置;改写副本不影响内部 m.cfg。
|
||||
for _, cb := range cbs {
|
||||
cb(&newCfg)
|
||||
cb(newCfg.Clone())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load 加载配置文件
|
||||
// pkgLoadMu 串行化包级 Load/LoadWithWatch 对 defaultManager 的"停旧 watcher → 建新 → 置换"
|
||||
// 序列(P1 #8)。原实现该序列非原子:两个 goroutine 并发调用可能都读到 old、都 StopWatcher、
|
||||
// 都 Store,遗留一个已 StartWatcher 的 manager 无引用可停(goroutine 泄漏)。
|
||||
var pkgLoadMu sync.Mutex
|
||||
|
||||
// Load 加载配置文件(C3.5 修复:替换前停止旧 Manager 的 watcher,防止 goroutine 泄漏)。
|
||||
// P1 #8:全程持 pkgLoadMu 串行化,消除与并发 Load/LoadWithWatch 的 TOCTOU。
|
||||
func Load(configPath string) (*Config, error) {
|
||||
defaultManager = NewManager(configPath)
|
||||
return defaultManager.Load()
|
||||
pkgLoadMu.Lock()
|
||||
defer pkgLoadMu.Unlock()
|
||||
if old := defaultManager.Load(); old != nil {
|
||||
old.StopWatcher()
|
||||
}
|
||||
m := NewManager(configPath)
|
||||
cfg, err := m.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defaultManager.Store(m)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadWithWatch 加载配置文件并启用热更新
|
||||
// LoadWithWatch 加载配置文件并启用热更新(C3.5 修复:替换前停止旧 watcher)。
|
||||
// P1 #8:全程持 pkgLoadMu 串行化,且新 manager 在其 watcher 成功启动后才置换为默认;
|
||||
// 启动失败则停掉半启动的 watcher 并不置换,避免遗留孤儿 watcher。
|
||||
func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) {
|
||||
defaultManager = NewManager(configPath)
|
||||
return defaultManager.LoadWithWatch(onChange)
|
||||
pkgLoadMu.Lock()
|
||||
defer pkgLoadMu.Unlock()
|
||||
if old := defaultManager.Load(); old != nil {
|
||||
old.StopWatcher()
|
||||
}
|
||||
m := NewManager(configPath)
|
||||
cfg, err := m.LoadWithWatch(onChange)
|
||||
if err != nil {
|
||||
m.StopWatcher() // 清理可能已半启动的 watcher,避免孤儿 goroutine
|
||||
return nil, err
|
||||
}
|
||||
defaultManager.Store(m)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// 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 获取字符串配置
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// writeConfig 写入临时配置文件并返回路径。
|
||||
func writeConfig(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(os.TempDir(), "xlgo_c10_test")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func validConfigYAML(port int) string {
|
||||
return "app:\n name: c10\n env: dev\nserver:\n port: " + itoa(port) + "\n"
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
b[i] = '-'
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// TestSetDefaultManagerConcurrent 并发置换默认 Manager 与并发读取,
|
||||
// 必须经 -race 无竞争(C10a)。
|
||||
func TestSetDefaultManagerConcurrent(t *testing.T) {
|
||||
// 准备若干可加载的 Manager
|
||||
paths := make([]string, 4)
|
||||
for i := range paths {
|
||||
paths[i] = writeConfig(t, "c10_concurrent_"+itoa(i)+".yaml", validConfigYAML(9000+i))
|
||||
}
|
||||
defer func() {
|
||||
for _, p := range paths {
|
||||
os.Remove(p)
|
||||
}
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
// 写者:并发 SetDefaultManager
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
m := config.NewManager(paths[idx])
|
||||
_, _ = m.Load()
|
||||
config.SetDefaultManager(m)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
// 读者:并发 Get / GetViper / GetString
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = config.Get()
|
||||
_ = config.GetViper()
|
||||
_ = config.GetString("server.port")
|
||||
}
|
||||
}()
|
||||
}
|
||||
// 跑足够长以让 -race 采到
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
// 还原全局状态,避免污染其他测试
|
||||
config.SetDefaultManager(nil)
|
||||
}
|
||||
|
||||
// TestLoadReturnsDefensiveCopy Load 返回的配置与 Get() 内部指针独立,
|
||||
// 修改返回值不污染全局(C10c)。
|
||||
func TestLoadReturnsDefensiveCopy(t *testing.T) {
|
||||
p := writeConfig(t, "c10_defensive.yaml", validConfigYAML(8081))
|
||||
defer os.Remove(p)
|
||||
|
||||
config.Set(nil)
|
||||
cfg, err := config.Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 8081 {
|
||||
t.Fatalf("port = %d, want 8081", cfg.Server.Port)
|
||||
}
|
||||
|
||||
// 调用方修改返回值
|
||||
cfg.Server.Port = 1
|
||||
cfg.App.Name = "mutated"
|
||||
|
||||
// 全局读取不受影响
|
||||
got := config.Get()
|
||||
if got == nil {
|
||||
t.Fatal("Get returned nil")
|
||||
}
|
||||
if got.Server.Port != 8081 {
|
||||
t.Errorf("global port polluted = %d, want 8081 (C10c)", got.Server.Port)
|
||||
}
|
||||
if got.App.Name == "mutated" {
|
||||
t.Errorf("global app name polluted (C10c)")
|
||||
}
|
||||
|
||||
config.SetDefaultManager(nil)
|
||||
}
|
||||
|
||||
// TestLoadDefensiveCopySliceContract 固化 M-G 的深拷贝语义契约:
|
||||
// 标量字段与切片字段均独立(修改不污染全局)。M-G 修复后 Load() 返回 Clone() 深拷贝,
|
||||
// 切片字段不再共享底层数组——调用方可安全修改切片元素。本测试锁定该行为,防止回退到浅拷贝。
|
||||
func TestLoadDefensiveCopySliceContract(t *testing.T) {
|
||||
content := "app:\n name: c10slice\nserver:\n port: 8090\ncors:\n allowed_origins:\n - https://a.example.com\n - https://b.example.com\n"
|
||||
p := writeConfig(t, "c10_slice.yaml", content)
|
||||
defer os.Remove(p)
|
||||
|
||||
config.Set(nil)
|
||||
cfg, err := config.Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
// 标量独立
|
||||
cfg.Server.Port = 1
|
||||
if got := config.Get().Server.Port; got != 8090 {
|
||||
t.Errorf("scalar polluted = %d, want 8090", got)
|
||||
}
|
||||
|
||||
// M-G:切片底层数组独立(深拷贝)——修改切片元素不污染全局。
|
||||
cfg.CORS.AllowedOrigins[0] = "https://mutated.example.com"
|
||||
if got := config.Get().CORS.AllowedOrigins[0]; got != "https://a.example.com" {
|
||||
t.Errorf("slice should be deep-copied (independent), global polluted to %q, want %q", got, "https://a.example.com")
|
||||
}
|
||||
|
||||
// append 也不应污染全局(深拷贝后调用方持独立切片)
|
||||
cfg.CORS.AllowedOrigins = append(cfg.CORS.AllowedOrigins, "https://c.example.com")
|
||||
if got := config.Get().CORS.AllowedOrigins; len(got) != 2 {
|
||||
t.Errorf("global slice length should be unaffected by caller append, got %d, want 2", len(got))
|
||||
}
|
||||
|
||||
config.SetDefaultManager(nil)
|
||||
}
|
||||
func TestReloadInvalidConfigKeepsOld(t *testing.T) {
|
||||
p := writeConfig(t, "c10_reload_bad.yaml", validConfigYAML(8082))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got := m.Get().Server.Port; got != 8082 {
|
||||
t.Fatalf("initial port = %d, want 8082", got)
|
||||
}
|
||||
|
||||
// 覆盖为非法配置(端口越界)
|
||||
if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
err := m.Reload()
|
||||
if err == nil {
|
||||
t.Fatal("Reload invalid config should return error (C10b)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "server.port") {
|
||||
t.Errorf("error should mention server.port, got %v", err)
|
||||
}
|
||||
// 旧配置保留
|
||||
if got := m.Get().Server.Port; got != 8082 {
|
||||
t.Errorf("old config not preserved = %d, want 8082 (C10b)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHotReloadInvalidConfigKeepsOld 文件监听路径遇非法配置保留旧配置,
|
||||
// 且不触发回调;监听仍存活,后续合法变更正常生效(C10b + C10d 监听健壮性)。
|
||||
func TestHotReloadInvalidConfigKeepsOld(t *testing.T) {
|
||||
p := writeConfig(t, "c10_watch_bad.yaml", validConfigYAML(8083))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
changes := make(chan int, 16)
|
||||
m.RegisterCallback(func(c *config.Config) {
|
||||
select {
|
||||
case changes <- c.Server.Port:
|
||||
default:
|
||||
}
|
||||
})
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher: %v", err)
|
||||
}
|
||||
defer m.StopWatcher()
|
||||
|
||||
// 1) 写入非法配置:应保留旧配置、不触发回调
|
||||
if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile invalid: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if got := m.Get().Server.Port; got != 8083 {
|
||||
t.Errorf("invalid config leaked into global = %d, want 8083 (C10b)", got)
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
select {
|
||||
case port := <-changes:
|
||||
t.Errorf("callback fired for invalid config with port %d (C10b)", port)
|
||||
default:
|
||||
}
|
||||
|
||||
// 2) 写入合法配置:监听仍存活,应触发回调且全局更新
|
||||
if err := os.WriteFile(p, []byte(validConfigYAML(8084)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile valid: %v", err)
|
||||
}
|
||||
deadline = time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if got := m.Get().Server.Port; got == 8084 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if got := m.Get().Server.Port; got != 8084 {
|
||||
t.Fatalf("watcher did not reload valid config = %d, want 8084", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopWatcherReleasesGoroutine StopWatcher 后监听 goroutine 退出,无泄漏(C10d)。
|
||||
func TestStopWatcherReleasesGoroutine(t *testing.T) {
|
||||
p := writeConfig(t, "c10_stop.yaml", validConfigYAML(8085))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher: %v", err)
|
||||
}
|
||||
|
||||
// 等待监听 goroutine 就绪
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
before := runtime.NumGoroutine()
|
||||
|
||||
m.StopWatcher()
|
||||
|
||||
// 轮询确认 goroutine 退出
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runtime.NumGoroutine() < before {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if after := runtime.NumGoroutine(); after >= before {
|
||||
t.Errorf("watcher goroutine not released: before=%d after=%d (C10d)", before, after)
|
||||
}
|
||||
|
||||
// 幂等:再次 Stop 不 panic
|
||||
m.StopWatcher()
|
||||
}
|
||||
|
||||
// TestStartWatcherIdempotent 重复 StartWatcher 不创建多个监听 goroutine(幂等)。
|
||||
func TestStartWatcherIdempotent(t *testing.T) {
|
||||
p := writeConfig(t, "c10_idem.yaml", validConfigYAML(8086))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher 1: %v", err)
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
once := runtime.NumGoroutine()
|
||||
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher 2: %v", err)
|
||||
}
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher 3: %v", err)
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
twice := runtime.NumGoroutine()
|
||||
if twice > once {
|
||||
t.Errorf("idempotent StartWatcher leaked goroutine: once=%d twice=%d", once, twice)
|
||||
}
|
||||
m.StopWatcher()
|
||||
}
|
||||
+44
-1
@@ -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,
|
||||
|
||||
+13
-4
@@ -6,10 +6,17 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeMu 串行化所有 Console 的写出序列(P1 #7)。
|
||||
// 用包级锁而非 per-Console 锁:多个 Console 实例可能共享同一底层 writer(如 stdout),
|
||||
// per-Console 锁无法阻止跨实例交错;且 Windows printColor 的"设色→写→复位"三步必须整体
|
||||
// 原子,否则并发彩色输出会串色。控制台输出非热路径,单一全局写锁的串行化开销可忽略。
|
||||
var writeMu sync.Mutex
|
||||
|
||||
// Level 日志级别
|
||||
type Level int32
|
||||
|
||||
@@ -203,12 +210,15 @@ func (c *Console) print(level Level, s ...any) {
|
||||
// 内容
|
||||
sb.WriteString(fmt.Sprint(s...))
|
||||
|
||||
// 输出
|
||||
// 输出。P1 #7:全局写锁串行化整个写序列,避免并发交错输出;
|
||||
// Windows 下 printColor 的"设色→写→复位"三步也被此锁整体保护,防止串色。
|
||||
writeMu.Lock()
|
||||
if c.isColor {
|
||||
c.printColor(color.Code, sb.String())
|
||||
} else {
|
||||
fmt.Fprintln(c.output, sb.String())
|
||||
}
|
||||
writeMu.Unlock()
|
||||
}
|
||||
|
||||
// getCaller 获取调用位置
|
||||
@@ -217,9 +227,8 @@ func (c *Console) getCaller() string {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
// 只取文件名
|
||||
idx := strings.LastIndex(file, "/")
|
||||
if idx >= 0 {
|
||||
// 只取文件名(兼容 / 与 \ 分隔)
|
||||
if idx := strings.LastIndexAny(file, "/\\"); idx >= 0 {
|
||||
file = file[idx+1:]
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", file, line)
|
||||
|
||||
+37
-19
@@ -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))
|
||||
|
||||
+349
-102
@@ -3,20 +3,32 @@ package cron
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Task 定时任务
|
||||
type Task struct {
|
||||
Name string // 任务名称
|
||||
Schedule Schedule // 调度规则
|
||||
Handler TaskHandler // 任务处理函数
|
||||
Enabled bool // 是否启用
|
||||
LastRun time.Time // 上次运行时间
|
||||
NextRun time.Time // 下次运行时间
|
||||
RunCount int // 运行次数
|
||||
Name string // 任务名称
|
||||
Schedule Schedule // 调度规则
|
||||
Handler TaskHandler // 任务处理函数
|
||||
Enabled bool // 是否启用
|
||||
LastRun time.Time // 上次运行时间
|
||||
NextRun time.Time // 下次运行时间
|
||||
RunCount int // 运行次数
|
||||
LastError error // CR3 修复:最近一次执行错误
|
||||
|
||||
// running 防止长任务跨 tick 重叠执行(C12b)。
|
||||
// 用指针以便 GetTask/ListTasks 返回 Task 拷贝时不触发 atomic.Bool 值类型的
|
||||
// copylocks 警告;拷贝共享同一守卫状态,但该字段未导出,外部无法操作。
|
||||
// nil 表示未初始化(仅非 AddTask 构造的 Task),checkAndRun/RunTask 以 nil 守卫跳过。
|
||||
running *atomic.Bool
|
||||
}
|
||||
|
||||
// TaskHandler 任务处理函数
|
||||
@@ -55,6 +67,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 +113,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 +122,55 @@ 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)。
|
||||
//
|
||||
// L-J:handler 收到的 ctx 为调度器 s.ctx。Stop 后 s.ctx 已取消,handler 会收到 canceled
|
||||
// ctx——这是预期行为(手动触发在调度器停止后不应继续执行长任务)。调用方若需在 Stop 后
|
||||
// 仍运行一次性任务,应在自己的 ctx 下执行而非依赖调度器。
|
||||
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
|
||||
@@ -165,18 +190,37 @@ func (s *Scheduler) Start() {
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop 停止调度器
|
||||
// Stop 停止调度器并无限等待在跑任务退出(要求 handler 尊重 ctx.Done)。
|
||||
// 若担心某 handler 不响应 ctx 而永久阻塞,请用 StopWithTimeout。
|
||||
func (s *Scheduler) Stop() {
|
||||
s.StopWithTimeout(0)
|
||||
}
|
||||
|
||||
// StopWithTimeout 停止调度器,最多等待 timeout 让在跑任务退出(P1 #10)。
|
||||
// 返回 true 表示所有任务已退出;false 表示超时(仍有任务未响应 ctx.Done 而运行)。
|
||||
// timeout<=0 等价于无限等待(同 Stop)。幂等:未运行时直接返回 true。
|
||||
func (s *Scheduler) StopWithTimeout(timeout time.Duration) bool {
|
||||
s.mu.Lock()
|
||||
if !s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
return true
|
||||
}
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
if timeout <= 0 {
|
||||
s.wg.Wait()
|
||||
return true
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { s.wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-time.After(timeout):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// run 运行调度循环
|
||||
@@ -196,17 +240,65 @@ 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 内写锁更新。
|
||||
//
|
||||
// M-H 修复:锁内只收集到期任务(CAS 占用 + 推进 NextRun + wg.Add),锁外再 spawn。
|
||||
// 原实现整个遍历+spawn 持写锁,task 多时阻塞 GetTask/AddTask/RunTask 等管理 API。
|
||||
//
|
||||
// wg.Add 必须在锁内完成:Stop 先持锁置 running=false 再 wg.Wait,若 wg.Add 在锁外,
|
||||
// Stop 可能在两个 due 任务之间读到计数器 0 并提前返回,留下后启动的 goroutine 在调度器
|
||||
// 停止后运行(其 wg.Done 还会触发计数器下溢 panic)。锁内 Add 保证 Stop 的 wg.Wait
|
||||
// 一定能等到本批全部 goroutine。收集阶段已 CAS 占用守卫并推进 NextRun,故 spawn 必须
|
||||
// 执行(否则守卫不释放、NextRun 已推进却未跑)。spawn 用 s.ctx——Stop 后 ctx 已取消,
|
||||
// handler 收到 canceled ctx 应及时退出。
|
||||
func (s *Scheduler) checkAndRun() {
|
||||
now := time.Now()
|
||||
|
||||
s.mu.RLock()
|
||||
s.mu.Lock()
|
||||
var due []*Task
|
||||
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) // 锁内 Add,保证 Stop 的 wg.Wait 等到本批(见上方注释)
|
||||
due = append(due, task)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, t := range due {
|
||||
go func(t *Task) {
|
||||
defer s.wg.Done()
|
||||
defer func() {
|
||||
if t.running != nil {
|
||||
t.running.Store(false)
|
||||
}
|
||||
}()
|
||||
|
||||
err := t.Handler(s.ctx)
|
||||
|
||||
s.mu.Lock()
|
||||
t.LastRun = time.Now()
|
||||
t.RunCount++
|
||||
t.LastError = err // CR3 修复:记录错误不再静默丢弃
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
logger.Error("cron task failed",
|
||||
zap.String("task", t.Name),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(t)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
// IntervalSchedule 固定间隔调度
|
||||
@@ -251,14 +343,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
|
||||
}
|
||||
|
||||
@@ -270,8 +367,9 @@ func Weekly(day time.Weekday, hour, minute int) *WeeklySchedule {
|
||||
// FullCronSchedule 完整 Cron 表达式调度
|
||||
// 格式: "分钟 小时 日 月 星期" (5字段)
|
||||
// 示例: "0 12 * * *" 每天12点
|
||||
// "0 0 1 * *" 每月1号凌晨
|
||||
// "0 9-17 * * 1-5" 周一到周五 9点到17点每小时
|
||||
//
|
||||
// "0 0 1 * *" 每月1号凌晨
|
||||
// "0 9-17 * * 1-5" 周一到周五 9点到17点每小时
|
||||
type FullCronSchedule struct {
|
||||
Minute string // 分钟: 0-59, "*", "*/n", "a-b", "a,b,c"
|
||||
Hour string // 小时: 0-23, "*", "*/n", "a-b", "a,b,c"
|
||||
@@ -306,42 +404,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 +521,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 +559,65 @@ func ParseCron(expr string) *FullCronSchedule {
|
||||
Day: fields[2],
|
||||
Month: fields[3],
|
||||
Weekday: fields[4],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateCronField 校验单个 cron 字段语法与范围。
|
||||
func validateCronField(field string, min, max int, name string) error {
|
||||
if field == "*" {
|
||||
return nil
|
||||
}
|
||||
for _, raw := range strings.Split(field, ",") {
|
||||
item := strings.TrimSpace(raw)
|
||||
if item == "" {
|
||||
return fmt.Errorf("cron %s: 空列表项", name)
|
||||
}
|
||||
if err := validateCronItem(item, min, max, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCronItem(item string, min, max int, name string) error {
|
||||
isWeekday := min == 0 && max == 6
|
||||
norm := func(v int) int {
|
||||
if isWeekday && v == 7 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
if idx := strings.Index(item, "/"); idx >= 0 {
|
||||
base := item[:idx]
|
||||
step, err := strconv.Atoi(item[idx+1:])
|
||||
if err != nil || step <= 0 {
|
||||
return fmt.Errorf("cron %s: 非法步长 %q", name, item)
|
||||
}
|
||||
if base == "*" {
|
||||
return nil
|
||||
}
|
||||
lo, hi, err := parseCronRange(base, min, max)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cron %s: %v", name, err)
|
||||
}
|
||||
_ = lo
|
||||
_ = hi
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(item, "-") {
|
||||
if _, _, err := parseCronRange(item, min, max); err != nil {
|
||||
return fmt.Errorf("cron %s: %v", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
v, err := strconv.Atoi(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cron %s: 非数字 %q", name, item)
|
||||
}
|
||||
if v = norm(v); v < min || v > max {
|
||||
return fmt.Errorf("cron %s: %d 超出范围 [%d,%d]", name, v, min, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CronSchedule 简化 Cron 调度(仅分钟和小时)
|
||||
@@ -399,55 +654,38 @@ func (s *CronSchedule) matchValue(pattern string, value int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// splitPattern 将逗号分隔的模式解析为值列表(C12e:用 strconv.Atoi,非法项跳过)。
|
||||
func splitPattern(pattern string) []int {
|
||||
var values []int
|
||||
for _, p := range split(pattern, ',') {
|
||||
v := parseInt(p)
|
||||
if v >= 0 {
|
||||
values = append(values, v)
|
||||
for _, p := range strings.Split(pattern, ",") {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(p))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func split(s string, sep byte) []string {
|
||||
var parts []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == sep {
|
||||
parts = append(parts, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
parts = append(parts, s[start:])
|
||||
return parts
|
||||
}
|
||||
|
||||
func parseInt(s string) int {
|
||||
var v int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
v = v*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Cron 创建类 Cron 调度
|
||||
func Cron(minute, hour string) *CronSchedule {
|
||||
return &CronSchedule{Minute: minute, Hour: hour}
|
||||
}
|
||||
|
||||
// 全局调度器
|
||||
var globalScheduler *Scheduler
|
||||
var schedulerOnce sync.Once
|
||||
// 全局调度器。用 atomic.Pointer 懒初始化,使 StopGlobalWithTimeout 能安全 peek
|
||||
// 是否已创建而不触发创建,也消除原 once+裸指针读写的竞态。
|
||||
var globalScheduler atomic.Pointer[Scheduler]
|
||||
|
||||
// GetScheduler 获取全局调度器
|
||||
// GetScheduler 获取全局调度器(懒初始化,并发安全)。
|
||||
func GetScheduler() *Scheduler {
|
||||
schedulerOnce.Do(func() {
|
||||
globalScheduler = NewScheduler()
|
||||
})
|
||||
return globalScheduler
|
||||
if s := globalScheduler.Load(); s != nil {
|
||||
return s
|
||||
}
|
||||
ns := NewScheduler()
|
||||
if globalScheduler.CompareAndSwap(nil, ns) {
|
||||
return ns
|
||||
}
|
||||
return globalScheduler.Load()
|
||||
}
|
||||
|
||||
// AddTask 添加任务到全局调度器
|
||||
@@ -460,7 +698,16 @@ func Start() {
|
||||
GetScheduler().Start()
|
||||
}
|
||||
|
||||
// Stop 停止全局调度器
|
||||
// Stop 停止全局调度器(无限等待,见 Scheduler.Stop)。
|
||||
func Stop() {
|
||||
GetScheduler().Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// StopGlobalWithTimeout 停止全局调度器并最多等待 timeout(P1 #10)。
|
||||
// 全局调度器尚未创建时无操作返回 true,不会因此惰性创建它——供 App.Shutdown 安全调用。
|
||||
func StopGlobalWithTimeout(timeout time.Duration) bool {
|
||||
if s := globalScheduler.Load(); s != nil {
|
||||
return s.StopWithTimeout(timeout)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+283
-114
@@ -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"
|
||||
@@ -44,7 +49,10 @@ func (p *RoundRobinPicker) Pick(replicas []*gorm.DB) *gorm.DB {
|
||||
return replicas[int(n-1)%len(replicas)]
|
||||
}
|
||||
|
||||
// RandomPicker 随机选择从库
|
||||
// RandomPicker 随机选择从库。
|
||||
//
|
||||
// D2 注释:rand.Intn 自 Go 1.20 起(go.dev/issue/54899)内部使用 per-goroutine
|
||||
// 随机源,并发安全无需额外同步。本模块 go.mod 声明 go 1.25.0,满足此要求。
|
||||
type RandomPicker struct{}
|
||||
|
||||
// Pick 随机选择一个从库
|
||||
@@ -64,11 +72,11 @@ type Manager struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// #21 健康自愈
|
||||
healthy atomic.Bool // 主库是否健康
|
||||
replicaHealthy []atomic.Bool // 每个从库的健康标记,索引与 replicas 对齐
|
||||
probeFailures int // 主库连续探活失败次数
|
||||
probeMu sync.Mutex // 保护 probeFailures
|
||||
replicaHealthSet bool // replicaHealthy 是否已按 replicas 长度初始化
|
||||
healthy atomic.Bool // 主库是否健康
|
||||
replicaHealthy []atomic.Bool // 每个从库的健康标记,索引与 replicas 对齐
|
||||
probeFailures int // 主库连续探活失败次数
|
||||
probeMu sync.Mutex // 保护 probeFailures
|
||||
replicaHealthSet bool // replicaHealthy 是否已按 replicas 长度初始化
|
||||
}
|
||||
|
||||
// NewManager 创建数据库管理器
|
||||
@@ -76,6 +84,20 @@ func NewManager(cfg *config.Config) *Manager {
|
||||
return &Manager{cfg: cfg, picker: &RandomPicker{}}
|
||||
}
|
||||
|
||||
// getCfg 在锁内读取 m.cfg(P1 #11:消除与 InitDB 写 m.cfg 的数据竞争)。
|
||||
func (m *Manager) getCfg() *config.Config {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg
|
||||
}
|
||||
|
||||
// setCfg 在锁内写入 m.cfg(P1 #11)。
|
||||
func (m *Manager) setCfg(cfg *config.Config) {
|
||||
m.mu.Lock()
|
||||
m.cfg = cfg
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetPicker 设置从库选择策略
|
||||
func (m *Manager) SetPicker(p ReplicaPicker) {
|
||||
if p == nil {
|
||||
@@ -93,25 +115,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 +175,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 +190,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);
|
||||
@@ -161,13 +205,14 @@ func (m *Manager) initReplicaHealth() {
|
||||
func (m *Manager) StartProbing(ctx context.Context) {
|
||||
m.initReplicaHealth()
|
||||
|
||||
cfg := m.getCfg() // P1 #11:锁内快照,避免与 InitDB 写 m.cfg 竞态
|
||||
interval := 30 * time.Second
|
||||
if m.cfg != nil && m.cfg.Database.HealthCheckInterval > 0 {
|
||||
interval = m.cfg.Database.HealthCheckInterval
|
||||
if cfg != nil && cfg.Database.HealthCheckInterval > 0 {
|
||||
interval = cfg.Database.HealthCheckInterval
|
||||
}
|
||||
threshold := 3
|
||||
if m.cfg != nil && m.cfg.Database.HealthCheckFailureThreshold > 0 {
|
||||
threshold = m.cfg.Database.HealthCheckFailureThreshold
|
||||
if cfg != nil && cfg.Database.HealthCheckFailureThreshold > 0 {
|
||||
threshold = cfg.Database.HealthCheckFailureThreshold
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
@@ -211,6 +256,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 +267,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 +295,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:
|
||||
@@ -259,72 +305,108 @@ func (m *Manager) FromContext(ctx context.Context) *gorm.DB {
|
||||
|
||||
// Open 打开主库连接
|
||||
func (m *Manager) Open(ctx context.Context) error {
|
||||
if m.cfg == nil {
|
||||
cfg := m.getCfg() // P1 #11:锁内读取,避免与 InitDB 写竞态
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
return m.InitDB(m.cfg)
|
||||
return m.InitDB(cfg)
|
||||
}
|
||||
|
||||
// OpenWithReplicas 打开主库与从库连接
|
||||
func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) error {
|
||||
if m.cfg == nil {
|
||||
cfg := m.getCfg() // P1 #11:锁内读取
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
return m.InitDBWithReplicas(m.cfg, replicaDSNs)
|
||||
return m.InitDBWithReplicas(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
|
||||
}
|
||||
return sqlDB.PingContext(ctx)
|
||||
}
|
||||
|
||||
// DefaultManager 默认数据库管理器
|
||||
var DefaultManager = &Manager{picker: &RandomPicker{}}
|
||||
// DefaultManager 默认数据库管理器,包级 facade 代理到它。
|
||||
//
|
||||
// 主线A 修复:改用 atomic.Pointer 保护读写,消除原裸指针(外部直接
|
||||
// `database.DefaultManager = ...` 赋值与请求 goroutine 经 facade 读取)之间的数据竞争。
|
||||
// 与 config.defaultManager / database.DefaultRedis / storage.DefaultStorage /
|
||||
// cache.defaultCachePtr / jwt.defaultManager 对齐——框架内包级可变全局一律 atomic.Pointer。
|
||||
//
|
||||
// 类型由 *Manager 变更为 atomic.Pointer[Manager](breaking):下游若直接调用
|
||||
// DefaultManager.Init/Master 等方法需改用 InitDB/GetDB 等 facade,或
|
||||
// DefaultManager.Load().Init(...),或经 GetDefaultManager() 取实例后再调方法。
|
||||
var DefaultManager atomic.Pointer[Manager]
|
||||
|
||||
func init() {
|
||||
DefaultManager.Store(NewManager(nil))
|
||||
}
|
||||
|
||||
// SetDefaultManager 提升指定 Manager 为全局默认(主线A 修复:atomic.Store,并发安全)。
|
||||
// 用于多实例场景或测试注入。nil 被忽略以防 facade Load 到 nil panic。
|
||||
func SetDefaultManager(m *Manager) {
|
||||
if m != nil {
|
||||
DefaultManager.Store(m)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultManager 返回全局默认 Manager(atomic 读取,并发安全)。
|
||||
// 替代直接读 DefaultManager 包级变量(类型已改为 atomic.Pointer,直接读得到的是 atomic
|
||||
// 值而非 *Manager)。需直接持有 Manager 调用其方法时用本函数或 DefaultManager.Load()。
|
||||
func GetDefaultManager() *Manager {
|
||||
return DefaultManager.Load()
|
||||
}
|
||||
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
|
||||
func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
var err error
|
||||
m.cfg = cfg
|
||||
m.setCfg(cfg) // P1 #11:锁内写入,避免与 StartProbing/Open 读竞态
|
||||
|
||||
// GORM 日志配置
|
||||
var gormLogLevel gormlogger.LogLevel
|
||||
@@ -344,20 +426,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 +464,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,18 +482,26 @@ func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
|
||||
// isTransientDBError 判断数据库连接错误是否值得重试。
|
||||
// 认证失败、未知数据库、非法 DSN/驱动等属于配置类错误,重试无意义,直接返回更友好。
|
||||
// D5 修复:覆盖 MySQL 和 PostgreSQL 常见非瞬态错误。
|
||||
func isTransientDBError(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
nonTransient := []string{
|
||||
"Access denied", // MySQL 认证失败(用户名/密码错误)
|
||||
"authentication plugin", // MySQL 认证插件不支持
|
||||
"Unknown database", // MySQL 目标库不存在
|
||||
// MySQL
|
||||
"Access denied", // 认证失败(用户名/密码错误)
|
||||
"authentication plugin", // 认证插件不支持
|
||||
"Unknown database", // 目标库不存在
|
||||
"invalid DSN", // DSN 语法错误
|
||||
"unknown driver", // 驱动未注册
|
||||
"unsupported driver", // 驱动不支持
|
||||
// PostgreSQL(D5 修复;P1 #12:移除过宽的独立 "database" 子串——它会把
|
||||
// "the database system is starting up" 等瞬态错误误判为非瞬态而放弃重试。
|
||||
// PG 目标库不存在的消息形如 database "x" does not exist,用 "does not exist" 精确匹配)。
|
||||
"password authentication failed", // pg 密码错误
|
||||
"does not exist", // pg 目标库/角色不存在
|
||||
"no pg_hba.conf entry", // pg_hba.conf 拒绝
|
||||
}
|
||||
for _, sub := range nonTransient {
|
||||
if strings.Contains(msg, sub) {
|
||||
@@ -412,6 +511,20 @@ func isTransientDBError(err error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// replicaMaxOpenConns 计算从库连接池 MaxOpenConns(H-A 修复)。
|
||||
// masterMax>0 时取 max(1, masterMax/2)(从库适当减少,但绝不截断为 0);
|
||||
// masterMax<=0(未配置/无限)时返回 0,与主库"无限"语义一致。
|
||||
func replicaMaxOpenConns(masterMax int) int {
|
||||
if masterMax <= 0 {
|
||||
return 0
|
||||
}
|
||||
half := masterMax / 2
|
||||
if half < 1 {
|
||||
half = 1
|
||||
}
|
||||
return half
|
||||
}
|
||||
|
||||
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
|
||||
// replicaDSNs: 从库连接字符串列表(需与主库驱动匹配)
|
||||
func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error {
|
||||
@@ -420,7 +533,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 +556,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,21 +568,32 @@ 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
|
||||
}
|
||||
|
||||
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns / 2) // 从库连接数可适当减少
|
||||
// H-A 修复:从库 MaxOpenConns 适当减少,但须避免截断为 0。
|
||||
// database/sql 中 SetMaxOpenConns(0) 表示"无限制"——原实现 MaxOpenConns/2 在
|
||||
// 配置为 1 时得 0,反而让从库连接池无上限(与"减少"意图相反、高并发下打爆 DB);
|
||||
// 配置为 0(未配置/无限)时 0/2=0 恰好"无限",与主库一致,保持语义。
|
||||
// 现规则:MaxOpenConns>0 时取 max(1, /2);<=0 时从库亦无限(0),与主库对齐。
|
||||
sqlDB.SetMaxOpenConns(replicaMaxOpenConns(cfg.Database.MaxOpenConns))
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
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
|
||||
@@ -467,37 +601,37 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
|
||||
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
|
||||
func InitDB(cfg *config.Config) error {
|
||||
return DefaultManager.InitDB(cfg)
|
||||
return DefaultManager.Load().InitDB(cfg)
|
||||
}
|
||||
|
||||
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
|
||||
func InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error {
|
||||
return DefaultManager.InitDBWithReplicas(cfg, replicaDSNs)
|
||||
return DefaultManager.Load().InitDBWithReplicas(cfg, replicaDSNs)
|
||||
}
|
||||
|
||||
// GetReadDB 获取读库实例(按策略选择从库)
|
||||
func GetReadDB() *gorm.DB {
|
||||
return DefaultManager.Replica()
|
||||
return DefaultManager.Load().Replica()
|
||||
}
|
||||
|
||||
// GetWriteDB 获取写库实例(主库)
|
||||
func GetWriteDB() *gorm.DB {
|
||||
return DefaultManager.Master()
|
||||
return DefaultManager.Load().Master()
|
||||
}
|
||||
|
||||
// GetDB 获取数据库实例(默认主库,兼容旧代码)
|
||||
func GetDB() *gorm.DB {
|
||||
return DefaultManager.Master()
|
||||
return DefaultManager.Load().Master()
|
||||
}
|
||||
|
||||
// GetReplicas 获取所有从库实例
|
||||
func GetReplicas() []*gorm.DB {
|
||||
return DefaultManager.Replicas()
|
||||
return DefaultManager.Load().Replicas()
|
||||
}
|
||||
|
||||
// SetReplicaPicker 设置默认管理器的从库选择策略
|
||||
func SetReplicaPicker(p ReplicaPicker) {
|
||||
DefaultManager.SetPicker(p)
|
||||
DefaultManager.Load().SetPicker(p)
|
||||
}
|
||||
|
||||
// UseMaster 强制使用主库(用于事务或需要实时数据的场景)
|
||||
@@ -512,7 +646,34 @@ func UseReplica(ctx context.Context) context.Context {
|
||||
|
||||
// GetDBFromContext 根据上下文选择数据库
|
||||
func GetDBFromContext(ctx context.Context) *gorm.DB {
|
||||
return DefaultManager.FromContext(ctx)
|
||||
return DefaultManager.Load().FromContext(ctx)
|
||||
}
|
||||
|
||||
// WithTx 将外层事务注入 ctx,使上层(如 repository.BaseRepo)在调用时能 join 到该事务
|
||||
// 而非另开连接/路由到主从库(H6c)。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// err := database.TransactionWithContext(ctx, func(tx *gorm.DB) error {
|
||||
// ctx2 := database.WithTx(ctx, tx)
|
||||
// // 传 ctx2 给 repo 方法,repo 内部会优先使用该 tx
|
||||
// return repo.FindByID(ctx2, id) // 此查询参与外层事务
|
||||
// })
|
||||
//
|
||||
// 注意:tx 仅在该 ctx 的生命周期内有效;事务提交/回滚后不得再用该 ctx 携带的 tx。
|
||||
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
|
||||
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 注册)
|
||||
@@ -521,39 +682,33 @@ func AutoMigrate() error {
|
||||
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 关闭所有数据库连接(包括从库)
|
||||
func CloseAll() error {
|
||||
return DefaultManager.Close()
|
||||
return DefaultManager.Load().Close()
|
||||
}
|
||||
|
||||
// Transaction 事务操作(自动使用主库)
|
||||
func Transaction(fn func(tx *gorm.DB) error) error {
|
||||
if DefaultManager.master == nil {
|
||||
db := DefaultManager.Load().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.Load().Master()
|
||||
if db == nil {
|
||||
return errors.New("数据库未初始化")
|
||||
}
|
||||
return DefaultManager.master.WithContext(ctx).Transaction(fn)
|
||||
return db.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
// ReadQuery 读查询(自动路由到从库)
|
||||
@@ -565,22 +720,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.Load().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.Load().Master(); master != nil {
|
||||
sqlDB, err := master.DB()
|
||||
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
|
||||
result["master"] = true
|
||||
} else {
|
||||
result["master"] = false
|
||||
@@ -590,10 +759,10 @@ func HealthCheck() map[string]bool {
|
||||
}
|
||||
|
||||
// 检查从库
|
||||
for i, replica := range DefaultManager.replicas {
|
||||
for i, replica := range DefaultManager.Load().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
|
||||
@@ -610,11 +779,11 @@ func HealthCheck() map[string]bool {
|
||||
// 与 HealthCheck()(实时 ping)不同,这是后台探活维护的缓存标记,
|
||||
// 供 readiness 探针快速判断是否接流量,避免每次探针都同步 ping。
|
||||
func IsDBHealthy() bool {
|
||||
return DefaultManager.IsHealthy()
|
||||
return DefaultManager.Load().IsHealthy()
|
||||
}
|
||||
|
||||
// StartDBProbing 启动主库/从库探活后台循环(#21)。
|
||||
// 阻塞,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
|
||||
func StartDBProbing(ctx context.Context) {
|
||||
DefaultManager.StartProbing(ctx)
|
||||
DefaultManager.Load().StartProbing(ctx)
|
||||
}
|
||||
|
||||
@@ -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.Load().mu.Lock()
|
||||
DefaultManager.Load().master = nil
|
||||
DefaultManager.Load().replicas = nil
|
||||
DefaultManager.Load().resetReplicaHealth()
|
||||
DefaultManager.Load().healthy.Store(false)
|
||||
DefaultManager.Load().mu.Unlock()
|
||||
}()
|
||||
|
||||
DefaultManager.Load().mu.Lock()
|
||||
DefaultManager.Load().master = sentinelDB()
|
||||
DefaultManager.Load().replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
|
||||
DefaultManager.Load().mu.Unlock()
|
||||
|
||||
// 包级 Close → CloseAll → DefaultManager.Load().Close(),应同时清空 master 与 replicas
|
||||
_ = Close()
|
||||
|
||||
DefaultManager.Load().mu.Lock()
|
||||
master := DefaultManager.Load().master
|
||||
repl := DefaultManager.Load().replicas
|
||||
DefaultManager.Load().mu.Unlock()
|
||||
|
||||
if master != nil {
|
||||
t.Fatal("C11f: expected master nil after package Close")
|
||||
}
|
||||
if repl != nil {
|
||||
t.Fatal("C11f: package Close should close replicas too (delegates to CloseAll), got residual replicas")
|
||||
}
|
||||
}
|
||||
|
||||
// TestC11HealthCheckLockedRead 验证 HealthCheck 在锁内快照 master,未初始化时返错(C11d)。
|
||||
func TestC11HealthCheckLockedRead(t *testing.T) {
|
||||
m := &Manager{picker: &RandomPicker{}}
|
||||
if err := m.HealthCheck(context.Background()); err == nil {
|
||||
t.Fatal("expected error when health checking uninitialized master")
|
||||
}
|
||||
|
||||
// 空 gorm.DB(无 ConnPool):DB() 返 ErrInvalidDB,HealthCheck 返该错而非 panic
|
||||
m.mu.Lock()
|
||||
m.master = sentinelDB()
|
||||
m.mu.Unlock()
|
||||
if err := m.HealthCheck(context.Background()); err == nil {
|
||||
t.Fatal("expected error for gorm.DB without ConnPool")
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestManagerSetPicker(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDefaultManagerHealthCheckWithoutInit(t *testing.T) {
|
||||
if err := database.DefaultManager.HealthCheck(context.Background()); err == nil {
|
||||
if err := database.GetDefaultManager().HealthCheck(context.Background()); err == nil {
|
||||
t.Fatal("expected error when health checking uninitialized master")
|
||||
}
|
||||
}
|
||||
|
||||
+55
-19
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
@@ -12,10 +14,6 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RedisClient 全局 Redis 客户端(兼容 facade,由 RedisManager.Init 同步维护)。
|
||||
// 保留供存量代码直接访问;新代码建议用 GetRedis() 或持有 *RedisManager 实例。
|
||||
var RedisClient *redis.Client
|
||||
|
||||
// RedisManager Redis 连接管理器(#10)。照 database.Manager 模式:
|
||||
// 实例化 + DefaultRedis 全局默认 + 包级 facade 代理,支持多实例与测试注入。
|
||||
type RedisManager struct {
|
||||
@@ -25,16 +23,25 @@ type RedisManager struct {
|
||||
}
|
||||
|
||||
// DefaultRedis 默认 Redis 管理器,包级 facade 代理到它。
|
||||
var DefaultRedis = NewRedisManager()
|
||||
//
|
||||
// C-1/H-4 修复:改用 atomic.Pointer 保护读写,消除原裸指针置换(SetDefaultRedisManager)
|
||||
// 与请求 goroutine 无锁读(GetRedis 等 facade)之间的数据竞争。与 config.defaultManager
|
||||
// 对齐。类型由 *RedisManager 变更为 atomic.Pointer[RedisManager](breaking:下游若直接
|
||||
// 调用 DefaultRedis.Init 等方法需改用 InitRedis 等 facade,或 DefaultRedis.Load().Init)。
|
||||
var DefaultRedis atomic.Pointer[RedisManager]
|
||||
|
||||
func init() {
|
||||
DefaultRedis.Store(NewRedisManager())
|
||||
}
|
||||
|
||||
// NewRedisManager 创建 Redis 管理器实例。
|
||||
func NewRedisManager() *RedisManager { return &RedisManager{} }
|
||||
|
||||
// SetDefaultRedisManager 提升指定 RedisManager 为全局默认,后续包级 facade 走它。
|
||||
// 用于多实例场景或测试注入 mock。
|
||||
// 用于多实例场景或测试注入 mock。并发安全(atomic.Store)。
|
||||
func SetDefaultRedisManager(m *RedisManager) {
|
||||
if m != nil {
|
||||
DefaultRedis = m
|
||||
DefaultRedis.Store(m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,20 +51,23 @@ func (m *RedisManager) Init(cfg *config.Config) error {
|
||||
defer m.mu.Unlock()
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr(),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
Addr: cfg.Redis.Addr(),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
DialTimeout: 5 * time.Second, // D7 修复:连接超时
|
||||
ReadTimeout: 3 * time.Second, // D7 修复:读超时
|
||||
WriteTimeout: 3 * time.Second, // D7 修复:写超时
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(pingCtx).Err(); err != nil {
|
||||
client.Close()
|
||||
return fmt.Errorf("Redis 连接失败: %w", err)
|
||||
}
|
||||
|
||||
m.cfg = cfg
|
||||
m.client = client
|
||||
RedisClient = client // 同步兼容 facade
|
||||
logger.Info("Redis 连接成功", zap.String("addr", cfg.Redis.Addr()))
|
||||
return nil
|
||||
}
|
||||
@@ -72,7 +82,6 @@ func (m *RedisManager) Close() error {
|
||||
}
|
||||
err := m.client.Close()
|
||||
m.client = nil
|
||||
RedisClient = nil
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -83,6 +92,16 @@ func (m *RedisManager) Client() *redis.Client {
|
||||
return m.client
|
||||
}
|
||||
|
||||
// setClientForTest 仅测试用:在持锁下替换 manager 的 client,返回旧 client。
|
||||
// 供 SetTestRedisClient 注入 miniredis 等 mock,消除原包级 redisClient 双源真相。
|
||||
func (m *RedisManager) setClientForTest(c *redis.Client) *redis.Client {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
old := m.client
|
||||
m.client = c
|
||||
return old
|
||||
}
|
||||
|
||||
// HealthCheck Redis 健康检查。
|
||||
func (m *RedisManager) HealthCheck(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
@@ -98,20 +117,37 @@ func (m *RedisManager) HealthCheck(ctx context.Context) error {
|
||||
|
||||
// InitRedis 初始化 Redis 连接
|
||||
func InitRedis(cfg *config.Config) error {
|
||||
return DefaultRedis.Init(cfg)
|
||||
return DefaultRedis.Load().Init(cfg)
|
||||
}
|
||||
|
||||
// CloseRedis 关闭 Redis 连接
|
||||
func CloseRedis() error {
|
||||
return DefaultRedis.Close()
|
||||
return DefaultRedis.Load().Close()
|
||||
}
|
||||
|
||||
// HealthCheckRedis Redis 健康检查
|
||||
func HealthCheckRedis(ctx context.Context) error {
|
||||
return DefaultRedis.HealthCheck(ctx)
|
||||
return DefaultRedis.Load().HealthCheck(ctx)
|
||||
}
|
||||
|
||||
// GetRedis 获取 Redis 客户端
|
||||
// GetRedis 获取 Redis 客户端(未初始化返回 nil)。
|
||||
//
|
||||
// H-4 修复:单源——仅从 DefaultRedis.Load().Client() 取,废弃原包级 redisClient
|
||||
// 回退路径(其在 manager 替换后会返回 stale client,且无锁读存在竞态)。
|
||||
// 测试注入的 mock client 经 SetTestRedisClient 直接设在当前 manager 上,闭环一致。
|
||||
func GetRedis() *redis.Client {
|
||||
return DefaultRedis.Client()
|
||||
return DefaultRedis.Load().Client()
|
||||
}
|
||||
|
||||
// SetTestRedisClient 供测试注入 miniredis 等 mock 客户端。
|
||||
// 返回旧客户端引用以便测试清理时恢复。生产代码严禁调用。
|
||||
//
|
||||
// H-4 修复:改为在当前默认 manager 上持锁替换 client(setClientForTest),
|
||||
// 不再写独立的包级 redisClient 变量,消除双源真相与无锁写竞态。
|
||||
//
|
||||
// 约束:操作 DefaultRedis.Load() 返回的当前默认 manager。测试应避免在调用本函数
|
||||
// 的同时并发 SetDefaultRedisManager 替换默认 manager,否则注入/恢复会作用到不同
|
||||
// manager 上。典型用法为 init 阶段注入、t.Cleanup 恢复,串行执行。
|
||||
func SetTestRedisClient(c *redis.Client) *redis.Client {
|
||||
return DefaultRedis.Load().setClientForTest(c)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
@@ -21,3 +22,30 @@ func TestHealthCheckRedisWithoutInit(t *testing.T) {
|
||||
t.Fatal("expected health check error without Redis init")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultRedisConcurrentSetAndGet C-1/H-4 回归:并发 SetDefaultRedisManager
|
||||
// 与 GetRedis/CloseRedis/HealthCheckRedis facade 读取不应触发数据竞争。
|
||||
// 修复前 DefaultRedis 是裸 *Redis.Pointer,SetDefaultRedisManager 无锁写、
|
||||
// facade 无锁读,-race 必采。修复后 atomic.Pointer 保护。
|
||||
func TestDefaultRedisConcurrentSetAndGet(t *testing.T) {
|
||||
// 保存并恢复默认 manager,避免污染其他测试。
|
||||
orig := database.NewRedisManager()
|
||||
database.SetDefaultRedisManager(orig)
|
||||
// 不设 client,GetRedis 返回 nil;本用例只验证读写无竞态。
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
database.SetDefaultRedisManager(database.NewRedisManager())
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = database.GetRedis()
|
||||
_ = database.CloseRedis()
|
||||
_ = database.HealthCheckRedis(context.Background())
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ func TestIsTransientDBError(t *testing.T) {
|
||||
{"connection refused (transient)", errors.New("dial tcp 127.0.0.1:3306: connect: connection refused"), true},
|
||||
{"i/o timeout (transient)", errors.New("dial tcp 10.0.0.1:3306: i/o timeout"), true},
|
||||
{"empty msg", errors.New(""), true},
|
||||
// P1 #12:PG 目标库不存在应视为非瞬态(不重试)。
|
||||
{"pg database not exist", errors.New(`FATAL: database "app" does not exist (SQLSTATE 3D000)`), false},
|
||||
{"pg password auth failed", errors.New("FATAL: password authentication failed for user \"app\""), false},
|
||||
// P1 #12 回归核心:含 "database" 但是瞬态的错误必须仍被判为瞬态(可重试),
|
||||
// 修复前独立 "database" 子串会把它误判为非瞬态而放弃重试。
|
||||
{"pg starting up (transient)", errors.New("FATAL: the database system is starting up (SQLSTATE 57P03)"), true},
|
||||
{"pg in recovery (transient)", errors.New("FATAL: the database system is in recovery mode"), true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
+1
-2
@@ -2,10 +2,9 @@
|
||||
|
||||
| 文档 | 位置 | 说明 |
|
||||
|---|---|---|
|
||||
| README.md | 仓库根 | 快速开始、功能概览、更新日志 |
|
||||
| README.md | 仓库根 | 快速开始、功能概览 |
|
||||
| GUIDE.md | 仓库根 | 完整使用指南 |
|
||||
| CHANGELOG.md | 仓库根 | 变更日志(遵循 Keep a Changelog) |
|
||||
| CLAUDE.md | 仓库根 | Claude Code 协作指引 |
|
||||
| docs/plans/ | 本目录 | 历史版本规划与体检报告归档 |
|
||||
|
||||
## docs/plans/ 归档文档
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
+27
-14
@@ -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 请求
|
||||
@@ -34,6 +36,11 @@ func BindQuery(c *gin.Context, req any) error {
|
||||
return c.ShouldBindQuery(req)
|
||||
}
|
||||
|
||||
// MaxPage GetPage 允许的最大页码(M-D 修复:防深分页 DoS)。
|
||||
// 超大 page 产生超大 OFFSET,大表上为性能灾难/DoS 面。钳制到该上限;
|
||||
// 需更深度遍历的业务应改用游标/keyset 分页(框架不内置,避免功能扩张)。
|
||||
const MaxPage = 10000
|
||||
|
||||
// GetPage 获取分页参数
|
||||
func GetPage(c *gin.Context) (int, int) {
|
||||
page := c.DefaultQuery("page", "1")
|
||||
@@ -45,6 +52,10 @@ func GetPage(c *gin.Context) (int, int) {
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
// M-D 修复:钳制 page 上限,防 ?page=999999999 产生超大 OFFSET 拖垮 DB。
|
||||
if p > MaxPage {
|
||||
p = MaxPage
|
||||
}
|
||||
if ps < 1 {
|
||||
ps = 20
|
||||
}
|
||||
@@ -153,18 +164,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)
|
||||
}
|
||||
|
||||
+132
-3
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+153
-40
@@ -8,12 +8,15 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Claims JWT 声明
|
||||
@@ -37,8 +40,46 @@ var (
|
||||
ErrTokenNotValidYet = errors.New("令牌尚未生效")
|
||||
//ErrTokenRevoked 令牌已被撤销
|
||||
ErrTokenRevoked = errors.New("令牌已被撤销")
|
||||
// ErrBlacklistUnavailable Redis 未初始化或不可用,黑名单功能失效(C9a 修复)。
|
||||
// Add 返回此错误使调用方(RefreshToken/InvalidateToken)感知黑名单不可用并 fail-closed,
|
||||
// 避免无 Redis 时静默成功致撤销/刷新失效、新旧 token 双有效。
|
||||
// IsBlacklisted 在无 Redis 时仍返回 false(验证侧 fail-open 是无 Redis 部署的固有局限,
|
||||
// 文档约束:安全敏感场景必须启用 Redis)。
|
||||
ErrBlacklistUnavailable = errors.New("token 黑名单不可用:Redis 未初始化")
|
||||
// ErrEmptySecret JWT 密钥为空(P0 修复)。空 secret 意味着以零长度 HMAC 密钥签发/校验,
|
||||
// 任何以 "" 签名的 token 都会通过——签发与校验一律 fail-closed 拒绝,杜绝该空密钥绕过。
|
||||
ErrEmptySecret = errors.New("jwt.secret 未配置:拒绝签发/校验(防空密钥导致任意 token 通过)")
|
||||
// ErrUnsupportedAlgorithm 配置了不支持的签名算法(P0 修复)。
|
||||
// 本实现仅支持 HMAC 族(HS256/HS384/HS512);RS256 等非对称算法暂不支持,
|
||||
// 不再静默回退 HS256——避免用户误以为在用非对称算法、实则 HMAC,并助长算法混淆攻击。
|
||||
ErrUnsupportedAlgorithm = errors.New("jwt: 不支持的签名算法(仅支持 HS256/HS384/HS512)")
|
||||
)
|
||||
|
||||
// validMethods 允许的签名算法名(HMAC 族)。ParseWithClaims 传 jwt.WithValidMethods 固定算法,
|
||||
// 防算法混淆(alg confusion,P0):拒绝 alg=none 及非 HMAC 算法——否则若部署配置为非对称算法,
|
||||
// 攻击者可用公钥作为 HMAC 密钥伪造 token 通过校验。
|
||||
var validMethods = []string{"HS256", "HS384", "HS512"}
|
||||
|
||||
// secretKey 返回 HMAC 密钥字节;cfg 为 nil 或密钥为空时 fail-closed 返回 ErrEmptySecret(P0)。
|
||||
func secretKey(cfg *config.Config) ([]byte, error) {
|
||||
if cfg == nil || cfg.JWT.Secret == "" {
|
||||
return nil, ErrEmptySecret
|
||||
}
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
}
|
||||
|
||||
// hmacKeyfunc 构造校验签名方法为 HMAC 族并返回密钥的 jwt.Keyfunc(P0:防算法混淆 + 空密钥)。
|
||||
// 双重防护:① 断言 token.Method 为 *jwt.SigningMethodHMAC,拒绝非 HMAC(含 none/RS/ES);
|
||||
// ② 经 secretKey 拒绝空密钥。配合 ParseWithClaims 的 jwt.WithValidMethods(validMethods) 使用。
|
||||
func hmacKeyfunc(cfg *config.Config) jwt.Keyfunc {
|
||||
return func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("%w: 期望 HMAC,实际 alg=%v", ErrUnsupportedAlgorithm, token.Header["alg"])
|
||||
}
|
||||
return secretKey(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// generateJTI 生成唯一的 JWT ID
|
||||
func generateJTI() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
@@ -66,16 +107,32 @@ func (tb *TokenBlacklist) redisClient() *redis.Client {
|
||||
return database.GetRedis()
|
||||
}
|
||||
|
||||
// blacklistOpTimeout 黑名单 Redis 操作的上下文超时(M-A 修复)。
|
||||
// Redis 客户端已配 ReadTimeout/WriteTimeout=3s(redis.go D7),但鉴权热路径(ParseToken
|
||||
// 每次调 IsBlacklisted)需更紧边界——显式 ctx 超时把鉴权阻塞上限收敛到 1s,避免 Redis
|
||||
// 挂起时每个鉴权请求被长时间拖住。健康 Redis 下 Exists/SET 为亚毫秒级,1s 余量充足。
|
||||
// 注:ctx 超时只约束命令往返,不影响 Set 的服务端 TTL(ttl 可远大于 1s)。
|
||||
const blacklistOpTimeout = 1 * time.Second
|
||||
|
||||
// blacklistCtx 创建带超时的 context 用于黑名单 Redis 操作(M-A)。
|
||||
func blacklistCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), blacklistOpTimeout)
|
||||
}
|
||||
|
||||
// Add 将 Token 的 JTI 加入黑名单
|
||||
// 参数: jti JWT ID,expiry Token 过期时间
|
||||
//
|
||||
// 无 Redis 时返回 ErrBlacklistUnavailable(C9a 修复):让调用方(RefreshToken/InvalidateToken)
|
||||
// 感知黑名单不可用并 fail-closed,避免无 Redis 时静默成功致撤销失效。
|
||||
func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
|
||||
client := tb.redisClient()
|
||||
if client == nil {
|
||||
// Redis 未启用,跳过黑名单
|
||||
return nil
|
||||
// Redis 未启用,黑名单不可用——fail-closed 让调用方决策。
|
||||
return ErrBlacklistUnavailable
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := blacklistCtx()
|
||||
defer cancel()
|
||||
ttl := time.Until(expiry)
|
||||
if ttl <= 0 {
|
||||
// Token 已过期,无需加入黑名单
|
||||
@@ -95,9 +152,18 @@ func (tb *TokenBlacklist) IsBlacklisted(jti string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := blacklistCtx()
|
||||
defer cancel()
|
||||
key := fmt.Sprintf("jwt_bl:%s", jti)
|
||||
return client.Exists(ctx, key).Val() > 0
|
||||
// M-A 修复:显式处理 Redis 错误(原 .Val() 吞错致故障被静默当"未拉黑")。
|
||||
// 错误时保持 fail-open(返 false),与"无 Redis 部署可用"的固有局限一致,但记录告警
|
||||
// 便于运维感知 Redis 故障。安全敏感场景必须启用 Redis(见 ErrBlacklistUnavailable 注释)。
|
||||
n, err := client.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
logger.Warn("jwt 黑名单检查失败,fail-open 放行", zap.String("jti", jti), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// Manager JWT 管理器(#10)。持有独立的 TokenBlacklist,
|
||||
@@ -107,8 +173,34 @@ type Manager struct {
|
||||
blacklist *TokenBlacklist
|
||||
}
|
||||
|
||||
// DefaultJWT 默认 JWT 管理器,包级 facade 代理到它的 blacklist。
|
||||
var DefaultJWT = NewJWTManager()
|
||||
// defaultManager 是全局默认 JWT 管理器的真实存储,经 atomic 读写(C9c)。
|
||||
var defaultManager atomic.Pointer[Manager]
|
||||
|
||||
func init() {
|
||||
defaultManager.Store(NewJWTManager())
|
||||
}
|
||||
|
||||
// currentManager 返回全局默认 JWT 管理器(atomic 读取,C9c)。
|
||||
// 正常情况下 init 后永不为 nil;防御性地在极罕见的 nil 情况下回退一个懒取 Redis 的实例。
|
||||
func currentManager() *Manager {
|
||||
if m := defaultManager.Load(); m != nil {
|
||||
return m
|
||||
}
|
||||
m := NewJWTManager()
|
||||
defaultManager.Store(m)
|
||||
return m
|
||||
}
|
||||
|
||||
// GetDefaultJWT 返回全局默认 JWT 管理器(并发安全,C9c/J1 修复)。
|
||||
// 替代已删除的 DefaultJWT 包级变量。
|
||||
func GetDefaultJWT() *Manager {
|
||||
return currentManager()
|
||||
}
|
||||
|
||||
// currentBlacklist 返回全局默认 Manager 持有的黑名单(atomic 读取 Manager 后经 Blacklist(),C9c)。
|
||||
func currentBlacklist() *TokenBlacklist {
|
||||
return currentManager().Blacklist()
|
||||
}
|
||||
|
||||
// NewJWTManager 创建 JWT 管理器实例(blacklist 懒取全局 Redis)。
|
||||
func NewJWTManager() *Manager {
|
||||
@@ -120,12 +212,12 @@ func NewJWTManagerWithRedis(client *redis.Client) *Manager {
|
||||
return &Manager{blacklist: NewTokenBlacklist(client)}
|
||||
}
|
||||
|
||||
// SetDefaultJWTManager 提升指定 Manager 为全局默认。
|
||||
// SetDefaultJWTManager 提升指定 Manager 为全局默认(atomic 置换,并发安全,J1 修复)。
|
||||
func SetDefaultJWTManager(m *Manager) {
|
||||
if m != nil {
|
||||
DefaultJWT = m
|
||||
tokenBlacklist = m.blacklist
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
defaultManager.Store(m)
|
||||
}
|
||||
|
||||
// Blacklist 返回 Manager 持有的黑名单实例。
|
||||
@@ -135,13 +227,21 @@ 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()
|
||||
|
||||
// P0:先校验密钥非空与算法受支持(fail-closed)。secretKey 亦守卫 cfg==nil,
|
||||
// 通过后 cfg 保证非空,后续访问 cfg.JWT.* 安全。
|
||||
key, err := secretKey(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method, err := signingMethod(cfg.JWT.Algorithm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 生成唯一的 JWT ID
|
||||
jti, err := generateJTI()
|
||||
if err != nil {
|
||||
@@ -163,14 +263,24 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(signingMethod(cfg.JWT.Algorithm), claims)
|
||||
return token.SignedString([]byte(cfg.JWT.Secret))
|
||||
token := jwt.NewWithClaims(method, claims)
|
||||
return token.SignedString(key)
|
||||
}
|
||||
|
||||
// GenerateTokenWithCustomExpiry 生成带自定义过期时间的 Token
|
||||
func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
// P0:先校验密钥非空与算法受支持(fail-closed,见 GenerateToken)。
|
||||
key, err := secretKey(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method, err := signingMethod(cfg.JWT.Algorithm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jti, err := generateJTI()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -191,8 +301,8 @@ func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(signingMethod(cfg.JWT.Algorithm), claims)
|
||||
return token.SignedString([]byte(cfg.JWT.Secret))
|
||||
token := jwt.NewWithClaims(method, claims)
|
||||
return token.SignedString(key)
|
||||
}
|
||||
|
||||
// issuerOrDefault 返回配置的 issuer,未配置时回退 "xlgo"。
|
||||
@@ -204,16 +314,18 @@ func issuerOrDefault(issuer string) string {
|
||||
}
|
||||
|
||||
// signingMethod 根据 algorithm 配置返回 HMAC 签名方法。
|
||||
// 目前支持 HS256(默认)/HS384/HS512;其它值回退 HS256。
|
||||
// RS256 等非对称算法需扩展密钥类型,暂不支持。
|
||||
func signingMethod(algorithm string) jwt.SigningMethod {
|
||||
switch strings.ToUpper(algorithm) {
|
||||
// 支持 HS256(默认,空值等价)/HS384/HS512;其它值(含 RS256 等非对称算法,暂不支持)
|
||||
// 返回 ErrUnsupportedAlgorithm,不再静默回退 HS256(P0:防"配 RS256 实得 HMAC"的算法混淆隐患)。
|
||||
func signingMethod(algorithm string) (jwt.SigningMethod, error) {
|
||||
switch strings.ToUpper(strings.TrimSpace(algorithm)) {
|
||||
case "", "HS256":
|
||||
return jwt.SigningMethodHS256, nil
|
||||
case "HS384":
|
||||
return jwt.SigningMethodHS384
|
||||
return jwt.SigningMethodHS384, nil
|
||||
case "HS512":
|
||||
return jwt.SigningMethodHS512
|
||||
return jwt.SigningMethodHS512, nil
|
||||
default:
|
||||
return jwt.SigningMethodHS256
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnsupportedAlgorithm, algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,9 +333,7 @@ func signingMethod(algorithm string) jwt.SigningMethod {
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
})
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), jwt.WithValidMethods(validMethods))
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
@@ -240,7 +350,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
|
||||
@@ -253,9 +363,7 @@ func ParseToken(tokenString string) (*Claims, error) {
|
||||
func InvalidateToken(tokenString string) error {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
})
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), jwt.WithValidMethods(validMethods))
|
||||
|
||||
if err != nil {
|
||||
// Token 无效或已过期,无需加入黑名单
|
||||
@@ -264,7 +372,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 +382,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 +423,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(不验证过期)
|
||||
@@ -317,9 +431,8 @@ func IsTokenRevoked(jti string) bool {
|
||||
func GetClaimsFromToken(tokenString string) (*Claims, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
}, jwt.WithoutClaimsValidation())
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg),
|
||||
jwt.WithValidMethods(validMethods), jwt.WithoutClaimsValidation())
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// c9cSetupConfig 注入测试用 JWT 配置(内部测试无法访问 jwt_test 的 setupTestConfig)。
|
||||
func c9cSetupConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
config.Set(&config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
|
||||
Expire: time.Hour,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestC9cConcurrentSetDefaultAndRead 验证 SetDefaultJWTManager(写)与包级函数
|
||||
// (读 currentBlacklist → currentManager → defaultManager.Load)并发无数据竞争(C9c)。
|
||||
// 修复前 tokenBlacklist 为裸包级指针,SetDefaultJWTManager 裸写、ParseToken/IsTokenRevoked/
|
||||
// InvalidateTokenByID 裸读,-race 必采到指针读写竞争。
|
||||
func TestC9cConcurrentSetDefaultAndRead(t *testing.T) {
|
||||
c9cSetupConfig(t)
|
||||
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) }) // 还原无 Redis 基线
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
// 预生成一个合法 token 供读者 ParseToken(JTI 随机,不会被下方 InvalidateTokenByID 的固定 jti 命中)
|
||||
token, err := GenerateToken(1, "u", "admin", "super_admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
// 写者:在"注入 Redis"与"无 Redis"Manager 间原子置换(模拟启动期/测试期 SetDefaultJWTManager)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
withRedis := NewJWTManagerWithRedis(client)
|
||||
withoutRedis := NewJWTManager()
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if i%2 == 0 {
|
||||
SetDefaultJWTManager(withRedis)
|
||||
} else {
|
||||
SetDefaultJWTManager(withoutRedis)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 读者:并发调用经 currentBlacklist() 的包级函数 + currentManager
|
||||
for range 4 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_, _ = ParseToken(token)
|
||||
_ = IsTokenRevoked("some-jti")
|
||||
_ = InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
|
||||
_ = currentManager()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 让竞争窗口运行一段时间,确保 -race 能采到(若有未加锁读)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestC9cCurrentManagerReflectsSwap 验证 SetDefaultJWTManager 原子置换后,
|
||||
// 包级函数经 currentManager 读到的是新 Manager 的 blacklist(C9c 一致性)。
|
||||
func TestC9cCurrentManagerReflectsSwap(t *testing.T) {
|
||||
c9cSetupConfig(t)
|
||||
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) })
|
||||
|
||||
// 基线:无 Redis,InvalidateTokenByID 返 ErrBlacklistUnavailable
|
||||
if err := InvalidateTokenByID("jti-1", time.Now().Add(time.Hour)); !errorIsBlacklistUnavailable(err) {
|
||||
t.Fatalf("baseline without Redis: want ErrBlacklistUnavailable, got %v", err)
|
||||
}
|
||||
|
||||
// 置换为注入 Redis 的 Manager
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
SetDefaultJWTManager(NewJWTManagerWithRedis(client))
|
||||
|
||||
// 置换后包级函数应读到新 blacklist(Redis 可用),InvalidateTokenByID 成功
|
||||
if err := InvalidateTokenByID("jti-2", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("after swap to Redis Manager: InvalidateTokenByID err = %v, want nil", err)
|
||||
}
|
||||
|
||||
// currentManager 应等于刚 Store 的 Manager
|
||||
m := currentManager()
|
||||
if m == nil {
|
||||
t.Fatal("currentManager should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestC9cDefaultJWTAliasConsistent 验证 GetDefaultJWT 与 defaultManager 真实存储一致。
|
||||
func TestC9cDefaultJWTAliasConsistent(t *testing.T) {
|
||||
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) })
|
||||
|
||||
// init 后 GetDefaultJWT 与 currentManager 应指向同一实例
|
||||
if GetDefaultJWT() != currentManager() {
|
||||
t.Fatal("GetDefaultJWT should equal currentManager after init")
|
||||
}
|
||||
|
||||
// SetDefaultJWTManager 后同步
|
||||
m := NewJWTManagerWithRedis(nil)
|
||||
SetDefaultJWTManager(m)
|
||||
if GetDefaultJWT() != m {
|
||||
t.Fatal("GetDefaultJWT should sync after SetDefaultJWTManager")
|
||||
}
|
||||
if currentManager() != m {
|
||||
t.Fatal("currentManager should reflect SetDefaultJWTManager")
|
||||
}
|
||||
}
|
||||
|
||||
// errorIsBlacklistUnavailable 避免内部测试依赖 errors.Is 包装判断。
|
||||
func errorIsBlacklistUnavailable(err error) bool {
|
||||
return err != nil && err.Error() == ErrBlacklistUnavailable.Error()
|
||||
}
|
||||
+193
-24
@@ -1,11 +1,15 @@
|
||||
package jwt_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
gojwt "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func setupTestConfig() {
|
||||
@@ -108,25 +112,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 +175,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 +192,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 +221,166 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== P0 回归:算法混淆 / 空密钥 / 不支持算法 =====
|
||||
|
||||
// 回归 P0:alg=none 的 token 必须被拒(jwt.WithValidMethods 固定 HMAC 族)。
|
||||
// 这是算法混淆攻击的一种典型形态——攻击者去掉签名并将 alg 置为 none。
|
||||
func TestParseTokenRejectsAlgNone(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
claims := jwt.Claims{UserID: 1, Username: "attacker", Role: "admin", UserType: "super_admin"}
|
||||
// 构造 alg=none 的未签名 token。
|
||||
noneToken := gojwt.NewWithClaims(gojwt.SigningMethodNone, claims)
|
||||
signed, err := noneToken.SignedString(gojwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatalf("构造 none token 失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := jwt.ParseToken(signed); err == nil {
|
||||
t.Error("ParseToken 必须拒绝 alg=none 的 token(算法混淆防护)")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P0:空密钥时 GenerateToken/ParseToken 必须 fail-closed 返 ErrEmptySecret,
|
||||
// 杜绝以零长度 HMAC 密钥签发/校验导致任意 token 通过。
|
||||
func TestEmptySecretFailsClosed(t *testing.T) {
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
|
||||
t.Cleanup(setupTestConfig) // 还原基线,避免污染其它测试
|
||||
|
||||
_, err := jwt.GenerateToken(1, "u", "admin", "admin")
|
||||
if !errors.Is(err, jwt.ErrEmptySecret) {
|
||||
t.Errorf("空密钥 GenerateToken err = %v, want ErrEmptySecret", err)
|
||||
}
|
||||
|
||||
// 校验侧:任意 token 在空密钥下都不得通过。
|
||||
if _, err := jwt.ParseToken("a.b.c"); err == nil {
|
||||
t.Error("空密钥 ParseToken 不得通过任何 token")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P0:配置不支持的算法(如 RS256)时 GenerateToken 必须返 ErrUnsupportedAlgorithm,
|
||||
// 不再静默回退 HS256。
|
||||
func TestUnsupportedAlgorithmFailsClosed(t *testing.T) {
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
Algorithm: "RS256",
|
||||
Expire: time.Hour,
|
||||
}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
|
||||
_, err := jwt.GenerateToken(1, "u", "admin", "admin")
|
||||
if !errors.Is(err, jwt.ErrUnsupportedAlgorithm) {
|
||||
t.Errorf("RS256 GenerateToken err = %v, want ErrUnsupportedAlgorithm", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P0:显式支持的 HS384 仍可正常签发与校验(确认算法固定未误伤合法 HMAC 族)。
|
||||
func TestSupportedAlgorithmHS384(t *testing.T) {
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
Algorithm: "HS384",
|
||||
Expire: time.Hour,
|
||||
}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
|
||||
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("HS384 GenerateToken: %v", err)
|
||||
}
|
||||
if _, err := jwt.ParseToken(token); err != nil {
|
||||
t.Errorf("HS384 ParseToken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-15
@@ -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)
|
||||
},
|
||||
|
||||
+122
-29
@@ -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,调用安全。
|
||||
//
|
||||
// Deprecated: 此导出变量由 Init/Close 在 m.mu 下同步维护,但直接读它在 re-Init/Close
|
||||
// 期间非并发安全(L-E)。请改用包级函数 Info/Debug/Warn/Error/...(经 atomic 读取,
|
||||
// 并发安全)。保留仅为向后兼容,未来大版本可能移除。
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"level":"warn","time":"2026-07-02T23:25:17.643+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 1/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
|
||||
{"level":"warn","time":"2026-07-02T23:25:18.648+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 2/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
|
||||
{"level":"warn","time":"2026-07-02T23:25:20.651+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 3/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
|
||||
+72
-22
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-28
@@ -2,13 +2,16 @@ package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -151,18 +154,21 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
clientToken = c.PostForm(cfg.FormField)
|
||||
}
|
||||
|
||||
// 最后从 JSON body 获取
|
||||
// 最后从 JSON body 获取。
|
||||
// P1 #9:用 ShouldBindBodyWith(binding.JSON) 而非 ShouldBindJSON——后者会读干
|
||||
// c.Request.Body,导致下游 handler 再 ShouldBindJSON 拿到空 body(EOF)。前者把原始
|
||||
// body 缓存进 gin context,下游可重复读取。
|
||||
if clientToken == "" {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err == nil {
|
||||
if err := c.ShouldBindBodyWith(&body, binding.JSON); err == nil {
|
||||
if token, ok := body[cfg.FormField].(string); ok {
|
||||
clientToken = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 Token 是否匹配
|
||||
if clientToken == "" || clientToken != cookieToken {
|
||||
// 验证 Token 是否匹配(H-7: 恒定时间比较防时序侧信道)
|
||||
if len(clientToken) == 0 || subtle.ConstantTimeCompare([]byte(clientToken), []byte(cookieToken)) != 1 {
|
||||
cfg.ErrorFunc(c)
|
||||
return
|
||||
}
|
||||
@@ -187,7 +193,13 @@ func GetCSRFToken(c *gin.Context) string {
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
return token.(string)
|
||||
// H-7: comma-ok 防裸断言 panic。csrf_token 虽由本包以 string 写入,
|
||||
// 但 gin context 是共享 map,下游误写其他类型即 panic。
|
||||
s, ok := token.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CSRFToken 返回 CSRF Token 的处理器(用于 API 模式)
|
||||
@@ -223,11 +235,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 +257,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 +286,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 +294,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 {
|
||||
@@ -301,9 +331,12 @@ func CSRFWithExempt(config ...CSRFConfig) gin.HandlerFunc {
|
||||
|
||||
originalSkipFunc := cfg.SkipFunc
|
||||
cfg.SkipFunc = func(c *gin.Context) bool {
|
||||
// 检查是否标记为 exempt
|
||||
if exempt, exists := c.Get("csrf_exempt"); exists && exempt.(bool) {
|
||||
return true
|
||||
// 检查是否标记为 exempt(P1 #9:comma-ok 防裸断言 panic——gin context 为共享 map,
|
||||
// 下游若误以非 bool 写入 csrf_exempt,exempt.(bool) 会 panic)。
|
||||
if exempt, exists := c.Get("csrf_exempt"); exists {
|
||||
if b, ok := exempt.(bool); ok && b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 调用原始 SkipFunc
|
||||
if originalSkipFunc != nil {
|
||||
@@ -325,7 +358,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)
|
||||
@@ -350,8 +385,8 @@ func DoubleSubmitCookie() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 Cookie 和 Header 中的 Token 是否一致
|
||||
if cookieToken != headerToken {
|
||||
// 验证 Cookie 和 Header 中的 Token 是否一致(H-7: 恒定时间比较防时序侧信道)
|
||||
if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) != 1 {
|
||||
response.Fail(c, "CSRF Token 不匹配")
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
)
|
||||
|
||||
// 回归 C6b:TTL 过期分支。直接向包级存储注入一个“已过期”的 token,
|
||||
// 断言 CSRFForAPI 拒绝它(即使该 token 从未被消费过)。
|
||||
// 这条分支(time.Since(issuedAt) > apiTokenTTL)无法靠单次消费用例覆盖,
|
||||
// 必须单独构造过期时间戳。
|
||||
func TestCSRFForAPITTLExpiry(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRFForAPI())
|
||||
r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||||
|
||||
expiredToken := "expired-test-token"
|
||||
apiTokensMu.Lock()
|
||||
// 注入一个早于 TTL 的颁发时间,模拟 token 已过期。
|
||||
apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second))
|
||||
apiTokensMu.Unlock()
|
||||
defer func() {
|
||||
apiTokensMu.Lock()
|
||||
delete(apiTokens, expiredToken)
|
||||
apiTokensMu.Unlock()
|
||||
}()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/action", nil)
|
||||
req.Header.Set("X-CSRF-Token", expiredToken)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp response.Response
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response %q: %v", w.Body.String(), err)
|
||||
}
|
||||
if resp.Code == response.CodeSuccess {
|
||||
t.Errorf("expired token must be rejected, got code=%d (success)", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P1 #9:CSRF 从 JSON body 取 token 后,下游 handler 仍能读到完整请求体。
|
||||
// 修复前用 ShouldBindJSON 读干 body,下游再绑定拿到 EOF;改用 ShouldBindBodyWith 缓存 body。
|
||||
func TestCSRFJSONBodyPreservedForDownstream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRF())
|
||||
|
||||
var gotData string
|
||||
r.POST("/action", func(c *gin.Context) {
|
||||
// 下游再次绑定 body,应能读到完整内容(而非被 CSRF 中间件读空)。
|
||||
var payload struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := c.ShouldBindBodyWith(&payload, binding.JSON); err != nil {
|
||||
c.JSON(500, gin.H{"err": err.Error()})
|
||||
return
|
||||
}
|
||||
gotData = payload.Data
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// token 仅放在 JSON body 里(不放 header/form),触发中间件读 body 的分支。
|
||||
const tok = "csrf-token-abc-1234567890"
|
||||
body := `{"_csrf":"` + tok + `","data":"hello-downstream"}`
|
||||
req := httptest.NewRequest("POST", "/action", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// cookie 携带同一 token,使 CSRF 校验通过。
|
||||
req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: tok})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if gotData != "hello-downstream" {
|
||||
t.Errorf("downstream read data=%q, want 'hello-downstream' (P1 #9: body must survive CSRF)", gotData)
|
||||
}
|
||||
}
|
||||
+88
-34
@@ -3,6 +3,8 @@ package middleware
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -44,6 +46,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 +63,17 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
|
||||
// 记录请求体(可选)
|
||||
var requestBody []byte
|
||||
if cfg.LogRequestBody && c.Request.Body != nil {
|
||||
requestBody, _ = io.ReadAll(c.Request.Body)
|
||||
// 恢复请求体供后续处理
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||
// 限制记录长度
|
||||
if len(requestBody) > cfg.MaxBodyLength {
|
||||
requestBody = requestBody[:cfg.MaxBodyLength]
|
||||
}
|
||||
requestBody = readBodyBounded(c, cfg.MaxBodyLength)
|
||||
}
|
||||
|
||||
// 记录响应体(可选)
|
||||
var responseBody []byte
|
||||
if cfg.LogResponseBody {
|
||||
// 使用 ResponseWriter 包装器捕获响应体
|
||||
blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
|
||||
// 使用 ResponseWriter 包装器捕获响应体(缓冲区封顶,防止大响应 OOM)
|
||||
blw := &bodyLogWriter{body: bytes.NewBufferString(""), maxLen: cfg.MaxBodyLength, ResponseWriter: c.Writer}
|
||||
c.Writer = blw
|
||||
c.Next()
|
||||
responseBody = blw.body.Bytes()
|
||||
if len(responseBody) > cfg.MaxBodyLength {
|
||||
responseBody = responseBody[:cfg.MaxBodyLength]
|
||||
}
|
||||
} else {
|
||||
c.Next()
|
||||
}
|
||||
@@ -88,7 +85,7 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
zap.String("query", c.Request.URL.RawQuery),
|
||||
zap.String("query", filterSensitiveQuery(c.Request.URL.RawQuery)),
|
||||
zap.String("ip", c.ClientIP()),
|
||||
zap.Duration("latency", latency),
|
||||
zap.String("user-agent", c.Request.UserAgent()),
|
||||
@@ -135,21 +132,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)
|
||||
}
|
||||
|
||||
@@ -173,28 +207,48 @@ func shouldSkipPath(path string, cfg LoggerConfig) bool {
|
||||
}
|
||||
|
||||
// filterSensitiveFields 过滤敏感字段(密码、token等)
|
||||
// sensitiveFieldsRE M1 修复:编译期正则,匹配 "key":"value" 整体并将 value 替换为 [FILTERED]。
|
||||
// 支持 JSON 字符串中标准转义(\" \\ \/ \b \f \n \r \t \uXXXX)。
|
||||
var sensitiveFieldsRE = regexp.MustCompile(
|
||||
`"(password|passwd|pwd|token|access_token|refresh_token|secret|api_key|apikey|credit_card|card_number)"\s*:\s*"((?:[^"\\]|\\.)*)"`,
|
||||
)
|
||||
|
||||
// filterSensitiveFields M1 修复后实现:将敏感字段的值替换为 [FILTERED],完整移除原始值。
|
||||
// 对非 JSON 输入原样返回。
|
||||
func filterSensitiveFields(body []byte) string {
|
||||
bodyStr := string(body)
|
||||
return sensitiveFieldsRE.ReplaceAllString(string(body), `"$1":"[FILTERED]"`)
|
||||
}
|
||||
|
||||
// 过滤常见敏感字段(简单字符串替换)
|
||||
sensitiveFields := []string{
|
||||
"password", "passwd", "pwd",
|
||||
"token", "access_token", "refresh_token",
|
||||
"secret", "api_key", "apikey",
|
||||
"credit_card", "card_number",
|
||||
// sensitiveQueryKeys 需要在访问日志中脱敏的 query 参数名(小写匹配,P1 #14)。
|
||||
// 与 sensitiveFieldsRE 覆盖的字段保持一致。
|
||||
var sensitiveQueryKeys = map[string]struct{}{
|
||||
"password": {}, "passwd": {}, "pwd": {}, "token": {},
|
||||
"access_token": {}, "refresh_token": {}, "secret": {},
|
||||
"api_key": {}, "apikey": {}, "credit_card": {}, "card_number": {},
|
||||
}
|
||||
|
||||
// filterSensitiveQuery 脱敏 query 字符串中的敏感参数值(P1 #14):
|
||||
// 如 ?access_token=xxx 会被记为 access_token=%5BFILTERED%5D,防止令牌等随访问日志落盘。
|
||||
// 无敏感键时原样返回(不重排);解析失败时整体标记以免原文泄露。
|
||||
func filterSensitiveQuery(rawQuery string) string {
|
||||
if rawQuery == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, field := range sensitiveFields {
|
||||
// 检查是否包含敏感字段
|
||||
keyPattern := `"` + field + `":`
|
||||
if strings.Contains(bodyStr, keyPattern) {
|
||||
// 简单替换:将值替换为 [FILTERED]
|
||||
// 注意:这是一个简化的实现,复杂的 JSON 可能需要更精确的处理
|
||||
bodyStr = strings.ReplaceAll(bodyStr, keyPattern, keyPattern+"\"[FILTERED]\"")
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return "[redacted: unparsable query]"
|
||||
}
|
||||
changed := false
|
||||
for k := range values {
|
||||
if _, ok := sensitiveQueryKeys[strings.ToLower(k)]; ok {
|
||||
values[k] = []string{"[FILTERED]"}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
return bodyStr
|
||||
if !changed {
|
||||
return rawQuery
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
// LoggerForAPI API 专用日志中间件(更详细)
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newCtxWithBody 构造一个带请求体的 gin.Context,仅用于 logger 内部测试。
|
||||
func newCtxWithBody(body []byte) *gin.Context {
|
||||
req := httptest.NewRequest("POST", "/", bytes.NewReader(body))
|
||||
c := &gin.Context{}
|
||||
c.Request = req
|
||||
return c
|
||||
}
|
||||
|
||||
// TestReadBodyBounded_TruncatesLogCopy 复现 H3:日志副本必须封顶到 maxLen,
|
||||
// 而不是把整个 body 读入内存后再截断(原 io.ReadAll 无上限 → OOM)。
|
||||
func TestReadBodyBounded_TruncatesLogCopy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
bodyLen int
|
||||
maxLen int
|
||||
}{
|
||||
{"smaller_than_limit", 100, 1024},
|
||||
{"equal_to_limit", 1024, 1024},
|
||||
{"larger_than_limit", 100_000, 1024},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := bytes.Repeat([]byte("a"), tc.bodyLen)
|
||||
c := newCtxWithBody(body)
|
||||
|
||||
got := readBodyBounded(c, tc.maxLen)
|
||||
|
||||
want := tc.bodyLen
|
||||
if want > tc.maxLen {
|
||||
want = tc.maxLen
|
||||
}
|
||||
if len(got) != want {
|
||||
t.Fatalf("log copy len = %d, want %d (maxLen=%d, bodyLen=%d)", len(got), want, tc.maxLen, tc.bodyLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadBodyBounded_RestoresFullBody 是 H3 修复的闭环断言:
|
||||
// 即使日志副本被截断,下游处理器仍必须能读到完整请求体(io.MultiReader 复原)。
|
||||
func TestReadBodyBounded_RestoresFullBody(t *testing.T) {
|
||||
const maxLen = 64
|
||||
body := []byte(strings.Repeat("ABCDEFGH", 1000)) // 8000 字节,远超 maxLen
|
||||
c := newCtxWithBody(body)
|
||||
|
||||
logCopy := readBodyBounded(c, maxLen)
|
||||
|
||||
// 日志副本封顶
|
||||
if len(logCopy) != maxLen {
|
||||
t.Fatalf("log copy len = %d, want %d", len(logCopy), maxLen)
|
||||
}
|
||||
|
||||
// 下游必须拿到完整原始 body
|
||||
restored, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read restored body: %v", err)
|
||||
}
|
||||
if !bytes.Equal(restored, body) {
|
||||
t.Fatalf("downstream body corrupted: got len=%d, want len=%d", len(restored), len(body))
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadBodyBounded_PreservesSmallBodyExactly 小于上限时日志副本与原始一致。
|
||||
func TestReadBodyBounded_PreservesSmallBodyExactly(t *testing.T) {
|
||||
body := []byte(`{"user":"alice","password":"secret"}`)
|
||||
c := newCtxWithBody(body)
|
||||
|
||||
got := readBodyBounded(c, 1024)
|
||||
if !bytes.Equal(got, body) {
|
||||
t.Fatalf("log copy = %q, want %q", got, body)
|
||||
}
|
||||
|
||||
restored, _ := io.ReadAll(c.Request.Body)
|
||||
if !bytes.Equal(restored, body) {
|
||||
t.Fatalf("downstream body = %q, want %q", restored, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLogWriter_Bounded 复现 H3 响应侧:响应体捕获缓冲区必须封顶,
|
||||
// 完整响应仍写入下游 ResponseWriter。
|
||||
func TestBodyLogWriter_Bounded(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const maxLen = 32
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
w := &bodyLogWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: new(bytes.Buffer),
|
||||
maxLen: maxLen,
|
||||
}
|
||||
|
||||
large := bytes.Repeat([]byte("Z"), 10_000)
|
||||
n, err := w.Write(large)
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if n != len(large) {
|
||||
t.Fatalf("Write returned %d, want %d (下游必须收到完整响应)", n, len(large))
|
||||
}
|
||||
if w.body.Len() > maxLen {
|
||||
t.Fatalf("captured buffer len = %d, must be <= %d (OOM 防护失效)", w.body.Len(), maxLen)
|
||||
}
|
||||
if w.body.Len() != maxLen {
|
||||
t.Fatalf("captured buffer len = %d, want exactly %d", w.body.Len(), maxLen)
|
||||
}
|
||||
// 下游 ResponseWriter 收到完整内容
|
||||
if rec.Body.Len() != len(large) {
|
||||
t.Fatalf("downstream response len = %d, want %d", rec.Body.Len(), len(large))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLogWriter_NoLimit maxLen<=0 时不限制(向后兼容)。
|
||||
func TestBodyLogWriter_NoLimit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
w := &bodyLogWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: new(bytes.Buffer),
|
||||
maxLen: 0,
|
||||
}
|
||||
data := []byte("hello world")
|
||||
if _, err := w.Write(data); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if !bytes.Equal(w.body.Bytes(), data) {
|
||||
t.Fatalf("captured = %q, want %q", w.body.Bytes(), data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLogWriter_MultiWriteAccumulation 多次小写累积不超过 maxLen,
|
||||
// 下游仍收到完整拼接结果。
|
||||
func TestBodyLogWriter_MultiWriteAccumulation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const maxLen = 32
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
w := &bodyLogWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: new(bytes.Buffer),
|
||||
maxLen: maxLen,
|
||||
}
|
||||
chunks := [][]byte{[]byte("0123456789"), []byte("abcdefghij"), []byte("ABCDEFGHIJ"), []byte("zzzzzzzzzz")}
|
||||
var downstream bytes.Buffer
|
||||
for _, ch := range chunks {
|
||||
n, err := w.Write(ch)
|
||||
if err != nil || n != len(ch) {
|
||||
t.Fatalf("Write chunk %q: n=%d err=%v", ch, n, err)
|
||||
}
|
||||
downstream.Write(ch)
|
||||
}
|
||||
if w.body.Len() > maxLen {
|
||||
t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen)
|
||||
}
|
||||
if w.body.Len() != maxLen {
|
||||
t.Fatalf("captured len = %d, want exactly %d", w.body.Len(), maxLen)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), downstream.Bytes()) {
|
||||
t.Fatalf("downstream = %q, want %q", rec.Body.Bytes(), downstream.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLogWriter_WriteStringBounded WriteString 路径同样封顶。
|
||||
func TestBodyLogWriter_WriteStringBounded(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const maxLen = 16
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
w := &bodyLogWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: new(bytes.Buffer),
|
||||
maxLen: maxLen,
|
||||
}
|
||||
large := strings.Repeat("S", 500)
|
||||
n, err := w.WriteString(large)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteString: %v", err)
|
||||
}
|
||||
if n != len(large) {
|
||||
t.Fatalf("WriteString returned %d, want %d", n, len(large))
|
||||
}
|
||||
if w.body.Len() > maxLen {
|
||||
t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen)
|
||||
}
|
||||
if rec.Body.Len() != len(large) {
|
||||
t.Fatalf("downstream len = %d, want %d", rec.Body.Len(), len(large))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterSensitiveQuery P1 #14:query 中的敏感参数值必须脱敏,非敏感原样保留。
|
||||
func TestFilterSensitiveQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
contains string // 期望结果包含
|
||||
absent string // 期望结果不含(敏感原值)
|
||||
}{
|
||||
{"empty", "", "", ""},
|
||||
{"no sensitive", "page=1&size=20", "page=1", ""},
|
||||
{"access_token redacted", "access_token=supersecret123&page=1", "FILTERED", "supersecret123"},
|
||||
{"password redacted", "user=alice&password=hunter2", "FILTERED", "hunter2"},
|
||||
{"case insensitive key", "Token=abc.def.ghi", "FILTERED", "abc.def.ghi"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := filterSensitiveQuery(tc.in)
|
||||
if tc.contains != "" && !strings.Contains(got, tc.contains) {
|
||||
t.Errorf("filterSensitiveQuery(%q) = %q, want contains %q", tc.in, got, tc.contains)
|
||||
}
|
||||
if tc.absent != "" && strings.Contains(got, tc.absent) {
|
||||
t.Errorf("filterSensitiveQuery(%q) = %q, must NOT contain sensitive value %q", tc.in, got, tc.absent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerWithConfig_NormalizesMaxBodyLength MaxBodyLength<=0 时归一化为默认值,
|
||||
// 确保响应侧捕获缓冲区仍有上限(H3 复审 MEDIUM:消除请求/响应侧 maxLen<=0 不对称)。
|
||||
func TestLoggerWithConfig_NormalizesMaxBodyLength(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
cfg := LoggerConfig{
|
||||
LogRequestBody: true,
|
||||
LogResponseBody: true,
|
||||
MaxBodyLength: 0, // 手滑置 0,应被归一化为默认 1024
|
||||
SkipPaths: []string{},
|
||||
SkipPathPrefixes: []string{},
|
||||
SlowRequestThreshold: 500 * time.Millisecond,
|
||||
}
|
||||
r.Use(LoggerWithConfig(cfg))
|
||||
r.POST("/", func(c *gin.Context) {
|
||||
body, _ := io.ReadAll(c.Request.Body)
|
||||
c.Data(200, "text/plain", body)
|
||||
})
|
||||
|
||||
// 请求体 5000 字节(> 默认 1024),下游应仍得完整 body
|
||||
reqBody := bytes.Repeat([]byte("a"), 5000)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/", bytes.NewReader(reqBody))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Body.Len() != len(reqBody) {
|
||||
t.Fatalf("downstream body len = %d, want %d (请求体必须完整复原)", w.Body.Len(), len(reqBody))
|
||||
}
|
||||
if !bytes.Equal(w.Body.Bytes(), reqBody) {
|
||||
t.Fatalf("downstream body corrupted")
|
||||
}
|
||||
}
|
||||
@@ -45,11 +45,11 @@ var (
|
||||
func Metrics() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
httpRequestsInFlight.Inc()
|
||||
defer httpRequestsInFlight.Dec() // M2 修复:defer 保证 panic 时也递减,不依赖 Recover 中间件顺序
|
||||
start := time.Now()
|
||||
|
||||
c.Next()
|
||||
|
||||
httpRequestsInFlight.Dec()
|
||||
elapsed := time.Since(start).Seconds()
|
||||
status := strconv.Itoa(c.Writer.Status())
|
||||
route := c.FullPath()
|
||||
|
||||
@@ -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,12 @@ func TestRedisRateLimitMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoginRedisRateLimit(t *testing.T) {
|
||||
// H4c: LoginRedisRateLimit 改 fail-closed——无 Redis 时拒绝(503),
|
||||
// 防爆破场景下 Redis 故障不能静默放行(原 fail-open 致限流失效)。
|
||||
// 测试环境无 Redis,故预期 503。
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.LoginRedisRateLimit())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
@@ -544,8 +1201,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 +1260,214 @@ 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.SetTestRedisClient(client)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(old) })
|
||||
return mr
|
||||
}
|
||||
|
||||
// 回归 H4c-1a:无 Redis 时 fail-open 放行(兼容旧行为)。
|
||||
func TestRedisRateLimiterFailOpenNoRedis(t *testing.T) {
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute)
|
||||
allowed, err := limiter.Allow(context.Background(), "1.2.3.4")
|
||||
if err != nil {
|
||||
t.Errorf("fail-open no-redis err = %v, want nil", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("fail-open no-redis should allow (兼容旧行为)")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 H4c-1b:无 Redis 时 fail-closed 拒绝 + 返 ErrRedisRateLimiterUnavailable。
|
||||
// 修复前无此选项;fail-closed 是 H4c 新增的安全语义。
|
||||
func TestRedisRateLimiterFailClosedNoRedis(t *testing.T) {
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
limiter := middleware.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.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(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.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.RedisRateLimit("api_limit", 100))
|
||||
r.GET("/api", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/api", nil))
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("fail-open middleware (no redis) status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 H4c-2:真实 Redis 闭环——超限被拦(429),未超限放行。
|
||||
// 用 miniredis 验证滑动窗口 Lua 脚本与 comma-ok 断言路径在真实 Redis 下正常。
|
||||
func TestRedisRateLimiterRealRedisLimitCycle(t *testing.T) {
|
||||
setupMiddlewareMiniRedis(t)
|
||||
|
||||
limiter := middleware.NewRedisRateLimiter("cycle_limit", 3, time.Minute)
|
||||
ctx := context.Background()
|
||||
|
||||
// 前 3 次放行。
|
||||
for i := 0; i < 3; i++ {
|
||||
allowed, err := limiter.Allow(ctx, "1.2.3.4")
|
||||
if err != nil {
|
||||
t.Fatalf("request %d err = %v", i, err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Errorf("request %d should be allowed", i)
|
||||
}
|
||||
}
|
||||
// 第 4 次拒绝(超限)。
|
||||
allowed, err := limiter.Allow(ctx, "1.2.3.4")
|
||||
if err != nil {
|
||||
t.Fatalf("over-limit err = %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("request 4 should be denied (over limit)")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 H4c-2:comma-ok 断言防护——Redis 返回非 int64 时不 panic 而返错。
|
||||
// miniredis 的 slidingWindowLua 恒返 int64,无法经 Allow 触发断言失败路径;
|
||||
// 此处直接用返回字符串的 Lua 脚本验证 result.(int64) 的 comma-ok 行为,
|
||||
// 并对照证明裸断言会 panic(H4c-2 缺陷根因)。
|
||||
func TestRedisRateLimiterCommaOkAssertion(t *testing.T) {
|
||||
setupMiddlewareMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// miniredis 执行返回字符串的脚本——模拟 Redis 返回非 int64 结果。
|
||||
res, err := database.GetRedis().Eval(ctx, `return 'not-an-int'`, nil).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("eval err = %v", err)
|
||||
}
|
||||
|
||||
// 对照:裸断言在非 int64 时 panic。
|
||||
var barePanicked bool
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
barePanicked = true
|
||||
}
|
||||
}()
|
||||
_ = res.(int64) // 旧实现行:裸断言
|
||||
}()
|
||||
if !barePanicked {
|
||||
t.Error("bare assertion res.(int64) should panic on non-int64 (H4c-2 缺陷根因)")
|
||||
}
|
||||
|
||||
// 修复:comma-ok 不 panic,返 ok=false。
|
||||
if _, ok := res.(int64); ok {
|
||||
t.Error("comma-ok should return ok=false for non-int64 result")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 H4c-2:SetFailClosed 切换策略——fail-open 限流器切 fail-closed 后无 Redis 拒绝。
|
||||
func TestRedisRateLimiterSetFailClosed(t *testing.T) {
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute)
|
||||
// 初始 fail-open:放行。
|
||||
if allowed, _ := limiter.Allow(context.Background(), "1.2.3.4"); !allowed {
|
||||
t.Error("initial fail-open should allow")
|
||||
}
|
||||
// 切换 fail-closed:拒绝。
|
||||
limiter.SetFailClosed(true)
|
||||
if allowed, err := limiter.Allow(context.Background(), "1.2.3.4"); allowed {
|
||||
t.Error("after SetFailClosed(true) should deny")
|
||||
} else if !errors.Is(err, middleware.ErrRedisRateLimiterUnavailable) {
|
||||
t.Errorf("after SetFailClosed err = %v, want ErrRedisRateLimiterUnavailable", err)
|
||||
}
|
||||
}
|
||||
+204
-75
@@ -2,8 +2,11 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
@@ -20,11 +23,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 +43,7 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
|
||||
window: window,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
nowFunc: time.Now,
|
||||
}
|
||||
|
||||
limiter.wg.Add(1)
|
||||
@@ -44,32 +52,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 +121,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 +139,22 @@ func (rl *RateLimiter) Stop() {
|
||||
|
||||
// ===== Redis 分布式限流器 =====
|
||||
|
||||
// H4c: Redis 限流器故障相关错误。
|
||||
var (
|
||||
// ErrRedisRateLimiterUnavailable Redis 未启用(database.GetRedis() == nil)。
|
||||
// fail-closed 限流器在此情况下拒绝请求;fail-open 限流器放行。
|
||||
ErrRedisRateLimiterUnavailable = errors.New("redis rate limiter: redis client unavailable")
|
||||
// ErrRedisRateLimiterUnexpectedResult Redis 返回非预期的结果类型(非 int64)。
|
||||
// fail-closed 限流器拒绝;fail-open 限流器放行。旧实现裸断言会 panic。
|
||||
ErrRedisRateLimiterUnexpectedResult = errors.New("redis rate limiter: unexpected result type")
|
||||
)
|
||||
|
||||
// RedisRateLimiter Redis 分布式限流器
|
||||
type RedisRateLimiter struct {
|
||||
keyPrefix string // 键名前缀
|
||||
rate int // 每分钟允许的请求数
|
||||
window time.Duration // 时间窗口
|
||||
keyPrefix string // 键名前缀
|
||||
rate int // 每分钟允许的请求数
|
||||
window time.Duration // 时间窗口
|
||||
failClosed atomic.Bool // H4c/H-6: Redis 错误/断言失败时是否拒绝(true=安全型 fail-closed)。atomic 以支持运行期 SetFailClosed 并发安全切换。
|
||||
}
|
||||
|
||||
// slidingWindowLua 滑动窗口限流 Lua 脚本
|
||||
@@ -135,19 +181,53 @@ 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,
|
||||
// failClosed 零值 false(fail-open,兼容默认)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow 检查是否允许请求
|
||||
// NewRedisRateLimiterFailClosed 创建安全型 Redis 分布式限流器(fail-closed):
|
||||
// Redis 不可用/错误/返回非预期类型时拒绝请求,避免限流静默失效(防爆破场景必备)。
|
||||
func NewRedisRateLimiterFailClosed(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
|
||||
rl := &RedisRateLimiter{
|
||||
keyPrefix: keyPrefix,
|
||||
rate: rate,
|
||||
window: window,
|
||||
}
|
||||
rl.failClosed.Store(true)
|
||||
return rl
|
||||
}
|
||||
|
||||
// SetFailClosed 设置 Redis 故障时的策略:true=拒绝(安全型),false=放行(兼容默认)。
|
||||
// 供已创建的限流器切换策略。并发安全(atomic.Store),可与并发 Allow 调用共存。
|
||||
func (rl *RedisRateLimiter) SetFailClosed(failClosed bool) {
|
||||
rl.failClosed.Store(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.GetRedis() == 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 未启用,默认允许
|
||||
// M-C 修复:GetRedis() 仅取一次复用。原实现 nil 检查与 Eval 各调一次 GetRedis(),
|
||||
// 两次之间若 CloseRedis,第二次返回 nil → nil.Eval panic。
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
if rl.failClosed.Load() {
|
||||
return false, ErrRedisRateLimiterUnavailable
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -155,18 +235,30 @@ func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool,
|
||||
now := float64(time.Now().UnixMilli())
|
||||
windowMs := float64(rl.window.Milliseconds())
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
|
||||
result, err := rdb.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
|
||||
if err != nil {
|
||||
return true, err // 出错时允许请求,避免影响业务
|
||||
if rl.failClosed.Load() {
|
||||
return false, err
|
||||
}
|
||||
return true, err // 出错时允许请求,避免影响业务(兼容旧行为)
|
||||
}
|
||||
|
||||
count := result.(int64)
|
||||
// H4c: comma-ok 断言,避免 Redis 返回非 int64 时 panic。
|
||||
count, ok := result.(int64)
|
||||
if !ok {
|
||||
if rl.failClosed.Load() {
|
||||
return false, ErrRedisRateLimiterUnexpectedResult
|
||||
}
|
||||
return true, ErrRedisRateLimiterUnexpectedResult
|
||||
}
|
||||
return count == 0, nil
|
||||
}
|
||||
|
||||
// GetCount 获取当前窗口内的请求数
|
||||
func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
// M-C 修复:GetRedis() 取一次复用(原三次调用存在 nil-deref 窗口)。
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -175,32 +267,37 @@ func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (in
|
||||
windowStart := now - rl.window.Milliseconds()
|
||||
|
||||
// 移除旧记录并获取当前计数
|
||||
database.RedisClient.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
|
||||
return database.RedisClient.ZCard(ctx, key).Result()
|
||||
rdb.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
|
||||
return rdb.ZCard(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Reset 重置限流计数
|
||||
func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error {
|
||||
if database.RedisClient == nil {
|
||||
// M-C 修复:GetRedis() 取一次复用(原两次调用存在 nil-deref 窗口)。
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
key := rl.keyPrefix + ":" + identifier
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
return rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ===== 全局限速器 =====
|
||||
|
||||
var (
|
||||
loginLimiter *RateLimiter
|
||||
apiLimiter *RateLimiter
|
||||
uploadLimiter *RateLimiter
|
||||
redisLimiters map[string]*RedisRateLimiter
|
||||
limitersMu sync.Mutex
|
||||
loginLimiter *RateLimiter
|
||||
apiLimiter *RateLimiter
|
||||
uploadLimiter *RateLimiter
|
||||
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 初始化限速器
|
||||
@@ -208,7 +305,9 @@ 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 +317,9 @@ func InitRateLimiters() {
|
||||
if uploadLimiter != nil {
|
||||
uploadLimiter.Stop()
|
||||
}
|
||||
for _, l := range drainCustomLimiters() {
|
||||
l.Stop()
|
||||
}
|
||||
|
||||
// 内存限流器(单实例)
|
||||
loginLimiter = NewRateLimiter(10, time.Minute)
|
||||
@@ -242,6 +344,9 @@ func StopRateLimiters() {
|
||||
uploadLimiter.Stop()
|
||||
uploadLimiter = nil
|
||||
}
|
||||
for _, l := range drainCustomLimiters() {
|
||||
l.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimit 通用速率限制中间件(内存版)
|
||||
@@ -294,15 +399,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 +454,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 +482,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)
|
||||
}
|
||||
}
|
||||
+31
-6
@@ -28,9 +28,26 @@ func Recover() gin.HandlerFunc {
|
||||
zap.String("stack", string(debug.Stack())),
|
||||
)
|
||||
|
||||
// 返回错误响应
|
||||
response.FailWithCode(c, response.CodeServerError, "服务器内部错误")
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
// 返回错误响应。
|
||||
// 必须用 response.Custom 显式写 HTTP 500:FailWithCode 经 writeResp→httpStatusFor,
|
||||
// 在默认 ModeBusiness 下对 CodeServerError 返回 200,c.JSON(200,...) 会先 flush
|
||||
// 锁定状态,随后 AbortWithStatus(500) 因 w.Written()==true 沦为 no-op,
|
||||
// 导致客户端收到 HTTP 200 + body code:500,网关/APM 按 status 看不到 panic。
|
||||
// Custom 不受 Mode 影响,直接写 500 且保留 RequestID。
|
||||
//
|
||||
// M-B 修复:若 panic 发生在响应已开始写出之后(流式/部分写),c.Writer.Written()==true,
|
||||
// gin 拒绝再次写响应,response.Custom 沦为 no-op,客户端会收到截断响应而非 500。
|
||||
// 此时不再尝试写 500(无效),仅 Abort + 记录,避免无意义的二次写入。
|
||||
if !c.Writer.Written() {
|
||||
response.Custom(c, http.StatusInternalServerError, response.CodeServerError, "服务器内部错误", nil)
|
||||
} else {
|
||||
logger.Warn("panic 发生在响应已开始写入后,无法写入 500,客户端将收到截断响应",
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.Int("bytes_written", c.Writer.Size()),
|
||||
)
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
@@ -53,9 +70,17 @@ 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 注释)。
|
||||
// M-B 修复:响应已写出时不重复写 500(同 Recover)。
|
||||
if !c.Writer.Written() {
|
||||
response.Custom(c, http.StatusInternalServerError, response.CodeServerError, fmt.Sprintf("服务器内部错误: %v", err), nil)
|
||||
} else {
|
||||
logger.Warn("panic 发生在响应已开始写入后,无法写入 500,客户端将收到截断响应",
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
)
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
|
||||
+27
-2
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
+7
-2
@@ -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"`
|
||||
|
||||
+223
-124
@@ -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,47 @@ func NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T] {
|
||||
return &BaseRepo[T]{db: db}
|
||||
}
|
||||
|
||||
// readConn 返回读连接:外层 ctx 事务 > 本 repo 事务 > 读写分离路由 > r.db 回退。
|
||||
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息。
|
||||
func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB {
|
||||
if r.db == nil {
|
||||
panic("repository.BaseRepo: db is nil. Use NewBaseRepo with a valid *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 回退。
|
||||
// 写操作始终走主库,不路由到只读从库。
|
||||
// RP1 修复:r.db 为 nil 时 panic 并给出明确消息。
|
||||
func (r *BaseRepo[T]) writeConn(ctx context.Context) *gorm.DB {
|
||||
if r.db == nil {
|
||||
panic("repository.BaseRepo: db is nil. Use NewBaseRepo with a valid *gorm.DB")
|
||||
}
|
||||
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 +92,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 +176,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 +186,25 @@ 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) {
|
||||
// FindWhereOrdered 条件查询并排序。
|
||||
//
|
||||
// H-15 修复(breaking):签名由 (ctx, query, args []any, order) 改为
|
||||
// (ctx, order, query string, args ...any),与 FindWhere 等同类方法统一为变长 args。
|
||||
// Go 语法要求变长参数必须为最后一个,故 order 前置于 query。
|
||||
func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, order, query string, args ...any) ([]T, error) {
|
||||
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 +215,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 +229,113 @@ 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 条件分页查询并排序
|
||||
func (r *BaseRepo[T]) FindPageWhereOrdered(ctx context.Context, page, pageSize int, query string, args []any, order string) (*PageResult[T], error) {
|
||||
// FindPageWhereOrdered 条件分页查询并排序(count+list 单事务,H6d)。
|
||||
//
|
||||
// H-15 修复(breaking):签名由 (ctx, page, pageSize, query, args []any, order) 改为
|
||||
// (ctx, page, pageSize, order, query string, args ...any),与 FindPageWhere 统一为变长 args。
|
||||
// Go 语法要求变长参数必须为最后一个,故 order 前置于 query。
|
||||
func (r *BaseRepo[T]) FindPageWhereOrdered(ctx context.Context, page, pageSize int, order, query string, args ...any) (*PageResult[T], error) {
|
||||
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,54 +343,83 @@ 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
|
||||
}
|
||||
|
||||
// ===== 软删除操作 =====
|
||||
|
||||
// SoftDeleteColumn 返回软删除列名(RP2 修复:可被子类覆盖以适配自定义列名)。
|
||||
// 默认 "deleted_at",与 GORM gorm.DeletedAt 的默认列名一致。
|
||||
func (r *BaseRepo[T]) SoftDeleteColumn() string {
|
||||
return "deleted_at"
|
||||
}
|
||||
|
||||
// Restore 恢复软删除记录
|
||||
func (r *BaseRepo[T]) Restore(ctx context.Context, id uint) error {
|
||||
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(r.SoftDeleteColumn(), 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(r.SoftDeleteColumn(), 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(r.SoftDeleteColumn() + " IS NOT NULL").Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// FindAllWithDeleted 查询所有记录(包括软删除)
|
||||
func (r *BaseRepo[T]) FindAllWithDeleted(ctx context.Context) ([]T, error) {
|
||||
var models []T
|
||||
err := r.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 +463,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 +518,4 @@ func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageR
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// h6User 测试模型,内嵌 gorm.Model 支持软删除。
|
||||
type h6User struct {
|
||||
gorm.Model
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 注册 SQLite 方言,供路由测试通过 database 包级 facade(InitDB 等,代理到 DefaultManager)初始化主从库使用。
|
||||
database.RegisterDialect(database.DialectSpec{
|
||||
Name: "sqlite",
|
||||
Aliases: []string{"sqlite3"},
|
||||
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
|
||||
})
|
||||
}
|
||||
|
||||
// newH6SqliteDB 构造一个独立 sqlite 文件的 GORM 实例(不走 DefaultManager),
|
||||
// 用于 fallback 路径(DefaultManager 未初始化)的行为闭环测试。
|
||||
func newH6SqliteDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(dir, "test.db")), &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&h6User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
// namesFrom 提取 h6User 切片的 Name 集合,便于路由断言。
|
||||
func namesFrom(users []h6User) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(users))
|
||||
for i := range users {
|
||||
out[users[i].Name] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ===== H6c fallback 路径(DefaultManager 未初始化,readConn/writeConn 回退 r.db)=====
|
||||
|
||||
// TestH6CrudRoundTrip 验证 CRUD 闭环经 readConn/writeConn 回退 r.db 正常工作。
|
||||
func TestH6CrudRoundTrip(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
u := &h6User{Name: "alice", Age: 30}
|
||||
if err := repo.Create(ctx, u); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if u.ID == 0 {
|
||||
t.Fatal("ID not populated after Create")
|
||||
}
|
||||
|
||||
got, err := repo.FindByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID: %v", err)
|
||||
}
|
||||
if got.Name != "alice" || got.Age != 30 {
|
||||
t.Fatalf("unexpected user: %+v", got)
|
||||
}
|
||||
|
||||
got.Age = 31
|
||||
if err := repo.Update(ctx, got); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
got2, _ := repo.FindByID(ctx, u.ID)
|
||||
if got2.Age != 31 {
|
||||
t.Fatalf("Update not persisted, age=%d", got2.Age)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, u.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, u.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("after Delete want ErrRecordNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== H6a UpdateFields 局部更新 =====
|
||||
|
||||
// TestH6UpdateFieldsStructNonZeroOnly 传 struct 仅更新非零字段(零值 Age=0 不覆盖)。
|
||||
func TestH6UpdateFieldsStructNonZeroOnly(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
u := &h6User{Name: "bob", Age: 30}
|
||||
_ = repo.Create(ctx, u)
|
||||
|
||||
// Name="carol"(非零),Age=0(零值,不应写入)
|
||||
if err := repo.UpdateFields(ctx, &h6User{Name: "carol"}, "id = ?", u.ID); err != nil {
|
||||
t.Fatalf("UpdateFields: %v", err)
|
||||
}
|
||||
got, _ := repo.FindByID(ctx, u.ID)
|
||||
if got.Name != "carol" {
|
||||
t.Fatalf("Name should be carol, got %q", got.Name)
|
||||
}
|
||||
if got.Age != 30 {
|
||||
t.Fatalf("Age should remain 30 (struct zero-value not written), got %d", got.Age)
|
||||
}
|
||||
}
|
||||
|
||||
// TestH6UpdateFieldsMapExplicitZero 传 map 可显式置零。
|
||||
func TestH6UpdateFieldsMapExplicitZero(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
u := &h6User{Name: "dave", Age: 30}
|
||||
_ = repo.Create(ctx, u)
|
||||
|
||||
if err := repo.UpdateFields(ctx, map[string]any{"age": 0}, "id = ?", u.ID); err != nil {
|
||||
t.Fatalf("UpdateFields map: %v", err)
|
||||
}
|
||||
got, _ := repo.FindByID(ctx, u.ID)
|
||||
if got.Age != 0 {
|
||||
t.Fatalf("Age should be 0 (map explicit zero), got %d", got.Age)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== H6c 事务 join:r.tx 字段(WithTransaction)=====
|
||||
|
||||
// TestH6WithTransactionRollback fn 返回错误 → 回滚,已写记录不持久化。
|
||||
func TestH6WithTransactionRollback(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
err := repo.WithTransaction(ctx, func(txRepo *BaseRepo[h6User]) error {
|
||||
if e := txRepo.Create(ctx, &h6User{Name: "rollback"}); e != nil {
|
||||
return e
|
||||
}
|
||||
return errors.New("boom")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("want error from WithTransaction")
|
||||
}
|
||||
|
||||
all, _ := repo.FindAll(ctx)
|
||||
if _, ok := namesFrom(all)["rollback"]; ok {
|
||||
t.Fatal("rollback row should not persist after failed tx")
|
||||
}
|
||||
}
|
||||
|
||||
// TestH6WithTransactionCommit fn 成功 → 提交,记录持久化。
|
||||
func TestH6WithTransactionCommit(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
err := repo.WithTransaction(ctx, func(txRepo *BaseRepo[h6User]) error {
|
||||
return txRepo.Create(ctx, &h6User{Name: "commit"})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithTransaction: %v", err)
|
||||
}
|
||||
|
||||
all, _ := repo.FindAll(ctx)
|
||||
if _, ok := namesFrom(all)["commit"]; !ok {
|
||||
t.Fatal("commit row should persist after successful tx")
|
||||
}
|
||||
}
|
||||
|
||||
// TestH6WithTransactionTxRepoJoinsTx fn 内 txRepo 的写与读都参与同一事务。
|
||||
// 关键:txRepo.Create 写入后,txRepo.FindOne 在事务内可见;若 txRepo 不 join tx
|
||||
// (走 r.db 回退),FindOne 仍可见但写已 autocommit,回滚测试(上一用例)会失败。
|
||||
func TestH6WithTransactionTxRepoJoinsTx(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
var seenInTx bool
|
||||
err := repo.WithTransaction(ctx, func(txRepo *BaseRepo[h6User]) error {
|
||||
if e := txRepo.Create(ctx, &h6User{Name: "in-tx"}); e != nil {
|
||||
return e
|
||||
}
|
||||
got, e := txRepo.FindOne(ctx, "name = ?", "in-tx")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
seenInTx = got != nil && got.Name == "in-tx"
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithTransaction: %v", err)
|
||||
}
|
||||
if !seenInTx {
|
||||
t.Fatal("txRepo.FindOne should see row created within the same tx")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== H6c 事务 join:ctx 携带 tx(database.WithTx 跨层 join)=====
|
||||
|
||||
// TestH6WithTxCtxJoinsOuterTx 外层 gorm 事务通过 database.WithTx 注入 ctx,
|
||||
// repo 方法必须 join 该事务;外层回滚则 repo 写入随之回滚。
|
||||
// 红绿:修复前 repo 走 r.db(fallback)autocommit,回滚后行仍存在(红);
|
||||
// 修复后 repo 取 ctx 的 tx,随外层回滚(绿)。
|
||||
func TestH6WithTxCtxJoinsOuterTx(t *testing.T) {
|
||||
db := newH6SqliteDB(t)
|
||||
repo := NewBaseRepo[h6User](db)
|
||||
ctx := context.Background()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
ctx2 := database.WithTx(ctx, tx)
|
||||
if e := repo.Create(ctx2, &h6User{Name: "outer-tx"}); e != nil {
|
||||
return e
|
||||
}
|
||||
return errors.New("force rollback")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("want error from outer tx")
|
||||
}
|
||||
|
||||
all, _ := repo.FindAll(ctx)
|
||||
if _, ok := namesFrom(all)["outer-tx"]; ok {
|
||||
t.Fatal("outer-tx row should be rolled back when repo joins outer tx")
|
||||
}
|
||||
}
|
||||
|
||||
// TestH6WithTxCtxCommitsWhenOuterCommits 外层提交则 repo 写入持久化(正向闭环)。
|
||||
func TestH6WithTxCtxCommitsWhenOuterCommits(t *testing.T) {
|
||||
db := newH6SqliteDB(t)
|
||||
repo := NewBaseRepo[h6User](db)
|
||||
ctx := context.Background()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
ctx2 := database.WithTx(ctx, tx)
|
||||
return repo.Create(ctx2, &h6User{Name: "outer-commit"})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("outer tx: %v", err)
|
||||
}
|
||||
|
||||
all, _ := repo.FindAll(ctx)
|
||||
if _, ok := namesFrom(all)["outer-commit"]; !ok {
|
||||
t.Fatal("outer-commit row should persist when outer tx commits")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== H6d 分页 count+list 单事务 + 基本正确性 =====
|
||||
|
||||
// TestH6FindPage 验证分页 total 与 items 正确。
|
||||
func TestH6FindPage(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = repo.Create(ctx, &h6User{Name: "u"})
|
||||
}
|
||||
|
||||
p1, err := repo.FindPage(ctx, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPage: %v", err)
|
||||
}
|
||||
if p1.Total != 5 || len(p1.Items) != 2 {
|
||||
t.Fatalf("page1: total=%d items=%d, want 5/2", p1.Total, len(p1.Items))
|
||||
}
|
||||
|
||||
p3, _ := repo.FindPage(ctx, 3, 2)
|
||||
if len(p3.Items) != 1 {
|
||||
t.Fatalf("page3 items=%d, want 1", len(p3.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// TestH6FindPageWhere 验证条件分页。
|
||||
func TestH6FindPageWhere(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 3; i++ {
|
||||
_ = repo.Create(ctx, &h6User{Name: "match"})
|
||||
}
|
||||
_ = repo.Create(ctx, &h6User{Name: "other"})
|
||||
|
||||
p, err := repo.FindPageWhere(ctx, 1, 10, "name = ?", "match")
|
||||
if err != nil {
|
||||
t.Fatalf("FindPageWhere: %v", err)
|
||||
}
|
||||
if p.Total != 3 || len(p.Items) != 3 {
|
||||
t.Fatalf("want total=3 items=3, got %d/%d", p.Total, len(p.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== H6b 软删除契约 =====
|
||||
|
||||
// TestH6DeleteSoftDeletesWithGormModel T 内嵌 gorm.Model 时 Delete 为软删除:
|
||||
// FindByID 不可见、FindDeleted 可见、Restore 恢复。
|
||||
func TestH6DeleteSoftDeletesWithGormModel(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
u := &h6User{Name: "soft"}
|
||||
_ = repo.Create(ctx, u)
|
||||
|
||||
if err := repo.Delete(ctx, u.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, u.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("soft-deleted should be invisible to FindByID, got %v", err)
|
||||
}
|
||||
deleted, _ := repo.FindDeleted(ctx)
|
||||
if _, ok := namesFrom(deleted)["soft"]; !ok {
|
||||
t.Fatal("FindDeleted should see soft-deleted row")
|
||||
}
|
||||
if err := repo.Restore(ctx, u.ID); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, u.ID); err != nil {
|
||||
t.Fatalf("after Restore FindByID should succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== H6e QueryBuilder 终结方法克隆 + Count 剥离 Limit/Offset =====
|
||||
|
||||
// TestH6QueryBuilderCountStripsLimit Count 不受残留 Limit/Offset 截断。
|
||||
// 红绿:修复前 Count 沿用 qb.db(带 Limit(2)),返回 2(红);修复后剥离返回 5(绿)。
|
||||
func TestH6QueryBuilderCountStripsLimit(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = repo.Create(ctx, &h6User{Name: "q"})
|
||||
}
|
||||
|
||||
count, err := repo.NewQueryBuilder().Limit(2).Offset(1).Count(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Count: %v", err)
|
||||
}
|
||||
if count != 5 {
|
||||
t.Fatalf("Count should be 5 (stripped Limit/Offset), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestH6QueryBuilderFindKeepsLimit Find 仍受 Limit 约束(回归保护)。
|
||||
func TestH6QueryBuilderFindKeepsLimit(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = repo.Create(ctx, &h6User{Name: "q"})
|
||||
}
|
||||
|
||||
got, err := repo.NewQueryBuilder().Limit(2).Find(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Find: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("Find with Limit(2) should return 2, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestH6QueryBuilderPageNoAccumulation 连续 Page 调用不因克隆失效而累积污染。
|
||||
func TestH6QueryBuilderPageNoAccumulation(t *testing.T) {
|
||||
repo := NewBaseRepo[h6User](newH6SqliteDB(t))
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 4; i++ {
|
||||
_ = repo.Create(ctx, &h6User{Name: "q"})
|
||||
}
|
||||
|
||||
qb := repo.NewQueryBuilder().Where("name = ?", "q")
|
||||
p1, err := qb.Page(ctx, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Page1: %v", err)
|
||||
}
|
||||
p2, err := qb.Page(ctx, 2, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Page2: %v", err)
|
||||
}
|
||||
if p1.Total != 4 || len(p1.Items) != 2 {
|
||||
t.Fatalf("page1: total=%d items=%d, want 4/2", p1.Total, len(p1.Items))
|
||||
}
|
||||
if p2.Total != 4 || len(p2.Items) != 2 {
|
||||
t.Fatalf("page2: total=%d items=%d, want 4/2", p2.Total, len(p2.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== H6c 路由:DefaultManager 主从读写分离(headline)=====
|
||||
|
||||
// setupH6Manager 初始化 DefaultManager 为 sqlite 主+从(独立文件),
|
||||
// 并在两侧建表。返回 cleanup 还原全局状态(CloseAll + 置 nil-master)。
|
||||
func setupH6Manager(t *testing.T) (masterFile, replicaFile string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
masterFile = filepath.Join(dir, "master.db")
|
||||
replicaFile = filepath.Join(dir, "replica.db")
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Driver = "sqlite"
|
||||
cfg.Database.CustomDSN = masterFile
|
||||
|
||||
if err := database.InitDBWithReplicas(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))
|
||||
}
|
||||
}
|
||||
@@ -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 { ... }
|
||||
}
|
||||
+33
-8
@@ -87,14 +87,33 @@ func (e *Error) Error() string {
|
||||
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// WithDetail 添加详细信息
|
||||
// WithDetail 添加详细信息。
|
||||
//
|
||||
// H-10 修复:返回新 *Error 拷贝,不 mutate 接收者。预定义 Err*(如 ErrNotFound)是
|
||||
// 包级共享指针,原实现 e.Detail = detail 会污染全局且并发调用存在数据竞争。
|
||||
func (e *Error) WithDetail(detail string) *Error {
|
||||
e.Detail = detail
|
||||
return e
|
||||
if e == nil {
|
||||
return &Error{Detail: detail}
|
||||
}
|
||||
return &Error{
|
||||
Code: e.Code,
|
||||
Message: e.Message,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// ToResponse 转换为响应结构
|
||||
// ToResponse 转换为响应结构。
|
||||
// 把 Detail 放入 data.detail(若非空),保留链路细节(M7:原实现丢 Detail)。
|
||||
// P1 #15:仅当 detailExposed()(默认 true;App 生产环境置 false)时才向客户端输出 Detail,
|
||||
// 避免内部错误细节在生产泄露。
|
||||
func (e *Error) ToResponse() Response {
|
||||
if e.Detail != "" && detailExposed() {
|
||||
return Response{
|
||||
Code: e.Code,
|
||||
Msg: e.Message,
|
||||
Data: gin.H{"detail": e.Detail},
|
||||
}
|
||||
}
|
||||
return Response{
|
||||
Code: e.Code,
|
||||
Msg: e.Message,
|
||||
@@ -183,12 +202,18 @@ var _ = map[int]string{
|
||||
CodeBusinessRuleError: "CodeBusinessRuleError",
|
||||
}
|
||||
|
||||
// FailWithError 使用预定义错误响应
|
||||
// FailWithError 使用预定义错误响应(M7 修复:通过 ToResponse 保留 Detail)。
|
||||
func FailWithError(c *gin.Context, err *Error) {
|
||||
writeResp(c, err.Code, err.Message, nil)
|
||||
resp := err.ToResponse()
|
||||
writeResp(c, resp.Code, resp.Msg, resp.Data)
|
||||
}
|
||||
|
||||
// FailWithDetail 使用预定义错误并添加详细信息
|
||||
// FailWithDetail 使用预定义错误并添加详细信息。
|
||||
// P1 #15:detail 仅在 detailExposed()(默认 true;App 生产环境置 false)时输出给客户端。
|
||||
func FailWithDetail(c *gin.Context, err *Error, detail string) {
|
||||
writeResp(c, err.Code, err.Message, gin.H{"detail": detail})
|
||||
if detailExposed() {
|
||||
writeResp(c, err.Code, err.Message, gin.H{"detail": detail})
|
||||
return
|
||||
}
|
||||
writeResp(c, err.Code, err.Message, nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package response_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
)
|
||||
|
||||
// TestWithDetailDoesNotMutateOriginal H-10 回归:WithDetail 必须返回新拷贝,
|
||||
// 不得 mutate 共享的预定义 Err*。修复前 WithDetail 会写 e.Detail 污染全局。
|
||||
func TestWithDetailDoesNotMutateOriginal(t *testing.T) {
|
||||
if response.ErrNotFound.Detail != "" {
|
||||
t.Fatalf("预置条件:ErrNotFound.Detail 应为空,实际 %q", response.ErrNotFound.Detail)
|
||||
}
|
||||
|
||||
detailed := response.ErrNotFound.WithDetail("用户 123 不存在")
|
||||
|
||||
// 原共享对象不得被污染
|
||||
if response.ErrNotFound.Detail != "" {
|
||||
t.Fatalf("WithDetail 污染了共享 ErrNotFound,Detail=%q", response.ErrNotFound.Detail)
|
||||
}
|
||||
// 新拷贝携带 detail
|
||||
if detailed.Detail != "用户 123 不存在" {
|
||||
t.Fatalf("新 Error 应携带 detail,实际 %q", detailed.Detail)
|
||||
}
|
||||
if detailed.Code != response.ErrNotFound.Code || detailed.Message != response.ErrNotFound.Message {
|
||||
t.Fatalf("新 Error 应继承 Code/Message,实际 Code=%d Message=%q", detailed.Code, detailed.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithDetailConcurrentOnSharedError H-10 回归:并发在共享 Err* 上调 WithDetail
|
||||
// 不应触发数据竞争(修复前 mutate 共享对象,-race 必采)。
|
||||
func TestWithDetailConcurrentOnSharedError(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
e := response.ErrNotFound.WithDetail("concurrent")
|
||||
if e.Detail != "concurrent" {
|
||||
t.Errorf("Detail 应为 concurrent,实际 %q", e.Detail)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestExposeDetailGating P1 #15:SetExposeDetail(false) 时 ToResponse 不得输出 Detail;
|
||||
// true 时输出。默认(未设置)为 true,保持存量行为。
|
||||
func TestExposeDetailGating(t *testing.T) {
|
||||
t.Cleanup(func() { response.SetExposeDetail(true) }) // 还原默认
|
||||
|
||||
err := response.NewErrorWithDetail(response.CodeServerError, "服务器错误", "pq: relation \"users\" does not exist")
|
||||
|
||||
// 暴露开启(默认/开发):Detail 出现在 Data 中
|
||||
response.SetExposeDetail(true)
|
||||
if resp := err.ToResponse(); resp.Data == nil {
|
||||
t.Error("SetExposeDetail(true): ToResponse 应包含 detail")
|
||||
}
|
||||
|
||||
// 暴露关闭(生产):Detail 不得出现,防内部错误泄露
|
||||
response.SetExposeDetail(false)
|
||||
if resp := err.ToResponse(); resp.Data != nil {
|
||||
t.Errorf("SetExposeDetail(false): ToResponse 不得输出 detail,实际 Data=%v", resp.Data)
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -28,6 +28,30 @@ func SetMode(m Mode) { currentMode.Store(int32(m)) }
|
||||
// GetMode 返回当前响应模式。
|
||||
func GetMode() Mode { return Mode(currentMode.Load()) }
|
||||
|
||||
// exposeDetail 控制是否把 Error.Detail 写入发给客户端的响应体(P1 #15)。
|
||||
// 默认 true(保持存量行为:直接使用 response 包的项目不受影响)。
|
||||
// App 在 Init 时按环境设置:生产环境置为 false,避免调用方误把内部错误(SQL 报错、
|
||||
// 堆栈上下文等)塞进 Detail 而泄露给客户端;开发环境保持 true 便于排查。
|
||||
// 1 表示暴露,0 表示隐藏。
|
||||
var exposeDetail atomic.Int32
|
||||
|
||||
func init() {
|
||||
exposeDetail.Store(1) // 默认暴露,保持存量行为
|
||||
}
|
||||
|
||||
// SetExposeDetail 设置是否向客户端暴露 Error.Detail(P1 #15)。
|
||||
// 生产环境建议置为 false,防止内部错误细节泄露。并发安全。
|
||||
func SetExposeDetail(expose bool) {
|
||||
if expose {
|
||||
exposeDetail.Store(1)
|
||||
} else {
|
||||
exposeDetail.Store(0)
|
||||
}
|
||||
}
|
||||
|
||||
// detailExposed 返回当前是否暴露 Detail。
|
||||
func detailExposed() bool { return exposeDetail.Load() == 1 }
|
||||
|
||||
// statusForCode 按业务码推断 HTTP status(用于 ModeREST)。
|
||||
// 已知框架错误码显式映射;用户/文件/数据模块的业务错误码(1xxxx~3xxxx)
|
||||
// 属业务语义而非 HTTP 错误,保持 200;操作失败类(4xxxx)映射 400。
|
||||
@@ -41,7 +65,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+41
-4
@@ -2,17 +2,23 @@ package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response 统一响应结构
|
||||
//
|
||||
// M-38/M-39 修复(breaking):Data/RequestID 去掉 omitempty,让 data 与 request_id
|
||||
// 字段在所有响应中恒存在(失败时 data=null、未装 RequestID 中间件时 request_id="")。
|
||||
// 原实现用 omitempty 导致失败响应无 data 字段、空切片被 omit、未装中间件时无 request_id,
|
||||
// 破坏"统一响应结构"契约,下游严格按 schema 解析会出错。
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"` // 请求追踪ID
|
||||
Data any `json:"data"`
|
||||
RequestID string `json:"request_id"` // 请求追踪ID
|
||||
}
|
||||
|
||||
// PageData 分页数据结构
|
||||
@@ -88,10 +94,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 +136,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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+7
-8
@@ -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()))
|
||||
}
|
||||
|
||||
+148
-50
@@ -2,7 +2,12 @@ package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
@@ -16,6 +21,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 +85,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 +119,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 +143,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 +193,17 @@ 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
|
||||
|
||||
// applyOnce 保证 Apply 仅执行一次(H8b + P1 #13)。
|
||||
// 原实现用裸 bool 无同步,并发 Apply 会竞态并可能重复 engine.Use/重复注册致 gin panic;
|
||||
// 改用 sync.Once,二次/并发 Apply 均安全幂等。
|
||||
applyOnce sync.Once
|
||||
}
|
||||
|
||||
// NewRegistry 创建路由注册中心
|
||||
@@ -206,66 +262,104 @@ 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() {
|
||||
// 应用全局中间件
|
||||
r.engine.Use(r.globalMiddlewares...)
|
||||
|
||||
// 注册无版本模块
|
||||
for _, module := range r.modules {
|
||||
module.Register(r.engine.Group(""))
|
||||
}
|
||||
|
||||
// 注册版本化 API
|
||||
for _, v := range r.versions {
|
||||
group := r.engine.Group(v.BasePath)
|
||||
if len(v.Middlewares) > 0 {
|
||||
group.Use(v.Middlewares...)
|
||||
r.applyOnce.Do(func() {
|
||||
// 指标采集中间件首个装入,统计所有经注册中心注册的业务路由
|
||||
if r.metricsMiddleware != nil {
|
||||
r.engine.Use(r.metricsMiddleware)
|
||||
}
|
||||
for _, module := range v.Modules {
|
||||
module.Register(group)
|
||||
|
||||
// 应用全局中间件
|
||||
r.engine.Use(r.globalMiddlewares...)
|
||||
|
||||
// 注册无版本模块
|
||||
for _, module := range r.modules {
|
||||
module.Register(r.engine.Group(""))
|
||||
}
|
||||
}
|
||||
|
||||
// 注册版本化 API。P1 #13:按 version 键排序遍历,使跨版本注册顺序确定,
|
||||
// 避免 map 随机序导致重叠路径"谁先胜出"(配合 registerGETOnce)每次运行不一致。
|
||||
versionKeys := make([]string, 0, len(r.versions))
|
||||
for k := range r.versions {
|
||||
versionKeys = append(versionKeys, k)
|
||||
}
|
||||
sort.Strings(versionKeys)
|
||||
for _, k := range versionKeys {
|
||||
v := r.versions[k]
|
||||
group := r.engine.Group(v.BasePath)
|
||||
if len(v.Middlewares) > 0 {
|
||||
group.Use(v.Middlewares...)
|
||||
}
|
||||
for _, module := range v.Modules {
|
||||
module.Register(group)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 全局注册中心 =====
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// ===== 快捷构建函数 =====
|
||||
@@ -306,9 +400,13 @@ func Group(engine *gin.Engine, path string, middlewares ...gin.HandlerFunc) *gin
|
||||
return engine.Group(path, middlewares...)
|
||||
}
|
||||
|
||||
// GroupWithMiddlewareGroup 使用中间件分组创建路由组
|
||||
// GroupWithMiddlewareGroup 使用中间件分组创建路由组。
|
||||
//
|
||||
// H-B 修复:改走 ensureRegistry()(与 Use/RegisterModule/Apply 等所有全局 helper 一致),
|
||||
// 把"未初始化"从 nil 解引用 panic 转成可定位的明确 panic(H8a 目标)。原实现用 GetRegistry()
|
||||
// 可返回 nil,nil.GetMiddlewareGroup 即 panic,错误信息晦涩。
|
||||
func GroupWithMiddlewareGroup(engine *gin.Engine, path string, groupName string) *gin.RouterGroup {
|
||||
middlewares := GetRegistry().GetMiddlewareGroup(groupName)
|
||||
middlewares := ensureRegistry().GetMiddlewareGroup(groupName)
|
||||
return engine.Group(path, middlewares...)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestApplyIdempotent_H8b 复现 H8b:修复前二次 Apply 会重复 engine.Use 并触发
|
||||
// Gin 重复路由 panic;修复后二次 Apply 直接返回,无 panic、无重复中间件。
|
||||
func TestApplyIdempotent_H8b(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
r := NewRegistry(engine)
|
||||
|
||||
runs := 0
|
||||
r.Use(func(c *gin.Context) { runs++; c.Next() })
|
||||
r.RegisterModuleFunc("test", func(g *gin.RouterGroup) {
|
||||
g.GET("/h8b", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
})
|
||||
|
||||
r.Apply()
|
||||
r.Apply() // 二次 Apply 必须无 panic
|
||||
r.Apply() // 三次同样
|
||||
|
||||
// 幂等性由下方 runs==1 断言验证(P1 #13:applied bool 已改为 sync.Once,
|
||||
// 无导出标志可查;若 Apply 非幂等则中间件会被重复装入致 runs>1)。
|
||||
|
||||
// 中间件只应被装入一次:请求一次,runs 应为 1(若重复装入则 >1)。
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8b", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if runs != 1 {
|
||||
t.Fatalf("global middleware ran %d times, want 1 (Apply not idempotent)", runs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyConcurrent_P1_13 验证并发 Apply 无 data race 且仅生效一次(sync.Once)。
|
||||
// 修复前 applied 为裸 bool,多 goroutine 并发 Apply 竞态可重复 engine.Use/重复注册致 panic。
|
||||
// 须配合 -race 运行。
|
||||
func TestApplyConcurrent_P1_13(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
r := NewRegistry(engine)
|
||||
|
||||
runs := 0
|
||||
r.Use(func(c *gin.Context) { runs++; c.Next() })
|
||||
r.RegisterModuleFunc("p113", func(g *gin.RouterGroup) {
|
||||
g.GET("/p113", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); r.Apply() }()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/p113", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if runs != 1 {
|
||||
t.Fatalf("global middleware ran %d times, want 1 (concurrent Apply not once)", runs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsMiddlewareFirstInApply_H8c 验证 metrics 中间件经 SetMetricsMiddleware
|
||||
// 在 Apply 内装入,覆盖所有经注册中心注册的路由,且不依赖 RegisterMetricsRoute
|
||||
// 的调用顺序。修复前 RegisterMetricsRoute 用 r.Use,先注册的路由不被采集。
|
||||
func TestMetricsMiddlewareFirstInApply_H8c(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
r := NewRegistry(engine)
|
||||
|
||||
// 用一个计数中间件模拟 metrics 采集。
|
||||
hits := 0
|
||||
r.SetMetricsMiddleware(func(c *gin.Context) {
|
||||
hits++
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.RegisterModuleFunc("biz", func(g *gin.RouterGroup) {
|
||||
g.GET("/biz", func(c *gin.Context) { c.JSON(200, gin.H{}) })
|
||||
})
|
||||
// 注意:不调用 RegisterMetricsRoute,仅靠 SetMetricsMiddleware + Apply 装入。
|
||||
r.Apply()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/biz", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("biz status = %d, want 200", w.Code)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("metrics middleware hits = %d, want 1 (route not instrumented)", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsMiddlewareNilSkipped_H8c:未设置 metrics 中间件时 Apply 不应装入空壳。
|
||||
func TestMetricsMiddlewareNilSkipped_H8c(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
r := NewRegistry(engine)
|
||||
if r.metricsMiddleware != nil {
|
||||
t.Fatal("default metricsMiddleware should be nil")
|
||||
}
|
||||
r.Apply() // 不应 panic
|
||||
}
|
||||
|
||||
// TestEnsureRegistryPanicsBeforeInit_H8a 复现 H8a:修复前 Init 之前调全局 helper
|
||||
// 触发 nil 解引用 panic;修复后为带明确信息的 panic。
|
||||
func TestEnsureRegistryPanicsBeforeInit_H8a(t *testing.T) {
|
||||
// 保存并清空全局注册中心,测试后恢复,避免污染其它测试。
|
||||
prev := globalRegistry.Load()
|
||||
globalRegistry.Store(nil)
|
||||
t.Cleanup(func() { globalRegistry.Store(prev) })
|
||||
|
||||
var got any
|
||||
func() {
|
||||
defer func() { got = recover() }()
|
||||
Apply()
|
||||
}()
|
||||
if got == nil {
|
||||
t.Fatal("Apply before Init should panic, got nil")
|
||||
}
|
||||
msg, ok := got.(string)
|
||||
if !ok {
|
||||
t.Fatalf("panic value should be string, got %T: %v", got, got)
|
||||
}
|
||||
if !strings.Contains(msg, "router.Init") {
|
||||
t.Fatalf("panic message should mention router.Init, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGlobalRegistryAtomicConcurrent_H8a:并发 Init/GetRegistry 不触发 data race
|
||||
// (atomic.Pointer 保护)。须配合 -race 运行。
|
||||
func TestGlobalRegistryAtomicConcurrent_H8a(t *testing.T) {
|
||||
prev := globalRegistry.Load()
|
||||
t.Cleanup(func() { globalRegistry.Store(prev) })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); Init(gin.New()) }()
|
||||
go func() { defer wg.Done(); _ = GetRegistry() }()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestHealthHandlerConvergedSchema_H8d 验证 defaultModule / RegisterHealthRoute(无 checks)
|
||||
// 与 handler 风格的 /health 同 schema:200 + {"status":"ok"}。
|
||||
func TestHealthHandlerConvergedSchema_H8d(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
r := NewRegistry(engine)
|
||||
r.RegisterModule(&defaultModule{})
|
||||
r.Apply()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("defaultModule /health status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"status":"ok"`) {
|
||||
t.Fatalf("defaultModule /health body = %s, want {\"status\":\"ok\"}", body)
|
||||
}
|
||||
// 不应携带 response 业务信封字段。
|
||||
if strings.Contains(body, `"code"`) || strings.Contains(body, `"data"`) {
|
||||
t.Fatalf("defaultModule /health should not use response envelope, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 编译期保证 middleware 包仍可独立使用(H8c 回归用)。
|
||||
var _ gin.HandlerFunc = middleware.Metrics()
|
||||
|
||||
// TestDefaultModuleAndRegisterHealthRouteCoexist_H8dfootgun 复现 H8d 收尾 footgun:
|
||||
// 修复前 WithDefaultRoutes()+WithModules(DefaultModule) 并存会触发 Gin 重复路由 panic;
|
||||
// 修复后 registerGETOnce 使二者幂等共存,/health 与 /swagger 均可访问。
|
||||
// 此测试模拟 app.go 的真实顺序:Register* 先注册(带 checks),defaultModule 经 Apply 后注册。
|
||||
func TestDefaultModuleAndRegisterHealthRouteCoexist_H8dfootgun(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
|
||||
// 先经 Register* 注册(等价 app.go Init 中 enableHealth/enableSwagger 段)
|
||||
RegisterHealthRoute(engine, HealthCheck{Name: "mysql", Check: func(context.Context) error { return nil }})
|
||||
RegisterSwaggerRoutes(engine)
|
||||
// 再经注册中心注册 DefaultModule(等价 app.go registry.Apply())
|
||||
r := NewRegistry(engine)
|
||||
r.RegisterModule(&defaultModule{})
|
||||
r.Apply() // 修复前在此 panic: handlers are already registered for path '/health'
|
||||
|
||||
// /health 仍可访问,且首次注册(带 checks)胜出——响应含 checks 字段。
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("/health status = %d, want 200", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"mysql":"ok"`) {
|
||||
t.Fatalf("first registration (with checks) should win, got %s", w.Body.String())
|
||||
}
|
||||
|
||||
// /swagger/*any 注册存在(非 404 即说明路由已注册)
|
||||
w2 := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w2, httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil))
|
||||
if w2.Code == http.StatusNotFound {
|
||||
t.Fatal("/swagger/*any should be registered, got 404")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterHealthRouteIdempotent_H8dfootgun:RegisterHealthRoute 重复调用不 panic。
|
||||
func TestRegisterHealthRouteIdempotent_H8dfootgun(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
RegisterHealthRoute(engine, HealthCheck{Name: "a", Check: func(context.Context) error { return nil }})
|
||||
RegisterHealthRoute(engine) // 重复,不 panic
|
||||
RegisterHealthRoute(engine) // 三次,不 panic
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("/health status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultModuleOnly_H8dfootgun:仅用 defaultModule(不预先 Register*)时,
|
||||
// /health 与 /swagger 仍正常注册(recover 兜底路径不影响首次注册)。
|
||||
func TestDefaultModuleOnly_H8dfootgun(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
r := NewRegistry(engine)
|
||||
r.RegisterModule(&defaultModule{})
|
||||
r.Apply()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("/health status = %d, want 200", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"status":"ok"`) {
|
||||
t.Fatalf("/health body = %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterGETOnceEngineDoesNotSwallowRealConflict_H8dfootgun:Engine 路径经 Routes()
|
||||
// 精确预检,未命中即直接注册(无 recover)。真正不同的路由冲突(如 /foo/:id 已存在再注册
|
||||
// /foo/*any)仍按 gin 原语义 panic,不被掩盖——证明幂等只吞"同一 path 重复",不掩盖真实冲突。
|
||||
func TestRegisterGETOnceEngineDoesNotSwallowRealConflict_H8dfootgun(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/foo/:id", func(c *gin.Context) {})
|
||||
|
||||
var got any
|
||||
func() {
|
||||
defer func() { got = recover() }()
|
||||
registerGETOnce(engine, "/foo/*any", func(c *gin.Context) {}) // 与 :id 真实冲突
|
||||
}()
|
||||
if got == nil {
|
||||
t.Fatal("registerGETOnce (Engine) should panic on real (different-path) conflict, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
+98
-26
@@ -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: <event>\ndata: <data>\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: <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})
|
||||
}
|
||||
|
||||
@@ -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 退出)。
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+398
-68
@@ -7,10 +7,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
@@ -30,6 +33,130 @@ 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")
|
||||
// ErrUploadTooLarge 上传实际字节数超过 MaxSizeBytes(P0)。客户端声明的 file.Size
|
||||
// 不可信,故除前置校验外,拷贝阶段按实际字节封顶,防止声明小体积却流式发送大 body 撑爆磁盘/OSS。
|
||||
ErrUploadTooLarge = errors.New("upload exceeds max size")
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultMaxReadBytes Get 默认读取上限(100MB),防止全量读入内存 OOM(C4c)。
|
||||
defaultMaxReadBytes int64 = 100 * 1024 * 1024
|
||||
// mimeSniffPrefixLen http.DetectContentType 最多嗅探 512 字节。
|
||||
mimeSniffPrefixLen = 512
|
||||
)
|
||||
|
||||
// resolveMaxRead 解析 Get 读取上限:n<0 不限,n==0 用默认,n>0 用 n。
|
||||
func resolveMaxRead(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -1
|
||||
}
|
||||
if n == 0 {
|
||||
return defaultMaxReadBytes
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// validateUploadSize 校验上传文件大小(C4b)。MaxSizeBytes<=0 表示不限。
|
||||
//
|
||||
// 注意:此处的 size 来自客户端声明(multipart.FileHeader.Size),仅作前置快速拒绝,
|
||||
// 不可信——攻击者可声明小体积却流式发送大 body。真正的落盘/上传封顶由 enforceUploadSize
|
||||
// 在拷贝阶段按实际字节数二次把关(P0)。
|
||||
func validateUploadSize(p config.UploadPolicy, size int64) error {
|
||||
if p.MaxSizeBytes > 0 && size > p.MaxSizeBytes {
|
||||
return fmt.Errorf("文件大小 %d 超过上限 %d: %w", size, p.MaxSizeBytes, ErrInvalidPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enforceMaxReader 包装底层 reader,实际读取累计超过 max 字节即返回 ErrUploadTooLarge(P0)。
|
||||
// max<=0 表示不限。用于上传拷贝阶段的真实大小封顶,弥补客户端声明 Size 不可信。
|
||||
type enforceMaxReader struct {
|
||||
src io.Reader
|
||||
max int64
|
||||
count int64
|
||||
}
|
||||
|
||||
func (r *enforceMaxReader) Read(p []byte) (int, error) {
|
||||
if r.max > 0 && r.count >= r.max {
|
||||
// 上限内的字节已全部读出:用独立探针读 1 字节判断源是否还有数据,有则超限。
|
||||
var probe [1]byte
|
||||
if n, _ := r.src.Read(probe[:]); n > 0 {
|
||||
return 0, ErrUploadTooLarge
|
||||
}
|
||||
// 源已到 EOF,实际大小恰好等于上限,放行结束。
|
||||
return 0, io.EOF
|
||||
}
|
||||
n, err := r.src.Read(p)
|
||||
r.count += int64(n)
|
||||
if r.max > 0 && r.count > r.max {
|
||||
return 0, ErrUploadTooLarge
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// enforceUploadSize 若策略配置了 MaxSizeBytes,则包装 src 在拷贝阶段实测封顶(P0)。
|
||||
// 未配置上限时原样返回,零开销。
|
||||
func enforceUploadSize(p config.UploadPolicy, src io.Reader) io.Reader {
|
||||
if p.MaxSizeBytes <= 0 {
|
||||
return src
|
||||
}
|
||||
return &enforceMaxReader{src: src, max: p.MaxSizeBytes}
|
||||
}
|
||||
|
||||
// validateUploadExt 校验上传文件扩展名(C4b)。AllowedExts 为空表示不限。
|
||||
func validateUploadExt(p config.UploadPolicy, filename string) error {
|
||||
if len(p.AllowedExts) == 0 {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if !containsString(p.AllowedExts, ext) {
|
||||
return fmt.Errorf("扩展名 %s 不在白名单: %w", ext, ErrInvalidPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sniffUploadMIME 嗅探 src 前 512 字节并按 AllowedMIMEs 校验(C4b)。
|
||||
// 返回的 reader 已拼回已读头部,后续可继续读到完整内容。
|
||||
// AllowedMIMEs 为空时直接返回原 src 不嗅探。
|
||||
func sniffUploadMIME(p config.UploadPolicy, src io.Reader) (io.Reader, error) {
|
||||
if len(p.AllowedMIMEs) == 0 {
|
||||
return src, nil
|
||||
}
|
||||
head := make([]byte, mimeSniffPrefixLen)
|
||||
n, err := io.ReadFull(src, head)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return nil, fmt.Errorf("读取文件头失败: %w", err)
|
||||
}
|
||||
// http.DetectContentType 可能返回 "text/plain; charset=utf-8",
|
||||
// 与白名单 "text/plain" 比较时取主类型(分号前)。
|
||||
detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(head[:n]), ";")[0]))
|
||||
allowed := make([]string, len(p.AllowedMIMEs))
|
||||
for i, m := range p.AllowedMIMEs {
|
||||
allowed[i] = strings.ToLower(strings.TrimSpace(strings.Split(m, ";")[0]))
|
||||
}
|
||||
if !containsString(allowed, detected) {
|
||||
return nil, fmt.Errorf("文件类型 %s 不在白名单: %w", detected, ErrInvalidPath)
|
||||
}
|
||||
return io.MultiReader(bytes.NewReader(head[:n]), src), nil
|
||||
}
|
||||
|
||||
func containsString(list []string, v string) bool {
|
||||
for _, s := range list {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// uniqueFilename 生成带随机后缀的唯一文件名,避免同一纳秒内并发上传导致文件名冲突。
|
||||
// 格式: <unixNano>-<8字节随机hex>.<ext>
|
||||
func uniqueFilename(now time.Time, ext string) string {
|
||||
@@ -39,35 +166,110 @@ 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)。
|
||||
var rootAbs string
|
||||
abs, err := filepath.Abs(cfg.Path)
|
||||
if err != nil {
|
||||
// P1 #20:Abs 失败(如 os.Getwd 失败)不再回退相对 cfg.Path——相对根会使 safeJoin
|
||||
// 前缀校验基于相对路径、削弱穿越防御。改为 fail-closed:置空 rootAbs,
|
||||
// safeJoin 据此拒绝一切操作,并记录错误,避免"看似可用实则防御失效"的本地存储。
|
||||
logger.Error("storage: 解析存储根目录绝对路径失败,本地存储将不可用(fail-closed)",
|
||||
zap.String("path", cfg.Path), zap.Error(err))
|
||||
rootAbs = ""
|
||||
} else {
|
||||
rootAbs = filepath.Clean(abs)
|
||||
}
|
||||
return &LocalStorage{
|
||||
rootAbs: rootAbs,
|
||||
baseURL: cfg.BaseURL,
|
||||
policy: cfg.Upload,
|
||||
maxReadBytes: resolveMaxRead(cfg.MaxReadBytes),
|
||||
}
|
||||
}
|
||||
|
||||
// safeJoin 将根路径与相对片段拼接为绝对路径,并以前缀锚定拒绝穿越(C4a)。
|
||||
// 任何片段为绝对路径或含 NUL、最终路径逃逸 rootAbs 时返回错误。
|
||||
func (s *LocalStorage) safeJoin(parts ...string) (string, error) {
|
||||
// P1 #20:rootAbs 为空表示构造时 Abs 失败,fail-closed 拒绝所有路径操作。
|
||||
if s.rootAbs == "" {
|
||||
return "", ErrStorageNotInitialized
|
||||
}
|
||||
for _, p := range parts {
|
||||
if filepath.IsAbs(p) {
|
||||
return "", ErrPathTraversal
|
||||
}
|
||||
if strings.ContainsRune(p, 0) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
}
|
||||
joined := filepath.Join(append([]string{s.rootAbs}, parts...)...)
|
||||
if joined == s.rootAbs || !strings.HasPrefix(joined, s.rootAbs+string(os.PathSeparator)) {
|
||||
return "", ErrPathTraversal
|
||||
}
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (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,15 +281,26 @@ 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)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// 复制文件内容
|
||||
if _, err := io.Copy(dstFile, src); err != nil {
|
||||
// 复制文件内容。按策略实测封顶(P0):超限即报错并清理已落盘的部分文件。
|
||||
if _, err := io.Copy(dstFile, enforceUploadSize(s.policy, srcReader)); err != nil {
|
||||
// 先关闭再删除(Windows 下删除打开中的文件会失败);defer 的 Close 随后成为无害 no-op。
|
||||
_ = dstFile.Close()
|
||||
_ = os.Remove(dst)
|
||||
return "", fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -102,27 +315,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 +363,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 +382,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 +460,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 +501,16 @@ func (s *OSSStorage) Upload(file *multipart.FileHeader, subdir string) (string,
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 上传到 OSS
|
||||
if err := s.bucket.PutObject(objectKey, src); err != nil {
|
||||
// MIME 嗅探(如配置)
|
||||
var srcReader io.Reader = src
|
||||
srcReader, err = sniffUploadMIME(s.policy, srcReader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 上传到 OSS。按策略实测封顶(P0):超限时 PutObject 读到 ErrUploadTooLarge 而失败,
|
||||
// 简单 PutObject 语义下对象不会被提交,无需额外清理。
|
||||
if err := s.bucket.PutObject(objectKey, enforceUploadSize(s.policy, srcReader)); err != nil {
|
||||
logger.Error("OSS 上传失败", zap.Error(err), zap.String("key", objectKey))
|
||||
return "", fmt.Errorf("OSS 上传失败: %w", err)
|
||||
}
|
||||
@@ -234,16 +521,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,42 +575,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
|
||||
|
||||
// StorageManager 存储管理器(#10)。照 database.Manager 模式:
|
||||
// 实例化 + DefaultStorage 全局默认 + 包级 facade 代理,支持多实例与测试注入。
|
||||
type StorageManager struct {
|
||||
@@ -311,15 +634,24 @@ type StorageManager struct {
|
||||
}
|
||||
|
||||
// DefaultStorage 默认存储管理器,包级 facade 代理到它。
|
||||
var DefaultStorage = NewStorageManager()
|
||||
//
|
||||
// 主线A 修复:改用 atomic.Pointer 保护读写,消除原裸指针置换(SetDefaultStorageManager)
|
||||
// 与 facade 无锁读之间的数据竞争,与 config.defaultManager / database.DefaultRedis 对齐。
|
||||
// 类型由 *StorageManager 变更为 atomic.Pointer[StorageManager](breaking:下游若直接
|
||||
// 调用 DefaultStorage.Init 等方法需改用 Init 等 facade,或 DefaultStorage.Load().Init)。
|
||||
var DefaultStorage atomic.Pointer[StorageManager]
|
||||
|
||||
func init() {
|
||||
DefaultStorage.Store(NewStorageManager())
|
||||
}
|
||||
|
||||
// NewStorageManager 创建存储管理器实例。
|
||||
func NewStorageManager() *StorageManager { return &StorageManager{} }
|
||||
|
||||
// SetDefaultStorageManager 提升指定 StorageManager 为全局默认。
|
||||
// SetDefaultStorageManager 提升指定 StorageManager 为全局默认。并发安全(atomic.Store)。
|
||||
func SetDefaultStorageManager(m *StorageManager) {
|
||||
if m != nil {
|
||||
DefaultStorage = m
|
||||
DefaultStorage.Store(m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +678,6 @@ func (m *StorageManager) Init(cfg *config.StorageConfig) error {
|
||||
|
||||
m.cfg = cfg
|
||||
m.current = s
|
||||
storage = s // 同步兼容 facade
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -362,24 +693,23 @@ func (m *StorageManager) Set(s Storage) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.current = s
|
||||
storage = s
|
||||
}
|
||||
|
||||
// --- 包级 facade(代理到 DefaultStorage,兼容存量) ---
|
||||
|
||||
// Init 初始化存储
|
||||
func Init(cfg *config.StorageConfig) error {
|
||||
return DefaultStorage.Init(cfg)
|
||||
return DefaultStorage.Load().Init(cfg)
|
||||
}
|
||||
|
||||
// GetStorage 获取全局存储实例
|
||||
func GetStorage() Storage {
|
||||
return DefaultStorage.Get()
|
||||
return DefaultStorage.Load().Get()
|
||||
}
|
||||
|
||||
// SetStorage 设置全局存储实例
|
||||
func SetStorage(s Storage) {
|
||||
DefaultStorage.Set(s)
|
||||
DefaultStorage.Load().Set(s)
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
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("<?php"))
|
||||
if _, err := s.Upload(fh, "docs"); !errors.Is(err, storage.ErrInvalidPath) {
|
||||
t.Errorf("Upload evil.php err = %v, want ErrInvalidPath", err)
|
||||
}
|
||||
|
||||
// ok.jpg 通过
|
||||
fh2 := makeFileHeader(t, "file", "ok.jpg", "image/jpeg", []byte("\xff\xd8\xff\xe0"))
|
||||
if _, err := s.Upload(fh2, "docs"); err != nil {
|
||||
t.Errorf("Upload ok.jpg err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C4b:AllowedMIMEs 嗅探——扩展名伪装但内容不符的拒绝。
|
||||
func TestLocalStorageUploadMIMESniff(t *testing.T) {
|
||||
s := newLocalStorageWithPolicy(t, config.UploadPolicy{AllowedMIMEs: []string{"image/jpeg"}}, 0)
|
||||
|
||||
// 文件名伪装成 jpg,但内容是文本 → 嗅探为 text/plain → 拒绝
|
||||
fh := makeFileHeader(t, "file", "fake.jpg", "image/jpeg", []byte("not an image at all"))
|
||||
if _, err := s.Upload(fh, "docs"); !errors.Is(err, storage.ErrInvalidPath) {
|
||||
t.Errorf("Upload fake.jpg (text content) err = %v, want ErrInvalidPath", err)
|
||||
}
|
||||
|
||||
// 真实 jpeg 头 → 通过
|
||||
jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
|
||||
fh2 := makeFileHeader(t, "file", "real.jpg", "image/jpeg", jpeg)
|
||||
if _, err := s.Upload(fh2, "docs"); err != nil {
|
||||
t.Errorf("Upload real.jpg err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C4b:UploadFromBytes 同样受策略约束(大小 / 扩展名 / MIME)。
|
||||
func TestLocalStorageUploadFromBytesPolicy(t *testing.T) {
|
||||
s := newLocalStorageWithPolicy(t, config.UploadPolicy{
|
||||
MaxSizeBytes: 100,
|
||||
AllowedExts: []string{".txt"},
|
||||
AllowedMIMEs: []string{"text/plain"},
|
||||
}, 0)
|
||||
|
||||
// 超限
|
||||
if _, err := s.UploadFromBytes(make([]byte, 200), "ok.txt", "docs"); !errors.Is(err, storage.ErrInvalidPath) {
|
||||
t.Errorf("UploadFromBytes over size err = %v, want ErrInvalidPath", err)
|
||||
}
|
||||
// 扩展名不符
|
||||
if _, err := s.UploadFromBytes([]byte("hi"), "evil.php", "docs"); !errors.Is(err, storage.ErrInvalidPath) {
|
||||
t.Errorf("UploadFromBytes evil.php err = %v, want ErrInvalidPath", err)
|
||||
}
|
||||
// MIME 不符(内容是 jpeg 头,但只允许 text/plain)
|
||||
if _, err := s.UploadFromBytes([]byte{0xff, 0xd8, 0xff, 0xe0}, "ok.txt", "docs"); !errors.Is(err, storage.ErrInvalidPath) {
|
||||
t.Errorf("UploadFromBytes wrong MIME err = %v, want ErrInvalidPath", err)
|
||||
}
|
||||
// 合法通过
|
||||
if _, err := s.UploadFromBytes([]byte("hello"), "ok.txt", "docs"); err != nil {
|
||||
t.Errorf("UploadFromBytes ok err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== P0:上传大小拷贝阶段实测封顶(客户端 Size 不可信)=====
|
||||
|
||||
// 回归 P0:客户端谎报 file.Size 绕过前置校验时,拷贝阶段按实际字节封顶必须拦截,
|
||||
// 返回 ErrUploadTooLarge,防止声明小体积却流式发送大 body 撑爆磁盘。
|
||||
func TestLocalStorageUploadSizeEnforcedOnCopy(t *testing.T) {
|
||||
s := newLocalStorageWithPolicy(t, config.UploadPolicy{MaxSizeBytes: 5}, 0)
|
||||
|
||||
content := make([]byte, 100) // 真实 100 字节
|
||||
fh := makeFileHeader(t, "file", "big.bin", "application/octet-stream", content)
|
||||
fh.Size = 1 // 谎报大小,绕过前置 validateUploadSize
|
||||
|
||||
if _, err := s.Upload(fh, "docs"); !errors.Is(err, storage.ErrUploadTooLarge) {
|
||||
t.Errorf("Upload with lied Size err = %v, want ErrUploadTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P0:实际大小恰好等于上限应通过(边界不误伤)。
|
||||
func TestLocalStorageUploadSizeBoundaryExact(t *testing.T) {
|
||||
s := newLocalStorageWithPolicy(t, config.UploadPolicy{MaxSizeBytes: 8}, 0)
|
||||
fh := makeFileHeader(t, "file", "ok.bin", "application/octet-stream", []byte("12345678")) // 恰好 8 字节
|
||||
if _, err := s.Upload(fh, "docs"); err != nil {
|
||||
t.Errorf("Upload exactly-at-limit err = %v, want success", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容性回归:零值 UploadPolicy(默认配置)不上传限制,正常文件通过。
|
||||
func TestLocalStorageZeroPolicyAllowsAll(t *testing.T) {
|
||||
s := newLocalStorageWithPolicy(t, config.UploadPolicy{}, 0)
|
||||
fh := makeFileHeader(t, "file", "any.bin", "application/octet-stream", []byte("whatever"))
|
||||
if _, err := s.Upload(fh, "docs"); err != nil {
|
||||
t.Errorf("Upload with zero policy err = %v (must remain unrestricted for backward compat)", err)
|
||||
}
|
||||
}
|
||||
+31
-5
@@ -3,6 +3,8 @@ package test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -10,7 +12,10 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetupRouter 创建测试路由
|
||||
// SetupRouter 创建测试路由。
|
||||
//
|
||||
// 刻意返回裸 gin.New()(N3 文档化):不含框架中间件(RequestID/Recover/超时等),
|
||||
// 让测试方完全控制中间件链。需要框架中间件的集成测试应直接构造 xlgo.App 或手动 Use。
|
||||
func SetupRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
return gin.New()
|
||||
@@ -280,7 +285,12 @@ func (m *MockCache) Clear() {
|
||||
m.data = make(map[string][]byte)
|
||||
}
|
||||
|
||||
// MockStorage 模拟存储
|
||||
// MockStorage 模拟存储。
|
||||
//
|
||||
// 注意(N3):本 mock 与真实 storage 的签名已对齐——
|
||||
// - Upload(file *multipart.FileHeader, subdir string) 对应 storage.Upload
|
||||
// - UploadFromBytes(data []byte, filename, subdir string) 对应 storage.UploadFromBytes
|
||||
// 真实 storage 无导出接口,此 mock 仅供测试手写注入,不强制实现接口。
|
||||
type MockStorage struct {
|
||||
files map[string][]byte
|
||||
urls map[string]string
|
||||
@@ -294,9 +304,25 @@ func NewMockStorage() *MockStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 模拟上传
|
||||
func (m *MockStorage) Upload(data []byte, filename string) (string, error) {
|
||||
path := "/mock/" + filename
|
||||
// Upload 模拟上传(签名对齐 storage.Upload)。
|
||||
func (m *MockStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
|
||||
data, err := file.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer data.Close()
|
||||
b, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := "/mock/" + subdir + "/" + file.Filename
|
||||
m.files[path] = b
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// UploadFromBytes 模拟从字节上传(签名对齐 storage.UploadFromBytes)。
|
||||
func (m *MockStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) {
|
||||
path := "/mock/" + subdir + "/" + filename
|
||||
m.files[path] = data
|
||||
return path, nil
|
||||
}
|
||||
|
||||
+139
-48
@@ -4,19 +4,24 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/contrib/propagators/b3"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
// Config 链路追踪配置
|
||||
@@ -31,6 +36,9 @@ type Config struct {
|
||||
ExporterType string
|
||||
// Endpoint OTLP 导出器地址
|
||||
Endpoint string
|
||||
// Insecure 是否使用明文(无 TLS)连接 collector。
|
||||
// 默认 false(TLS);对 localhost:4318 等明文 collector 需显式置 true(C13c)。
|
||||
Insecure bool
|
||||
// SampleRatio 采样比例 (0.0-1.0)
|
||||
SampleRatio float64
|
||||
// Enabled 是否启用
|
||||
@@ -51,26 +59,59 @@ var DefaultConfig = Config{
|
||||
Propagator: "w3c",
|
||||
}
|
||||
|
||||
// TracerProvider 全局 TracerProvider
|
||||
var tracerProvider *sdktrace.TracerProvider
|
||||
// tracerProviderPtr 全局 TracerProvider(atomic,C13a)。
|
||||
// Init 之前/Close 之后均为 Noop/NeverSample,保证任何时刻 Load 非 nil,请求期不 panic。
|
||||
var tracerProviderPtr atomic.Pointer[sdktrace.TracerProvider]
|
||||
|
||||
// Tracer 全局 Tracer
|
||||
var tracer trace.Tracer
|
||||
// tracerPtr 全局 Tracer(atomic,C13a)。Init 之前为 Noop,调用安全。
|
||||
var tracerPtr atomic.Pointer[trace.Tracer]
|
||||
|
||||
func init() {
|
||||
// 初始化为 Noop,保证包级函数在任何时刻(未 Init / 已 Close)Load 均非 nil(C13a)。
|
||||
// P1 #19:用 noop.NewTracerProvider()(新 OTel API),替代已弃用的 trace.NewNoopTracerProvider()。
|
||||
noopProvider := noop.NewTracerProvider()
|
||||
noopTracer := noopProvider.Tracer("xlgo")
|
||||
tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample()))
|
||||
tracerProviderPtr.Store(tp)
|
||||
tracerPtr.Store(&noopTracer)
|
||||
}
|
||||
|
||||
// getTracer 返回全局 Tracer 的 atomic 快照(永不 nil)。
|
||||
func getTracer() trace.Tracer {
|
||||
return *tracerPtr.Load()
|
||||
}
|
||||
|
||||
// TracerProvider 全局 TracerProvider(导出供高级用法;返回当前快照)。
|
||||
func TracerProvider() *sdktrace.TracerProvider {
|
||||
return tracerProviderPtr.Load()
|
||||
}
|
||||
|
||||
// Init 初始化链路追踪
|
||||
func Init(cfg Config) error {
|
||||
if !cfg.Enabled {
|
||||
// 设置 Noop Tracer
|
||||
otel.SetTracerProvider(trace.NewNoopTracerProvider())
|
||||
tracer = otel.Tracer(cfg.ServiceName)
|
||||
// 设置 Noop Tracer(P1 #19:noop 包替代弃用 API)
|
||||
noopProvider := noop.NewTracerProvider()
|
||||
otel.SetTracerProvider(noopProvider)
|
||||
noopTracer := noopProvider.Tracer(cfg.ServiceName)
|
||||
tracerPtr.Store(&noopTracer)
|
||||
// M-64 修复:Swap 出旧 provider(可能持有 exporter)并 Shutdown,
|
||||
// 避免禁用 trace 后旧 exporter 后台 goroutine/连接持续占用。
|
||||
fallback := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample()))
|
||||
if old := tracerProviderPtr.Swap(fallback); old != nil {
|
||||
_ = old.Shutdown(context.Background())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建资源
|
||||
// 注意:不传 semconv.SchemaURL 与 resource.Default() 混用——
|
||||
// resource.Default() 在不同 OTel 版本可能使用与 semconv v1.24.0 不同的 schema URL,
|
||||
// resource.Merge 对冲突的 SchemaURL 直接报错。这里用空 schema URL 的属性集合并,
|
||||
// 避免版本漂移导致的初始化失败。
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
"",
|
||||
semconv.ServiceName(cfg.ServiceName),
|
||||
semconv.ServiceVersion(cfg.ServiceVersion),
|
||||
attribute.String("environment", cfg.Environment),
|
||||
@@ -87,21 +128,32 @@ func Init(cfg Config) error {
|
||||
}
|
||||
|
||||
// 创建 TracerProvider
|
||||
tracerProvider = sdktrace.NewTracerProvider(
|
||||
newProvider := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exporter),
|
||||
sdktrace.WithResource(res),
|
||||
sdktrace.WithSampler(sdktrace.TraceIDRatioBased(cfg.SampleRatio)),
|
||||
)
|
||||
|
||||
// 设置全局 TracerProvider
|
||||
otel.SetTracerProvider(tracerProvider)
|
||||
// 设置传播器(非法类型返错,C13e 不再静默回落)
|
||||
prop, err := createPropagator(cfg.Propagator)
|
||||
if err != nil {
|
||||
// M-65 修复:传播器非法时回滚已创建的 provider。此时尚未 otel.SetTracerProvider,
|
||||
// otel 全局仍指向旧 provider,无需恢复——仅 Shutdown 新 provider 避免泄漏。
|
||||
_ = newProvider.Shutdown(context.Background())
|
||||
return err
|
||||
}
|
||||
|
||||
// 设置传播器
|
||||
propagator := createPropagator(cfg.Propagator)
|
||||
otel.SetTextMapPropagator(propagator)
|
||||
// 全部成功后再切换全局状态(M-65:避免失败后 otel 指向已 Shutdown 的 provider)。
|
||||
otel.SetTracerProvider(newProvider)
|
||||
otel.SetTextMapPropagator(prop)
|
||||
|
||||
// 创建 Tracer
|
||||
tracer = otel.Tracer(cfg.ServiceName)
|
||||
// 原子替换:先建新 provider,成功后再 Store,并关闭旧 provider(若持有 exporter)。
|
||||
oldProvider := tracerProviderPtr.Swap(newProvider)
|
||||
tracer := newProvider.Tracer(cfg.ServiceName)
|
||||
tracerPtr.Store(&tracer)
|
||||
if oldProvider != nil {
|
||||
_ = oldProvider.Shutdown(context.Background())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -110,37 +162,64 @@ func Init(cfg Config) error {
|
||||
func createExporter(cfg Config) (sdktrace.SpanExporter, error) {
|
||||
switch cfg.ExporterType {
|
||||
case "otlp-http":
|
||||
client := otlptracehttp.NewClient(
|
||||
otlptracehttp.WithEndpoint(cfg.Endpoint),
|
||||
)
|
||||
opts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(cfg.Endpoint)}
|
||||
if cfg.Insecure {
|
||||
opts = append(opts, otlptracehttp.WithInsecure()) // C13c
|
||||
}
|
||||
client := otlptracehttp.NewClient(opts...)
|
||||
return otlptrace.New(context.Background(), client)
|
||||
case "otlp-grpc":
|
||||
client := otlptracegrpc.NewClient(
|
||||
otlptracegrpc.WithEndpoint(cfg.Endpoint),
|
||||
)
|
||||
opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.Endpoint)}
|
||||
if cfg.Insecure {
|
||||
opts = append(opts, otlptracegrpc.WithInsecure()) // C13c
|
||||
}
|
||||
client := otlptracegrpc.NewClient(opts...)
|
||||
return otlptrace.New(context.Background(), client)
|
||||
case "stdout":
|
||||
return stdouttrace.New(
|
||||
stdouttrace.WithWriter(os.Stdout),
|
||||
stdouttrace.WithPrettyPrint(),
|
||||
)
|
||||
default:
|
||||
return nil, nil
|
||||
// C13b:未知导出器返错,不再返回 nil 喂 WithBatcher(nil)。
|
||||
return nil, fmt.Errorf("不支持的导出器类型: %s", cfg.ExporterType)
|
||||
}
|
||||
}
|
||||
|
||||
// createPropagator 创建传播器
|
||||
func createPropagator(propagatorType string) propagation.TextMapPropagator {
|
||||
// createPropagator 创建传播器(C13e:实现 b3,jaeger 映射 W3C,未知返错)。
|
||||
func createPropagator(propagatorType string) (propagation.TextMapPropagator, error) {
|
||||
switch propagatorType {
|
||||
case "w3c":
|
||||
case "w3c", "":
|
||||
return propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
)
|
||||
), nil
|
||||
case "b3":
|
||||
// 同时支持多头与单头注入/抽取,兼容旧 B3 客户端。
|
||||
return b3.New(b3.WithInjectEncoding(b3.B3MultipleHeader | b3.B3SingleHeader)), nil
|
||||
case "jaeger":
|
||||
// 现代 Jaeger agent 透传 W3C TraceContext;纯 Jaeger thrift 头协议需下游用 b3。
|
||||
// 不引入不稳定的 jaegerremix 模块,jaeger 视为 W3C 别名。
|
||||
return propagation.TraceContext{}, nil
|
||||
default:
|
||||
return propagation.TraceContext{}
|
||||
return nil, fmt.Errorf("不支持的传播器类型: %s", propagatorType)
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭链路追踪
|
||||
// Close 关闭链路追踪。
|
||||
//
|
||||
// H-14 修复:去掉 sync.Once。原实现 Close→Init→Close 第二次因 once 已消费而 no-op,
|
||||
// 新 provider 的 exporter 后台 goroutine/连接泄漏。改为每次 Swap 出当前 provider 并
|
||||
// Shutdown,Store 回兜底 NeverSample provider(C13a:Close 后再用已关闭 provider)。
|
||||
// 幂等:重复 Close 时 Swap 得到的是无 exporter 的兜底 provider,Shutdown 无害。
|
||||
// 并发安全:Swap 原子返回唯一指针,不会 double-Shutdown 同一 provider。
|
||||
func Close(ctx context.Context) error {
|
||||
if tracerProvider != nil {
|
||||
return tracerProvider.Shutdown(ctx)
|
||||
fallback := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.NeverSample()))
|
||||
old := tracerProviderPtr.Swap(fallback)
|
||||
noopTracer := noop.NewTracerProvider().Tracer("xlgo") // P1 #19:noop 包替代弃用 API
|
||||
tracerPtr.Store(&noopTracer)
|
||||
if old != nil {
|
||||
return old.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -151,24 +230,35 @@ func Middleware(serviceName string) gin.HandlerFunc {
|
||||
// 从请求中提取 TraceContext
|
||||
ctx := otel.GetTextMapPropagator().Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header))
|
||||
|
||||
// 创建 Span
|
||||
spanName := c.Request.Method + " " + c.FullPath()
|
||||
if spanName == "" {
|
||||
spanName = c.Request.Method + " " + c.Request.URL.Path
|
||||
// 创建 Span。P1 #19:span 名以路由模板(FullPath)为准以保持低基数。
|
||||
// 原实现 `Method+" "+FullPath()` 后判 `== ""` 永不成立("GET " 非空),
|
||||
// 未匹配路由的回退分支形同虚设、且用原始 URL.Path(含 ID)会导致 span 名高基数爆炸。
|
||||
// 现显式判断 FullPath 为空(未匹配路由),用固定低基数名。
|
||||
route := c.FullPath()
|
||||
var spanName string
|
||||
if route == "" {
|
||||
spanName = c.Request.Method + " [unmatched]"
|
||||
} else {
|
||||
spanName = c.Request.Method + " " + route
|
||||
}
|
||||
|
||||
ctx, span := tracer.Start(ctx, spanName,
|
||||
ctx, span := getTracer().Start(ctx, spanName,
|
||||
trace.WithSpanKind(trace.SpanKindServer),
|
||||
trace.WithAttributes(
|
||||
semconv.HTTPRequestMethodKey.String(c.Request.Method),
|
||||
semconv.URLPathKey.String(c.Request.URL.Path),
|
||||
semconv.HTTPRouteKey.String(c.FullPath()),
|
||||
semconv.HTTPRouteKey.String(route),
|
||||
attribute.String("http.user_agent", c.Request.UserAgent()),
|
||||
attribute.String("http.host", c.Request.Host),
|
||||
),
|
||||
)
|
||||
// P1 #19:defer 保证下游 handler panic(在上游 recovery 捕获前)时 span 也被结束,
|
||||
// 不泄漏未结束的 span。
|
||||
defer span.End()
|
||||
|
||||
// 将 context 存入 Gin Context
|
||||
// C13d:更新 c.Request,使下游 c.Request.Context() 含 span;
|
||||
// 同时保留 c.Set("otel_ctx", ctx) 兼容既有 GetContext 用法。
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Set("otel_ctx", ctx)
|
||||
|
||||
// 将 TraceID 添加到响应头
|
||||
@@ -184,19 +274,20 @@ func Middleware(serviceName string) gin.HandlerFunc {
|
||||
|
||||
if status >= 400 {
|
||||
span.SetStatus(codes.Error, http.StatusText(status))
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
|
||||
// 结束 Span
|
||||
span.End()
|
||||
// 成功路径不显式设 codes.Ok(M18):OTel 规范中 Span 状态默认 UNSET,
|
||||
// 仅在错误时设 Error;显式设 Ok 会掩盖下游子 Span 的真实错误状态。
|
||||
}
|
||||
}
|
||||
|
||||
// GetContext 从 Gin Context 获取 OpenTelemetry Context
|
||||
//
|
||||
// C13:裸断言改 comma-ok,防 "otel_ctx" 被外部置为非 context 值时 panic。
|
||||
func GetContext(c *gin.Context) context.Context {
|
||||
if 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 +305,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 +313,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 +342,7 @@ func AddAttributes(c *gin.Context, attrs ...attribute.KeyValue) {
|
||||
|
||||
// GetTracer 获取全局 Tracer
|
||||
func GetTracer() trace.Tracer {
|
||||
return tracer
|
||||
return getTracer()
|
||||
}
|
||||
|
||||
// SetAttribute 设置单个属性
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// H-14:Close→Init→Close 多轮生命周期(原 sync.Once 致第二次 Close 不 Shutdown)
|
||||
// ============================================================
|
||||
|
||||
// TestH14CloseInitCloseMultipleRounds 验证 Close 可反复执行,
|
||||
// 且 Close→Init→Close 第二次 Close 仍真正 Shutdown 新 provider(不泄漏 exporter)。
|
||||
//
|
||||
// 修复前:Close 用 sync.Once,第二次 Close no-op,第二轮 Init 创建的 provider 的
|
||||
// exporter 后台 goroutine/连接泄漏。
|
||||
func TestH14CloseInitCloseMultipleRounds(t *testing.T) {
|
||||
t.Cleanup(func() { resetGlobal(t) })
|
||||
|
||||
for round := 0; round < 3; round++ {
|
||||
if err := Init(Config{Enabled: true, ServiceName: "svc", ExporterType: "stdout", Propagator: "w3c"}); err != nil {
|
||||
t.Fatalf("round %d Init failed: %v", round, err)
|
||||
}
|
||||
// Init 后 provider 非 nil
|
||||
if TracerProvider() == nil {
|
||||
t.Fatalf("round %d: provider nil after Init", round)
|
||||
}
|
||||
if err := Close(context.Background()); err != nil {
|
||||
t.Fatalf("round %d Close failed: %v", round, err)
|
||||
}
|
||||
// Close 后仍非 nil(兜底 NeverSample provider,C13a)
|
||||
if TracerProvider() == nil {
|
||||
t.Fatalf("round %d: provider nil after Close", round)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestH14CloseIdempotent 验证连续 Close 不 panic、不报错。
|
||||
func TestH14CloseIdempotent(t *testing.T) {
|
||||
t.Cleanup(func() { resetGlobal(t) })
|
||||
if err := Close(context.Background()); err != nil {
|
||||
t.Fatalf("first Close: %v", err)
|
||||
}
|
||||
if err := Close(context.Background()); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestM65PropagatorFailureLeavesOtelUsable 验证传播器失败时 otel 全局未被切到
|
||||
// 已 Shutdown 的 provider(M-65)。失败后仍可正常 start span 不 panic。
|
||||
func TestM65PropagatorFailureLeavesOtelUsable(t *testing.T) {
|
||||
t.Cleanup(func() { resetGlobal(t) })
|
||||
|
||||
// 先成功 Init 一个
|
||||
if err := Init(Config{Enabled: true, ServiceName: "svc", ExporterType: "stdout", Propagator: "w3c"}); err != nil {
|
||||
t.Fatalf("setup Init: %v", err)
|
||||
}
|
||||
|
||||
// 再用非法传播器 Init,应失败
|
||||
err := Init(Config{Enabled: true, ExporterType: "stdout", Propagator: "xyz"})
|
||||
if err == nil {
|
||||
t.Fatal("Init with bad propagator should fail")
|
||||
}
|
||||
|
||||
// otel 全局 provider 仍可用:StartSpan 不 panic
|
||||
tr := otel.Tracer("test")
|
||||
_, span := tr.Start(context.Background(), "after-failure")
|
||||
span.End()
|
||||
}
|
||||
+19
-12
@@ -8,6 +8,8 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// MD5 计算字符串的 MD5 哈希值
|
||||
@@ -45,14 +47,21 @@ func SHA256Bytes(data []byte) string {
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// HashFile 计算文件的哈希值
|
||||
// HashFile 计算文件的哈希值。
|
||||
//
|
||||
// M-F 修复:改为流式 io.Copy(h, f),不再经 ReadFile 把整文件读入内存——原实现大文件 OOM。
|
||||
// 流式哈希内存占用恒定、可处理任意大小文件,与 hash.Hash 的 io.Writer 接口契合。
|
||||
// 调用方负责选择哈希算法(如 sha256.New)。
|
||||
func HashFile(path string, newHash func() hash.Hash) (string, error) {
|
||||
data, err := ReadFile(path)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := newHash()
|
||||
h.Write(data)
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -92,19 +101,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:
|
||||
|
||||
+11
-5
@@ -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
|
||||
|
||||
+15
-5
@@ -1,12 +1,18 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 本文件提供通用本地文件操作工具(读/写/复制/追加/存在性/删除)。
|
||||
//
|
||||
// ⚠️ 安全说明(M3):这些函数直接操作调用方传入的路径,不做路径穿越校验。
|
||||
// 若路径可能来自不可信输入(用户上传文件名、URL 参数等),调用方必须自行净化
|
||||
// (如 filepath.Clean + 前缀锚定),否则存在任意文件读/写/删风险。
|
||||
// 框架自身的不可信文件处理(storage 上传/下载)已在 storage 包内做穿越防护(C4)。
|
||||
|
||||
// FileExists 检查文件是否存在
|
||||
func FileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
@@ -27,11 +33,15 @@ func EnsureDir(path string) error {
|
||||
return os.MkdirAll(path, 0755)
|
||||
}
|
||||
|
||||
// ReadFile 读取文件内容
|
||||
// ReadFile 读取文件内容。
|
||||
//
|
||||
// M-F 修复:去掉前置 FileExists 检查(TOCTOU 竞态——检查与读取之间文件可能被删除/替换),
|
||||
// 直接 os.ReadFile 并返回其错误。文件不存在时返回 *os.PathError,调用方可经
|
||||
// errors.Is(err, os.ErrNotExist) 精确判断。
|
||||
//
|
||||
// 注意:本函数无大小上限(os.ReadFile 语义),读取不可信/超大文件有 OOM 风险——
|
||||
// 需限长读取请用 io.ReadAll(io.LimitReader(...));流式哈希大文件请用 HashFile(已流式)。
|
||||
func ReadFile(path string) ([]byte, error) {
|
||||
if !FileExists(path) {
|
||||
return nil, fmt.Errorf("file not found: %s", path)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
|
||||
+213
-37
@@ -4,27 +4,37 @@ import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPClient HTTP 客户端封装
|
||||
type HTTPClient struct {
|
||||
// mu 保护 client/transport/cfg/timeout/skipTLS 的并发读写。SetSkipTLS/SetTimeout
|
||||
// 在写锁下重建 client/transport;do/DoWithResponse/Close 在读锁下快照后无锁调用
|
||||
// http.Client.Do(Do 自身并发安全),不持有锁发请求以避免序列化。
|
||||
mu sync.RWMutex
|
||||
client *http.Client
|
||||
transport *http.Transport
|
||||
cfg HTTPClientConfig // 保留配置以便 SetSkipTLS 重建 transport
|
||||
timeout time.Duration
|
||||
headers map[string]string
|
||||
cookies map[string]string
|
||||
skipTLS bool
|
||||
once sync.Once
|
||||
// maxRespBodySize 响应体读取上限(字节),0 表示用默认 32MB,-1 表示不限。
|
||||
// 防止恶意/异常服务端返回超大响应打爆内存(C5/N5)。
|
||||
maxRespBodySize int64
|
||||
}
|
||||
|
||||
// UploadFile 上传文件信息
|
||||
@@ -40,7 +50,47 @@ 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
|
||||
// BlockPrivateNetworks 启用 SSRF 防护(P0,默认 false 保持兼容)。启用后,连接建立时
|
||||
// 校验解析出的目标 IP,拒绝回环/私有(RFC1918+ULA)/链路本地/元数据(169.254.169.254)/
|
||||
// 未指定/多播等内网地址。校验在 DialContext.Control 中进行,对重定向的每一跳同样生效。
|
||||
// 当 URL 可能来自用户输入(webhook、头像抓取等)时应开启。
|
||||
BlockPrivateNetworks bool
|
||||
}
|
||||
|
||||
// ErrSSRFBlocked 目标 IP 落在被拦截网段(SSRF 防护,P0)。
|
||||
var ErrSSRFBlocked = errors.New("ssrf guard: 目标 IP 属被拦截网段(回环/私有/链路本地/元数据)")
|
||||
|
||||
// isBlockedIP 判断 IP 是否应被 SSRF 防护拦截。
|
||||
// 覆盖:回环、私有(RFC1918 + ULA fc00::/7)、链路本地(含 169.254.169.254 云元数据 / fe80::)、
|
||||
// 未指定(0.0.0.0/::)、多播。
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast()
|
||||
}
|
||||
|
||||
// ssrfControl 是 net.Dialer.Control 回调:在 DNS 解析后、真正 dial 前校验目标 IP,
|
||||
// 命中内网段即拒绝。放在 Control 层可覆盖 DNS 重绑定与重定向的每一跳(P0)。
|
||||
func ssrfControl(network, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("%w: 无法解析地址 %q", ErrSSRFBlocked, address)
|
||||
}
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf("%w: %s", ErrSSRFBlocked, ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultHTTPClientConfig 默认配置
|
||||
@@ -50,7 +100,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 客户端
|
||||
@@ -59,10 +110,35 @@ func NewHTTPClient() *HTTPClient {
|
||||
return NewHTTPClientWithConfig(cfg)
|
||||
}
|
||||
|
||||
// NewSSRFSafeHTTPClient 创建启用 SSRF 防护的 HTTP 客户端(P0):拒绝连接内网目标 IP。
|
||||
// 适用于目标 URL 可能来自用户输入的场景(webhook 回调、远程图片抓取等)。
|
||||
func NewSSRFSafeHTTPClient() *HTTPClient {
|
||||
cfg := DefaultHTTPClientConfig
|
||||
cfg.BlockPrivateNetworks = true
|
||||
return NewHTTPClientWithConfig(cfg)
|
||||
}
|
||||
|
||||
// NewHTTPClientWithConfig 使用自定义配置创建 HTTP 客户端
|
||||
func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
|
||||
transport, client := buildHTTPClientPair(cfg)
|
||||
return &HTTPClient{
|
||||
client: client,
|
||||
transport: transport,
|
||||
cfg: cfg,
|
||||
timeout: cfg.Timeout,
|
||||
headers: make(map[string]string),
|
||||
cookies: make(map[string]string),
|
||||
skipTLS: cfg.SkipTLSVerify,
|
||||
maxRespBodySize: cfg.MaxResponseBodySize,
|
||||
}
|
||||
}
|
||||
|
||||
// buildHTTPClientPair 按 cfg 构造 (transport, client) 对。SetSkipTLS 重建时复用。
|
||||
func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
|
||||
// Transport 在初始化时创建,连接池可复用
|
||||
transport := &http.Transport{
|
||||
// #nosec G402 -- InsecureSkipVerify 仅在调用方显式设 cfg.SkipTLSVerify=true 时启用,
|
||||
// 默认 false 校验 TLS(H2 修复)。自签证书场景需 opt-in。
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.SkipTLSVerify,
|
||||
},
|
||||
@@ -72,55 +148,130 @@ func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
|
||||
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
|
||||
DisableCompression: false,
|
||||
}
|
||||
|
||||
// SSRF 防护(P0):启用后为拨号器装 Control 回调,连接建立时拦截内网目标 IP。
|
||||
if cfg.BlockPrivateNetworks {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
Control: ssrfControl,
|
||||
}
|
||||
transport.DialContext = dialer.DialContext
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
|
||||
return &HTTPClient{
|
||||
client: client,
|
||||
transport: transport,
|
||||
timeout: cfg.Timeout,
|
||||
headers: make(map[string]string),
|
||||
cookies: make(map[string]string),
|
||||
skipTLS: cfg.SkipTLSVerify,
|
||||
}
|
||||
return transport, client
|
||||
}
|
||||
|
||||
// SetTimeout 设置超时时间
|
||||
// currentClient 在读锁下快照当前 *http.Client(H-12:与 SetSkipTLS/SetTimeout 重建无竞态)。
|
||||
func (c *HTTPClient) currentClient() *http.Client {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.client
|
||||
}
|
||||
|
||||
// currentTransport 在读锁下快照当前 *http.Transport。
|
||||
func (c *HTTPClient) currentTransport() *http.Transport {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.transport
|
||||
}
|
||||
|
||||
// SetTimeout 设置超时时间。
|
||||
// H-12 修复:在写锁下重建 *http.Client(复用 transport 保留连接池),避免与并发 Do 对
|
||||
// client.Timeout 字段的数据竞争。
|
||||
func (c *HTTPClient) SetTimeout(timeout time.Duration) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.timeout = timeout
|
||||
c.client.Timeout = timeout
|
||||
c.cfg.Timeout = timeout
|
||||
_, client := buildHTTPClientPair(c.cfg)
|
||||
// 复用旧 transport 保留连接池:新 client 用旧 transport + 新 timeout。
|
||||
client.Transport = c.transport
|
||||
c.client = client
|
||||
return c
|
||||
}
|
||||
|
||||
// SetHeader 设置请求头
|
||||
// SetHeader 设置请求头(P0:写锁保护,与并发 do/DoWithResponse 读 map 无竞态)。
|
||||
func (c *HTTPClient) SetHeader(key, value string) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.headers[key] = value
|
||||
c.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
// SetHeaders 批量设置请求头
|
||||
// SetHeaders 批量设置请求头(P0:写锁保护)。
|
||||
func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
for k, v := range headers {
|
||||
c.headers[k] = v
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
// SetCookie 设置 Cookie
|
||||
// SetCookie 设置 Cookie(P0:写锁保护)。
|
||||
func (c *HTTPClient) SetCookie(key, value string) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.cookies[key] = value
|
||||
c.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
// SetSkipTLS 设置是否跳过 TLS 验证
|
||||
// 注意: 修改 TLS 配置需要重新创建 Transport
|
||||
// snapshotHeadersCookies 在读锁下拷贝 headers/cookies,供 do/DoWithResponse 在锁外应用到请求,
|
||||
// 消除与 SetHeader/SetHeaders/SetCookie 的 map 读写竞态(P0)。
|
||||
func (c *HTTPClient) snapshotHeadersCookies() (headers, cookies map[string]string) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
headers = make(map[string]string, len(c.headers))
|
||||
for k, v := range c.headers {
|
||||
headers[k] = v
|
||||
}
|
||||
cookies = make(map[string]string, len(c.cookies))
|
||||
for k, v := range c.cookies {
|
||||
cookies[k] = v
|
||||
}
|
||||
return headers, cookies
|
||||
}
|
||||
|
||||
// SetSkipTLS 设置是否跳过 TLS 验证。
|
||||
// 跳过 TLS 校验会暴露于 MITM 攻击,仅在受控环境(如自签证书的内网服务)且明确风险时启用,
|
||||
// 生产环境应保持 false。
|
||||
//
|
||||
// H-12 修复:原实现直接覆盖 c.transport.TLSClientConfig 指针,与并发 Do 读取该字段
|
||||
// 存在数据竞争(-race 必采),且注释自承"需重建 Transport"却未重建。改为在写锁下用
|
||||
// 新配置重建 transport+client 并原子替换,旧 transport 释放空闲连接(保留旧 TLS 配置的
|
||||
// idle 连接不再被复用)。支持运行期并发调用与并发请求无竞态。
|
||||
func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.skipTLS = skip
|
||||
c.transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: skip,
|
||||
c.cfg.SkipTLSVerify = skip
|
||||
transport, client := buildHTTPClientPair(c.cfg)
|
||||
oldTransport := c.transport
|
||||
c.transport = transport
|
||||
c.client = client
|
||||
c.mu.Unlock()
|
||||
// 锁外释放旧 transport 的空闲连接(CloseIdleConnections 仅关 idle 连接,不影响在途请求)。
|
||||
if oldTransport != nil {
|
||||
oldTransport.CloseIdleConnections()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SetBlockPrivateNetworks 运行期开关 SSRF 防护(P0)。开启后连接内网 IP(回环/私有/链路本地/
|
||||
// 元数据等)会被拒绝并返回 ErrSSRFBlocked。写锁下重建 transport+client 并原子替换,
|
||||
// 与并发请求无竞态;旧 transport 的空闲连接在锁外释放。
|
||||
func (c *HTTPClient) SetBlockPrivateNetworks(block bool) *HTTPClient {
|
||||
c.mu.Lock()
|
||||
c.cfg.BlockPrivateNetworks = block
|
||||
transport, client := buildHTTPClientPair(c.cfg)
|
||||
oldTransport := c.transport
|
||||
c.transport = transport
|
||||
c.client = client
|
||||
c.mu.Unlock()
|
||||
if oldTransport != nil {
|
||||
oldTransport.CloseIdleConnections()
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -211,15 +362,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 {
|
||||
@@ -285,18 +439,17 @@ func (c *HTTPClient) Request(method, urlStr string, body []byte) ([]byte, error)
|
||||
|
||||
// do 执行请求(使用共享的 client 和 transport)
|
||||
func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
|
||||
// 设置请求头
|
||||
for k, v := range c.headers {
|
||||
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
|
||||
headers, cookies := c.snapshotHeadersCookies()
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// 设置 Cookie
|
||||
for k, v := range c.cookies {
|
||||
for k, v := range cookies {
|
||||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||||
}
|
||||
|
||||
// 发送请求(使用初始化时创建的 client)
|
||||
resp, err := c.client.Do(req)
|
||||
// 发送请求(H-12:快照 client,与 SetSkipTLS/SetTimeout 重建无竞态)
|
||||
resp, err := c.currentClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -307,25 +460,48 @@ 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 执行请求并返回完整响应
|
||||
// DoWithResponse 执行请求并返回完整响应。
|
||||
//
|
||||
// 注意:调用方负责关闭返回的 resp.Body;且此方法不套用 maxRespBodySize 读封顶(由调用方掌控)。
|
||||
func (c *HTTPClient) DoWithResponse(req *http.Request) (*http.Response, error) {
|
||||
for k, v := range c.headers {
|
||||
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
|
||||
headers, cookies := c.snapshotHeadersCookies()
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
for k, v := range c.cookies {
|
||||
for k, v := range cookies {
|
||||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||||
}
|
||||
|
||||
return c.client.Do(req)
|
||||
return c.currentClient().Do(req)
|
||||
}
|
||||
|
||||
// Close 关闭客户端(释放连接池资源)
|
||||
func (c *HTTPClient) Close() {
|
||||
c.transport.CloseIdleConnections()
|
||||
if t := c.currentTransport(); t != nil {
|
||||
t.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// JSONMarshal 内部 JSON 序列化函数
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
)
|
||||
|
||||
// 回归 H2:默认 HTTPClient 必须 校验 TLS(InsecureSkipVerify=false),
|
||||
// 访问自签证书的 https server 应失败。旧实现 DefaultHTTPClientConfig.SkipTLSVerify=true,
|
||||
// HTTPGet/Post 默认可被 MITM。
|
||||
func TestHTTPClientDefaultVerifiesTLS(t *testing.T) {
|
||||
// 启动一个使用自签证书的 TLS server(httptest 自动生成证书,未被客户端信任)。
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 默认客户端(H2 修复后 SkipTLSVerify=false)应因证书校验失败而报错。
|
||||
c := utils.NewHTTPClient()
|
||||
if _, err := c.Get(srv.URL, nil); err == nil {
|
||||
t.Error("default HTTPClient should fail TLS verification against self-signed cert (H2: was InsecureSkipVerify=true)")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 H2:HTTPGet 包级函数(经 DefaultHTTPClient)默认同样校验 TLS。
|
||||
func TestHTTPGetDefaultVerifiesTLS(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := utils.HTTPGet(srv.URL, nil); err == nil {
|
||||
t.Error("HTTPGet should fail TLS verification against self-signed cert by default")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 H2:显式 SetSkipTLS(true) 后可访问自签证书 server(opt-in 跳过校验)。
|
||||
func TestHTTPClientSkipTLSOptIn(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := utils.NewHTTPClient()
|
||||
c.SetSkipTLS(true)
|
||||
|
||||
body, err := c.Get(srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Get with SkipTLS=true should succeed against self-signed cert, got: %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Errorf("body = %q, want ok", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 H2:NewHTTPClientWithConfig 显式设 SkipTLSVerify=true 可跳过校验。
|
||||
func TestHTTPClientWithConfigSkipTLS(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := utils.NewHTTPClientWithConfig(utils.HTTPClientConfig{
|
||||
SkipTLSVerify: true,
|
||||
})
|
||||
body, err := c.Get(srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Get with config SkipTLSVerify=true should succeed, got: %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Errorf("body = %q, want ok", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 H2:DefaultHTTPClientConfig.SkipTLSVerify 默认 false。
|
||||
func TestDefaultHTTPClientConfigNoSkipTLS(t *testing.T) {
|
||||
if utils.DefaultHTTPClientConfig.SkipTLSVerify {
|
||||
t.Error("DefaultHTTPClientConfig.SkipTLSVerify = true, want false (H2: default must verify TLS)")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 H2:默认 transport 的 TLSClientConfig.InsecureSkipVerify 为 false。
|
||||
// 直接构造一个对 https 的请求,确认未跳过校验。用 errors 判断是否为 x509 校验类错误。
|
||||
func TestDefaultClientTransportVerifiesTLS(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := utils.NewHTTPClient()
|
||||
// 用自定义 client 配自定义 transport 不可行(封装),直接调 Get 验证错误类型。
|
||||
_, err := c.Get(srv.URL, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected TLS verification error, got nil")
|
||||
}
|
||||
// 错误应包含证书校验相关提示(如 x509 / certificate / tls)。
|
||||
if !isTLSError(err) {
|
||||
t.Logf("err = %v (not strictly x509 classified, but default did reject — acceptable)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// isTLSError 粗略判断错误是否为 TLS 证书校验失败。
|
||||
func isTLSError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var ve *tls.CertificateVerificationError
|
||||
return errors.As(err, &ve)
|
||||
}
|
||||
|
||||
// TestHTTPClientSetSkipTLSConcurrent H-12 回归:并发 SetSkipTLS 与 Get 请求不应触发
|
||||
// 数据竞争,且 SetSkipTLS(true) 后能成功访问自签 TLS server。
|
||||
// 修复前 SetSkipTLS 直接覆盖 transport.TLSClientConfig 指针,与并发 Do 读该字段竞态。
|
||||
func TestHTTPClientSetSkipTLSConcurrent(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := utils.NewHTTPClient()
|
||||
defer c.Close()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
// 并发切换 TLS 策略
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
c.SetSkipTLS(i%2 == 0)
|
||||
}(i)
|
||||
}
|
||||
// 并发发请求(自签 server,仅 skip=true 时成功,skip=false 时 TLS 失败,均不应 panic/竞态)
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = c.Get(srv.URL, nil)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 最终切到 skip=true,应能成功访问自签 server(验证 SetSkipTLS 重建后功能正常)
|
||||
c.SetSkipTLS(true)
|
||||
if _, err := c.Get(srv.URL, nil); err != nil {
|
||||
t.Errorf("after SetSkipTLS(true), GET should succeed against self-signed server, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== P0:headers/cookies map 并发安全 =====
|
||||
|
||||
// 回归 P0:并发 SetHeader/SetCookie 与 Get 请求不应触发数据竞争(-race)。
|
||||
// 修复前 Set* 无锁写 map、do 无锁读 map,并发即竞态可 panic。
|
||||
func TestHTTPClientConcurrentHeadersNoRace(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := utils.NewHTTPClient()
|
||||
defer c.Close()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
c.SetHeader("X-Req", string(rune('A'+i%26)))
|
||||
c.SetCookie("sid", string(rune('0'+i%10)))
|
||||
c.SetHeaders(map[string]string{"X-Batch": "v"})
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = c.Get(srv.URL, nil)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ===== P0:SSRF 防护 =====
|
||||
|
||||
// 回归 P0:启用 SSRF 防护后,连接回环地址(httptest server 监听 127.0.0.1)必须被拒绝,
|
||||
// 返回 ErrSSRFBlocked。
|
||||
func TestHTTPClientSSRFBlocksLoopback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("secret-internal"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := utils.NewSSRFSafeHTTPClient()
|
||||
defer c.Close()
|
||||
|
||||
_, err := c.Get(srv.URL, nil)
|
||||
if err == nil {
|
||||
t.Fatal("SSRF-safe client should refuse to connect to loopback address")
|
||||
}
|
||||
if !errors.Is(err, utils.ErrSSRFBlocked) {
|
||||
t.Errorf("err = %v, want ErrSSRFBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P0:未启用 SSRF 防护时(默认),回环连接照常放行(兼容性回归)。
|
||||
func TestHTTPClientSSRFDisabledAllowsLoopback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := utils.NewHTTPClient() // 默认不启用 SSRF 防护
|
||||
defer c.Close()
|
||||
|
||||
body, err := c.Get(srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("default client should allow loopback, got %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Errorf("body = %q, want ok", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P0:SetBlockPrivateNetworks 运行期开关生效——开启后回环被拒。
|
||||
func TestHTTPClientSetBlockPrivateNetworks(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := utils.NewHTTPClient()
|
||||
defer c.Close()
|
||||
|
||||
// 开启前放行
|
||||
if _, err := c.Get(srv.URL, nil); err != nil {
|
||||
t.Fatalf("before enabling guard, GET should succeed, got %v", err)
|
||||
}
|
||||
// 开启后拒绝
|
||||
c.SetBlockPrivateNetworks(true)
|
||||
if _, err := c.Get(srv.URL, nil); !errors.Is(err, utils.ErrSSRFBlocked) {
|
||||
t.Errorf("after enabling guard, err = %v, want ErrSSRFBlocked", err)
|
||||
}
|
||||
}
|
||||
+102
-56
@@ -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<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
|
||||
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
|
||||
letterBytes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
digitBytes = "0123456789"
|
||||
)
|
||||
|
||||
// RandString 生成指定长度的随机字符串(字母+数字)
|
||||
func RandString(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
b := make([]byte, n)
|
||||
r := randPool.Get().(*rand.Rand)
|
||||
defer randPool.Put(r)
|
||||
// ErrRandInvalidLength 长度过大(超过 1<<20,保护熵池)。n<=0 时返回空串而非此错误。
|
||||
var ErrRandInvalidLength = errors.New("invalid random length")
|
||||
|
||||
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(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<min 自动交换。crypto/rand 读取失败时返回错误。
|
||||
func RandIntSecure(min, max int) (int, error) {
|
||||
if min == max {
|
||||
return min, nil
|
||||
}
|
||||
if max < min {
|
||||
min, max = max, min
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return min + int(n.Int64()), nil
|
||||
}
|
||||
|
||||
// RandInt64Secure 返回 [min, max) 范围内的密码学安全随机 int64。
|
||||
// 基于 crypto/rand + big.Int 拒绝采样(无偏)。min==max 返回 min;max<min 自动交换。
|
||||
func RandInt64Secure(min, max int64) (int64, error) {
|
||||
if min == max {
|
||||
return min, nil
|
||||
}
|
||||
if max < min {
|
||||
min, max = max, min
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(max-min))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return min + n.Int64(), nil
|
||||
}
|
||||
|
||||
+12
-26
@@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -51,8 +52,11 @@ func DefaultIfBlank(s, def string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// IsEmpty 检查任意类型是否为空值
|
||||
// 支持: string, []T, map[K]V, nil, 零值
|
||||
// IsEmpty 检查任意类型是否为空值。
|
||||
//
|
||||
// 实际支持(N4 文档修正):nil、空 string、空 []byte。
|
||||
// 注意:通用 []T / map[K]V 走 default 分支恒返回 false(不为空)——若需对任意 slice/map
|
||||
// 判空,请用 len() 显式判断。文档原称支持 slice/map 与实现不符,已修正。
|
||||
func IsEmpty(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
@@ -135,29 +139,11 @@ func StrLen(s string) int {
|
||||
return utf8.RuneCountInString(s)
|
||||
}
|
||||
|
||||
// EqualsIgnoreCase 不区分大小写比较字符串
|
||||
// EqualsIgnoreCase 不区分大小写比较字符串。
|
||||
//
|
||||
// L-C 修复:改用 strings.EqualFold(标准库 Unicode 大小写折叠)。原实现仅做 ASCII
|
||||
// 字节级折叠('A'-'Z' → +32),对非 ASCII 字符(如 "É" vs "é")误判为不等。
|
||||
// EqualFold 同时更快(无逐字节循环开销)。
|
||||
func EqualsIgnoreCase(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(a); i++ {
|
||||
ca := a[i]
|
||||
cb := b[i]
|
||||
if ca != cb {
|
||||
// 转小写比较
|
||||
if ca >= 'A' && ca <= 'Z' {
|
||||
ca += 32
|
||||
}
|
||||
if cb >= 'A' && cb <= 'Z' {
|
||||
cb += 32
|
||||
}
|
||||
if ca != cb {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
|
||||
+3
-2
@@ -28,10 +28,11 @@ func (b *URLBuilder) AddQuery(key, value string) *URLBuilder {
|
||||
return b
|
||||
}
|
||||
|
||||
// AddQueries 批量添加查询参数
|
||||
// AddQueries 批量添加查询参数(追加,同 key 多值共存,M2 修复:原实现用 Set 会覆盖)。
|
||||
// params 为 nil 时无操作(遍历 nil map 安全,不 panic)。
|
||||
func (b *URLBuilder) AddQueries(params map[string]string) *URLBuilder {
|
||||
for k, v := range params {
|
||||
b.query.Set(k, v)
|
||||
b.query.Add(k, v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
+219
-56
@@ -8,56 +8,10 @@ import (
|
||||
)
|
||||
|
||||
// ===== Random Tests =====
|
||||
|
||||
func TestRandString(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 := utils.RandString(tt.length)
|
||||
if tt.length <= 0 {
|
||||
if s != "" {
|
||||
t.Errorf("RandString(%d) should return empty", tt.length)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(s) != tt.length {
|
||||
t.Errorf("RandString(%d) length = %d", tt.length, len(s))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 测试 uniqueness
|
||||
results := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
s := utils.RandString(16)
|
||||
if results[s] {
|
||||
t.Error("RandString generated duplicate")
|
||||
}
|
||||
results[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandDigit(t *testing.T) {
|
||||
s := utils.RandDigit(6)
|
||||
if len(s) != 6 {
|
||||
t.Errorf("RandDigit length = %d", len(s))
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '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) {
|
||||
// 正常范围
|
||||
@@ -541,6 +495,28 @@ func TestUUIDShort(t *testing.T) {
|
||||
if len(uuid) != 32 {
|
||||
t.Errorf("UUIDShort length = %d", len(uuid))
|
||||
}
|
||||
// U1 回归:确保无破折号
|
||||
if uuid[8] == '-' || uuid[12] == '-' || uuid[16] == '-' || uuid[20] == '-' {
|
||||
t.Errorf("UUIDShort contains dashes at unexpected positions: %s", uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUUIDShort_NoDashes(t *testing.T) {
|
||||
// U1 回归:100 次生成全部不含破折号,长度精确 32
|
||||
for range 100 {
|
||||
s := utils.UUIDShort()
|
||||
if len(s) != 32 {
|
||||
t.Fatalf("UUIDShort length = %d, want 32", len(s))
|
||||
}
|
||||
for j, c := range s {
|
||||
if c == '-' {
|
||||
t.Fatalf("UUIDShort contains dash at position %d: %s", j, s)
|
||||
}
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
t.Fatalf("UUIDShort non-hex char at position %d: %c in %s", j, c, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUUIDValid(t *testing.T) {
|
||||
@@ -607,17 +583,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<min 自动交换、crypto/rand 无偏。
|
||||
func TestRandIntSecure(t *testing.T) {
|
||||
// 正常范围 [1,100)
|
||||
for i := 0; i < 1000; i++ {
|
||||
n, err := utils.RandIntSecure(1, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("RandIntSecure err: %v", err)
|
||||
}
|
||||
if n < 1 || n >= 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<min 自动交换。
|
||||
func TestRandInt64Secure(t *testing.T) {
|
||||
for i := 0; i < 1000; i++ {
|
||||
n, err := utils.RandInt64Secure(0, 1000000)
|
||||
if err != nil {
|
||||
t.Fatalf("RandInt64Secure err: %v", err)
|
||||
}
|
||||
if n < 0 || n >= 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -9,9 +11,9 @@ func UUID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// UUIDShort 生成短 UUID(无横线)
|
||||
// UUIDShort 生成短 UUID(无横线,32 个十六进制字符)
|
||||
func UUIDShort() string {
|
||||
return uuid.New().String()[:32]
|
||||
return strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||
}
|
||||
|
||||
// UUIDParse 解析 UUID 字符串
|
||||
|
||||
+13
-15
@@ -4,28 +4,29 @@ import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// L-B 修复:正则预编译到包级变量,避免 IsPhone/IsEmail/IsIPv4/IsIDCard 每次调用
|
||||
// regexp.MatchString 重编译(高 QPS 下浪费 CPU)。零行为变化。
|
||||
var (
|
||||
phoneRE = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||||
emailRE = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
ipv4RE = regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}$`)
|
||||
idCardRE = regexp.MustCompile(`^\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$`)
|
||||
)
|
||||
|
||||
// IsPhone 检查是否为有效的中国大陆手机号
|
||||
// 注意: 正则基于当前号段,新号段开放时需更新
|
||||
func IsPhone(phone string) bool {
|
||||
// 1开头,第二位为3-9,共11位
|
||||
pattern := `^1[3-9]\d{9}$`
|
||||
matched, _ := regexp.MatchString(pattern, phone)
|
||||
return matched
|
||||
return phoneRE.MatchString(phone)
|
||||
}
|
||||
|
||||
// IsEmail 检查是否为有效的邮箱地址
|
||||
func IsEmail(email string) bool {
|
||||
// 简单邮箱验证:xxx@xxx.xxx
|
||||
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
|
||||
matched, _ := regexp.MatchString(pattern, email)
|
||||
return matched
|
||||
return emailRE.MatchString(email)
|
||||
}
|
||||
|
||||
// IsIPv4 检查是否为有效的 IPv4 地址
|
||||
func IsIPv4(ip string) bool {
|
||||
pattern := `^(\d{1,3}\.){3}\d{1,3}$`
|
||||
matched, _ := regexp.MatchString(pattern, ip)
|
||||
if !matched {
|
||||
if !ipv4RE.MatchString(ip) {
|
||||
return false
|
||||
}
|
||||
// 验证每个段在 0-255 范围内
|
||||
@@ -42,10 +43,7 @@ func IsIPv4(ip string) bool {
|
||||
// IsIDCard 检查是否为有效的中国身份证号(18位)
|
||||
// 注意: 仅校验格式,不校验校验位
|
||||
func IsIDCard(id string) bool {
|
||||
// 18位身份证:6位地区码 + 8位生日 + 3位顺序码 + 1位校验码
|
||||
pattern := `^\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$`
|
||||
matched, _ := regexp.MatchString(pattern, id)
|
||||
return matched
|
||||
return idCardRE.MatchString(id)
|
||||
}
|
||||
|
||||
// IsChinese 检查字符串是否全部为中文字符
|
||||
|
||||
@@ -15,10 +15,15 @@ type PasswordConfig struct {
|
||||
RequireSpecial bool // 需要特殊字符
|
||||
}
|
||||
|
||||
// DefaultPasswordConfig 默认密码配置
|
||||
// DefaultPasswordConfig 默认密码配置。
|
||||
//
|
||||
// P1 #17:MaxLength 由 128 收敛到 72(字节)。bcrypt 在 72 字节处静默截断——
|
||||
// 若允许更长密码,两个前 72 字节相同的不同密码会被视为同一密码通过认证。
|
||||
// length 用 len()(字节)度量,与 bcrypt 的字节截断口径一致。需支持超长密码时,
|
||||
// 应在哈希前先做 SHA-256 预哈希再交给 bcrypt,而非放宽此上限。
|
||||
var DefaultPasswordConfig = PasswordConfig{
|
||||
MinLength: 8, // 最少8位
|
||||
MaxLength: 128, // 最多128位
|
||||
MinLength: 8, // 最少8位
|
||||
MaxLength: 72, // 最多72字节(bcrypt 截断边界,见上)
|
||||
RequireUpper: true,
|
||||
RequireLower: true,
|
||||
RequireDigit: true,
|
||||
|
||||
@@ -80,8 +80,8 @@ func TestDefaultPasswordConfig(t *testing.T) {
|
||||
if cfg.MinLength != 8 {
|
||||
t.Errorf("MinLength = %d, want 8", cfg.MinLength)
|
||||
}
|
||||
if cfg.MaxLength != 128 {
|
||||
t.Errorf("MaxLength = %d, want 128", cfg.MaxLength)
|
||||
if cfg.MaxLength != 72 {
|
||||
t.Errorf("MaxLength = %d, want 72 (P1 #17: bcrypt 72-byte truncation boundary)", cfg.MaxLength)
|
||||
}
|
||||
if !cfg.RequireUpper || !cfg.RequireLower || !cfg.RequireDigit {
|
||||
t.Error("Default config should require upper, lower, digit")
|
||||
@@ -346,6 +346,12 @@ func TestValidatePhone(t *testing.T) {
|
||||
if errors3 == nil {
|
||||
t.Error("Phone not starting with 1 should fail")
|
||||
}
|
||||
|
||||
// P1 #17:长度合规、以 1 开头但含非数字字符必须拒绝(原实现会误通过)。
|
||||
invalidNonDigit := TestPhone{Phone: "1abcdefghij"}
|
||||
if errs := validation.ValidateStruct(invalidNonDigit); errs == nil {
|
||||
t.Error("Phone with non-digit chars should fail (P1 #17)")
|
||||
}
|
||||
}
|
||||
|
||||
type TestUsername struct {
|
||||
@@ -377,6 +383,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) {
|
||||
|
||||
+76
-20
@@ -4,14 +4,20 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// Validator 全局验证器实例
|
||||
var Validator *validator.Validate
|
||||
// Validator 全局验证器实例。
|
||||
//
|
||||
// H-13 修复:改用 atomic.Pointer 保护读写,消除原裸指针(InitValidator 无锁写、
|
||||
// ValidateStruct 无锁读)在运行期热重载/并发校验时的数据竞争。类型由 *validator.Validate
|
||||
// 变更为 atomic.Pointer[validator.Validate](breaking:下游若直接读 validation.Validator.Struct
|
||||
// 需改用 ValidateStruct,或 validation.Validator.Load().Struct)。
|
||||
var Validator atomic.Pointer[validator.Validate]
|
||||
|
||||
// ValidationError 验证错误
|
||||
type ValidationError struct {
|
||||
@@ -77,8 +83,6 @@ func (ve ValidationErrors) FirstMessage() string {
|
||||
// InitValidator 初始化验证器
|
||||
func InitValidator() {
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
Validator = v
|
||||
|
||||
// 注册自定义标签名函数(优先使用 label,其次 json)
|
||||
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||
// 优先使用 label tag 作为字段显示名
|
||||
@@ -95,39 +99,61 @@ func InitValidator() {
|
||||
return name
|
||||
})
|
||||
|
||||
// 注册自定义验证规则
|
||||
// 注册自定义验证规则(先注册再 Store,避免并发 ValidateStruct 拿到
|
||||
// "已 Store 但自定义规则未注册完"的 validator)
|
||||
registerCustomValidations(v)
|
||||
|
||||
// 全部配置完成后再发布,确保 Load 得到的 validator 永远是完整的
|
||||
Validator.Store(v)
|
||||
}
|
||||
}
|
||||
|
||||
// registerCustomValidations 注册自定义验证规则
|
||||
// registerCustomValidations 注册自定义验证规则。
|
||||
//
|
||||
// P1 #18:每个 RegisterValidation 的错误都必须检查——注册失败会导致该 tag 静默不存在、
|
||||
// 所有输入被视为通过(password/idcard 等 fail-open 安全漏洞)。用 must 包装,在 init
|
||||
// 期以明确信息 panic(配置类错误应启动即暴露,而非运行期静默放行)。
|
||||
func registerCustomValidations(v *validator.Validate) {
|
||||
must := func(tag string, fn validator.Func) {
|
||||
if err := v.RegisterValidation(tag, fn); err != nil {
|
||||
panic(fmt.Sprintf("validation: 注册校验规则 %q 失败: %v", tag, err))
|
||||
}
|
||||
}
|
||||
|
||||
// 密码强度验证
|
||||
v.RegisterValidation("password", func(fl validator.FieldLevel) bool {
|
||||
must("password", func(fl validator.FieldLevel) bool {
|
||||
password := fl.Field().String()
|
||||
valid, _ := ValidatePassword(password)
|
||||
return valid
|
||||
})
|
||||
|
||||
// 手机号验证(中国大陆)
|
||||
v.RegisterValidation("phone", func(fl validator.FieldLevel) bool {
|
||||
must("phone", func(fl validator.FieldLevel) bool {
|
||||
phone := fl.Field().String()
|
||||
if len(phone) != 11 {
|
||||
if len(phone) != 11 || !strings.HasPrefix(phone, "1") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(phone, "1")
|
||||
// P1 #17:要求全数字——原实现仅查长度+前缀,"1abcdefghij" 这类会误通过。
|
||||
for i := 0; i < len(phone); i++ {
|
||||
if phone[i] < '0' || phone[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 用户名验证(字母开头,允许字母数字下划线)
|
||||
v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
|
||||
must("username", func(fl validator.FieldLevel) bool {
|
||||
username := fl.Field().String()
|
||||
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
|
||||
}
|
||||
@@ -136,7 +162,7 @@ func registerCustomValidations(v *validator.Validate) {
|
||||
})
|
||||
|
||||
// 手机号严格验证(验证运营商号段)
|
||||
v.RegisterValidation("phone_strict", func(fl validator.FieldLevel) bool {
|
||||
must("phone_strict", func(fl validator.FieldLevel) bool {
|
||||
phone := fl.Field().String()
|
||||
if len(phone) != 11 {
|
||||
return false
|
||||
@@ -163,16 +189,15 @@ func registerCustomValidations(v *validator.Validate) {
|
||||
return false
|
||||
})
|
||||
|
||||
// 身份证号验证(简化版)
|
||||
v.RegisterValidation("idcard", func(fl validator.FieldLevel) bool {
|
||||
// 身份证号验证(18 位带校验位;15 位仅格式,向后兼容旧号段)。
|
||||
must("idcard", func(fl validator.FieldLevel) bool {
|
||||
id := fl.Field().String()
|
||||
if len(id) != 18 && len(id) != 15 {
|
||||
return false
|
||||
}
|
||||
// 简化验证:只检查长度和基本格式
|
||||
// 基本格式:前 17 位(18 位号)或全部(15 位号)须为数字,18 位号末位可为 X。
|
||||
for i, c := range id {
|
||||
if i == len(id)-1 && len(id) == 18 {
|
||||
// 最后一位可以是 X
|
||||
if !isDigit(c) && c != 'X' && c != 'x' {
|
||||
return false
|
||||
}
|
||||
@@ -182,6 +207,12 @@ func registerCustomValidations(v *validator.Validate) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 18 位号校验校验位(M5:原仅查长度+格式,无校验位可被任意构造通过)。
|
||||
if len(id) == 18 {
|
||||
if !validateIDCardChecksum(id) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -194,13 +225,38 @@ 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 {
|
||||
v := Validator.Load()
|
||||
if v == nil {
|
||||
InitValidator()
|
||||
v = Validator.Load()
|
||||
}
|
||||
|
||||
err := Validator.Struct(s)
|
||||
err := v.Struct(s)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,22 +4,67 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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 +100,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 +145,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 +239,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 +265,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 +277,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
|
||||
}
|
||||
}
|
||||
@@ -228,18 +312,28 @@ func HandleFunc(fn HandlerFunc) gin.HandlerFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// SetCheckOrigin 设置 Origin 检查函数
|
||||
// SetCheckOrigin 设置 Origin 检查函数。
|
||||
//
|
||||
// L-H:直接写包级 upgrader.CheckOrigin 无锁,与并发 Upgrade(读 upgrader.CheckOrigin)
|
||||
// 存在数据竞争。约定仅在 HTTP 服务启动前(init/main 阶段、路由注册前)调用一次,
|
||||
// 不要在服务运行期与请求处理并发切换。运行期动态切换 Origin 策略请改用自有 upgrader 实例。
|
||||
func SetCheckOrigin(fn func(r *http.Request) bool) {
|
||||
upgrader.CheckOrigin = fn
|
||||
}
|
||||
|
||||
// Hub 连接管理中心(用于广播)
|
||||
// Hub 连接管理中心(用于广播)。
|
||||
// W1 修复:添加 stop channel + Stop() 方法,支持优雅退出。
|
||||
type Hub struct {
|
||||
connections map[*Connection]bool
|
||||
register chan *Connection
|
||||
unregister chan *Connection
|
||||
broadcast chan []byte
|
||||
mu sync.RWMutex
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once // H-8: 保证 close(stop) 仅一次,并发 Stop 不 double-close panic
|
||||
runOnce sync.Once // H-9: 保证 Run 仅执行一次
|
||||
runStarted atomic.Bool // H-9: 标记 Run 是否已启动,Stop 据此决定是否等待
|
||||
runDone chan struct{} // H-9: Run 退出时 close,替代 WaitGroup 避免 Add/Wait 竞态
|
||||
}
|
||||
|
||||
// NewHub 创建 Hub
|
||||
@@ -249,53 +343,107 @@ func NewHub() *Hub {
|
||||
register: make(chan *Connection),
|
||||
unregister: make(chan *Connection),
|
||||
broadcast: make(chan []byte, 256),
|
||||
stop: make(chan struct{}),
|
||||
runDone: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Run 运行 Hub
|
||||
// Run 运行 Hub。消费 register/unregister/broadcast/stop 四个 channel。
|
||||
//
|
||||
// W1 修复:新增 stop 退出分支,Stop() 方法 close(stop) 通知退出。
|
||||
// 死锁修复(C2a):广播分支不再向自身 unregister channel 回环。
|
||||
//
|
||||
// H-9 修复:用 runOnce 守卫 Run 仅执行一次;用 runDone channel 替代 WaitGroup。
|
||||
// 原实现 wg.Add(1) 在 Run 内、wg.Wait() 在 Stop 内,二者并发时违反 WaitGroup
|
||||
// "Add(正delta且计数器为0) 必须 happens-before Wait" 的约束,-race 必采。
|
||||
// 改为 Run 启动时 Store runStarted=true 并在退出时 close(runDone);Stop 仅在
|
||||
// runStarted 为 true 时 <-runDone 等待,未启动则直接返回,无竞态。
|
||||
func (h *Hub) Run() {
|
||||
for {
|
||||
select {
|
||||
case conn := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.connections[conn] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
case conn := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if _, ok := h.connections[conn]; ok {
|
||||
delete(h.connections, conn)
|
||||
conn.Close()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
case message := <-h.broadcast:
|
||||
h.mu.RLock()
|
||||
for conn := range h.connections {
|
||||
if err := conn.Send(message); err != nil {
|
||||
h.mu.RUnlock()
|
||||
h.unregister <- conn
|
||||
h.mu.RLock()
|
||||
h.runOnce.Do(func() {
|
||||
h.runStarted.Store(true)
|
||||
defer close(h.runDone)
|
||||
for {
|
||||
select {
|
||||
case <-h.stop:
|
||||
// 关闭所有连接后退出
|
||||
h.mu.Lock()
|
||||
for conn := range h.connections {
|
||||
delete(h.connections, conn)
|
||||
conn.Close()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return
|
||||
|
||||
case conn := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.connections[conn] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
case conn := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if _, ok := h.connections[conn]; ok {
|
||||
delete(h.connections, conn)
|
||||
conn.Close()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
case message := <-h.broadcast:
|
||||
// 持写锁单次遍历,失败的连接行内移除并关闭,不回环 unregister channel(C2a 修复)。
|
||||
h.mu.Lock()
|
||||
for conn := range h.connections {
|
||||
if err := conn.Send(message); err != nil {
|
||||
delete(h.connections, conn)
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Stop 停止 Hub(W1 修复:优雅退出机制)。
|
||||
// close(stop) 通知 Run() 退出;若 Run 已启动则等待其完全结束(<-runDone)。
|
||||
//
|
||||
// H-8 修复:用 stopOnce 保证 close(stop) 仅执行一次。原实现 select{<-stop/default:close(stop)}
|
||||
// 在并发 Stop 时两个调用方都可能走 default 分支同时 close → double-close panic;
|
||||
// 原注释声称的"stopped 标志保护"实际不存在(文档撒谎)。stopOnce 彻底消除该竞态。
|
||||
//
|
||||
// H-9 修复:仅在 runStarted 为 true 时等待 runDone;Run 未启动时直接返回,
|
||||
// 避免 WaitGroup Add/Wait 竞态,且 Stop 先于 Run 调用后误再调 Run 也不会 panic。
|
||||
// 幂等:重复/并发调用安全。
|
||||
func (h *Hub) Stop() {
|
||||
h.stopOnce.Do(func() {
|
||||
close(h.stop)
|
||||
})
|
||||
if h.runStarted.Load() {
|
||||
<-h.runDone
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册连接
|
||||
// Register 注册连接(W1 修复:Hub 已 stop 时 non-blocking 返回,避免永久阻塞)。
|
||||
func (h *Hub) Register(conn *Connection) {
|
||||
h.register <- conn
|
||||
select {
|
||||
case h.register <- conn:
|
||||
case <-h.stop:
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister 注销连接
|
||||
// Unregister 注销连接(W1 修复:Hub 已 stop 时 non-blocking 返回)。
|
||||
func (h *Hub) Unregister(conn *Connection) {
|
||||
h.unregister <- conn
|
||||
select {
|
||||
case h.unregister <- conn:
|
||||
case <-h.stop:
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast 广播消息
|
||||
// Broadcast 广播消息(W1 修复:Hub 已 stop 时 non-blocking 返回)。
|
||||
func (h *Hub) Broadcast(message []byte) {
|
||||
h.broadcast <- message
|
||||
select {
|
||||
case h.broadcast <- message:
|
||||
case <-h.stop:
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastJSON 广播 JSON 消息
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/ws"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// newWSTestServer 启动一个带 ws.Handle 的 httptest server,返回 server 与 dialer。
|
||||
// handler 可为 nil(用默认空处理器)。
|
||||
func newWSTestServer(t *testing.T, handler ws.Handler) (*httptest.Server, *websocket.Dialer) {
|
||||
t.Helper()
|
||||
r := gin.New()
|
||||
if handler == nil {
|
||||
handler = &ws.DefaultHandler{}
|
||||
}
|
||||
r.GET("/ws", ws.Handle(handler))
|
||||
srv := httptest.NewServer(r)
|
||||
t.Cleanup(srv.Close)
|
||||
dialer := &websocket.Dialer{HandshakeTimeout: 2 * time.Second}
|
||||
return srv, dialer
|
||||
}
|
||||
|
||||
func wsURL(srv *httptest.Server) string {
|
||||
return "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
|
||||
}
|
||||
|
||||
// dial 连接并返回客户端 conn。
|
||||
func dial(t *testing.T, srv *httptest.Server, dialer *websocket.Dialer) *websocket.Conn {
|
||||
t.Helper()
|
||||
c, _, err := dialer.Dial(wsURL(srv), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { c.Close() })
|
||||
return c
|
||||
}
|
||||
|
||||
// ===== C2b:Close 与并发 Send 不 panic =====
|
||||
|
||||
// 回归 C2b:并发 Close 与 Send 不能 send-on-closed panic。
|
||||
// 旧实现 Close 同时 close(c.send),并发 Send 的 select 伪随机选中 send 分支即 panic。
|
||||
func TestConnectionCloseConcurrentSendNoPanic(t *testing.T) {
|
||||
srv, dialer := newWSTestServer(t, nil)
|
||||
client := dial(t, srv, dialer)
|
||||
|
||||
// 服务端 conn 经 Handle 持有;通过 Hub 注册拿到服务端 Connection。
|
||||
hub := ws.NewHub()
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { /* Hub.Run 无退出,靠进程结束 */ })
|
||||
|
||||
// 用 HandleFunc 包装拿到服务端 Connection。
|
||||
var srvConn *ws.Connection
|
||||
var got atomic.Value // *ws.Connection
|
||||
r := gin.New()
|
||||
r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) {
|
||||
got.Store(conn)
|
||||
}))
|
||||
srv2 := httptest.NewServer(r)
|
||||
t.Cleanup(srv2.Close)
|
||||
c2, _, err := dialer.Dial("ws"+strings.TrimPrefix(srv2.URL, "http")+"/ws", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c2.Close()
|
||||
_ = client
|
||||
|
||||
// 发一条消息触发服务端 OnMessage,拿到 srvConn。
|
||||
if err := c2.WriteMessage(websocket.TextMessage, []byte("hi")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
// 等待服务端拿到连接。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if v := got.Load(); v != nil {
|
||||
srvConn = v.(*ws.Connection)
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if srvConn == nil {
|
||||
t.Fatal("timeout waiting for server-side Connection")
|
||||
}
|
||||
|
||||
// 并发:一个 goroutine 反复 Send,主 goroutine Close。
|
||||
var panicked atomic.Value
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
panicked.Store(r)
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 2000; i++ {
|
||||
// Send 在 Close 后会返回 error,正常;不应 panic。
|
||||
_ = srvConn.Send([]byte("x"))
|
||||
}
|
||||
}()
|
||||
|
||||
// 给 Send 一点启动时间再 Close,制造最大竞态窗口。
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
srvConn.Close()
|
||||
wg.Wait()
|
||||
|
||||
if p := panicked.Load(); p != nil {
|
||||
t.Fatalf("concurrent Close/Send panicked (C2b send-on-closed): %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C2b:Close 后 Send 返回 error 而非 panic。
|
||||
func TestSendAfterCloseReturnsError(t *testing.T) {
|
||||
srv, dialer := newWSTestServer(t, nil)
|
||||
c := dial(t, srv, dialer)
|
||||
defer c.Close()
|
||||
|
||||
// 通过 HandleFunc 拿服务端 conn。
|
||||
var got atomic.Value
|
||||
r := gin.New()
|
||||
r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) {
|
||||
got.Store(conn)
|
||||
}))
|
||||
srv2 := httptest.NewServer(r)
|
||||
t.Cleanup(srv2.Close)
|
||||
c2, _, err := dialer.Dial("ws"+strings.TrimPrefix(srv2.URL, "http")+"/ws", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c2.Close()
|
||||
if err := c2.WriteMessage(websocket.TextMessage, []byte("hi")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var srvConn *ws.Connection
|
||||
for time.Now().Before(deadline) {
|
||||
if v := got.Load(); v != nil {
|
||||
srvConn = v.(*ws.Connection)
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if srvConn == nil {
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
srvConn.Close()
|
||||
// Close 后 Send 必须返回 error(非 panic)。
|
||||
if err := srvConn.Send([]byte("after-close")); err == nil {
|
||||
t.Error("Send after Close should return error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== C2a:Hub 广播不死锁 =====
|
||||
|
||||
// 回归 C2a:向含"已关闭"连接的 Hub 广播不能死锁,且失败连接被行内移除。
|
||||
// 旧实现:broadcast 中 conn.Send 失败 → h.unregister <- conn(向自己发,无消费者)→ 永久阻塞。
|
||||
// 这里直接 Close 服务端连接(closeChan 关闭),使 Send 走 closeChan 分支返回 error,
|
||||
// 触发 Hub 行内 delete + Close。
|
||||
func TestHubBroadcastDeadConnectionNoDeadlock(t *testing.T) {
|
||||
hub := ws.NewHub()
|
||||
go hub.Run()
|
||||
|
||||
var got atomic.Value
|
||||
r := gin.New()
|
||||
r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) {
|
||||
got.Store(conn)
|
||||
}))
|
||||
srv2 := httptest.NewServer(r)
|
||||
t.Cleanup(srv2.Close)
|
||||
dialer := &websocket.Dialer{HandshakeTimeout: 2 * time.Second}
|
||||
c2, _, err := dialer.Dial("ws"+strings.TrimPrefix(srv2.URL, "http")+"/ws", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c2.Close()
|
||||
if err := c2.WriteMessage(websocket.TextMessage, []byte("hi")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var srvConn *ws.Connection
|
||||
for time.Now().Before(deadline) {
|
||||
if v := got.Load(); v != nil {
|
||||
srvConn = v.(*ws.Connection)
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if srvConn == nil {
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
|
||||
hub.Register(srvConn)
|
||||
deadline = time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if hub.Count() == 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if hub.Count() != 1 {
|
||||
t.Fatalf("Hub Count = %d, want 1", hub.Count())
|
||||
}
|
||||
|
||||
// 直接关闭服务端连接(closeChan 关闭),使后续 Send 返回 error。
|
||||
srvConn.Close()
|
||||
// 给 closeChan 关闭传播一点时间。
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// 广播必须在超时内返回(不阻塞 Hub),且失败连接被行内移除。
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
hub.Broadcast([]byte("should-not-deadlock"))
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Hub.Broadcast deadlocked (C2a: unregister self-send loop)")
|
||||
}
|
||||
// 等待 Hub 内部清理失败的连接。
|
||||
deadline = time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if hub.Count() == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if hub.Count() != 0 {
|
||||
t.Errorf("Hub Count = %d, want 0 (failed conn should be removed inline)", hub.Count())
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C2a/C2b:Hub 广播到正常连接,客户端能收到。
|
||||
func TestHubBroadcastReachesClients(t *testing.T) {
|
||||
hub := ws.NewHub()
|
||||
go hub.Run()
|
||||
|
||||
// 用带 Hub 注册的 handler。
|
||||
handler := &ws.DefaultHandler{
|
||||
OnConnectFunc: func(conn *ws.Connection) {
|
||||
hub.Register(conn)
|
||||
},
|
||||
}
|
||||
srv, dialer := newWSTestServer(t, handler)
|
||||
|
||||
// 两个客户端。
|
||||
c1 := dial(t, srv, dialer)
|
||||
c2 := dial(t, srv, dialer)
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
// 等待两个连接注册。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && hub.Count() < 2 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if hub.Count() != 2 {
|
||||
t.Fatalf("Hub Count = %d, want 2", hub.Count())
|
||||
}
|
||||
|
||||
// 广播 JSON。
|
||||
if err := hub.BroadcastJSON(map[string]string{"msg": "hello"}); err != nil {
|
||||
t.Fatalf("BroadcastJSON: %v", err)
|
||||
}
|
||||
|
||||
// 两个客户端都应收到。
|
||||
for i, c := range []*websocket.Conn{c1, c2} {
|
||||
c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, msg, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("client %d read: %v", i, err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(msg, &m); err != nil {
|
||||
t.Fatalf("client %d unmarshal %q: %v", i, string(msg), err)
|
||||
}
|
||||
if m["msg"] != "hello" {
|
||||
t.Errorf("client %d got %q, want hello", i, m["msg"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C2a-residual:Send 非阻塞——缓冲满立即返回 ErrSendBufferFull 而非阻塞等待。
|
||||
// 旧实现(阻塞 select)在 writePump 退出但 closeChan 未关、send 缓冲满时会阻塞最长 pongWait,
|
||||
// 导致 Hub 广播持写锁期间 stall。非阻塞投递保证持锁期间永不阻塞。
|
||||
// 用 internal test(ws_send_internal_test.go)直接测私有 send channel,此处仅验证公开行为:
|
||||
// 连接 Close 后,Send 立即返回(不阻塞)。
|
||||
func TestSendNonBlockingOnClosed(t *testing.T) {
|
||||
var got atomic.Value
|
||||
r := gin.New()
|
||||
r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) {
|
||||
got.Store(conn)
|
||||
}))
|
||||
srv := httptest.NewServer(r)
|
||||
t.Cleanup(srv.Close)
|
||||
dialer := &websocket.Dialer{HandshakeTimeout: 2 * time.Second}
|
||||
c, _, err := dialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.WriteMessage(websocket.TextMessage, []byte("hi")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var srvConn *ws.Connection
|
||||
for time.Now().Before(deadline) {
|
||||
if v := got.Load(); v != nil {
|
||||
srvConn = v.(*ws.Connection)
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if srvConn == nil {
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
srvConn.Close()
|
||||
|
||||
// Close 后连续 Send 必须立即返回 error,不阻塞。
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 1000; i++ {
|
||||
_ = srvConn.Send([]byte("x"))
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Send after Close blocked (should be non-blocking)")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== C2c:半开连接超时退出(不永久泄漏 goroutine)=====
|
||||
|
||||
// 回归 C2c:客户端断开后,服务端读循环应在 pongWait 超时内退出(非永久阻塞)。
|
||||
// 用短超时配置不便(常量包级),此处用真实连接断开 + 有限等待验证 OnClose 被调用。
|
||||
func TestHalfOpenConnectionExitsOnClose(t *testing.T) {
|
||||
var closed atomic.Int32
|
||||
handler := &ws.DefaultHandler{
|
||||
OnCloseFunc: func(conn *ws.Connection) {
|
||||
closed.Add(1)
|
||||
},
|
||||
}
|
||||
srv, dialer := newWSTestServer(t, handler)
|
||||
c := dial(t, srv, dialer)
|
||||
|
||||
// 客户端主动关闭(发 close 帧)。
|
||||
c.Close()
|
||||
|
||||
// 服务端应在有限时间内 OnClose。pongWait=60s 太长,但客户端发的是正常 close,
|
||||
// ReadMessage 立即返回 close 错误,OnClose 应很快触发。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if closed.Load() > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Errorf("OnClose not called within timeout (C2c: read loop should exit on client close), closed=%d", closed.Load())
|
||||
}
|
||||
|
||||
// ===== 辅助:验证 errors 导入被使用 =====
|
||||
var _ = errors.New
|
||||
var _ = http.StatusOK
|
||||
|
||||
// TestHubStopConcurrentNoPanic H-8 回归:并发 Stop 不应 double-close panic。
|
||||
// 修复前 Stop 用 select{<-stop/default:close(stop)},两个 goroutine 都走 default
|
||||
// 同时 close → panic。修复后 stopOnce 保证 close 仅一次。
|
||||
func TestHubStopConcurrentNoPanic(t *testing.T) {
|
||||
hub := ws.NewHub()
|
||||
go hub.Run()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
hub.Stop() // 并发 Stop
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 再调一次 Stop(已 stop)也应安全返回
|
||||
hub.Stop()
|
||||
}
|
||||
|
||||
// TestHubStopBeforeRunNoPanic H-9 回归:Stop 先于 Run 调用,随后 Run 不应
|
||||
// 触发 wg 负计数 panic。
|
||||
func TestHubStopBeforeRunNoPanic(t *testing.T) {
|
||||
hub := ws.NewHub()
|
||||
hub.Stop() // Run 尚未启动,Wait 立即返回
|
||||
|
||||
// 之后启动 Run(实际场景是误用),应安全退出而非 panic
|
||||
go hub.Run()
|
||||
// 给 Run 一点时间观察到 stop 已 close 并退出
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
// 再次 Stop 确保 wg 归零(Run 的 runOnce 已执行,wg.Add/Done 配对)
|
||||
hub.Stop()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user