fix: 收口剩余语义契约问题

This commit is contained in:
杭州明婳科技
2026-07-08 23:52:23 +08:00
parent 2529b0a074
commit 21021d4001
14 changed files with 342 additions and 30 deletions
+6 -3
View File
@@ -40,19 +40,21 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
- **限流器非法配置改为 fail-fast**middleware/ratelimit.goM8):`NewRateLimiter` / `NewRedisRateLimiter` / `NewRedisRateLimiterFailClosed` 现在对 `rate <= 0``window <= 0` 直接 panic,避免零值窗口/零值配额静默产生不确定限流语义。下游应在配置加载阶段校验限流参数。
- **`handler.BindJSON` 默认限制 JSON body 为 1MiB**handler/handler.goM6):防止入口层无上限读取请求体导致 OOM。需要更大 JSON 的接口请改用 `handler.BindJSONWithMaxBytes(c, req, maxBytes)` 显式声明上限。
- **cron 非法任务配置改为 fail-fast**cron/cron.goM13):`AddTask` 拒绝 nil schedule / nil handler`Every(<=0)``Daily`/`Weekly` 越界时间、非法 weekday 会 panic,避免静默生成不推进或归一化跑偏的调度。
- **`cron.ParseCron` 非法表达式改为 fail-fast panic**cron/cron.goM13):旧行为会把非法表达式静默回退为每分钟执行,容易让拼写错误变成高频任务。动态输入请用 `ParseCronStrict` 处理 error;确实需要旧回退语义时改用新增 `ParseCronOrDefault`
- **repository 查询保护默认开启**repository/repository.goM5/N5):`FindAll` 默认最多返回 `DefaultFindAllLimit=1000` 条;明确需要全表扫描时改用 `FindAllUnbounded``FindPage*` / `QueryBuilder.Page` 会归一化 `page/pageSize` 并限制 `MaxPageSize=100``MaxPage=10000``Find*Ordered` / `QueryBuilder.Order` 只接受简单字段排序(如 `created_at DESC, id ASC`),复杂表达式/raw SQL 会返回 `ErrUnsafeOrder``UpdateBatch` 字段名不合法返回 `ErrUnsafeField`
### Fixed 🐛
- **M9 JWT issuer / refresh expiry 契约修复**`ParseToken``InvalidateToken``GetClaimsFromToken` 统一按当前配置校验 issuer`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键。
- **M9 JWT issuer / refresh expiry 契约修复**`ParseToken``InvalidateToken``GetClaimsFromToken` 统一按当前配置校验 issuer`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键;新增 `ParseTokenFailClosed` / `ParseTokenWithBlacklistPolicy` / `TokenBlacklist.IsBlacklistedE`,默认 `ParseToken` 仍保持黑名单检查 fail-open 兼容语义,安全敏感路由可显式选择 fail-closed;解析侧配置错误现在可通过 `errors.Is` 区分 `ErrEmptySecret` / `ErrUnsupportedAlgorithm``InvalidateToken` 使用不校验时序的解析路径,允许提前吊销 `nbf` 在未来的外部 token
- **M10 分布式锁参数与取消传播修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady``WithLock` / `WithLockAutoExtend` 现在把调用方 ctx 传入业务函数,避免取消后业务函数继续运行。
- **M10 cache 剩余错误语义收口**:新增 `cache.ExistsE` 与可选 `CacheExistChecker`,让调用方能区分 key 不存在和 Redis/backend 故障;保留旧 `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。
- **M10 cache 剩余错误语义收口**:新增 `cache.GetE` / `cache.ExistsE` 与可选 `CacheGetter` / `CacheExistChecker`,让调用方能区分 cache miss、Redis/backend 故障和反序列化错误;保留旧 `Get` / `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。
- **M2 config 热重载生命周期修复**:`StopWatcher` 会等待已触发的 reload/回调结束;包级 `Load` / `LoadWithWatch` 只有在新配置成功加载并启动 watcher 后才替换默认 manager,失败时保留旧 watcher`SetDefaultManager` 会停止旧 manager 的 watcher,避免全局置换后遗留 goroutine;数据库配置出现字段时会校验 driver/host/name/port,未知 driver 不再静默回退 MySQL`database.Dialector` 对未知 driver fail-closedMySQL DSN 转义用户名/库名,Postgres DSN 统一转义字符串字段。
- **M4 database nil 边界修复**`InitDB(ctx, nil)` / `InitDBWithReplicas(ctx, nil, ...)` / `InitRedis(nil)` 现在返回中文错误,不再空指针 panic;`UseMaster(nil)` / `UseReplica(nil)` / `GetDBFromContext(nil)` / `WithTx(nil, ...)` / `TxFromContext(nil)` / `TransactionWithContext(nil, ...)` / `ReadQuery(nil, ...)` / `WriteQuery(nil, ...)` / Redis health check 会把 nil context 归一化为 `context.Background()`,避免异常调用路径触发 panic`Dialector(nil)` 安全回退到 MySQL 空 DSN。
- **M4 database 全局置换资源释放修复**:`SetDefaultManager` / `SetDefaultRedisManager` 替换全局默认 manager 时会关闭旧 DB/Redis manager,避免包级默认资源反复置换后连接池泄漏;App 初始化改用 `SwapDefaultManager` / `SwapDefaultRedisManager` 暂存旧资源,保证失败回滚仍能恢复旧默认资源。
- **M4 database 初始化生命周期修复**:DB 初始化与主从库初始化统一接收 ctx,主库/从库 Ping 使用 `PingContext`,重试等待改为 `select ctx.Done()/time.After``Manager` 用生命周期锁串行化 Init/Close,避免 shutdown 与运行期重建交错;运行期重建从库后会立即重建健康标记,探活循环也能在发现健康标记缺失时自愈;包级 `HealthCheck()` 固定读取一次默认 manager 快照。
- **M11 SSE 换行注入修复**`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。
- **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost`ValidateStruct(nil)` 直接返回 nil。
- **M15 utils 转换语义收口**:新增 `utils.ToIntE` / `utils.ToInt64E` 返回解析错误;旧 `ToInt` / `ToInt64` 保留“失败返回 0”的兼容行为,调用方在 0 有业务含义时应迁移到严格变体。
- **M16 测试工具、脚手架与示例闭环修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache``MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码;`examples/full` 启动时初始化 `alice/secret`,登录校验 bcrypt 哈希,创建用户也保存哈希,避免示例首次运行无法登录或传播不验密/明文密码模式;README/GUIDE 限流示例不再引用不存在的 `handler.Login` / `handler.Upload`
- **M16 GUIDE/test API 不一致修复**:GUIDE 测试示例不再调用不存在的 `AssertCode` / `AssertJSONKeyExists`,统一改用现有 `AssertJSONContains`,避免照文档编写测试直接编译失败。
- **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。
@@ -60,6 +62,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
- **M13 cron handler panic 未 recover 崩进程**cron/cron.go):`RunTask``checkAndRun` 调度 goroutine 统一经新增 `executeTask(t)` 边界 `recover`panic 转为 error(含 `debug.Stack` 调用栈)记入 `task.LastError` 并向上返回,不再终止进程。外侧 `defer wg.Done()`/`running` 守卫释放不受影响(recover 在边界内完成)。顺带修复 `RunTask` 手动路径此前只更 `LastRun/RunCount`、不记 `LastError` 的子问题(现与调度路径一致)。
- **M6 response/handler 入口防护**response/error.goresponse/response.gohandler/handler.go):`FailWithError(nil)` / `FailWithDetail(nil, ...)` 回退统一服务器错误响应,不再 nil deref panic;新增 `response.DownloadReader` 支持大文件/对象存储流式下载,旧 `Download` / `DownloadWithContentType` 保持兼容并复用同一响应头逻辑。
- **M14 trace 剩余记录补齐**trace/trace.go):`SampleRatio` 拒绝 NaN/越界值;`Init`/`Close` 使用操作超时并在初始化失败时回滚 provider;`RecordError` / `RecordErrorToSpan` 对 nil 输入 no-op`Middleware(serviceName)` 写入 `service.name` attribute`X-Trace-ID` 不再输出全零 TraceID。
- **M7 health/readiness 探活超时**router/router.go):`HealthCheck` 新增 `Timeout` 字段,默认每个依赖检查 2s 超时;超时项返回 `"timeout"` 并使 `/health` / `/readyz` 返回 503。单个 check 同时最多一个执行中,panic 会 recover 为错误,避免 k8s/LB/监控探活被挂死依赖无限卡住或无限堆积 goroutine。
- **M13 cron 剩余边界收口**cron/cron.go):Stop 后再次 Start 会重建调度器 context,手动和调度执行不再收到已取消 ctx;Start/Stop 生命周期串行化,避免 Stop 等待期间重新 Start 触发 WaitGroup Add/Wait 交错。
- **M5 repository 安全边界收口**repository/repository.go):nil ctx 统一按 `context.Background()` 处理;`FindByIDs(nil)` 返回非 nil 空切片;`NewQueryBuilder` 复用 nil DB 明确 panic;批量空 ids 写操作 no-op;默认 `FindAll` 加上限并新增 `FindAllUnbounded`;排序/字段名白名单避免便捷 API 误接 raw SQL。
@@ -301,7 +304,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
- **C12b 无重叠守卫**`checkAndRun` 每秒 tick,长任务跨 tick 被反复 spawn 同一任务并发执行。新增 per-task `running *atomic.Bool` 守卫,`checkAndRun``RunTask` 均经 `CompareAndSwap(false,true)` 占用,正在执行则跳过/返错。
- **C12c Interval 漂移**`NextRun` 原在 handler 完成后以 `time.Now()` 锚定,每周期累积 handler 时长。改为 `checkAndRun` spawn 前 `task.NextRun = task.Schedule.Next(task.NextRun)`(以上次 `NextRun` 锚定),`runTask`/`RunTask` 不再更新 `NextRun`
- **C12d Weekly 跳周**:原 `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻,当天目标未到点被跳一周。重写为 `((day-now)+7)%7` 加天数后 `!next.After(now)` 才 +7,当天未到点返回本周、已过返回下周。
- **C12e cron 解析缺陷**`parseInt` 忽略非数字逐位累积,`1-5,8` 因先判 `-` 被当范围(`parseInt("5,8")=58`)、`garbage`→0 误触发、`*/garbage`→step=0 匹配全部、周日 `7` 不匹配。重写 `matchField`:列表分支独立于范围分支(先按逗号拆,每项判 `*/n`/`a-b/n`/`a-b`/单值),全用 `strconv.Atoi` 返错;weekday `7→0`,范围 `lo>hi` 环绕;歧义范围 `0-7`/`7-0` 拒绝。新增 `ParseCronStrict(expr) (*FullCronSchedule, error)` 严格校验;`ParseCron` 保留原签名,非法回退默认全 `*`
- **C12e cron 解析缺陷**`parseInt` 忽略非数字逐位累积,`1-5,8` 因先判 `-` 被当范围(`parseInt("5,8")=58`)、`garbage`→0 误触发、`*/garbage`→step=0 匹配全部、周日 `7` 不匹配。重写 `matchField`:列表分支独立于范围分支(先按逗号拆,每项判 `*/n`/`a-b/n`/`a-b`/单值),全用 `strconv.Atoi` 返错;weekday `7→0`,范围 `lo>hi` 环绕;歧义范围 `0-7`/`7-0` 拒绝。新增 `ParseCronStrict(expr) (*FullCronSchedule, error)` 严格校验;当前 Unreleased 已进一步将 `ParseCron` 非法输入改为 fail-fast panic,旧版回退默认全 `*` 的兼容语义迁移到 `ParseCronOrDefault`
- 无 API 签名变更(`ParseCron` 仍返 `*FullCronSchedule``AddTask`/`RunTask`/`GetTask`/`ListTasks` 签名不变);新增 `ParseCronStrict`(非 breaking)。`Task` 新增未导出 `running` 字段(外部不可构造)。行为变更:`GetTask`/`ListTasks` 返回拷贝(修改返回值不影响内部状态);`RunTask` 占用守卫期间再次调用返"任务正在执行中"错误;长任务不再重叠;调度不漂移;Weekly 当天未到点不再跳周;cron 解析拒绝非法表达式。
#### C13`trace/trace.go` opt-in 即崩 + 未实现导出器/传播器 + Middleware 不更新 c.Requesttrace/trace.go
+5
View File
@@ -774,9 +774,11 @@ response.FailWithDetail(c, response.ErrPasswordWrong, "连续错误3次将锁定
```go
// 文件下载
response.Download(c, "report.xlsx", fileData)
// 大文件/对象存储流式下载优先使用 DownloadReader
// HTML响应
response.HTML(c, "<html>...</html>")
// HTML 会原样输出;只传入可信或已清洗的 markup
// 页面跳转
response.Redirect(c, 302, "https://example.com")
@@ -1545,6 +1547,9 @@ cron.AddTask("noon", cron.Cron("0", "12"), doSomething) // 每天12:00
cron.AddTask("complex", cron.ParseCron("*/15 * * * *"), doSomething) // 每15分钟
cron.AddTask("monthly", cron.ParseCron("0 0 1 * *"), doSomething) // 每月1号凌晨
cron.AddTask("workday", cron.ParseCron("0 9-17 * * 1-5"), doSomething) // 工作日9-17点
// ParseCron 对非法表达式 fail-fast panic;动态输入请用 ParseCronStrict 处理 error。
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
```
### 13.2 启动与停止
+18
View File
@@ -357,6 +357,18 @@ if cacheService.Get(ctx, "user:1", &user) {
// 缓存命中
}
// 严格区分缓存未命中与 Redis/反序列化错误
if hit, err := cache.GetE(ctx, "user:1", &user); err != nil {
return err
} else if hit {
// 缓存命中
}
if exists, err := cache.ExistsE(ctx, "user:1"); err != nil {
return err
} else if exists {
// key 存在
}
// 删除缓存
cacheService.Delete(ctx, "user:1")
@@ -373,6 +385,9 @@ token, err := jwt.GenerateToken(userID, username, "admin", "admin")
// 解析 Token
claims, err := jwt.ParseToken(tokenString)
// 安全敏感路由可要求黑名单检查 fail-closed
claims, err = jwt.ParseTokenFailClosed(tokenString)
// 使 Token 失效(使用 JTI,高效)
jwt.InvalidateToken(tokenString)
@@ -531,6 +546,9 @@ cron.AddTask("weekly", cron.Weekly(time.Monday, 9, 0), weeklyTask)
cron.AddTask("every15min", cron.ParseCron("*/15 * * * *"), doSomething)
cron.AddTask("monthly", cron.ParseCron("0 0 1 * *"), doSomething) // 每月1号
// ParseCron 对非法表达式 fail-fast panic;动态输入请用 ParseCronStrict 处理 error。
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
// 启动调度器
cron.Start()
defer cron.Stop()
+43
View File
@@ -33,6 +33,12 @@ type CacheExistChecker interface {
ExistsE(ctx context.Context, key string) (bool, error)
}
// CacheGetter is implemented by cache backends that can distinguish a cache
// miss from a backend failure while loading a value.
type CacheGetter interface {
GetE(ctx context.Context, key string, dest any) (bool, error)
}
// redisCache Redis 缓存实现。
//
// 不在构造时快照 redis.ClientM12 修复:原 NewRedisCache 构造时取 database.GetRedis()
@@ -73,6 +79,29 @@ func (c *redisCache) Get(ctx context.Context, key string, dest any) bool {
return true
}
// GetE loads a cached JSON value and returns backend/deserialize errors to
// callers that need to distinguish a cache miss from Redis or data failures.
func (c *redisCache) GetE(ctx context.Context, key string, dest any) (bool, error) {
cli := c.client()
if cli == nil {
return false, ErrRedisNotReady
}
val, err := cli.Get(ctx, key).Result()
if err != nil {
if err == redis.Nil {
return false, nil
}
return false, err
}
if err := json.Unmarshal([]byte(val), dest); err != nil {
return false, err
}
return true, nil
}
// Set 设置缓存值
func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
cli := c.client()
@@ -255,3 +284,17 @@ func ExistsE(ctx context.Context, key string) (bool, error) {
}
return svc.Exists(ctx, key), nil
}
// GetE loads a cached JSON value and returns backend/deserialize errors. It
// complements the legacy bool-only CacheService.Get method without changing
// that public interface for downstream custom cache implementations.
func GetE(ctx context.Context, key string, dest any) (bool, error) {
svc := GetCache()
if svc == nil {
return false, ErrRedisNotReady
}
if getter, ok := svc.(CacheGetter); ok {
return getter.GetE(ctx, key, dest)
}
return svc.Get(ctx, key, dest), nil
}
+53
View File
@@ -95,6 +95,41 @@ func TestM10ExistsEReturnsBackendErrors(t *testing.T) {
}
}
func TestM10GetEReturnsBackendAndDecodeErrors(t *testing.T) {
setupM10MiniRedis(t)
c := &redisCache{}
ctx := context.Background()
var got string
ok, err := c.GetE(ctx, "missing", &got)
if err != nil || ok {
t.Fatalf("GetE missing = %v, err=%v; want false,nil", ok, err)
}
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set present: %v", err)
}
ok, err = c.GetE(ctx, "present", &got)
if err != nil || !ok || got != "value" {
t.Fatalf("GetE present = %v, %q, err=%v; want true,value,nil", ok, got, err)
}
if err := c.client().Set(ctx, "bad-json", "{", time.Minute).Err(); err != nil {
t.Fatalf("Set bad-json: %v", err)
}
ok, err = c.GetE(ctx, "bad-json", &got)
if err == nil || ok {
t.Fatalf("GetE bad-json = %v, err=%v; want false,error", ok, err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
ok, err = c.GetE(canceled, "present", &got)
if err == nil || ok {
t.Fatalf("GetE canceled = %v, err=%v; want false,error", ok, err)
}
}
func TestM10PackageExistsEUsesOptionalInterface(t *testing.T) {
setupM10MiniRedis(t)
orig := GetDefaultCache()
@@ -111,3 +146,21 @@ func TestM10PackageExistsEUsesOptionalInterface(t *testing.T) {
t.Fatalf("facade ExistsE = %v, err=%v; want true,nil", exists, err)
}
}
func TestM10PackageGetEUsesOptionalInterface(t *testing.T) {
setupM10MiniRedis(t)
orig := GetDefaultCache()
t.Cleanup(func() { SetDefaultCacheManager(orig) })
SetDefaultCacheManager(NewCacheManager())
Init()
ctx := context.Background()
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
t.Fatalf("Set through facade: %v", err)
}
var got string
ok, err := GetE(ctx, "present", &got)
if err != nil || !ok || got != "value" {
t.Fatalf("facade GetE = %v, %q, err=%v; want true,value,nil", ok, got, err)
}
}
+14 -1
View File
@@ -621,8 +621,21 @@ func parseCronRange(s string, min, max int) (int, int, error) {
// "0 0 1 * *" - 每月1号凌晨
// "0 0 * * 0" - 每周日凌晨
//
// 非法表达式回退默认全 "*"(每分钟执行)。需要严格校验请用 ParseCronStrict。
// 非法表达式会 panicfail-fast),动态输入请用 ParseCronStrict 处理 error
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
func ParseCron(expr string) *FullCronSchedule {
if sched, err := ParseCronStrict(expr); err == nil {
return sched
} else {
panic(err)
}
}
// ParseCronOrDefault parses a Cron expression and falls back to all "*" (every
// minute) when expr is invalid. Prefer ParseCronStrict or ParseCron for new
// code; this helper exists for callers that intentionally want legacy fallback
// semantics.
func ParseCronOrDefault(expr string) *FullCronSchedule {
if sched, err := ParseCronStrict(expr); err == nil {
return sched
}
+18 -9
View File
@@ -264,15 +264,15 @@ func TestC12eParseCronStrict(t *testing.T) {
{"0 12 * * *", true},
{"*/15 * * * *", true},
{"0 9-17 * * 1-5", true},
{"0 0 1 * 7", true}, // 周日 7 合法
{"0 0 1 * 7", true}, // 周日 7 合法
{"0 0 * * 0-7", false},
{"invalid", false},
{"1-5,8 0 * * *", true},
{"60 0 * * *", false}, // 分钟越界
{"0 25 * * *", false}, // 小时越界
{"0 0 0 * *", false}, // 日越界
{"0 0 * 13 *", false}, // 月越界
{"0 0 * * 9", false}, // 周越界
{"60 0 * * *", false}, // 分钟越界
{"0 25 * * *", false}, // 小时越界
{"0 0 0 * *", false}, // 日越界
{"0 0 * 13 *", false}, // 月越界
{"0 0 * * 9", false}, // 周越界
{"garbage 0 * * *", false},
{"*/0 * * * *", false}, // step=0 非法
}
@@ -287,11 +287,20 @@ func TestC12eParseCronStrict(t *testing.T) {
}
}
// TestC12eParseCronFallback 验证 ParseCron 非法回退默认全 *(保持原行为)。
// TestC12eParseCronFallback verifies ParseCron now fails fast on invalid input.
func TestC12eParseCronFallback(t *testing.T) {
s := cron.ParseCron("invalid")
defer func() {
if recover() == nil {
t.Fatal("ParseCron(invalid) should panic")
}
}()
_ = cron.ParseCron("invalid")
}
func TestC12eParseCronOrDefaultFallback(t *testing.T) {
s := cron.ParseCronOrDefault("invalid")
if s.Minute != "*" || s.Hour != "*" || s.Day != "*" || s.Month != "*" || s.Weekday != "*" {
t.Errorf("ParseCron(invalid) should fall back to all-*, got %+v", s)
t.Errorf("ParseCronOrDefault(invalid) should fall back to all-*, got %+v", s)
}
// 合法表达式不回退。
s = cron.ParseCron("1-5,8 0 * * *")
+9 -5
View File
@@ -232,10 +232,14 @@ func TestParseCron(t *testing.T) {
}
// 无效表达式返回默认
schedule = cron.ParseCron("invalid")
if schedule.Minute != "*" || schedule.Hour != "*" {
t.Error("ParseCron invalid should return default")
}
func() {
defer func() {
if recover() == nil {
t.Fatal("ParseCron invalid should panic")
}
}()
_ = cron.ParseCron("invalid")
}()
}
func TestFullCronScheduleMatch(t *testing.T) {
@@ -261,4 +265,4 @@ func TestFullCronScheduleMatch(t *testing.T) {
if next.Minute() != 10 || next.Hour() != 10 {
t.Errorf("Next after 10:07 should be 10:10, got %v", next)
}
}
}
+78 -12
View File
@@ -118,6 +118,16 @@ func (tb *TokenBlacklist) redisClient() *redis.Client {
// 注:ctx 超时只约束命令往返,不影响 Set 的服务端 TTL(ttl 可远大于 1s)。
const blacklistOpTimeout = 1 * time.Second
// BlacklistPolicy 控制解析 Token 时遇到黑名单查询错误的处理策略。
// ParseToken 为兼容未启用 Redis 的存量部署默认 fail-open;安全敏感路由应使用
// ParseTokenFailClosed 或 ParseTokenWithBlacklistPolicy(..., BlacklistFailClosed)。
type BlacklistPolicy int
const (
BlacklistFailOpen BlacklistPolicy = iota
BlacklistFailClosed
)
// blacklistCtx 创建带超时的 context 用于黑名单 Redis 操作(M-A)。
func blacklistCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), blacklistOpTimeout)
@@ -174,6 +184,24 @@ func (tb *TokenBlacklist) IsBlacklisted(jti string) bool {
return n > 0
}
// IsBlacklistedE 检查 JTI 是否在黑名单中,并返回 Redis/后端错误。
// 当黑名单可用性属于路由安全契约时使用它;IsBlacklisted 保留旧版 fail-open bool API。
func (tb *TokenBlacklist) IsBlacklistedE(jti string) (bool, error) {
client := tb.redisClient()
if client == nil {
return false, ErrBlacklistUnavailable
}
ctx, cancel := blacklistCtx()
defer cancel()
key := fmt.Sprintf("jwt_bl:%s", jti)
n, err := client.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
// Manager JWT 管理器(#10)。持有独立的 TokenBlacklist
// 支持多实例(如区分 user-token 与 refresh-token 黑名单)。
type Manager struct {
@@ -340,29 +368,67 @@ func validateIssuer(cfg *config.Config, claims *Claims) error {
return nil
}
func mapParseTokenError(err error) error {
if errors.Is(err, jwt.ErrTokenExpired) {
return ErrTokenExpired
}
if errors.Is(err, jwt.ErrTokenMalformed) {
return ErrTokenMalformed
}
if errors.Is(err, jwt.ErrTokenNotValidYet) {
return ErrTokenNotValidYet
}
if errors.Is(err, ErrEmptySecret) {
return ErrEmptySecret
}
if errors.Is(err, ErrUnsupportedAlgorithm) {
return ErrUnsupportedAlgorithm
}
return fmt.Errorf("%w: %w", ErrTokenInvalid, err)
}
func checkTokenBlacklist(claims *Claims, policy BlacklistPolicy) error {
if claims == nil || claims.JTI == "" {
return nil
}
revoked, err := currentBlacklist().IsBlacklistedE(claims.JTI)
if err != nil {
if policy == BlacklistFailClosed {
return err
}
logger.Warn("JWT 黑名单检查失败,fail-open 策略放行 token", zap.String("jti", claims.JTI), zap.Error(err))
return nil
}
if revoked {
return ErrTokenRevoked
}
return nil
}
// ParseToken 解析 JWT Token
func ParseToken(tokenString string) (*Claims, error) {
return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailOpen)
}
// ParseTokenFailClosed 解析 JWT Token;若黑名单后端不可检查,则拒绝该 Token。
func ParseTokenFailClosed(tokenString string) (*Claims, error) {
return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailClosed)
}
// ParseTokenWithBlacklistPolicy 使用显式黑名单查询策略解析 JWT Token。
func ParseTokenWithBlacklistPolicy(tokenString string, policy BlacklistPolicy) (*Claims, error) {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), parseOptions(cfg)...)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
if errors.Is(err, jwt.ErrTokenMalformed) {
return nil, ErrTokenMalformed
}
if errors.Is(err, jwt.ErrTokenNotValidYet) {
return nil, ErrTokenNotValidYet
}
return nil, ErrTokenInvalid
return nil, mapParseTokenError(err)
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
// 使用 JTI 检查黑名单(更高效)
if claims.JTI != "" && currentBlacklist().IsBlacklisted(claims.JTI) {
return nil, ErrTokenRevoked
if err := checkTokenBlacklist(claims, policy); err != nil {
return nil, err
}
return claims, nil
}
+60
View File
@@ -505,6 +505,66 @@ func TestInvalidateTokenRejectsEmptyJTI(t *testing.T) {
}
}
func TestParseTokenEmptySecretReturnsSentinel(t *testing.T) {
setupTestConfig()
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
if err != nil {
t.Fatalf("GenerateToken before empty secret: %v", err)
}
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
t.Cleanup(setupTestConfig)
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrEmptySecret) {
t.Fatalf("ParseToken empty secret err = %v, want ErrEmptySecret", err)
}
}
func TestParseTokenBlacklistPolicy(t *testing.T) {
setupTestConfig()
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) })
token, err := jwt.GenerateToken(1, "test", "admin", "admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := jwt.ParseToken(token); err != nil {
t.Fatalf("ParseToken default fail-open should pass without Redis: %v", err)
}
if _, err := jwt.ParseTokenFailClosed(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) {
t.Fatalf("ParseTokenFailClosed err = %v, want ErrBlacklistUnavailable", err)
}
}
func TestInvalidateTokenAllowsFutureNotBeforeToken(t *testing.T) {
setupTestConfig()
mr := setupMiniRedis(t)
claims := jwt.Claims{
UserID: 1,
Username: "external",
Role: "admin",
UserType: "admin",
JTI: "future-jti",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now().Add(10 * time.Minute)),
Issuer: "xlgo",
ID: "future-jti",
},
}
token := signTokenForTest(t, claims)
if err := jwt.InvalidateToken(token); err != nil {
t.Fatalf("InvalidateToken future nbf: %v", err)
}
if !mr.Exists("jwt_bl:future-jti") {
t.Fatal("future nbf token JTI should be blacklisted")
}
}
func signTokenForTest(t *testing.T, claims jwt.Claims) string {
t.Helper()
token, err := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims).SignedString([]byte("test-secret-key-1234567890123456789012"))
+3
View File
@@ -127,6 +127,8 @@ func asciiFallbackName(filename string) string {
}
// Download 文件下载响应
// Compatibility note: for large files or object-storage streams, prefer
// DownloadReader so the whole object is not buffered in []byte first.
func Download(c *gin.Context, filename string, data []byte) {
DownloadReader(c, filename, "application/octet-stream", int64(len(data)), bytesReader(data))
}
@@ -156,6 +158,7 @@ func bytesReader(data []byte) io.Reader {
}
// HTML HTML内容响应
// Security note: HTML writes raw markup and does not escape untrusted input.
func HTML(c *gin.Context, data string) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, data)
+12
View File
@@ -10,6 +10,12 @@ func ToInt(s string) int {
return n
}
// ToIntE converts a string to int and returns parse errors. Prefer it when 0
// is a meaningful value and parse failure must be distinguishable.
func ToIntE(s string) (int, error) {
return strconv.Atoi(s)
}
// ToIntDefault 字符串转 int,失败返回默认值
func ToIntDefault(s string, def int) int {
n, err := strconv.Atoi(s)
@@ -25,6 +31,12 @@ func ToInt64(s string) int64 {
return n
}
// ToInt64E converts a string to int64 and returns parse errors. Prefer it when
// 0 is a meaningful value and parse failure must be distinguishable.
func ToInt64E(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
}
// ToInt64Default 字符串转 int64,失败返回默认值
func ToInt64Default(s string, def int64) int64 {
n, err := strconv.ParseInt(s, 10, 64)
+20
View File
@@ -261,12 +261,32 @@ func TestToIntDefault(t *testing.T) {
}
}
func TestToIntE(t *testing.T) {
n, err := utils.ToIntE("123")
if err != nil || n != 123 {
t.Fatalf("ToIntE valid = %d, err=%v; want 123,nil", n, err)
}
if _, err := utils.ToIntE("abc"); err == nil {
t.Fatal("ToIntE invalid should return error")
}
}
func TestToInt64(t *testing.T) {
if utils.ToInt64("1234567890123") != 1234567890123 {
t.Error("ToInt64 failed")
}
}
func TestToInt64E(t *testing.T) {
n, err := utils.ToInt64E("1234567890123")
if err != nil || n != 1234567890123 {
t.Fatalf("ToInt64E valid = %d, err=%v; want 1234567890123,nil", n, err)
}
if _, err := utils.ToInt64E("abc"); err == nil {
t.Fatal("ToInt64E invalid should return error")
}
}
func TestToInt64Default(t *testing.T) {
if utils.ToInt64Default("abc", 999) != 999 {
t.Error("ToInt64Default failed")
+3
View File
@@ -477,6 +477,9 @@ func (h *Hub) closeAll() {
// H-9 修复:仅在 runStarted 为 true 时等待 runDoneRun 未启动时直接返回,
// 避免 WaitGroup Add/Wait 竞态,且 Stop 先于 Run 调用后误再调 Run 也不会 panic。
// 幂等:重复/并发调用安全。
// Stop discards pending broadcast messages instead of guaranteeing delivery;
// it is a shutdown boundary. Pending register/unregister entries are drained
// and all live connections are closed before Stop returns.
func (h *Hub) Stop() {
h.stopOnce.Do(func() {
h.lifecycleMu.Lock()