diff --git a/CHANGELOG.md b/CHANGELOG.md index 964ef62..15a9061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,19 +40,21 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **限流器非法配置改为 fail-fast**(middleware/ratelimit.go,M8):`NewRateLimiter` / `NewRedisRateLimiter` / `NewRedisRateLimiterFailClosed` 现在对 `rate <= 0` 或 `window <= 0` 直接 panic,避免零值窗口/零值配额静默产生不确定限流语义。下游应在配置加载阶段校验限流参数。 - **`handler.BindJSON` 默认限制 JSON body 为 1MiB**(handler/handler.go,M6):防止入口层无上限读取请求体导致 OOM。需要更大 JSON 的接口请改用 `handler.BindJSONWithMaxBytes(c, req, maxBytes)` 显式声明上限。 - **cron 非法任务配置改为 fail-fast**(cron/cron.go,M13):`AddTask` 拒绝 nil schedule / nil handler;`Every(<=0)`、`Daily`/`Weekly` 越界时间、非法 weekday 会 panic,避免静默生成不推进或归一化跑偏的调度。 +- **`cron.ParseCron` 非法表达式改为 fail-fast panic**(cron/cron.go,M13):旧行为会把非法表达式静默回退为每分钟执行,容易让拼写错误变成高频任务。动态输入请用 `ParseCronStrict` 处理 error;确实需要旧回退语义时改用新增 `ParseCronOrDefault`。 - **repository 查询保护默认开启**(repository/repository.go,M5/N5):`FindAll` 默认最多返回 `DefaultFindAllLimit=1000` 条;明确需要全表扫描时改用 `FindAllUnbounded`。`FindPage*` / `QueryBuilder.Page` 会归一化 `page/pageSize` 并限制 `MaxPageSize=100`、`MaxPage=10000`。`Find*Ordered` / `QueryBuilder.Order` 只接受简单字段排序(如 `created_at DESC, id ASC`),复杂表达式/raw SQL 会返回 `ErrUnsafeOrder`;`UpdateBatch` 字段名不合法返回 `ErrUnsafeField`。 ### 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-closed;MySQL DSN 转义用户名/库名,Postgres DSN 统一转义字符串字段。 - **M4 database nil 边界修复**:`InitDB(ctx, nil)` / `InitDBWithReplicas(ctx, nil, ...)` / `InitRedis(nil)` 现在返回中文错误,不再空指针 panic;`UseMaster(nil)` / `UseReplica(nil)` / `GetDBFromContext(nil)` / `WithTx(nil, ...)` / `TxFromContext(nil)` / `TransactionWithContext(nil, ...)` / `ReadQuery(nil, ...)` / `WriteQuery(nil, ...)` / Redis health check 会把 nil context 归一化为 `context.Background()`,避免异常调用路径触发 panic;`Dialector(nil)` 安全回退到 MySQL 空 DSN。 - **M4 database 全局置换资源释放修复**:`SetDefaultManager` / `SetDefaultRedisManager` 替换全局默认 manager 时会关闭旧 DB/Redis manager,避免包级默认资源反复置换后连接池泄漏;App 初始化改用 `SwapDefaultManager` / `SwapDefaultRedisManager` 暂存旧资源,保证失败回滚仍能恢复旧默认资源。 - **M4 database 初始化生命周期修复**:DB 初始化与主从库初始化统一接收 ctx,主库/从库 Ping 使用 `PingContext`,重试等待改为 `select ctx.Done()/time.After`;`Manager` 用生命周期锁串行化 Init/Close,避免 shutdown 与运行期重建交错;运行期重建从库后会立即重建健康标记,探活循环也能在发现健康标记缺失时自愈;包级 `HealthCheck()` 固定读取一次默认 manager 快照。 - **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。 - **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost;`ValidateStruct(nil)` 直接返回 nil。 +- **M15 utils 转换语义收口**:新增 `utils.ToIntE` / `utils.ToInt64E` 返回解析错误;旧 `ToInt` / `ToInt64` 保留“失败返回 0”的兼容行为,调用方在 0 有业务含义时应迁移到严格变体。 - **M16 测试工具、脚手架与示例闭环修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache` 与 `MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码;`examples/full` 启动时初始化 `alice/secret`,登录校验 bcrypt 哈希,创建用户也保存哈希,避免示例首次运行无法登录或传播不验密/明文密码模式;README/GUIDE 限流示例不再引用不存在的 `handler.Login` / `handler.Upload`。 - **M16 GUIDE/test API 不一致修复**:GUIDE 测试示例不再调用不存在的 `AssertCode` / `AssertJSONKeyExists`,统一改用现有 `AssertJSONContains`,避免照文档编写测试直接编译失败。 - **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。 @@ -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.go,response/response.go,handler/handler.go):`FailWithError(nil)` / `FailWithDetail(nil, ...)` 回退统一服务器错误响应,不再 nil deref panic;新增 `response.DownloadReader` 支持大文件/对象存储流式下载,旧 `Download` / `DownloadWithContentType` 保持兼容并复用同一响应头逻辑。 +- **M14 trace 剩余记录补齐**(trace/trace.go):`SampleRatio` 拒绝 NaN/越界值;`Init`/`Close` 使用操作超时并在初始化失败时回滚 provider;`RecordError` / `RecordErrorToSpan` 对 nil 输入 no-op;`Middleware(serviceName)` 写入 `service.name` attribute;`X-Trace-ID` 不再输出全零 TraceID。 - **M7 health/readiness 探活超时**(router/router.go):`HealthCheck` 新增 `Timeout` 字段,默认每个依赖检查 2s 超时;超时项返回 `"timeout"` 并使 `/health` / `/readyz` 返回 503。单个 check 同时最多一个执行中,panic 会 recover 为错误,避免 k8s/LB/监控探活被挂死依赖无限卡住或无限堆积 goroutine。 - **M13 cron 剩余边界收口**(cron/cron.go):Stop 后再次 Start 会重建调度器 context,手动和调度执行不再收到已取消 ctx;Start/Stop 生命周期串行化,避免 Stop 等待期间重新 Start 触发 WaitGroup Add/Wait 交错。 - **M5 repository 安全边界收口**(repository/repository.go):nil ctx 统一按 `context.Background()` 处理;`FindByIDs(nil)` 返回非 nil 空切片;`NewQueryBuilder` 复用 nil DB 明确 panic;批量空 ids 写操作 no-op;默认 `FindAll` 加上限并新增 `FindAllUnbounded`;排序/字段名白名单避免便捷 API 误接 raw SQL。 @@ -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.Request(trace/trace.go) diff --git a/GUIDE.md b/GUIDE.md index 930a1fa..fceac1c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -774,9 +774,11 @@ response.FailWithDetail(c, response.ErrPasswordWrong, "连续错误3次将锁定 ```go // 文件下载 response.Download(c, "report.xlsx", fileData) +// 大文件/对象存储流式下载优先使用 DownloadReader // HTML响应 response.HTML(c, "...") +// 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 启动与停止 diff --git a/README.md b/README.md index 9ac6784..f9ecbf2 100644 --- a/README.md +++ b/README.md @@ -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() diff --git a/cache/cache.go b/cache/cache.go index 9fea8b7..c8e8ab8 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -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.Client(M12 修复:原 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 +} diff --git a/cache/cache_m10_internal_test.go b/cache/cache_m10_internal_test.go index f0d58c7..a8b8a8b 100644 --- a/cache/cache_m10_internal_test.go +++ b/cache/cache_m10_internal_test.go @@ -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) + } +} diff --git a/cron/cron.go b/cron/cron.go index cf564ed..9bb468c 100644 --- a/cron/cron.go +++ b/cron/cron.go @@ -621,8 +621,21 @@ func parseCronRange(s string, min, max int) (int, int, error) { // "0 0 1 * *" - 每月1号凌晨 // "0 0 * * 0" - 每周日凌晨 // -// 非法表达式回退默认全 "*"(每分钟执行)。需要严格校验请用 ParseCronStrict。 +// 非法表达式会 panic(fail-fast),动态输入请用 ParseCronStrict 处理 error。 +// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。 func ParseCron(expr string) *FullCronSchedule { + if sched, err := ParseCronStrict(expr); err == nil { + return sched + } else { + panic(err) + } +} + +// ParseCronOrDefault parses a Cron expression and falls back to all "*" (every +// minute) when expr is invalid. Prefer ParseCronStrict or ParseCron for new +// code; this helper exists for callers that intentionally want legacy fallback +// semantics. +func ParseCronOrDefault(expr string) *FullCronSchedule { if sched, err := ParseCronStrict(expr); err == nil { return sched } diff --git a/cron/cron_c12_test.go b/cron/cron_c12_test.go index 700145c..c514fd9 100644 --- a/cron/cron_c12_test.go +++ b/cron/cron_c12_test.go @@ -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 * * *") diff --git a/cron/cron_test.go b/cron/cron_test.go index d0e6279..b22881e 100644 --- a/cron/cron_test.go +++ b/cron/cron_test.go @@ -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) } -} \ No newline at end of file +} diff --git a/jwt/jwt.go b/jwt/jwt.go index 12faaf3..e5eecb7 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -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 } diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go index 3189647..77f4106 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -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")) diff --git a/response/response.go b/response/response.go index 8ca3bd6..e114860 100644 --- a/response/response.go +++ b/response/response.go @@ -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) diff --git a/utils/convert.go b/utils/convert.go index 033e249..e0b5d4b 100644 --- a/utils/convert.go +++ b/utils/convert.go @@ -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) diff --git a/utils/utils_test.go b/utils/utils_test.go index 15a3ac7..9f000a4 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -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") diff --git a/ws/ws.go b/ws/ws.go index cb706b5..31005f7 100644 --- a/ws/ws.go +++ b/ws/ws.go @@ -477,6 +477,9 @@ func (h *Hub) closeAll() { // H-9 修复:仅在 runStarted 为 true 时等待 runDone;Run 未启动时直接返回, // 避免 WaitGroup Add/Wait 竞态,且 Stop 先于 Run 调用后误再调 Run 也不会 panic。 // 幂等:重复/并发调用安全。 +// Stop discards pending broadcast messages instead of guaranteeing delivery; +// it is a shutdown boundary. Pending register/unregister entries are drained +// and all live connections are closed before Stop returns. func (h *Hub) Stop() { h.stopOnce.Do(func() { h.lifecycleMu.Lock()