fix remaining framework contract gaps

This commit is contained in:
杭州明婳科技
2026-07-08 11:17:54 +08:00
parent 232b16d65d
commit 8770e9344a
13 changed files with 961 additions and 80 deletions
+7
View File
@@ -28,6 +28,9 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
- **`logger.DefaultLogger = m` 直接赋值不再驱动包级 facade**logger/logger.goM3):为消除 `SetDefaultLogManager()` 与包级 facade 并发读取默认 manager 的裸全局指针竞态,facade 改为读取内部 atomic 快照。`logger.DefaultLogger` 仍保持 `*LogManager` 类型,旧的 `logger.DefaultLogger.Init/Close/SetLevel/GetLevel` 直接调用仍可用;替换默认 manager 请使用 `logger.SetDefaultLogManager(m)`
- **`logger.Init` 拒绝明显非法日志配置**logger/logger.goM3):空日志目录、负数 `MaxSize/MaxBackups/MaxAge` 现在直接返回错误。依赖零值日志配置启动 `WithLogger()` 的下游需显式设置 `Log.Dir` 与非负轮转参数。
- **限流器非法配置改为 fail-fast**middleware/ratelimit.goM8):`NewRateLimiter` / `NewRedisRateLimiter` / `NewRedisRateLimiterFailClosed` 现在对 `rate <= 0``window <= 0` 直接 panic,避免零值窗口/零值配额静默产生不确定限流语义。下游应在配置加载阶段校验限流参数。
- **`handler.BindJSON` 默认限制 JSON body 为 1MiB**handler/handler.goM6):防止入口层无上限读取请求体导致 OOM。需要更大 JSON 的接口请改用 `handler.BindJSONWithMaxBytes(c, req, maxBytes)` 显式声明上限。
- **cron 非法任务配置改为 fail-fast**cron/cron.goM13):`AddTask` 拒绝 nil schedule / nil handler`Every(<=0)``Daily`/`Weekly` 越界时间、非法 weekday 会 panic,避免静默生成不推进或归一化跑偏的调度。
- **repository 查询保护默认开启**repository/repository.goM5/N5):`FindAll` 默认最多返回 `DefaultFindAllLimit=1000` 条;明确需要全表扫描时改用 `FindAllUnbounded``FindPage*` / `QueryBuilder.Page` 会归一化 `page/pageSize` 并限制 `MaxPageSize=100``MaxPage=10000``Find*Ordered` / `QueryBuilder.Order` 只接受简单字段排序(如 `created_at DESC, id ASC`),复杂表达式/raw SQL 会返回 `ErrUnsafeOrder``UpdateBatch` 字段名不合法返回 `ErrUnsafeField`
### Fixed 🐛
@@ -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.goresponse/response.gohandler/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
View File
@@ -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}
}
+83
View File
@@ -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)
}
}
-20
View File
@@ -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) 各版本章节。
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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/OffsetH6e),避免先前 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
}
+261
View File
@@ -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)
}
}
+11
View File
@@ -107,6 +107,13 @@ func (e *Error) WithDetail(detail string) *Error {
// P1 #15:仅当 detailExposed()(默认 trueApp 生产环境置 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 #15detail 仅在 detailExposed()(默认 trueApp 生产环境置 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
View File
@@ -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内容响应
+54 -1
View File
@@ -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
}
}
+77
View File
@@ -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
View File
@@ -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)
}
}
}