diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4fe7d..864842f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **`logger.DefaultLogger = m` 直接赋值不再驱动包级 facade**(logger/logger.go,M3):为消除 `SetDefaultLogManager()` 与包级 facade 并发读取默认 manager 的裸全局指针竞态,facade 改为读取内部 atomic 快照。`logger.DefaultLogger` 仍保持 `*LogManager` 类型,旧的 `logger.DefaultLogger.Init/Close/SetLevel/GetLevel` 直接调用仍可用;替换默认 manager 请使用 `logger.SetDefaultLogManager(m)`。 - **`logger.Init` 拒绝明显非法日志配置**(logger/logger.go,M3):空日志目录、负数 `MaxSize/MaxBackups/MaxAge` 现在直接返回错误。依赖零值日志配置启动 `WithLogger()` 的下游需显式设置 `Log.Dir` 与非负轮转参数。 - **限流器非法配置改为 fail-fast**(middleware/ratelimit.go,M8):`NewRateLimiter` / `NewRedisRateLimiter` / `NewRedisRateLimiterFailClosed` 现在对 `rate <= 0` 或 `window <= 0` 直接 panic,避免零值窗口/零值配额静默产生不确定限流语义。下游应在配置加载阶段校验限流参数。 +- **`handler.BindJSON` 默认限制 JSON body 为 1MiB**(handler/handler.go,M6):防止入口层无上限读取请求体导致 OOM。需要更大 JSON 的接口请改用 `handler.BindJSONWithMaxBytes(c, req, maxBytes)` 显式声明上限。 +- **cron 非法任务配置改为 fail-fast**(cron/cron.go,M13):`AddTask` 拒绝 nil schedule / nil handler;`Every(<=0)`、`Daily`/`Weekly` 越界时间、非法 weekday 会 panic,避免静默生成不推进或归一化跑偏的调度。 +- **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 🐛 @@ -37,6 +40,10 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。 - **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` 保持兼容并复用同一响应头逻辑。 +- **M7 health/readiness 探活超时**(router/router.go):`HealthCheck` 新增 `Timeout` 字段,默认每个依赖检查 2s 超时;超时项返回 `"timeout"` 并使 `/health` / `/readyz` 返回 503。单个 check 同时最多一个执行中,panic 会 recover 为错误,避免 k8s/LB/监控探活被挂死依赖无限卡住或无限堆积 goroutine。 +- **M13 cron 剩余边界收口**(cron/cron.go):Stop 后再次 Start 会重建调度器 context,手动和调度执行不再收到已取消 ctx;Start/Stop 生命周期串行化,避免 Stop 等待期间重新 Start 触发 WaitGroup Add/Wait 交错。 +- **M5 repository 安全边界收口**(repository/repository.go):nil ctx 统一按 `context.Background()` 处理;`FindByIDs(nil)` 返回非 nil 空切片;`NewQueryBuilder` 复用 nil DB 明确 panic;批量空 ids 写操作 no-op;默认 `FindAll` 加上限并新增 `FindAllUnbounded`;排序/字段名白名单避免便捷 API 误接 raw SQL。 - **M1 App 生命周期三类缺陷统一治理**(app.go): - **Init 失败无资源回滚** → 新增 `failAfterInit`:markStopping → cancel rootCtx → wg.Wait(10s) → cron 5s 显式超时停止 → `closeResources` 幂等关闭 db/redis/logger,回滚错误 `errors.Join` 进 `initErr` 不吞;先停 goroutine 再关资源,避免"关 DB 时探活 goroutine 仍在用"的竞态。 - **App.Go 与 wg.Wait race** → `lifecycleMu.RLock` 包住 `wg.Add`,`Shutdown` 持写锁翻 `stateStopping`,保证 Add happens-before Wait。 diff --git a/cron/cron.go b/cron/cron.go index 094698f..cf564ed 100644 --- a/cron/cron.go +++ b/cron/cron.go @@ -40,19 +40,57 @@ type Schedule interface { Next(now time.Time) time.Time } +func validateSchedule(schedule Schedule) { + switch s := schedule.(type) { + case *IntervalSchedule: + validateInterval(s.Interval) + case *DailySchedule: + validateClock(s.Hour, s.Minute, "Daily") + case *WeeklySchedule: + validateWeekday(s.Day) + validateClock(s.Hour, s.Minute, "Weekly") + } +} + +func validateInterval(interval time.Duration) { + if interval <= 0 { + panic("cron: interval must be positive") + } +} + +func validateClock(hour, minute int, name string) { + if hour < 0 || hour > 23 { + panic(fmt.Sprintf("cron: %s hour must be in [0,23]", name)) + } + if minute < 0 || minute > 59 { + panic(fmt.Sprintf("cron: %s minute must be in [0,59]", name)) + } +} + +func validateWeekday(day time.Weekday) { + if day < time.Sunday || day > time.Saturday { + panic("cron: Weekly day must be in [Sunday,Saturday]") + } +} + // Scheduler 调度器 type Scheduler struct { - tasks map[string]*Task - mu sync.RWMutex - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - running bool + tasks map[string]*Task + mu sync.RWMutex + lifecycleMu sync.Mutex + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + running bool +} + +func newSchedulerContext() (context.Context, context.CancelFunc) { + return context.WithCancel(context.Background()) } // NewScheduler 创建调度器 func NewScheduler() *Scheduler { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := newSchedulerContext() return &Scheduler{ tasks: make(map[string]*Task), ctx: ctx, @@ -62,6 +100,14 @@ func NewScheduler() *Scheduler { // AddTask 添加任务 func (s *Scheduler) AddTask(name string, schedule Schedule, handler TaskHandler) *Task { + if schedule == nil { + panic("cron: AddTask requires a non-nil schedule") + } + if handler == nil { + panic("cron: AddTask requires a non-nil handler") + } + validateSchedule(schedule) + task := &Task{ Name: name, Schedule: schedule, @@ -146,12 +192,19 @@ func (s *Scheduler) ListTasks() []*Task { // 共用此边界,panic 一律转为 error 记入 LastError 并向上返回,不破坏 running 守卫与 wg.Done // (recover 在本函数内部完成,外侧 defer 仍正常执行)。 func (s *Scheduler) executeTask(t *Task) (err error) { + s.mu.RLock() + ctx := s.ctx + s.mu.RUnlock() + return s.executeTaskWithContext(ctx, t) +} + +func (s *Scheduler) executeTaskWithContext(ctx context.Context, t *Task) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("cron task %q panic recovered: %v\n%s", t.Name, r, debug.Stack()) } }() - return t.Handler(s.ctx) + return t.Handler(ctx) } // RunTask 立即运行任务(手动触发,同步返回 handler 错误)。 @@ -194,16 +247,25 @@ func (s *Scheduler) RunTask(name string) error { // Start 启动调度器 func (s *Scheduler) Start() { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + s.mu.Lock() if s.running { s.mu.Unlock() return } + select { + case <-s.ctx.Done(): + s.ctx, s.cancel = newSchedulerContext() + default: + } + ctx := s.ctx s.running = true + s.wg.Add(1) s.mu.Unlock() - s.wg.Add(1) - go s.run() + go s.run(ctx) } // Stop 停止调度器并无限等待在跑任务退出(要求 handler 尊重 ctx.Done)。 @@ -216,15 +278,19 @@ func (s *Scheduler) Stop() { // 返回 true 表示所有任务已退出;false 表示超时(仍有任务未响应 ctx.Done 而运行)。 // timeout<=0 等价于无限等待(同 Stop)。幂等:未运行时直接返回 true。 func (s *Scheduler) StopWithTimeout(timeout time.Duration) bool { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + s.mu.Lock() if !s.running { s.mu.Unlock() return true } s.running = false + cancel := s.cancel s.mu.Unlock() - s.cancel() + cancel() if timeout <= 0 { s.wg.Wait() return true @@ -240,7 +306,7 @@ func (s *Scheduler) StopWithTimeout(timeout time.Duration) bool { } // run 运行调度循环 -func (s *Scheduler) run() { +func (s *Scheduler) run(ctx context.Context) { defer s.wg.Done() ticker := time.NewTicker(1 * time.Second) @@ -248,10 +314,10 @@ func (s *Scheduler) run() { for { select { - case <-s.ctx.Done(): + case <-ctx.Done(): return case <-ticker.C: - s.checkAndRun() + s.checkAndRun(ctx) } } } @@ -272,10 +338,23 @@ func (s *Scheduler) run() { // 一定能等到本批全部 goroutine。收集阶段已 CAS 占用守卫并推进 NextRun,故 spawn 必须 // 执行(否则守卫不释放、NextRun 已推进却未跑)。spawn 用 s.ctx——Stop 后 ctx 已取消, // handler 收到 canceled ctx 应及时退出。 -func (s *Scheduler) checkAndRun() { +func (s *Scheduler) checkAndRun(ctxs ...context.Context) { now := time.Now() + var ctx context.Context + enforceRunning := len(ctxs) > 0 && ctxs[0] != nil + if len(ctxs) > 0 && ctxs[0] != nil { + ctx = ctxs[0] + } else { + s.mu.RLock() + ctx = s.ctx + s.mu.RUnlock() + } s.mu.Lock() + if enforceRunning && (!s.running || ctx.Err() != nil) { + s.mu.Unlock() + return + } var due []*Task for _, task := range s.tasks { if !task.Enabled || task.NextRun.IsZero() || !now.After(task.NextRun) { @@ -301,7 +380,7 @@ func (s *Scheduler) checkAndRun() { } }() - err := s.executeTask(t) + err := s.executeTaskWithContext(ctx, t) s.mu.Lock() t.LastRun = time.Now() @@ -329,6 +408,7 @@ func (s *IntervalSchedule) Next(now time.Time) time.Time { // Every 每隔指定时间运行 func Every(interval time.Duration) *IntervalSchedule { + validateInterval(interval) return &IntervalSchedule{Interval: interval} } @@ -349,6 +429,7 @@ func (s *DailySchedule) Next(now time.Time) time.Time { // Daily 每日指定时间运行 func Daily(hour, minute int) *DailySchedule { + validateClock(hour, minute, "Daily") return &DailySchedule{Hour: hour, Minute: minute} } @@ -377,6 +458,8 @@ func (s *WeeklySchedule) Next(now time.Time) time.Time { // Weekly 每周指定时间运行 func Weekly(day time.Weekday, hour, minute int) *WeeklySchedule { + validateWeekday(day) + validateClock(hour, minute, "Weekly") return &WeeklySchedule{Day: day, Hour: hour, Minute: minute} } diff --git a/cron/cron_m13_internal_test.go b/cron/cron_m13_internal_test.go index ac4f27f..6cfa41c 100644 --- a/cron/cron_m13_internal_test.go +++ b/cron/cron_m13_internal_test.go @@ -9,6 +9,16 @@ import ( "time" ) +func mustPanicM13(t *testing.T, name string, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatalf("%s should panic", name) + } + }() + fn() +} + // TestCronRunTaskPanicRecovered_M13 回归:RunTask handler panic 时,executeTask 边界 // recover 转为 error 返回调用方并记入 LastError,不崩进程;running 守卫随后释放。 // 修复前:panic 直接上抛 → 测试进程崩溃。 @@ -83,3 +93,76 @@ func TestCronCheckAndRunPanicRecovered_M13(t *testing.T) { t.Fatal("canary did not run — sibling goroutine killed by panic?") } } + +func TestCronAddTaskRejectsNilScheduleOrHandler_M13(t *testing.T) { + s := NewScheduler() + mustPanicM13(t, "nil schedule", func() { + s.AddTask("nil-schedule", nil, func(context.Context) error { return nil }) + }) + mustPanicM13(t, "nil handler", func() { + s.AddTask("nil-handler", Every(time.Minute), nil) + }) +} + +func TestCronScheduleConstructorsRejectInvalidBounds_M13(t *testing.T) { + mustPanicM13(t, "Every(0)", func() { Every(0) }) + mustPanicM13(t, "Every(negative)", func() { Every(-time.Second) }) + mustPanicM13(t, "Daily hour", func() { Daily(24, 0) }) + mustPanicM13(t, "Daily minute", func() { Daily(23, 60) }) + mustPanicM13(t, "Weekly day", func() { Weekly(time.Weekday(9), 9, 0) }) + mustPanicM13(t, "Weekly hour", func() { Weekly(time.Monday, -1, 0) }) + mustPanicM13(t, "Weekly minute", func() { Weekly(time.Monday, 9, -1) }) +} + +func TestCronAddTaskRejectsInvalidExportedScheduleValues_M13(t *testing.T) { + s := NewScheduler() + mustPanicM13(t, "invalid interval schedule", func() { + s.AddTask("bad-interval", &IntervalSchedule{}, func(context.Context) error { return nil }) + }) + mustPanicM13(t, "invalid daily schedule", func() { + s.AddTask("bad-daily", &DailySchedule{Hour: 99}, func(context.Context) error { return nil }) + }) + mustPanicM13(t, "invalid weekly schedule", func() { + s.AddTask("bad-weekly", &WeeklySchedule{Day: time.Weekday(8)}, func(context.Context) error { return nil }) + }) +} + +func TestCronStartAfterStopRebuildsContext_M13(t *testing.T) { + s := NewScheduler() + s.AddTask("ctx", Every(time.Hour), func(ctx context.Context) error { + return ctx.Err() + }) + + s.Start() + s.Stop() + s.Start() + t.Cleanup(s.Stop) + + if err := s.RunTask("ctx"); err != nil { + t.Fatalf("RunTask after Stop/Start got ctx err = %v, want nil", err) + } +} + +func TestCronCheckAndRunSkipsAfterStopContextCanceled_M13(t *testing.T) { + s := NewScheduler() + var ran atomic.Int32 + s.AddTask("due", Every(time.Minute), func(context.Context) error { + ran.Add(1) + return nil + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + s.mu.Lock() + s.running = false + s.tasks["due"].NextRun = time.Now().Add(-time.Second) + s.mu.Unlock() + + s.checkAndRun(ctx) + s.wg.Wait() + + if got := ran.Load(); got != 0 { + t.Fatalf("task ran %d times after stopped/canceled ctx, want 0", got) + } +} diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f1cb9cb..0000000 --- a/docs/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# xlgo 文档 - -| 文档 | 位置 | 说明 | -|---|---|---| -| README.md | 仓库根 | 快速开始、功能概览 | -| GUIDE.md | 仓库根 | 完整使用指南 | -| CHANGELOG.md | 仓库根 | 变更日志(遵循 Keep a Changelog) | -| docs/plans/ | 本目录 | 历史版本规划与体检报告归档 | - -## docs/plans/ 归档文档 - -这些是各版本开发时的规划/评审文档,**反映当时状态**,代码可能已演进,阅读时请对照 CHANGELOG 确认现状。 - -| 文件 | 说明 | -|---|---| -| Version_Update_Plan_v1.0.2.md | v1.0.2 更新计划(已完成) | -| Version_v1.0.2_report.md | v1.0.2 体检报告,含 #1-#30 改进建议与版本路线图 | -| v2.0-review.md | 更早期的 v2.0 评估报告 | - -> v1.0.2 体检报告中的 #1-#30 改进项,完成情况见根目录 [CHANGELOG.md](../CHANGELOG.md) 各版本章节。 diff --git a/handler/handler.go b/handler/handler.go index 6fe355a..5721db7 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "strconv" "strings" @@ -10,6 +11,11 @@ import ( "github.com/gin-gonic/gin" ) +// DefaultMaxJSONBodyBytes 是 BindJSON 的默认请求体上限。 +// 入口层无上限读 JSON 会让任意 handler 暴露 OOM 面;需要更大请求体的业务可改用 +// BindJSONWithMaxBytes 显式声明上限。 +const DefaultMaxJSONBodyBytes int64 = 1 << 20 // 1 MiB + // HealthCheck 健康检查 // @Summary 健康检查 // @Description 检查服务是否正常运行 @@ -28,6 +34,16 @@ func HealthCheck(c *gin.Context) { // BindJSON 绑定 JSON 请求 func BindJSON(c *gin.Context, req any) error { + return BindJSONWithMaxBytes(c, req, DefaultMaxJSONBodyBytes) +} + +// BindJSONWithMaxBytes 绑定 JSON 请求,并限制最大 body 大小。 +// maxBytes<=0 时返回明确错误,避免 http.MaxBytesReader 的非正上限产生不可读语义。 +func BindJSONWithMaxBytes(c *gin.Context, req any, maxBytes int64) error { + if maxBytes <= 0 { + return errors.New("handler: max JSON body bytes must be positive") + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) return c.ShouldBindJSON(req) } diff --git a/handler/handler_test.go b/handler/handler_test.go index a10ca55..af3b9e1 100644 --- a/handler/handler_test.go +++ b/handler/handler_test.go @@ -62,8 +62,8 @@ func TestQueryInt(t *testing.T) { expected int }{ {"?page=10", 10}, - {"?page=abc", 1}, // 无效返回默认 - {"", 1}, // 无参数返回默认 + {"?page=abc", 1}, // 无效返回默认 + {"", 1}, // 无参数返回默认 } for _, tt := range tests { @@ -185,9 +185,9 @@ func TestGetPage(t *testing.T) { expectedSize int }{ {"?page=2&page_size=50", 2, 50}, - {"?page=0&page_size=0", 1, 20}, // 边界修正 + {"?page=0&page_size=0", 1, 20}, // 边界修正 {"?page=-1&page_size=200", 1, 100}, // 超限修正 - {"", 1, 20}, // 默认值 + {"", 1, 20}, // 默认值 } for _, tt := range tests { @@ -387,4 +387,28 @@ func TestBindJSON(t *testing.T) { if w.Code != 200 { t.Errorf("BindJSON valid status = %d", w.Code) } -} \ No newline at end of file +} + +func TestBindJSONWithMaxBytesRejectsOversizedBody_M6(t *testing.T) { + r := setupTestRouter() + r.POST("/test", func(c *gin.Context) { + var req struct { + Name string `json:"name"` + } + if err := handler.BindJSONWithMaxBytes(c, &req, 8); err == nil { + c.JSON(http.StatusOK, req) + return + } + handler.BadRequest(c, "绑定失败") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"name":"body-too-large"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + body := decodeBody(t, w) + if body.Code != response.CodeFail { + t.Fatalf("oversized BindJSON code = %d, want %d", body.Code, response.CodeFail) + } +} diff --git a/repository/repository.go b/repository/repository.go index b38998c..b7ff7b7 100644 --- a/repository/repository.go +++ b/repository/repository.go @@ -2,11 +2,30 @@ package repository import ( "context" + "errors" + "strings" + "unicode" "github.com/EthanCodeCraft/xlgo-core/database" "gorm.io/gorm" ) +const ( + // DefaultFindAllLimit caps FindAll to avoid accidental full-table scans. + DefaultFindAllLimit = 1000 + // DefaultPageSize is used when pageSize<=0. + DefaultPageSize = 20 + // MaxPageSize caps page queries to avoid Limit(0)/huge-limit footguns. + MaxPageSize = 100 + // MaxPage caps deep-offset queries; deeper traversal should use keyset pagination. + MaxPage = 10000 +) + +var ( + ErrUnsafeOrder = errors.New("repository: unsafe order clause") + ErrUnsafeField = errors.New("repository: unsafe field name") +) + // BaseRepository 基础仓库接口 type BaseRepository[T any] interface { // FindByID 根据 ID 查询 @@ -43,12 +62,26 @@ 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 { +func normalizeContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} + +func (r *BaseRepo[T]) requireDB() *gorm.DB { if r.db == nil { panic("repository.BaseRepo: db is nil. Use NewBaseRepo with a valid *gorm.DB") } + return r.db +} + +// readConn 返回读连接:先校验 repo 构造时注入的 r.db 非 nil,再按 +// 外层 ctx 事务 > 本 repo 事务 > 读写分离路由 > r.db 回退。 +// RP1 修复:r.db 为 nil 时 panic 并给出明确消息,即使 ctx 携带外层事务也要求 repo 构造合法。 +func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB { + ctx = normalizeContext(ctx) + base := r.requireDB() if tx := database.TxFromContext(ctx); tx != nil { return tx.WithContext(ctx) } @@ -58,16 +91,16 @@ func (r *BaseRepo[T]) readConn(ctx context.Context) *gorm.DB { if gdb := database.GetDBFromContext(ctx); gdb != nil { return gdb.WithContext(ctx) } - return r.db.WithContext(ctx) + return base.WithContext(ctx) } -// writeConn 返回写连接:外层 ctx 事务 > 本 repo 事务 > 主库 > r.db 回退。 +// writeConn 返回写连接:先校验 repo 构造时注入的 r.db 非 nil,再按 +// 外层 ctx 事务 > 本 repo 事务 > 主库 > r.db 回退。 // 写操作始终走主库,不路由到只读从库。 -// RP1 修复:r.db 为 nil 时 panic 并给出明确消息。 +// RP1 修复:r.db 为 nil 时 panic 并给出明确消息,即使 ctx 携带外层事务也要求 repo 构造合法。 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") - } + ctx = normalizeContext(ctx) + base := r.requireDB() if tx := database.TxFromContext(ctx); tx != nil { return tx.WithContext(ctx) } @@ -77,7 +110,7 @@ func (r *BaseRepo[T]) writeConn(ctx context.Context) *gorm.DB { if mdb := database.GetWriteDB(); mdb != nil { return mdb.WithContext(ctx) } - return r.db.WithContext(ctx) + return base.WithContext(ctx) } // FindByID 根据 ID 查询 @@ -135,13 +168,23 @@ func (r *BaseRepo[T]) HardDelete(ctx context.Context, id uint) error { // FindByIDs 批量查询 func (r *BaseRepo[T]) FindByIDs(ctx context.Context, ids []uint) ([]T, error) { + if len(ids) == 0 { + return []T{}, nil + } var models []T err := r.readConn(ctx).Where("id IN ?", ids).Find(&models).Error return models, err } -// FindAll 查询所有记录 +// FindAll 查询记录,默认最多返回 DefaultFindAllLimit 条,避免误用导致全表扫描。 +// 需要明确全量扫描时使用 FindAllUnbounded。 func (r *BaseRepo[T]) FindAll(ctx context.Context) ([]T, error) { + return r.FindLimited(ctx, DefaultFindAllLimit) +} + +// FindAllUnbounded 查询所有记录(无默认 limit)。 +// 仅用于明确需要全表扫描的后台任务/小表场景;在线请求优先使用分页或 FindAll。 +func (r *BaseRepo[T]) FindAllUnbounded(ctx context.Context) ([]T, error) { var models []T err := r.readConn(ctx).Find(&models).Error return models, err @@ -196,6 +239,9 @@ func (r *BaseRepo[T]) FindWhere(ctx context.Context, query string, args ...any) // (ctx, order, query string, args ...any),与 FindWhere 等同类方法统一为变长 args。 // Go 语法要求变长参数必须为最后一个,故 order 前置于 query。 func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, order, query string, args ...any) ([]T, error) { + if err := validateOrderClause(order); err != nil { + return nil, err + } var models []T err := r.readConn(ctx).Where(query, args...).Order(order).Find(&models).Error return models, err @@ -203,6 +249,9 @@ func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, order, query string, // FindOrdered 查询并排序 func (r *BaseRepo[T]) FindOrdered(ctx context.Context, order string, limit int) ([]T, error) { + if err := validateOrderClause(order); err != nil { + return nil, err + } var models []T query := r.readConn(ctx).Order(order) if limit > 0 { @@ -214,6 +263,9 @@ 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) { + if limit <= 0 { + return []T{}, nil + } var models []T err := r.readConn(ctx).Limit(limit).Find(&models).Error return models, err @@ -229,8 +281,25 @@ type PageResult[T any] struct { PageSize int `json:"page_size"` } +func normalizePage(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if page > MaxPage { + page = MaxPage + } + if pageSize <= 0 { + pageSize = DefaultPageSize + } + if pageSize > MaxPageSize { + pageSize = MaxPageSize + } + return page, pageSize +} + // pageOffset 计算 (page-1)*pageSize,负值归零。 func pageOffset(page, pageSize int) int { + page, pageSize = normalizePage(page, pageSize) offset := (page - 1) * pageSize if offset < 0 { offset = 0 @@ -248,6 +317,7 @@ func pageOffset(page, pageSize int) int { func (r *BaseRepo[T]) FindPage(ctx context.Context, page, pageSize int) (*PageResult[T], error) { var models []T var total int64 + page, pageSize = normalizePage(page, pageSize) offset := pageOffset(page, pageSize) err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error { if e := tx.Model(new(T)).Count(&total).Error; e != nil { @@ -263,8 +333,12 @@ func (r *BaseRepo[T]) FindPage(ctx context.Context, page, pageSize int) (*PageRe // FindPageOrdered 分页查询并排序(count+list 单事务,H6d) func (r *BaseRepo[T]) FindPageOrdered(ctx context.Context, page, pageSize int, order string) (*PageResult[T], error) { + if err := validateOrderClause(order); err != nil { + return nil, err + } var models []T var total int64 + page, pageSize = normalizePage(page, pageSize) offset := pageOffset(page, pageSize) err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error { if e := tx.Model(new(T)).Count(&total).Error; e != nil { @@ -282,6 +356,7 @@ func (r *BaseRepo[T]) FindPageOrdered(ctx context.Context, page, pageSize int, o func (r *BaseRepo[T]) FindPageWhere(ctx context.Context, page, pageSize int, query string, args ...any) (*PageResult[T], error) { var models []T var total int64 + page, pageSize = normalizePage(page, pageSize) offset := pageOffset(page, pageSize) err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error { if e := tx.Model(new(T)).Where(query, args...).Count(&total).Error; e != nil { @@ -301,8 +376,12 @@ func (r *BaseRepo[T]) FindPageWhere(ctx context.Context, page, pageSize int, que // (ctx, page, pageSize, order, query string, args ...any),与 FindPageWhere 统一为变长 args。 // Go 语法要求变长参数必须为最后一个,故 order 前置于 query。 func (r *BaseRepo[T]) FindPageWhereOrdered(ctx context.Context, page, pageSize int, order, query string, args ...any) (*PageResult[T], error) { + if err := validateOrderClause(order); err != nil { + return nil, err + } var models []T var total int64 + page, pageSize = normalizePage(page, pageSize) offset := pageOffset(page, pageSize) err := r.readConn(ctx).Transaction(func(tx *gorm.DB) error { if e := tx.Model(new(T)).Where(query, args...).Count(&total).Error; e != nil { @@ -325,16 +404,28 @@ func (r *BaseRepo[T]) CreateBatch(ctx context.Context, models []T) error { // UpdateBatch 批量更新(指定字段) func (r *BaseRepo[T]) UpdateBatch(ctx context.Context, ids []uint, field string, value any) error { + if len(ids) == 0 { + return nil + } + if err := validateIdentifier(field); err != nil { + return err + } return r.writeConn(ctx).Model(new(T)).Where("id IN ?", ids).Update(field, value).Error } // DeleteBatch 批量删除 func (r *BaseRepo[T]) DeleteBatch(ctx context.Context, ids []uint) error { + if len(ids) == 0 { + return nil + } return r.writeConn(ctx).Delete(new(T), ids).Error } // HardDeleteBatch 批量硬删除 func (r *BaseRepo[T]) HardDeleteBatch(ctx context.Context, ids []uint) error { + if len(ids) == 0 { + return nil + } return r.writeConn(ctx).Unscoped().Delete(new(T), ids).Error } @@ -364,16 +455,28 @@ func (r *BaseRepo[T]) SoftDeleteColumn() string { // Restore 恢复软删除记录 func (r *BaseRepo[T]) Restore(ctx context.Context, id uint) error { + if err := validateIdentifier(r.SoftDeleteColumn()); err != nil { + return err + } return r.writeConn(ctx).Model(new(T)).Unscoped().Where("id = ?", id).Update(r.SoftDeleteColumn(), nil).Error } // RestoreBatch 批量恢复软删除记录 func (r *BaseRepo[T]) RestoreBatch(ctx context.Context, ids []uint) error { + if len(ids) == 0 { + return nil + } + if err := validateIdentifier(r.SoftDeleteColumn()); err != nil { + return err + } return r.writeConn(ctx).Model(new(T)).Unscoped().Where("id IN ?", ids).Update(r.SoftDeleteColumn(), nil).Error } // FindDeleted 查询已软删除的记录 func (r *BaseRepo[T]) FindDeleted(ctx context.Context) ([]T, error) { + if err := validateIdentifier(r.SoftDeleteColumn()); err != nil { + return nil, err + } var models []T err := r.readConn(ctx).Unscoped().Where(r.SoftDeleteColumn() + " IS NOT NULL").Find(&models).Error return models, err @@ -425,11 +528,12 @@ func (r *BaseRepo[T]) WithTransaction(ctx context.Context, fn func(txRepo *BaseR type QueryBuilder[T any] struct { db *gorm.DB limit int + err error } // NewQueryBuilder 创建查询构建器 func (r *BaseRepo[T]) NewQueryBuilder() *QueryBuilder[T] { - return &QueryBuilder[T]{db: r.db.Model(new(T))} + return &QueryBuilder[T]{db: r.requireDB().Model(new(T))} } // Where 添加条件 @@ -446,12 +550,22 @@ func (qb *QueryBuilder[T]) Or(query string, args ...any) *QueryBuilder[T] { // Order 设置排序 func (qb *QueryBuilder[T]) Order(order string) *QueryBuilder[T] { + if qb.err != nil { + return qb + } + if err := validateOrderClause(order); err != nil { + qb.err = err + return qb + } qb.db = qb.db.Order(order) return qb } // Limit 设置数量限制 func (qb *QueryBuilder[T]) Limit(limit int) *QueryBuilder[T] { + if limit < 0 { + limit = 0 + } qb.limit = limit qb.db = qb.db.Limit(limit) return qb @@ -470,6 +584,10 @@ func (qb *QueryBuilder[T]) clone() *gorm.DB { // Find 执行查询 func (qb *QueryBuilder[T]) Find(ctx context.Context) ([]T, error) { + if qb.err != nil { + return nil, qb.err + } + ctx = normalizeContext(ctx) var models []T err := qb.clone().WithContext(ctx).Find(&models).Error return models, err @@ -477,6 +595,10 @@ func (qb *QueryBuilder[T]) Find(ctx context.Context) ([]T, error) { // First 执行查询并返回第一条 func (qb *QueryBuilder[T]) First(ctx context.Context) (*T, error) { + if qb.err != nil { + return nil, qb.err + } + ctx = normalizeContext(ctx) var model T err := qb.clone().WithContext(ctx).First(&model).Error if err != nil { @@ -488,6 +610,10 @@ func (qb *QueryBuilder[T]) First(ctx context.Context) (*T, error) { // Count 执行统计。 // 克隆并剥离 Limit/Offset(H6e),避免先前 Limit()/Offset() 残留截断统计行数。 func (qb *QueryBuilder[T]) Count(ctx context.Context) (int64, error) { + if qb.err != nil { + return 0, qb.err + } + ctx = normalizeContext(ctx) var count int64 err := qb.clone().Limit(-1).Offset(-1).WithContext(ctx).Count(&count).Error return count, err @@ -498,8 +624,13 @@ func (qb *QueryBuilder[T]) Count(ctx context.Context) (int64, error) { // 注意:与 FindPage 不同,Page 的 count+list 不包单事务(QueryBuilder 为轻量构建器); // 需要 total/items 快照一致请用 BaseRepo.FindPage。 func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageResult[T], error) { + if qb.err != nil { + return nil, qb.err + } + ctx = normalizeContext(ctx) var models []T var total int64 + page, pageSize = normalizePage(page, pageSize) // count 克隆并剥离 Limit/Offset countDB := qb.clone().Limit(-1).Offset(-1) @@ -519,3 +650,69 @@ func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageR PageSize: pageSize, }, nil } + +func validateIdentifier(field string) error { + if !isIdentifierPath(field) { + return ErrUnsafeField + } + return nil +} + +func isIdentifierPath(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return false + } + parts := strings.Split(s, ".") + for _, part := range parts { + if !isIdentifier(part) { + return false + } + } + return true +} + +func isIdentifier(s string) bool { + if s == "" { + return false + } + for i, r := range s { + if i == 0 { + if r != '_' && !unicode.IsLetter(r) { + return false + } + continue + } + if r != '_' && !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return false + } + } + return true +} + +func validateOrderClause(order string) error { + order = strings.TrimSpace(order) + if order == "" { + return ErrUnsafeOrder + } + for _, raw := range strings.Split(order, ",") { + item := strings.TrimSpace(raw) + if item == "" { + return ErrUnsafeOrder + } + fields := strings.Fields(item) + if len(fields) == 0 || len(fields) > 2 { + return ErrUnsafeOrder + } + if !isIdentifierPath(fields[0]) { + return ErrUnsafeOrder + } + if len(fields) == 2 { + dir := strings.ToUpper(fields[1]) + if dir != "ASC" && dir != "DESC" { + return ErrUnsafeOrder + } + } + } + return nil +} diff --git a/repository/repository_m5_internal_test.go b/repository/repository_m5_internal_test.go new file mode 100644 index 0000000..0213c33 --- /dev/null +++ b/repository/repository_m5_internal_test.go @@ -0,0 +1,261 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" +) + +func seedM5Users(t *testing.T, repo *BaseRepo[h6User], n int) { + t.Helper() + for i := 0; i < n; i++ { + if err := repo.Create(context.Background(), &h6User{Name: fmt.Sprintf("m5-%04d", i), Age: i}); err != nil { + t.Fatalf("seed user %d: %v", i, err) + } + } +} + +func TestM5NilContextFallsBackToBackground(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + + u := &h6User{Name: "nil-ctx"} + if err := repo.Create(nil, u); err != nil { + t.Fatalf("Create(nil ctx): %v", err) + } + got, err := repo.FindByID(nil, u.ID) + if err != nil { + t.Fatalf("FindByID(nil ctx): %v", err) + } + if got.Name != "nil-ctx" { + t.Fatalf("got %q, want nil-ctx", got.Name) + } +} + +func TestM5UnsafeOrderRejected(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + + cases := []struct { + name string + fn func() error + }{ + {"FindOrdered", func() error { + _, err := repo.FindOrdered(ctx, "name desc; drop table h6_users", 10) + return err + }}, + {"FindWhereOrdered", func() error { + _, err := repo.FindWhereOrdered(ctx, "name desc --", "name <> ?", "") + return err + }}, + {"FindPageOrdered", func() error { + _, err := repo.FindPageOrdered(ctx, 1, 10, "lower(name) desc") + return err + }}, + {"FindPageWhereOrdered", func() error { + _, err := repo.FindPageWhereOrdered(ctx, 1, 10, "name collate nocase", "name <> ?", "") + return err + }}, + {"QueryBuilder", func() error { + _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Find(ctx) + return err + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := tc.fn(); !errors.Is(err, ErrUnsafeOrder) { + t.Fatalf("err = %v, want ErrUnsafeOrder", err) + } + }) + } +} + +func TestM5QueryBuilderUnsafeOrderPropagatesToAllTerminators(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + seedM5Users(t, repo, 1) + + if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Find(ctx); !errors.Is(err, ErrUnsafeOrder) { + t.Fatalf("Find err = %v, want ErrUnsafeOrder", err) + } + if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").First(ctx); !errors.Is(err, ErrUnsafeOrder) { + t.Fatalf("First err = %v, want ErrUnsafeOrder", err) + } + if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Count(ctx); !errors.Is(err, ErrUnsafeOrder) { + t.Fatalf("Count err = %v, want ErrUnsafeOrder", err) + } + if _, err := repo.NewQueryBuilder().Order("name; drop table h6_users").Page(ctx, 1, 10); !errors.Is(err, ErrUnsafeOrder) { + t.Fatalf("Page err = %v, want ErrUnsafeOrder", err) + } +} + +func TestM5SafeOrderStillWorks(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + if err := repo.Create(ctx, &h6User{Name: "b"}); err != nil { + t.Fatalf("Create b: %v", err) + } + if err := repo.Create(ctx, &h6User{Name: "a"}); err != nil { + t.Fatalf("Create a: %v", err) + } + + got, err := repo.FindOrdered(ctx, "name ASC, id DESC", 10) + if err != nil { + t.Fatalf("FindOrdered safe order: %v", err) + } + if len(got) != 2 || got[0].Name != "a" || got[1].Name != "b" { + t.Fatalf("unexpected order: %+v", got) + } +} + +func TestM5UpdateBatchRejectsUnsafeFieldAndEmptyIDsNoop(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + ctx := context.Background() + u := &h6User{Name: "field", Age: 1} + if err := repo.Create(ctx, u); err != nil { + t.Fatalf("Create: %v", err) + } + + if err := repo.UpdateBatch(ctx, []uint{u.ID}, "age = age + 1", 2); !errors.Is(err, ErrUnsafeField) { + t.Fatalf("unsafe field err = %v, want ErrUnsafeField", err) + } + if err := repo.UpdateBatch(ctx, nil, "age = age + 1", 2); err != nil { + t.Fatalf("empty ids should noop before field validation, got %v", err) + } + if err := repo.UpdateBatch(ctx, []uint{u.ID}, "age", 2); err != nil { + t.Fatalf("safe field UpdateBatch: %v", err) + } + got, _ := repo.FindByID(ctx, u.ID) + if got.Age != 2 { + t.Fatalf("age = %d, want 2", got.Age) + } +} + +func TestM5FindByIDsEmptyReturnsEmptySlice(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + got, err := repo.FindByIDs(context.Background(), nil) + if err != nil { + t.Fatalf("FindByIDs empty: %v", err) + } + if got == nil { + t.Fatal("FindByIDs empty should return non-nil empty slice") + } + if len(got) != 0 { + t.Fatalf("len = %d, want 0", len(got)) + } +} + +func TestM5FindAllAppliesDefaultLimit(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + seedM5Users(t, repo, DefaultFindAllLimit+5) + + got, err := repo.FindAll(context.Background()) + if err != nil { + t.Fatalf("FindAll: %v", err) + } + if len(got) != DefaultFindAllLimit { + t.Fatalf("FindAll len = %d, want default limit %d", len(got), DefaultFindAllLimit) + } + + all, err := repo.FindAllUnbounded(context.Background()) + if err != nil { + t.Fatalf("FindAllUnbounded: %v", err) + } + if len(all) != DefaultFindAllLimit+5 { + t.Fatalf("FindAllUnbounded len = %d, want %d", len(all), DefaultFindAllLimit+5) + } +} + +func TestM5PaginationBounds(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + seedM5Users(t, repo, MaxPageSize+5) + + p, err := repo.FindPage(context.Background(), -10, 0) + if err != nil { + t.Fatalf("FindPage default bounds: %v", err) + } + if p.Page != 1 || p.PageSize != DefaultPageSize || len(p.Items) != DefaultPageSize { + t.Fatalf("page defaults = page:%d size:%d items:%d, want 1/%d/%d", + p.Page, p.PageSize, len(p.Items), DefaultPageSize, DefaultPageSize) + } + + p, err = repo.FindPage(context.Background(), 1, MaxPageSize+100) + if err != nil { + t.Fatalf("FindPage max size: %v", err) + } + if p.PageSize != MaxPageSize || len(p.Items) != MaxPageSize { + t.Fatalf("page max = size:%d items:%d, want %d/%d", p.PageSize, len(p.Items), MaxPageSize, MaxPageSize) + } + + p, err = repo.FindPage(context.Background(), MaxPage+1, 10) + if err != nil { + t.Fatalf("FindPage max page: %v", err) + } + if p.Page != MaxPage { + t.Fatalf("page = %d, want MaxPage %d", p.Page, MaxPage) + } +} + +func TestM5QueryBuilderNilDBPanicsClearly(t *testing.T) { + repo := NewBaseRepo[h6User](nil) + defer func() { + if rec := recover(); rec == nil { + t.Fatal("NewQueryBuilder with nil db should panic") + } + }() + _ = repo.NewQueryBuilder() +} + +func TestM5QueryBuilderNilContextAndPageBounds(t *testing.T) { + repo := NewBaseRepo[h6User](newH6SqliteDB(t)) + seedM5Users(t, repo, 25) + + count, err := repo.NewQueryBuilder().Where("created_at <= ?", time.Now().Add(time.Hour)).Count(nil) + if err != nil { + t.Fatalf("Count nil ctx: %v", err) + } + if count != 25 { + t.Fatalf("count = %d, want 25", count) + } + + p, err := repo.NewQueryBuilder().Page(nil, 1, 0) + if err != nil { + t.Fatalf("Page nil ctx: %v", err) + } + if p.PageSize != DefaultPageSize || len(p.Items) != DefaultPageSize { + t.Fatalf("QueryBuilder page size/items = %d/%d, want %d/%d", + p.PageSize, len(p.Items), DefaultPageSize, DefaultPageSize) + } +} + +func TestM5DeleteBatchEmptyIDsNoop(t *testing.T) { + db := newDryRunDB(t) + repo := NewBaseRepo[pageCountModel](db) + + if err := repo.DeleteBatch(context.Background(), nil); err != nil { + t.Fatalf("DeleteBatch empty ids: %v", err) + } + if got := db.Statement.SQL.String(); got != "" { + t.Fatalf("DeleteBatch empty ids should not issue SQL, got %s", got) + } + if err := repo.HardDeleteBatch(context.Background(), nil); err != nil { + t.Fatalf("HardDeleteBatch empty ids: %v", err) + } + if got := db.Statement.SQL.String(); got != "" { + t.Fatalf("HardDeleteBatch empty ids should not issue SQL, got %s", got) + } +} + +func TestM5FindAllUsesLimitSQL(t *testing.T) { + db := newDryRunDB(t) + repo := NewBaseRepo[pageCountModel](db) + var models []pageCountModel + result := repo.readConn(context.Background()).Limit(DefaultFindAllLimit).Find(&models) + sql := strings.ToUpper(result.Statement.SQL.String()) + if !strings.Contains(sql, "LIMIT") { + t.Fatalf("FindAll default path should include LIMIT, got %s", sql) + } +} diff --git a/response/error.go b/response/error.go index 4ace597..25e101f 100644 --- a/response/error.go +++ b/response/error.go @@ -107,6 +107,13 @@ func (e *Error) WithDetail(detail string) *Error { // P1 #15:仅当 detailExposed()(默认 true;App 生产环境置 false)时才向客户端输出 Detail, // 避免内部错误细节在生产泄露。 func (e *Error) ToResponse() Response { + if e == nil { + return Response{ + Code: CodeServerError, + Msg: ErrServerError.Message, + Data: nil, + } + } if e.Detail != "" && detailExposed() { return Response{ Code: e.Code, @@ -211,6 +218,10 @@ func FailWithError(c *gin.Context, err *Error) { // FailWithDetail 使用预定义错误并添加详细信息。 // P1 #15:detail 仅在 detailExposed()(默认 true;App 生产环境置 false)时输出给客户端。 func FailWithDetail(c *gin.Context, err *Error, detail string) { + if err == nil { + FailWithError(c, nil) + return + } if detailExposed() { writeResp(c, err.Code, err.Message, gin.H{"detail": detail}) return diff --git a/response/response.go b/response/response.go index b284181..8ca3bd6 100644 --- a/response/response.go +++ b/response/response.go @@ -1,10 +1,11 @@ package response import ( + "bytes" + "io" "net/http" "net/url" - "github.com/EthanCodeCraft/xlgo-core/utils" "github.com/gin-gonic/gin" ) @@ -127,18 +128,31 @@ func asciiFallbackName(filename string) string { // Download 文件下载响应 func Download(c *gin.Context, filename string, data []byte) { - c.Header("Content-Type", "application/octet-stream") - c.Header("Content-Disposition", contentDisposition(filename)) - c.Header("Content-Length", utils.ToString(len(data))) - c.Data(http.StatusOK, "application/octet-stream", data) + DownloadReader(c, filename, "application/octet-stream", int64(len(data)), bytesReader(data)) } // DownloadWithContentType 文件下载(自定义Content-Type) func DownloadWithContentType(c *gin.Context, filename string, contentType string, data []byte) { - c.Header("Content-Type", contentType) - c.Header("Content-Disposition", contentDisposition(filename)) - c.Header("Content-Length", utils.ToString(len(data))) - c.Data(http.StatusOK, contentType, data) + DownloadReader(c, filename, contentType, int64(len(data)), bytesReader(data)) +} + +// DownloadReader 流式下载响应。 +// +// 对大文件/对象存储下载优先使用此函数,避免先把完整内容读入 []byte 驻留内存。 +// contentLength 传 -1 表示未知长度;Gin 会省略 Content-Length。 +func DownloadReader(c *gin.Context, filename, contentType string, contentLength int64, r io.Reader) { + if contentType == "" { + contentType = "application/octet-stream" + } + headers := map[string]string{ + "Content-Disposition": contentDisposition(filename), + } + c.DataFromReader(http.StatusOK, contentLength, contentType, r, headers) +} + +// bytesReader 避免在公开 API 中暴露 bytes 包细节,同时让旧 []byte API 复用流式写路径。 +func bytesReader(data []byte) io.Reader { + return bytes.NewReader(data) } // HTML HTML内容响应 diff --git a/response/response_test.go b/response/response_test.go index e92e9b7..c979204 100644 --- a/response/response_test.go +++ b/response/response_test.go @@ -1,7 +1,9 @@ package response_test import ( + "encoding/json" "net/http/httptest" + "strings" "testing" "github.com/EthanCodeCraft/xlgo-core/response" @@ -85,6 +87,34 @@ func TestFailWithCode(t *testing.T) { } } +func TestFailWithErrorNilDoesNotPanic_M6(t *testing.T) { + r := setupTestRouter() + r.Use(func(c *gin.Context) { + c.Set("request_id", "req-m6") + c.Next() + }) + r.GET("/test", func(c *gin.Context) { + response.FailWithError(c, nil) + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + if w.Code != 200 { + t.Fatalf("FailWithError(nil) status = %d, want 200 in business mode", w.Code) + } + var body response.Response + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Code != response.CodeServerError { + t.Fatalf("code = %d, want %d", body.Code, response.CodeServerError) + } + if body.RequestID != "req-m6" { + t.Fatalf("request_id = %q, want req-m6", body.RequestID) + } +} + func TestUnauthorized(t *testing.T) { r := setupTestRouter() r.GET("/test", func(c *gin.Context) { @@ -242,6 +272,29 @@ func TestDownloadWithContentType(t *testing.T) { } } +func TestDownloadReaderStreamsContent_M6(t *testing.T) { + r := setupTestRouter() + r.GET("/test", func(c *gin.Context) { + response.DownloadReader(c, "big.txt", "text/plain", 11, strings.NewReader("hello world")) + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil)) + + if w.Code != 200 { + t.Fatalf("DownloadReader status = %d, want 200", w.Code) + } + if got := w.Header().Get("Content-Type"); !contains(got, "text/plain") { + t.Fatalf("Content-Type = %q, want text/plain", got) + } + if got := w.Header().Get("Content-Length"); got != "11" { + t.Fatalf("Content-Length = %q, want 11", got) + } + if got := w.Body.String(); got != "hello world" { + t.Fatalf("body = %q, want hello world", got) + } +} + func TestHTML(t *testing.T) { r := setupTestRouter() r.GET("/test", func(c *gin.Context) { @@ -343,4 +396,4 @@ func contains(s, sub string) bool { } } return false -} \ No newline at end of file +} diff --git a/router/health_test.go b/router/health_test.go index 2cc546b..aa5adde 100644 --- a/router/health_test.go +++ b/router/health_test.go @@ -6,7 +6,9 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" + "time" "github.com/EthanCodeCraft/xlgo-core/router" ) @@ -60,6 +62,81 @@ func TestRegisterHealthRouteWithFailingCheck(t *testing.T) { } } +func TestRegisterHealthRouteTimesOutStuckCheck_M7(t *testing.T) { + r := setupTestRouter() + router.RegisterHealthRoute(r, router.HealthCheck{ + Name: "stuck", + Timeout: 10 * time.Millisecond, + Check: func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + }, + }) + + w := httptest.NewRecorder() + start := time.Now() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", w.Code) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("health check took %s, want bounded timeout", elapsed) + } + if !strings.Contains(w.Body.String(), `"stuck":"timeout"`) { + t.Fatalf("body = %s, want timeout status", w.Body.String()) + } +} + +func TestRegisterHealthRouteRecoversCheckPanic_M7(t *testing.T) { + r := setupTestRouter() + router.RegisterHealthRoute(r, router.HealthCheck{ + Name: "panic", + Check: func(context.Context) error { + panic("boom") + }, + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", w.Code) + } + if !strings.Contains(w.Body.String(), `"panic":"error"`) { + t.Fatalf("body = %s, want panic check error status", w.Body.String()) + } +} + +func TestRegisterHealthRouteLimitsNonCooperativeCheckConcurrency_M7(t *testing.T) { + var started atomic.Int32 + release := make(chan struct{}) + defer close(release) + + r := setupTestRouter() + router.RegisterHealthRoute(r, router.HealthCheck{ + Name: "stuck", + Timeout: 10 * time.Millisecond, + Check: func(context.Context) error { + started.Add(1) + <-release + return nil + }, + }) + + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("request %d status = %d, want 503", i+1, w.Code) + } + } + + if got := started.Load(); got != 1 { + t.Fatalf("check started %d times, want 1 while first non-cooperative check is still running", got) + } +} + func TestRegisterDefaultRoutes(t *testing.T) { r := setupTestRouter() router.RegisterDefaultRoutes(r) diff --git a/router/router.go b/router/router.go index a41c370..31d416f 100644 --- a/router/router.go +++ b/router/router.go @@ -2,12 +2,15 @@ package router import ( "context" + "errors" "fmt" "net/http" + "runtime/debug" "sort" "strings" "sync" "sync/atomic" + "time" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" @@ -19,6 +22,76 @@ type HealthCheck struct { Name string Check func(context.Context) error Disabled bool + Timeout time.Duration +} + +// DefaultHealthCheckTimeout bounds each dependency check so /health and /readyz +// do not hang behind a stuck database/client call. +const DefaultHealthCheckTimeout = 2 * time.Second + +type healthCheckRunner struct { + check HealthCheck + running chan struct{} +} + +func newHealthCheckRunners(checks []HealthCheck) []healthCheckRunner { + runners := make([]healthCheckRunner, len(checks)) + for i, check := range checks { + runners[i] = healthCheckRunner{ + check: check, + running: make(chan struct{}, 1), + } + } + return runners +} + +func healthCheckTimeout(check HealthCheck) time.Duration { + if check.Timeout > 0 { + return check.Timeout + } + return DefaultHealthCheckTimeout +} + +func healthCheckStatusFromContext(err error) string { + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + return "error" +} + +func (r healthCheckRunner) run(ctx context.Context) (string, error) { + check := r.check + checkCtx, cancel := context.WithTimeout(ctx, healthCheckTimeout(check)) + defer cancel() + + select { + case r.running <- struct{}{}: + case <-checkCtx.Done(): + return healthCheckStatusFromContext(checkCtx.Err()), checkCtx.Err() + } + + errCh := make(chan error, 1) + go func() { + defer func() { + <-r.running + }() + defer func() { + if rec := recover(); rec != nil { + errCh <- fmt.Errorf("health check %q panic recovered: %v\n%s", check.Name, rec, debug.Stack()) + } + }() + errCh <- check.Check(checkCtx) + }() + + select { + case err := <-errCh: + if err == nil { + return "ok", nil + } + return healthCheckStatusFromContext(err), err + case <-checkCtx.Done(): + return healthCheckStatusFromContext(checkCtx.Err()), checkCtx.Err() + } } // registerGETOnce 幂等注册 GET 路由(H8d 收尾:消除 defaultModule 与 Register* 系列 @@ -59,14 +132,15 @@ func registerGETOnce(r gin.IRoutes, path string, h gin.HandlerFunc) { // runHealthChecks 执行所有检查项,返回总体状态、HTTP code 与逐项结果。 // 无检查项时视为健康(用于 /livez 与无依赖场景)。 -func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, map[string]string) { - if len(checks) == 0 { +func runHealthChecks(ctx context.Context, runners []healthCheckRunner) (string, int, map[string]string) { + if len(runners) == 0 { return "ok", http.StatusOK, nil } status := "ok" code := http.StatusOK - result := make(map[string]string, len(checks)) - for _, check := range checks { + result := make(map[string]string, len(runners)) + for _, runner := range runners { + check := runner.check if check.Name == "" { continue } @@ -74,13 +148,13 @@ func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, ma result[check.Name] = "disabled" continue } - if err := check.Check(ctx); err != nil { - result[check.Name] = "error" + checkStatus, err := runner.run(ctx) + result[check.Name] = checkStatus + if err != nil { status = "error" code = http.StatusServiceUnavailable continue } - result[check.Name] = "ok" } return status, code, result } @@ -90,8 +164,9 @@ func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, ma // 并附逐项结果。RegisterHealthRoute / RegisterReadinessRoute / defaultModule // 均委托此实现,避免三个 /health 行为与响应体不一致。 func healthHandler(checks []HealthCheck) gin.HandlerFunc { + runners := newHealthCheckRunners(checks) return func(c *gin.Context) { - status, code, result := runHealthChecks(c.Request.Context(), checks) + status, code, result := runHealthChecks(c.Request.Context(), runners) if result == nil { c.JSON(http.StatusOK, gin.H{"status": status}) return @@ -188,10 +263,10 @@ type MiddlewareGroup struct { // Registry 路由注册中心 type Registry struct { - engine *gin.Engine - modules []Module - versions map[string]*VersionedAPI - middlewareGroups map[string]*MiddlewareGroup + engine *gin.Engine + modules []Module + versions map[string]*VersionedAPI + middlewareGroups map[string]*MiddlewareGroup globalMiddlewares []gin.HandlerFunc // metricsMiddleware 在 Apply 时作为首个全局中间件装入(H8c), @@ -209,9 +284,9 @@ type Registry struct { // NewRegistry 创建路由注册中心 func NewRegistry(engine *gin.Engine) *Registry { return &Registry{ - engine: engine, - modules: make([]Module, 0), - versions: make(map[string]*VersionedAPI), + engine: engine, + modules: make([]Module, 0), + versions: make(map[string]*VersionedAPI), middlewareGroups: make(map[string]*MiddlewareGroup), } } @@ -239,7 +314,7 @@ type namedModule struct { fn func(r *gin.RouterGroup) } -func (m *namedModule) Name() string { return m.name } +func (m *namedModule) Name() string { return m.name } func (m *namedModule) Register(r *gin.RouterGroup) { m.fn(r) } // RegisterVersion 注册版本化 API @@ -468,4 +543,4 @@ func (r *RESTfulRoute) CRUD(list, detail, create, update, delete gin.HandlerFunc if delete != nil { r.Group.DELETE(r.Path+"/:id", delete) } -} \ No newline at end of file +}