fix remaining framework contract gaps
This commit is contained in:
@@ -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。
|
||||
|
||||
+99
-16
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) 各版本章节。
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+29
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+209
-12
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+23
-9
@@ -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内容响应
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+92
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user