Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dbbce34f5 | |||
| 96ce883d41 | |||
| 399950a9f6 | |||
| df78706dbb | |||
| 905cf7db4f | |||
| 9e11f8f007 | |||
| ffcb1a77b9 | |||
| 6730f12cd6 | |||
| 5eb3879a85 | |||
| 08ed43385d | |||
| 21021d4001 | |||
| 2529b0a074 | |||
| ca822dcadd | |||
| c64a992008 | |||
| 18eb62e2f0 | |||
| 74b65216b6 | |||
| 4ccbbfbf1b | |||
| 89721132a1 | |||
| 1ea3d8751f | |||
| 27c14499c2 | |||
| d73a46447c | |||
| 0610a159f0 | |||
| 667f0793fc | |||
| 7ca1d81f88 | |||
| 8ccfb162b8 | |||
| 9b9c5df676 | |||
| fed3e37348 | |||
| 8770e9344a | |||
| 232b16d65d | |||
| 56352fdd81 | |||
| 2d3dde99c2 | |||
| 86e126c42b | |||
| 6595e94677 | |||
| a74ea0c2f4 | |||
| 0f13292a46 | |||
| 75756dd5fb | |||
| 8421e06fa8 | |||
| 18e4f7e891 | |||
| 0c9f256dfc | |||
| cd3d629282 | |||
| deb3cc71bf | |||
| dcfd24b624 | |||
| 2cc8c70960 |
+15
-1
@@ -1,2 +1,16 @@
|
||||
report.md
|
||||
# Claude Code 协作指引(本地,不入库)
|
||||
CLAUDE.md
|
||||
|
||||
AGENTS.md
|
||||
|
||||
# 临时发版辅助文件
|
||||
gitHub_release_*.md
|
||||
|
||||
# 构建产物
|
||||
*.exe
|
||||
bin/
|
||||
md/
|
||||
.gocache/
|
||||
gpt_check_report_framework.md
|
||||
gpt_check_report_review.md
|
||||
gpt_check_report_module.md
|
||||
|
||||
+1141
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@
|
||||
|
||||
## 1. 快速开始
|
||||
|
||||
> **环境要求**:xlgo 需要 **Go 1.25+**。本项目作为新框架不背负旧版本兼容包袱,可直接使用 Go 1.25 的新特性。
|
||||
|
||||
### 1.1 安装框架
|
||||
|
||||
```bash
|
||||
@@ -57,37 +59,38 @@ myproject/
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. 加载配置
|
||||
cfg, err := config.Load("./config.yaml")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// 2. 创建应用
|
||||
// v1.0.2 起 xlgo.New 默认是轻量应用,不会自动初始化 MySQL/Redis/Storage,
|
||||
// 也不会自动注册 /health 或 /swagger/* 路由。
|
||||
// 通过 Option 显式启用所需组件即可。
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfigPath("./config.yaml"),
|
||||
xlgo.WithLogger(),
|
||||
xlgo.WithDefaultRoutes(), // 同时启用 /health 与 /swagger/*
|
||||
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
|
||||
)
|
||||
|
||||
// 3. 注册路由
|
||||
// 通过 Engine 直接挂路由,或使用 xlgo.WithModules(...) 注册模块
|
||||
app.GetRouter().GET("/hello", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "Hello World"})
|
||||
})
|
||||
|
||||
// 4. 启动应用(支持优雅关闭)
|
||||
if err := app.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 想要 batteries-included 体验,可改用 `xlgo.NewFullStack(...)` 或
|
||||
> `xlgo.RunFullStack(...)`,它会一次性启用 Logger / MySQL / Redis / Storage /
|
||||
> Wire / Health / Swagger / AutoMigrate 等组件。
|
||||
|
||||
**优雅关闭特性:**
|
||||
- 监听系统信号(SIGINT、SIGTERM)
|
||||
- 等待请求处理完成(最多 30 秒)
|
||||
@@ -108,7 +111,6 @@ app:
|
||||
env: "dev" # dev/test/prod
|
||||
debug: true
|
||||
base_url: "http://localhost:8080"
|
||||
token_expire: 86400 # Token过期时间(秒)
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
@@ -131,7 +133,7 @@ redis:
|
||||
|
||||
jwt:
|
||||
secret: your_jwt_secret_key
|
||||
expire: 86400
|
||||
expire: "24h" # time.Duration,如 "24h"/"30m"
|
||||
|
||||
log:
|
||||
dir: ./logs
|
||||
@@ -183,7 +185,8 @@ import "github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
logger.Init(cfg)
|
||||
|
||||
// 程序退出前同步
|
||||
defer logger.Sync()
|
||||
// v1.0.2 起 Sync 返回 error,可按需处理
|
||||
_ = logger.Sync()
|
||||
```
|
||||
|
||||
### 3.2 日志级别
|
||||
@@ -199,7 +202,8 @@ logger.Error("错误信息")
|
||||
logger.Infof("用户 %s 登录成功", username)
|
||||
logger.Warnf("请求失败: %v", err)
|
||||
|
||||
// 致命错误(会终止程序)
|
||||
// 致命错误(会终止程序)—— 仅在业务进程入口(main 等)使用
|
||||
// v1.0.2 起,框架内部已禁止使用 Fatal/Fatalf,初始化错误改为 error 返回
|
||||
logger.Fatalf("数据库连接失败: %v", err)
|
||||
|
||||
// 结构化日志
|
||||
@@ -211,7 +215,7 @@ logger.Info("用户登录",
|
||||
|
||||
### 3.3 彩色控制台输出
|
||||
|
||||
开发调试时使用彩色输出,一眼识别日志级别:
|
||||
`console` 包定位是**开发期彩色 stdout 工具**——跟 `fmt.Println` 同级,不写文件、不感知运行环境、不做任何隐式行为。
|
||||
|
||||
```go
|
||||
import "github.com/EthanCodeCraft/xlgo-core/console"
|
||||
@@ -226,30 +230,144 @@ console.Error("错误信息") // 红色
|
||||
c := console.New(
|
||||
console.WithColor(true),
|
||||
console.WithTime(true),
|
||||
console.WithCaller(true, 2),
|
||||
console.WithCaller(true), // skip 可选,默认 2
|
||||
console.WithLevel(console.LevelInfo),
|
||||
)
|
||||
c.Debug("自定义输出")
|
||||
c.Debug("此条不会输出,已被 LevelInfo 过滤")
|
||||
c.Info("自定义输出")
|
||||
```
|
||||
|
||||
#### console vs logger:怎么选?
|
||||
|
||||
| 维度 | `console` | `logger` |
|
||||
|---|---|---|
|
||||
| 定位 | 开发期肉眼调试 | 业务可观测性记录 |
|
||||
| 输出目标 | stdout(彩色) | 文件 + stdout |
|
||||
| 持久化 | ❌ | ✅ 滚动归档 |
|
||||
| 结构化 | 文本 | JSON 字段 |
|
||||
| 性能 | 一般 | zap 高性能 |
|
||||
| 默认级别 | `LevelDebug`(全开) | dev=Debug / prod=Info |
|
||||
| 适用场景 | 临时打印、开发联调 | 用户登录、订单事件、审计日志 |
|
||||
|
||||
**简单判断**:
|
||||
|
||||
- 这条信息上线后想留 → 用 `logger`
|
||||
- 这条信息上线就该消失 → 用 `console`,并在 main 中显式切到高级别
|
||||
|
||||
#### 上线前显式收紧 console 输出
|
||||
|
||||
```go
|
||||
func main() {
|
||||
cfg, _ := config.Load("./config.yaml")
|
||||
|
||||
// 显式选择:生产期 console 只看 Warn / Error
|
||||
if cfg.IsProduction() {
|
||||
console.SetLevel(console.LevelWarn)
|
||||
}
|
||||
|
||||
// 或者完全静默 console(业务全靠 logger)
|
||||
// console.SetLevel(console.LevelSilent)
|
||||
|
||||
app := xlgo.New(...)
|
||||
app.Run()
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:xlgo 不会自动根据 `app.env` 切换 console 级别——选择权完全在调用方。
|
||||
> 我们认为隐式切换会带来"开发期看到的 / 生产期看到的"行为不一致,调试体验更糟。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据库操作
|
||||
|
||||
xlgo 基于 GORM,驱动由配置 `database.driver` 决定。v1.0.2 起 GORM 方言通过 **可插拔注册表** 管理:内置 `mysql`(默认)与 `postgres`(含 `postgresql`、`pg` 别名),应用可通过 `database.RegisterDialect` 接入任意 GORM 驱动;`database.dsn` 字段始终可用于手写连接串。
|
||||
|
||||
### 4.1 初始化数据库
|
||||
|
||||
```go
|
||||
import "github.com/EthanCodeCraft/xlgo-core/database"
|
||||
|
||||
// 初始化 MySQL
|
||||
database.InitMySQL(cfg)
|
||||
// 初始化数据库(驱动由配置决定,等价于 database.DefaultManager.Load().InitDB(cfg))
|
||||
database.InitDB(cfg)
|
||||
|
||||
// 关闭连接
|
||||
defer database.Close()
|
||||
// 关闭全部连接(含从库)
|
||||
defer database.CloseAll()
|
||||
|
||||
// 获取数据库实例
|
||||
db := database.GetDB()
|
||||
db := database.GetDB() // 主库
|
||||
read := database.GetReadDB() // 从库(无从库时回退主库)
|
||||
```
|
||||
|
||||
### 4.1.1 主从读写分离
|
||||
|
||||
```go
|
||||
// 从库 DSN 列表需与主库驱动匹配
|
||||
database.InitDBWithReplicas(cfg, []string{
|
||||
"root:pass@tcp(slave1:3306)/db",
|
||||
"root:pass@tcp(slave2:3306)/db",
|
||||
})
|
||||
|
||||
// 选择策略:默认 Random,可换成 RoundRobin
|
||||
database.SetReplicaPicker(&database.RoundRobinPicker{})
|
||||
|
||||
// 强制使用主库(事务、需要实时数据的场景)
|
||||
ctx := database.UseMaster(context.Background())
|
||||
database.GetDBFromContext(ctx).Find(&users)
|
||||
|
||||
// 强制使用从库(报表查询)
|
||||
ctx = database.UseReplica(context.Background())
|
||||
database.GetDBFromContext(ctx).Find(&reports)
|
||||
```
|
||||
|
||||
### 4.1.2 实例化 Manager
|
||||
|
||||
需要多套数据库连接(如平台库 + 租户库)时,`database.NewManager(cfg)` 创建独立管理器,互不影响:
|
||||
|
||||
```go
|
||||
mgr := database.NewManager(cfg)
|
||||
if err := mgr.Open(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
defer mgr.Close()
|
||||
|
||||
mgr.SetPicker(&database.RoundRobinPicker{})
|
||||
db := mgr.FromContext(ctx)
|
||||
```
|
||||
|
||||
`database.SetDefaultManager(m)` 会把新 manager 提升为全局默认,并关闭被替换的旧 manager,避免连接池泄漏。需要失败回滚或延迟释放旧资源时,请使用 `database.SwapDefaultManager(m)` 并自行关闭其返回的旧 manager。
|
||||
|
||||
### 4.1.3 注册自定义 GORM 驱动
|
||||
|
||||
`database.RegisterDialect` 一次注册即让 `database.driver: <name>` 生效,DSN 构建器同步登记到 `config` 包:
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
database.RegisterDialect(database.DialectSpec{
|
||||
Name: "sqlite",
|
||||
Aliases: []string{"sqlite3"},
|
||||
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
|
||||
DSN: func(c *config.DatabaseConfig) string { return c.Name }, // Name 当作文件路径
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
之后只需把配置改成 `database.driver: sqlite` 即可。SQL Server / ClickHouse / TiDB 等同理。
|
||||
|
||||
诊断接口:
|
||||
|
||||
- `database.RegisteredDialects()` - 列出已注册驱动(含别名)
|
||||
- `database.LookupDialect(name)` - 检查某驱动是否可用
|
||||
- `config.RegisteredDrivers()` - 列出已登记的 DSN 构建器
|
||||
|
||||
未注册的驱动会回退到 MySQL 以保持向后兼容。
|
||||
|
||||
### 4.2 定义模型
|
||||
|
||||
```go
|
||||
@@ -276,13 +394,23 @@ import "github.com/EthanCodeCraft/xlgo-core/repository"
|
||||
// 创建仓库
|
||||
userRepo := repository.NewBaseRepo[model.User](database.GetDB())
|
||||
|
||||
// 连接路由(v1.1.1+ 修复 H6c):
|
||||
// - 读操作(FindByID/FindAll/FindPage/FindWhere/Count/Exists/...)默认路由到从库,
|
||||
// 支持 database.UseMaster(ctx)/UseReplica(ctx) 显式路由;未配置从库时回退主库。
|
||||
// - 写操作(Create/Update/Delete/*Batch/Restore/...)始终走主库,不路由到只读从库。
|
||||
// - DefaultManager 未初始化(如单测注入 sqlite)时回退到 NewBaseRepo 传入的 db。
|
||||
|
||||
// 基础 CRUD
|
||||
user, err := userRepo.FindByID(ctx, 1)
|
||||
users, err := userRepo.FindAll(ctx)
|
||||
users, err := userRepo.FindByIDs(ctx, []uint{1, 2, 3})
|
||||
err := userRepo.Create(ctx, &user)
|
||||
err := userRepo.Update(ctx, &user)
|
||||
err := userRepo.Delete(ctx, 1)
|
||||
err := userRepo.Update(ctx, &user) // Save 全列覆写(含零值),见下方 UpdateFields
|
||||
err := userRepo.Delete(ctx, 1) // T 含 gorm.Model 时软删除,否则硬删除
|
||||
|
||||
// 局部更新(推荐,H6a):避免 Save 全列覆写丢失更新/零值不可辨
|
||||
err := userRepo.UpdateFields(ctx, &model.User{Name: "new"}, "id = ?", id) // struct 仅更新非零字段
|
||||
err := userRepo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map 可显式置零
|
||||
|
||||
// 统计数量
|
||||
count, err := userRepo.Count(ctx)
|
||||
@@ -304,7 +432,7 @@ result, err := userRepo.FindPageOrdered(ctx, 1, 20, "created_at DESC")
|
||||
// 条件查询
|
||||
user, err := userRepo.FindOne(ctx, "email = ?", "test@example.com")
|
||||
users, err := userRepo.FindWhere(ctx, "status = ?", 1)
|
||||
users, err := userRepo.FindWhereOrdered(ctx, "status = ?", 1, []any{}, "created_at DESC")
|
||||
users, err := userRepo.FindWhereOrdered(ctx, "created_at DESC", "status = ?", 1)
|
||||
|
||||
// 排序查询
|
||||
users, err := userRepo.FindOrdered(ctx, "created_at DESC", 10)
|
||||
@@ -325,7 +453,7 @@ err := userRepo.RestoreBatch(ctx, []uint{1, 2})
|
||||
// 查询已删除的记录
|
||||
deletedUsers, err := userRepo.FindDeleted(ctx)
|
||||
|
||||
// 链式查询(灵活构建)
|
||||
// 链式查询(灵活构建;QueryBuilder 为单次使用、非并发安全)
|
||||
users, err := userRepo.NewQueryBuilder().
|
||||
Where("status = ?", 1).
|
||||
Where("role = ?", "admin").
|
||||
@@ -348,6 +476,15 @@ err := userRepo.WithTransaction(ctx, func(txRepo *repository.BaseRepo[model.User
|
||||
// 更新关联数据
|
||||
return txRepo.Update(ctx, &profile)
|
||||
})
|
||||
|
||||
// 跨层/跨 repo 事务 join(H6c):把外层事务注入 ctx 后传给任意 repo 方法
|
||||
err := database.TransactionWithContext(ctx, func(tx *gorm.DB) error {
|
||||
ctx2 := database.WithTx(ctx, tx)
|
||||
if err := userRepo.Create(ctx2, &user); err != nil {
|
||||
return err
|
||||
}
|
||||
return otherRepo.Update(ctx2, &other) // 同一事务
|
||||
})
|
||||
```
|
||||
|
||||
### 4.4 Model 基础模型
|
||||
@@ -376,6 +513,9 @@ type User struct {
|
||||
|
||||
### 5.1 初始化缓存
|
||||
|
||||
> **v1.4.0**:App 用户经 `WithRedis()` 启用时,`Init` 自动用 `app.redisManager.Client()` 初始化缓存
|
||||
> (走 App 自己的 Redis,多 App 隔离,不串越)。下方 `cache.Init()`(包级)用于 standalone 或回退全局 Redis。
|
||||
|
||||
```go
|
||||
import "github.com/EthanCodeCraft/xlgo-core/cache"
|
||||
|
||||
@@ -392,9 +532,11 @@ c := cache.GetCache()
|
||||
// 设置缓存
|
||||
c.Set(ctx, "user:1", userData, 30*time.Minute)
|
||||
|
||||
// 获取缓存
|
||||
// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err)
|
||||
var user User
|
||||
if c.Get(ctx, "user:1", &user) {
|
||||
if hit, err := c.Get(ctx, "user:1", &user); err != nil {
|
||||
return err
|
||||
} else if hit {
|
||||
// 缓存命中
|
||||
}
|
||||
|
||||
@@ -404,8 +546,11 @@ c.Delete(ctx, "user:1")
|
||||
// 批量删除(按模式)
|
||||
c.DeleteByPattern(ctx, "user:*")
|
||||
|
||||
// 检查是否存在
|
||||
exists := c.Exists(ctx, "user:1")
|
||||
// 检查是否存在(未命中 false,nil;后端错误返回 err)
|
||||
exists, err := c.Exists(ctx, "user:1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 键名前缀管理(多站点共用 Redis)
|
||||
@@ -425,7 +570,10 @@ cache.KSession("sid") // → "session:my_app:sid"
|
||||
|
||||
// 使用带前缀的缓存
|
||||
c.Set(ctx, cache.K("user:1"), userData, ttl)
|
||||
c.Get(ctx, cache.K("user:1"), &user)
|
||||
hit, err := c.Get(ctx, cache.K("user:1"), &user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 分布式锁(安全增强版)
|
||||
@@ -447,12 +595,12 @@ if token != nil {
|
||||
}
|
||||
|
||||
// 自动管理锁(简单场景)
|
||||
err := cache.WithLock(ctx, key, ttl, func() error {
|
||||
err := cache.WithLock(ctx, key, ttl, func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
// 自动续期锁(长任务场景)
|
||||
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func() error {
|
||||
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func(ctx context.Context) error {
|
||||
// 执行时间超过 TTL 时自动续期
|
||||
return nil
|
||||
})
|
||||
@@ -460,11 +608,14 @@ err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func()
|
||||
// 续期锁(手动续期)
|
||||
cache.ExtendLock(ctx, token, 30*time.Second)
|
||||
|
||||
// 检查锁是否被占用
|
||||
// 检查锁是否被占用(Redis 不可用时返回 ErrRedisNotReady,可经
|
||||
// errors.Is(err, cache.ErrRedisNotReady) 与"锁未占用"区分)
|
||||
locked, err := cache.IsLocked(ctx, key)
|
||||
|
||||
// 强制释放锁(管理场景)
|
||||
cache.ForceUnlock(ctx, key)
|
||||
// 强制释放锁(管理场景;Redis 不可用时返回 ErrRedisNotReady)
|
||||
if err := cache.ForceUnlock(ctx, key); err != nil {
|
||||
// 处理 Redis 不可用
|
||||
}
|
||||
```
|
||||
|
||||
**安全特性:**
|
||||
@@ -562,7 +713,9 @@ if !ok {
|
||||
| `max=20` | 最大长度 |
|
||||
| `email` | 邮箱格式 |
|
||||
| `phone` | 手机号格式 |
|
||||
| `password` | 密码强度(至少 8 位,包含字母和数字) |
|
||||
| `phone_strict` | 手机号严格校验(验证运营商号段) |
|
||||
| `password` | 密码强度(8-72 字节,需含大写+小写+数字) |
|
||||
| `username` | 字母开头,3-20 位字母/数字/下划线 |
|
||||
| `url` | URL 格式 |
|
||||
| `ip` | IP 地址格式 |
|
||||
|
||||
@@ -614,23 +767,29 @@ response.FailWithDetail(c, response.ErrPasswordWrong, "连续错误3次将锁定
|
||||
|
||||
### 7.4 预定义错误码
|
||||
|
||||
> 注:参数错误等业务化错误码请由业务项目自行定义,框架不再内置 `ErrInvalidParams`。
|
||||
> 推荐使用 `response.NewError(40001, "参数错误")` 在业务侧统一管理。
|
||||
|
||||
| 错误 | 码 | 说明 |
|
||||
| ------------------ | ------ | ---------- |
|
||||
| `ErrInvalidParams` | 000001 | 参数错误 |
|
||||
| `ErrUnauthorized` | 000401 | 未授权 |
|
||||
| `ErrNotFound` | 000404 | 资源不存在 |
|
||||
| `ErrServerError` | 000500 | 服务器错误 |
|
||||
| `ErrUserNotFound` | 010001 | 用户不存在 |
|
||||
| `ErrPasswordWrong` | 010004 | 密码错误 |
|
||||
| `ErrUnauthorized` | 401 | 未授权 |
|
||||
| `ErrForbidden` | 403 | 无权限访问 |
|
||||
| `ErrNotFound` | 404 | 资源不存在 |
|
||||
| `ErrRateLimit` | 429 | 请求过于频繁 |
|
||||
| `ErrServerError` | 500 | 服务器错误 |
|
||||
| `ErrUserNotFound` | 10001 | 用户不存在 |
|
||||
| `ErrPasswordWrong` | 10004 | 密码错误 |
|
||||
|
||||
### 7.5 特殊响应
|
||||
|
||||
```go
|
||||
// 文件下载
|
||||
response.Download(c, "report.xlsx", fileData)
|
||||
// 大文件/对象存储流式下载优先使用 DownloadReader
|
||||
|
||||
// HTML响应
|
||||
response.HTML(c, "<html>...</html>")
|
||||
// HTML 会原样输出;只传入可信或已清洗的 markup
|
||||
|
||||
// 页面跳转
|
||||
response.Redirect(c, 302, "https://example.com")
|
||||
@@ -678,7 +837,13 @@ r.Use(middleware.Recover())
|
||||
// 认证中间件
|
||||
r.Use(middleware.AuthRequired())
|
||||
|
||||
// 角色检查
|
||||
// 自定义用户类型权限
|
||||
r.Use(middleware.RequireUserTypes("tenant_admin", "platform_admin"))
|
||||
|
||||
// 自定义角色权限
|
||||
r.Use(middleware.RequireRoles("owner", "manager"))
|
||||
|
||||
// 默认快捷权限(super_admin/admin/staff 只是默认常量)
|
||||
r.Use(middleware.AdminRequired())
|
||||
|
||||
// CSRF防护
|
||||
@@ -744,30 +909,41 @@ r.Use(middleware.CSRFForAPI())
|
||||
token := middleware.GetCSRFToken(c)
|
||||
```
|
||||
|
||||
### 8.3 限流(支持 Redis 分布式限流)
|
||||
### 8.4 限流(支持 Redis 分布式限流)
|
||||
|
||||
> **v1.4.0**:App 持专属 `RateLimitRegistry`,`Shutdown` 调 `registry.Stop()`(不再调全局
|
||||
> `StopRateLimiters`,避免误停其他 App 的限流器)。下方包级 `LoginRateLimit`/`APIRateLimit`/等仍可用
|
||||
> (请求时解析全局默认 Registry,单 App 场景无变化)。**多 App per-App 计数隔离**用
|
||||
> `app.RateLimitRegistry().LoginRateLimit()` 等 App-bound 中间件(捕获 App 自己的 Registry,不查全局)。
|
||||
> **多 App 走各自 Redis** 用 `middleware.NewRedisRateLimiter(..., middleware.WithRedisClient(app.RedisClient()))`。
|
||||
|
||||
```go
|
||||
// 初始化限流器
|
||||
middleware.InitRateLimiters()
|
||||
|
||||
// 内存限流(单实例)
|
||||
r.POST("/login", middleware.LoginRateLimit(), handler.Login) // 每分钟10次
|
||||
r.POST("/upload", middleware.UploadRateLimit(), handler.Upload) // 每分钟20次
|
||||
r.Use(middleware.APIRateLimit()) // 每分钟100次
|
||||
r.POST("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"token": "..."})
|
||||
}) // 每分钟10次
|
||||
r.POST("/upload", middleware.UploadRateLimit(), func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"file": "..."})
|
||||
}) // 每分钟20次
|
||||
r.Use(middleware.APIRateLimit()) // 每分钟100次
|
||||
|
||||
// 自定义内存限流
|
||||
r.Use(middleware.CustomRateLimit(50, time.Minute)) // 每分钟50次
|
||||
|
||||
// Redis 分布式限流(多实例共享)
|
||||
r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次
|
||||
r.Use(middleware.LoginRedisRateLimit()) // 登录限流
|
||||
r.Use(middleware.APIRedisRateLimit()) // API限流
|
||||
r.Use(middleware.UploadRedisRateLimit()) // 上传限流
|
||||
r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次(fail-open:Redis 故障时放行)
|
||||
r.Use(middleware.LoginRedisRateLimit()) // 登录限流(fail-closed:Redis 故障时拒绝,防爆破)
|
||||
r.Use(middleware.APIRedisRateLimit()) // API限流(fail-open)
|
||||
r.Use(middleware.UploadRedisRateLimit()) // 上传限流(fail-closed:资源敏感,Redis 故障时拒绝)
|
||||
|
||||
// 自定义 Redis 限流
|
||||
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute))
|
||||
// 自定义 Redis 限流(默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed)
|
||||
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open
|
||||
r.Use(middleware.CustomRedisRateLimit("sensitive", 50, time.Minute, middleware.WithFailClosed(true))) // fail-closed(安全场景)
|
||||
|
||||
// 自定义标识限流(如按用户ID)
|
||||
// 自定义标识限流(如按用户ID,同样支持 WithFailClosed)
|
||||
r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Context) string {
|
||||
return fmt.Sprintf("user:%d", middleware.GetUserID(c))
|
||||
}))
|
||||
@@ -776,11 +952,16 @@ r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Con
|
||||
defer middleware.StopRateLimiters()
|
||||
```
|
||||
|
||||
> **fail-open vs fail-closed(H4c)**:Redis 限流器在 Redis 故障时有两种策略——
|
||||
> - **fail-open**(默认):Redis 故障时放行,避免影响业务,但限流静默失效。
|
||||
> - **fail-closed**(`WithFailClosed(true)`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、上传、敏感操作)必须用 fail-closed**。
|
||||
> `RedisRateLimiter` 可经 `NewRedisRateLimiter(..., WithFailClosed(true))` 构造或 `SetFailClosed(true)` 切换策略。
|
||||
|
||||
**内存限流 vs Redis 限流:**
|
||||
- 内存限流:单实例使用,简单高效
|
||||
- Redis 限流:多实例共享,滑动窗口算法,分布式场景必需
|
||||
|
||||
### 8.4 路由架构
|
||||
### 8.5 路由架构
|
||||
|
||||
框架提供灵活的路由系统,支持模块化、版本化 API 和中间件分组。
|
||||
|
||||
@@ -856,7 +1037,7 @@ v1.AddModuleFunc("user", userRoutes)
|
||||
// 创建分组
|
||||
authGroup := router.NewMiddlewareGroup("auth",
|
||||
middleware.AuthRequired(),
|
||||
middleware.AdminRequired(),
|
||||
middleware.RequireUserTypes("tenant_admin", "platform_admin"),
|
||||
)
|
||||
|
||||
publicGroup := router.NewMiddlewareGroup("public",
|
||||
@@ -915,19 +1096,27 @@ router.RegisterVersion(router.NewVersion("v1", "/api/v1"))
|
||||
// 应用路由
|
||||
router.Apply()
|
||||
|
||||
xlgo.StartServer(engine, 8080)
|
||||
// 启动服务(如需优雅关闭与生命周期管理,改用 app := xlgo.New(...); app.Run())
|
||||
engine.Run(":8080")
|
||||
```
|
||||
|
||||
#### 8.4.7 默认路由
|
||||
|
||||
框架自动注册以下默认路由:
|
||||
v1.0.2 起,`xlgo.New()` 默认是 **轻量应用**,不会自动注册任何默认路由。
|
||||
按需通过下列 Option 显式启用:
|
||||
|
||||
```
|
||||
/health -> 健康检查
|
||||
/swagger/*any -> Swagger 文档
|
||||
```go
|
||||
xlgo.New(
|
||||
xlgo.WithHealthRoutes(), // 注册 /health
|
||||
xlgo.WithSwaggerRoutes(), // 注册 /swagger/*any
|
||||
// 或一步到位:
|
||||
xlgo.WithDefaultRoutes(), // 同时注册 /health 与 /swagger/*any
|
||||
)
|
||||
```
|
||||
|
||||
如需禁用,创建应用时不传入默认模块即可。
|
||||
也可以使用 `xlgo.NewFullStack(...)` / `xlgo.RunFullStack(...)` 启用全部默认组件。
|
||||
|
||||
> 生产环境建议关闭 Swagger,仅保留 `WithHealthRoutes()`,避免文档接口意外暴露。
|
||||
|
||||
---
|
||||
|
||||
@@ -943,7 +1132,7 @@ import "github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
// 生成Token(自动包含唯一 JTI)
|
||||
token, err := jwt.GenerateToken(userID, username, "admin", "admin")
|
||||
|
||||
// 解析Token
|
||||
// 解析Token(默认 fail-closed:黑名单后端不可检查时拒绝 Token,需 Redis)
|
||||
claims, err := jwt.ParseToken(tokenString)
|
||||
|
||||
// 使Token失效(使用 JTI,内存占用约 30 字节)
|
||||
@@ -973,14 +1162,18 @@ claims, err := jwt.GetClaimsFromToken(tokenString)
|
||||
### 9.2 获取用户信息
|
||||
|
||||
```go
|
||||
// 从上下文获取用户ID
|
||||
// 一次性取出全部认证信息(v1.0.2 推荐)
|
||||
if user, ok := middleware.GetAuthUser(c); ok {
|
||||
_ = user.UserID
|
||||
_ = user.Username
|
||||
_ = user.Role
|
||||
_ = user.UserType
|
||||
}
|
||||
|
||||
// 单字段便捷函数(与旧版兼容)
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
// 获取用户名
|
||||
username := middleware.GetUsername(c)
|
||||
|
||||
// 获取用户类型(super_admin/admin/staff)
|
||||
userType := middleware.GetUserType(c)
|
||||
userType := middleware.GetUserType(c) // super_admin/admin/staff 等,由业务定义
|
||||
```
|
||||
|
||||
### 9.3 密码加密
|
||||
@@ -1029,11 +1222,17 @@ storage:
|
||||
|
||||
### 10.3 使用存储
|
||||
|
||||
> **v1.4.0**:App 用户经 `WithStorage()` 启用时,`Init` 自动初始化存储并提升为全局默认;`Shutdown`
|
||||
> 经 `StorageManager.Close()` 收口(当前 LocalStorage/OSSStorage 无 closeable 资源为 no-op,为未来
|
||||
> 驱动预留)。下方 `storage.Init()`(包级)用于 standalone。
|
||||
|
||||
```go
|
||||
import "github.com/EthanCodeCraft/xlgo-core/storage"
|
||||
|
||||
// 初始化
|
||||
storage.Init(&cfg.Storage)
|
||||
if err := storage.Init(&cfg.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 上传文件(从请求中获取)
|
||||
file, _ := c.FormFile("file")
|
||||
@@ -1051,8 +1250,12 @@ err := storage.Delete(path)
|
||||
// 获取文件内容
|
||||
data, err := storage.Get(path)
|
||||
|
||||
// 检查文件是否存在
|
||||
if storage.Exists(path) {
|
||||
// 检查文件是否存在(不存在 false,nil;后端错误返回 err)
|
||||
ok, err := storage.Exists(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
// 文件存在
|
||||
}
|
||||
```
|
||||
@@ -1063,19 +1266,32 @@ if storage.Exists(path) {
|
||||
|
||||
### 11.1 随机数生成
|
||||
|
||||
**安全选型**:字符串随机(token/OTP/验证码/会话 ID/nonce)用 `RandStringSecure`/`RandDigitSecure`
|
||||
(基于 `crypto/rand`,不可预测)。`RandString`/`RandDigit`(math/rand 版本)已移除——
|
||||
字符串随机的用途几乎都是安全场景,保留 math/rand 版本会诱导误用。
|
||||
|
||||
```go
|
||||
import "github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
|
||||
// 随机字符串(16位)
|
||||
token := utils.RandString(16)
|
||||
// 安全场景:token、会话 ID、API key(crypto/rand,不可预测)
|
||||
token, err := utils.RandStringSecure(32)
|
||||
if err != nil {
|
||||
// 处理 crypto/rand 失败(极罕见,通常仅系统熵池耗尽)
|
||||
}
|
||||
|
||||
// 随机数字(6位验证码)
|
||||
code := utils.RandDigit(6)
|
||||
// 安全场景:6 位 OTP 验证码、密码重置码(crypto/rand)
|
||||
code, err := utils.RandDigitSecure(6)
|
||||
|
||||
// 范围随机数
|
||||
// 安全场景:安全 nonce 范围、防猜抽奖、密钥分桶(crypto/rand 无偏)
|
||||
idx, err := utils.RandIntSecure(0, 1000)
|
||||
|
||||
// 范围随机数(非密码学安全,仅用于负载均衡/游戏/A-B 分桶等非安全场景)
|
||||
n := utils.RandInt(1, 100)
|
||||
```
|
||||
|
||||
> 非安全场景需要高性能随机串时,直接用标准库 `math/rand` 即可,框架不再提供
|
||||
> 易误用的 `RandString`/`RandDigit`。
|
||||
|
||||
### 11.2 字符串处理
|
||||
|
||||
```go
|
||||
@@ -1256,7 +1472,8 @@ if utils.UUIDValid(uuid) { }
|
||||
|
||||
| 分类 | 高分函数(⭐⭐⭐⭐⭐) | 说明 |
|
||||
|------|---------------------|------|
|
||||
| **随机** | `RandString/RandDigit` | sync.Pool 优化性能 |
|
||||
| **随机(安全)** | `RandStringSecure/RandDigitSecure/RandIntSecure/RandInt64Secure` | crypto/rand,token/OTP/nonce 用 |
|
||||
| **随机(范围)** | `RandInt/RandInt64` | math/rand,负载均衡/游戏等非安全场景 |
|
||||
| **字符串** | `IsBlank/DefaultIfBlank/StrLen` | 空值处理、Unicode支持 |
|
||||
| **时间** | `FormatDateTime/StartOfDay/EndOfMonth` | 标准格式、边界计算 |
|
||||
| **转换** | `ToIntDefault/CalcPageCount/CalcOffset` | 安全转换、分页计算 |
|
||||
@@ -1271,7 +1488,7 @@ if utils.UUIDValid(uuid) { }
|
||||
|
||||
| 改进 | 说明 |
|
||||
|------|------|
|
||||
| 性能优化 | `RandString/RandDigit` 使用 sync.Pool 复用随机源 |
|
||||
| 性能优化 | 非安全随机用 `RandInt/RandInt64`(math/rand,sync.Pool 复用源);安全场景用 `RandStringSecure/RandDigitSecure/RandIntSecure`(crypto/rand + big.Int 拒绝采样无偏) |
|
||||
| 类型安全 | 移除使用反射的函数,保持类型安全 |
|
||||
| 式调用 | `HTTPClient` 和 `URLBuilder` 支持链式调用 |
|
||||
| 零依赖 | 仅依赖 `google/uuid`,其余使用标准库 |
|
||||
@@ -1332,6 +1549,24 @@ count := hub.Count()
|
||||
|
||||
## 13. 定时任务
|
||||
|
||||
> **v1.4.0**:App 持专属调度器(实例隔离,多 App 互不污染)。**App 管理的任务用
|
||||
> `xlgo.WithCronTask(name, schedule, handler)` 注册**(蕴含启用 cron,`Init` 时注册+启动,
|
||||
> `Shutdown` 时停止并等待在跑任务退出)。下方包级 `cron.AddTask`/`cron.Start`/`cron.Stop` 仍可用于
|
||||
> standalone(无 App)或 `Init` 之后;**`Init` 之前**调包级 `cron.AddTask` 会注册到 init 默认
|
||||
> 调度器、被 App swap 丢弃。`app.Scheduler()` 返回 App 调度器,供动态注册与管理
|
||||
> (`AddTask`/`RemoveTask`/`RunTask`/`ListTasks`)。
|
||||
>
|
||||
> ```go
|
||||
> app := xlgo.New(xlgo.WithConfig(cfg),
|
||||
> xlgo.WithCronTask("cleanup", cron.Every(5*time.Minute), func(ctx context.Context) error {
|
||||
> return cleanupOldData()
|
||||
> }),
|
||||
> xlgo.WithCronTask("daily_report", cron.Daily(2, 0), generateReport),
|
||||
> )
|
||||
> // 动态注册(Init 后):app.Scheduler().AddTask(...)
|
||||
> if err := app.Run(); err != nil { /* ... */ }
|
||||
> ```
|
||||
|
||||
### 13.1 添加任务
|
||||
|
||||
```go
|
||||
@@ -1355,6 +1590,9 @@ cron.AddTask("noon", cron.Cron("0", "12"), doSomething) // 每天12:00
|
||||
cron.AddTask("complex", cron.ParseCron("*/15 * * * *"), doSomething) // 每15分钟
|
||||
cron.AddTask("monthly", cron.ParseCron("0 0 1 * *"), doSomething) // 每月1号凌晨
|
||||
cron.AddTask("workday", cron.ParseCron("0 9-17 * * 1-5"), doSomething) // 工作日9-17点
|
||||
|
||||
// ParseCron 对非法表达式 fail-fast panic;动态输入请用 ParseCronStrict 处理 error。
|
||||
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
|
||||
```
|
||||
|
||||
### 13.2 启动与停止
|
||||
@@ -1379,6 +1617,17 @@ scheduler.EnableTask("cleanup") // 启用任务
|
||||
|
||||
## 14. 链路追踪
|
||||
|
||||
> **v1.4.0**:`xlgo.WithTrace()` 把 trace 纳入 App 生命周期--`Init` 按 `config.Trace` 调
|
||||
> `trace.Init`、装入 `trace.Middleware`、`Shutdown` 调 `trace.Close` 刷出 span 并释放 exporter
|
||||
> 后台 goroutine。下方手动 `trace.Init`/`trace.Close`/`trace.Middleware` 仍可用于 standalone
|
||||
> (无 App)。**trace 不做实例隔离**--OTel `TracerProvider` 是进程级全局单例,多 App 进程共享,
|
||||
> 任一 `Shutdown` 会关全局导出;多 App 不要同时开 `WithTrace`。
|
||||
>
|
||||
> ```go
|
||||
> app := xlgo.New(xlgo.WithConfigPath("config.yaml"), xlgo.WithTrace())
|
||||
> // config.yaml 配置 trace.enabled / trace.endpoint / trace.exporter_type / trace.sample_ratio ...
|
||||
> ```
|
||||
|
||||
### 14.1 初始化
|
||||
|
||||
```go
|
||||
@@ -1389,11 +1638,18 @@ trace.Init(trace.Config{
|
||||
ServiceName: "my-service",
|
||||
Endpoint: "localhost:4318",
|
||||
ExporterType: "otlp-http",
|
||||
// Insecure: true, // 明文 collector(localhost:4318 等本地 collector 默认无 TLS,需显式开启)
|
||||
SampleRatio: 1.0,
|
||||
// Propagator: "w3c", // 可选 "w3c"(默认) / "b3" / "jaeger"(映射 W3C)
|
||||
})
|
||||
defer trace.Close(ctx)
|
||||
```
|
||||
|
||||
> **导出器类型**:`otlp-http` / `otlp-grpc` / `stdout`(写标准输出,便于调试)。未知类型 `Init` 返错。
|
||||
> **Insecure**:默认 `false`(TLS);对无 TLS 的本地 collector(如 `localhost:4318`)需显式置 `true`,否则握手失败。
|
||||
> **传播器**:`w3c`(默认,W3C TraceContext + Baggage)/ `b3`(同时支持单头与多头,兼容旧 B3 客户端)/ `jaeger`(映射为 W3C TraceContext——现代 Jaeger agent 透传 W3C;纯 Jaeger thrift 头协议请用 `b3`)。未知类型 `Init` 返错。
|
||||
> **未 Init 也安全**:未调用 `Init` 或 `Init(Enabled:false)` 时为 Noop tracer,`Middleware`/`StartSpan` 等不 panic。
|
||||
|
||||
### 14.2 使用中间件
|
||||
|
||||
```go
|
||||
@@ -1469,12 +1725,12 @@ func TestUserAPI(t *testing.T) {
|
||||
WithJSON(map[string]any{"name": "test"}).
|
||||
Execute()
|
||||
resp.AssertOK(t)
|
||||
resp.AssertCode(t, 1)
|
||||
resp.AssertJSONContains(t, "code", float64(1))
|
||||
|
||||
// GET请求
|
||||
resp = test.GET(router, "/api/users/1").Execute()
|
||||
resp.AssertOK(t)
|
||||
resp.AssertJSONKeyExists(t, "data.id")
|
||||
resp.AssertJSONContains(t, "data.id", float64(1))
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1527,7 +1783,8 @@ model/ → 数据模型定义
|
||||
func GetUser(c *gin.Context) {
|
||||
id := handler.PathInt64(c, "id", 0)
|
||||
if id == 0 {
|
||||
response.FailWithError(c, response.ErrInvalidParams)
|
||||
// 参数错误码由业务侧定义;这里直接返回通用失败 + 自定义消息
|
||||
response.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1564,8 +1821,9 @@ app:
|
||||
```go
|
||||
if cfg.IsProduction() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
// 生产环境禁用彩色输出
|
||||
console.Default = console.New(console.WithColor(false))
|
||||
// 生产环境收紧 console 输出(仅保留 Warn / Error)
|
||||
// 业务事件请使用 logger 包记录
|
||||
console.SetLevel(console.LevelWarn)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1610,13 +1868,17 @@ func Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 生成Token
|
||||
token, _ := jwt.GenerateToken(user.ID, user.Username, "admin", "admin")
|
||||
token, err := jwt.GenerateToken(user.ID, user.Username, "admin", "admin")
|
||||
if err != nil {
|
||||
response.FailWithError(c, response.ErrServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存用户信息
|
||||
cache.GetCache().Set(c.Request.Context(),
|
||||
cache.KSession(token),
|
||||
user,
|
||||
time.Duration(cfg.JWT.Expire)*time.Second)
|
||||
cfg.JWT.Expire)
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"token": token,
|
||||
@@ -1628,5 +1890,5 @@ func Login(c *gin.Context) {
|
||||
|
||||
---
|
||||
|
||||
_文档版本: v2.1_
|
||||
_最后更新: 2026-04-30_
|
||||
_文档版本: v1.2.0_
|
||||
_最后更新: 2026-07-04_
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ethan xlp
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -6,25 +6,89 @@
|
||||
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
|
||||
</p>
|
||||
|
||||
xlgo 是一个基于 Go + Gin 的轻量级 Web 开发框架,提供了完整的后端开发基础设施,包括配置管理、数据库访问、缓存、认证、日志、文件存储等常用功能。
|
||||
> 基于 Go + Gin 的后端开发框架。**组件全部 Manager 化**——既能像脚本一样用包级函数简单调用,又能像正经框架一样注入实例、跑多套、塞 mock 做单测。
|
||||
|
||||
## 为什么是 xlgo
|
||||
|
||||
多数 Gin 脚手架把数据库、Redis、缓存、JWT、日志做成**包级全局单例**:写小项目顺手,但一旦要做单元测试、同进程跑多套实例、或替换实现,就动弹不得。xlgo 把这些组件全部抽象成 `XxxManager`,同时保留包级便捷函数做 facade——**简单用法零成本,复杂场景不封顶**:
|
||||
|
||||
```go
|
||||
// 简单用法:包级函数,跟单例脚手架一样直接
|
||||
database.InitDB(cfg)
|
||||
db := database.GetDB()
|
||||
|
||||
// 同一份代码,也能注入实例 / 跑多套 / 塞 mock
|
||||
myDB := database.NewManager(cfg)
|
||||
myDB.Open(ctx) // 独立实例,不受全局影响
|
||||
database.SetDefaultManager(myDB) // 提升为全局默认,并关闭旧默认 manager
|
||||
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入自定义 CacheManager
|
||||
```
|
||||
|
||||
### 区别于一般 Gin 脚手架的几点
|
||||
|
||||
- **组件可注入** — database / redis / cache / jwt / storage / logger 均为 `Manager` + 全局 facade 双轨,支持多实例与 mock 注入,而非写死的包级单例
|
||||
- **生产就绪内置** — `/livez` `/readyz`、Prometheus `/metrics`、主库探活自愈、请求级超时、优雅关闭(等待 in-flight goroutine)开箱即用,不用自己搭
|
||||
- **框架内零 Fatal** — 错误一律返回由调用方处理,框架代码不 `panic` 杀进程
|
||||
- **默认轻量、按需启用** — `New()` 什么都不开,`NewFullStack()` 一键全开,中间任意组合;`Without*` 精细排除
|
||||
- **可插拔数据库方言** — GORM 方言注册表,内置 MySQL / PostgreSQL,可扩展 SQLite / ClickHouse 等
|
||||
|
||||
### 30 秒上手
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfigPath("./config.yaml"),
|
||||
xlgo.WithLogger(),
|
||||
xlgo.WithHealthRoutes(),
|
||||
)
|
||||
app.GetRouter().GET("/", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"hello": "xlgo"})
|
||||
})
|
||||
_ = app.Run()
|
||||
}
|
||||
```
|
||||
|
||||
## 框架特性
|
||||
|
||||
- **配置管理** - 支持 YAML 配置文件,环境变量覆盖,配置热更新
|
||||
- **数据库** - MySQL + GORM,支持自动迁移、重试机制、连接池、读写分离
|
||||
- **缓存** - Redis 缓存,支持分布式缓存、键前缀、TTL,SCAN 优化
|
||||
- **认证** - JWT 认证,支持 Token 黑名单、刷新机制
|
||||
- **日志** - 分级日志(API、数据库),日志轮转
|
||||
- **中间件** - CORS、限流、日志、认证、CSRF 防护
|
||||
- **文件存储** - 本地存储 + 阿里云 OSS 支持
|
||||
- **实时通信** - SSE 流式响应 + WebSocket 支持
|
||||
- **定时任务** - 内置任务调度器
|
||||
- **验证器** - 请求参数验证,支持自定义错误消息
|
||||
- **错误处理** - 统一错误码体系
|
||||
- **CLI 工具** - 脚手架工具,快速创建项目和代码
|
||||
**架构与可注入性**
|
||||
- **组件 Manager 化** - database / redis / cache / jwt / storage / logger 均为 `XxxManager` + 全局 facade 双轨,支持多实例与测试注入 mock
|
||||
- **可插拔数据库方言** - GORM 方言注册表,内置 MySQL / PostgreSQL,可注册 SQLite / ClickHouse 等任意驱动
|
||||
- **读写分离** - 主库 + 多副本,`ReplicaPicker` 策略可插拔(轮询 / 随机 / 自定义),context 路由强制主库
|
||||
- **Lifecycle Hooks** - `OnInit / OnStart / OnReady / OnStop` 生命周期钩子,可插拔启动/关闭逻辑
|
||||
|
||||
**生产就绪**
|
||||
- **健康探针** - `/livez`(存活)+ `/readyz`(就绪)+ `/health`(兼容),K8s probe 友好
|
||||
- **Prometheus 指标** - `/metrics` 端点 + 请求计数 / 耗时 / 在飞数采集中间件
|
||||
- **依赖健康自愈** - 主库后台探活,连续失败标记不健康联动 503;从库失败临时剔除、恢复自动纳入
|
||||
- **请求级超时** - `middleware.Timeout` 级联取消下游 GORM/Redis
|
||||
- **优雅关闭** - `App.Go` 管理后台 goroutine,Shutdown 时 cancel + 等待退出(带超时)
|
||||
- **配置校验** - 启动期 `Validate` 拦截非法配置(端口/密钥/必填字段),错误前置
|
||||
|
||||
**基础功能**
|
||||
- **配置管理** - YAML + 环境变量覆盖 + 热更新,`time.Duration` 字符串解析(`"24h"`)
|
||||
- **缓存** - Redis 缓存,键前缀、TTL、SCAN 优化、分布式锁
|
||||
- **认证** - JWT 认证,Token 黑名单、刷新机制,HMAC 算法可选
|
||||
- **日志** - 分级日志(通用 / API / DB 三分流),lumberjack 轮转
|
||||
- **中间件** - CORS、限流(内存 + Redis 版)、请求日志、认证、CSRF、RequestID、Recover
|
||||
- **文件存储** - 本地 + 阿里云 OSS
|
||||
- **实时通信** - SSE 流式响应 + WebSocket
|
||||
- **定时任务** - 内置调度器(interval / daily / weekly / cron)
|
||||
- **验证器** - 请求参数验证,自定义错误消息
|
||||
- **响应模式** - `ModeBusiness`(业务码,默认)/ `ModeREST`(按错误码映射 HTTP status)一键切换
|
||||
- **CLI 工具** - 脚手架 + 代码生成(handler/repository/model/service)
|
||||
|
||||
## 快速开始
|
||||
|
||||
> **环境要求**:xlgo 基于 Go 1.25+ 构建,请确保本地已安装 Go 1.25 或更高版本。
|
||||
|
||||
### 1. 安装
|
||||
|
||||
```bash
|
||||
@@ -45,10 +109,17 @@ go mod tidy
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
|
||||
port: 8080
|
||||
mode: development
|
||||
read_timeout: 15s
|
||||
write_timeout: 30s
|
||||
idle_timeout: 60s
|
||||
shutdown_timeout: 30s
|
||||
response_mode: business # business(默认,全200+业务码) 或 rest(按错误码映射HTTP status)
|
||||
|
||||
database:
|
||||
driver: mysql # mysql(默认)或 postgres
|
||||
host: localhost
|
||||
port: 3306
|
||||
user: root
|
||||
@@ -56,6 +127,7 @@ database:
|
||||
name: your_database
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
# dsn: "自定义连接字符串,设置后优先于上面的字段"
|
||||
|
||||
redis:
|
||||
host: localhost
|
||||
@@ -64,8 +136,11 @@ redis:
|
||||
db: 0
|
||||
|
||||
jwt:
|
||||
secret: your_jwt_secret_key
|
||||
expire: 86400
|
||||
secret: your_jwt_secret_key_at_least_32_bytes # ≥32 字节,否则启动期被 Validate 拦截
|
||||
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
|
||||
refresh_expire: "168h" # 刷新 token 过期时间
|
||||
issuer: xlgo
|
||||
algorithm: HS256 # HS256(默认)/HS384/HS512
|
||||
|
||||
storage:
|
||||
driver: local
|
||||
@@ -89,6 +164,43 @@ go run main.go
|
||||
|
||||
访问 http://localhost:8080/health 检查服务状态。
|
||||
|
||||
> v1.0.2 起,`xlgo.New()` 默认是轻量应用,不会自动初始化 MySQL、Redis、Storage 或 Swagger。需要完整基础设施时请显式使用 `WithMySQL()`、`WithRedis()`、`WithStorage()`、`WithSwaggerRoutes()`,或直接使用 `xlgo.NewFullStack()`。
|
||||
>
|
||||
> `Without*` 系列 Option(如 `WithoutSwaggerRoutes`)主要用于 `NewFullStack` / `RunFullStack` 启用全部组件后排除个别项,例如 `xlgo.NewFullStack(xlgo.WithoutSwaggerRoutes())` 全组件但关闭 Swagger。`xlgo.New()` 本身已全关,无需再 `Without`。
|
||||
|
||||
### 4. 模块路径与包名
|
||||
|
||||
xlgo 的 **模块路径** 是 `github.com/EthanCodeCraft/xlgo-core`,**包名** 是 `xlgo`(二者不同是 Go 惯例,如 `github.com/gin-gonic/gin` → 包名 `gin`)。import 时用别名 `xlgo` 即可:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfigPath("./config.yaml"),
|
||||
xlgo.WithLogger(),
|
||||
xlgo.WithHealthRoutes(),
|
||||
)
|
||||
// 注册一个示例路由
|
||||
app.GetRouter().GET("/", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "Hello xlgo!"})
|
||||
})
|
||||
// 启用 JWT 认证示例路由(需配合 WithMySQL/WithRedis 生成 token)
|
||||
_ = middleware.RequireUserTypes("admin")
|
||||
|
||||
_ = app.Run()
|
||||
}
|
||||
```
|
||||
|
||||
> 子包(`config` / `database` / `logger` / `middleware` ...)的包名与目录名一致,无需别名。
|
||||
|
||||
---
|
||||
|
||||
## 核心功能
|
||||
@@ -119,19 +231,22 @@ config.Reload()
|
||||
|
||||
### 数据库操作
|
||||
|
||||
xlgo 基于 GORM,主库与从库的驱动由配置 `database.driver` 决定,内置支持 `mysql`(默认)与 `postgres`,也可通过 `database.dsn` 使用任意自定义连接字符串。v1.0.2 起 GORM 方言通过 **可插拔注册表** 管理,应用可自行接入 SQLite、SQL Server、ClickHouse 等任意 GORM 驱动而无需修改框架。
|
||||
|
||||
```go
|
||||
// 初始化 MySQL
|
||||
database.InitMySQL(cfg)
|
||||
// 初始化数据库(驱动由配置决定)
|
||||
database.InitDB(cfg)
|
||||
defer database.Close()
|
||||
|
||||
// 主从读写分离
|
||||
database.InitMySQLWithReplicas(cfg, []string{
|
||||
// 主从读写分离(从库 DSN 需与主库驱动匹配)
|
||||
database.InitDBWithReplicas(cfg, []string{
|
||||
"root:pass@tcp(slave1:3306)/db",
|
||||
"root:pass@tcp(slave2:3306)/db",
|
||||
})
|
||||
|
||||
// 读操作自动路由到从库
|
||||
users := database.GetReadDB().Find(&users)
|
||||
var users []model.User
|
||||
database.GetReadDB().Find(&users)
|
||||
|
||||
// 强制使用主库
|
||||
ctx := database.UseMaster(context.Background())
|
||||
@@ -146,6 +261,46 @@ database.Transaction(func(tx *gorm.DB) error {
|
||||
status := database.HealthCheck()
|
||||
```
|
||||
|
||||
v1.0.2 起数据库状态由实例化的 `database.Manager` 管理,全局函数(`GetDB`、`GetReadDB`、`CloseAll` 等)作为默认 `DefaultManager` 的 facade 保留。可通过 `database.NewManager(cfg)` 创建独立管理器,并通过 `ReplicaPicker` 自定义从库选择策略:
|
||||
|
||||
```go
|
||||
// 独立管理器(不影响全局 DefaultManager)
|
||||
mgr := database.NewManager(cfg)
|
||||
if err := mgr.Open(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
defer mgr.Close()
|
||||
|
||||
// 从库选择策略:轮询(默认随机)
|
||||
database.SetReplicaPicker(&database.RoundRobinPicker{})
|
||||
```
|
||||
|
||||
`database.SetDefaultManager(m)` 表示把新 manager 接管为全局默认,并关闭被替换的旧 manager,避免连接池泄漏。需要失败回滚或延迟释放旧资源的初始化流程,请使用 `database.SwapDefaultManager(m)` 并自行决定何时关闭返回的旧 manager。
|
||||
|
||||
#### 注册自定义 GORM 方言
|
||||
|
||||
通过 `database.RegisterDialect` 一次注册即可让 `database.driver: <name>` 生效,DSN 构建器会同步登记到 `config` 包,因此 `cfg.Database.DSN()` 也会识别新驱动:
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
database.RegisterDialect(database.DialectSpec{
|
||||
Name: "sqlite",
|
||||
Aliases: []string{"sqlite3"},
|
||||
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
|
||||
DSN: func(c *config.DatabaseConfig) string { return c.Name }, // Name 当作文件路径
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
诊断接口:`database.RegisteredDialects()` 返回当前已注册的驱动名(含别名),`database.LookupDialect(name)` 用于检查某个驱动是否已就绪。未注册的驱动会回退到 MySQL 以保持向后兼容。
|
||||
|
||||
### Repository 泛型 CRUD
|
||||
|
||||
```go
|
||||
@@ -184,6 +339,8 @@ err := userRepo.WithTransaction(ctx, func(txRepo *repository.BaseRepo[model.User
|
||||
|
||||
### Redis 缓存
|
||||
|
||||
> **v1.4.0**:App 经 `WithRedis()` 启用时自动用 App 的 Redis 客户端初始化缓存(多 App 隔离,不串越)。下方 `cache.Init()` 用于 standalone。
|
||||
|
||||
```go
|
||||
// 初始化缓存
|
||||
cache.Init()
|
||||
@@ -195,11 +352,18 @@ cacheService := cache.GetCache()
|
||||
// 设置缓存
|
||||
cacheService.Set(ctx, "user:1", user, 10*time.Minute)
|
||||
|
||||
// 获取缓存
|
||||
// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err)
|
||||
var user User
|
||||
if cacheService.Get(ctx, "user:1", &user) {
|
||||
if hit, err := cacheService.Get(ctx, "user:1", &user); err != nil {
|
||||
return err
|
||||
} else if hit {
|
||||
// 缓存命中
|
||||
}
|
||||
if exists, err := cacheService.Exists(ctx, "user:1"); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
// key 存在
|
||||
}
|
||||
|
||||
// 删除缓存
|
||||
cacheService.Delete(ctx, "user:1")
|
||||
@@ -215,8 +379,12 @@ cacheService.DeleteByPattern(ctx, "user:*")
|
||||
token, err := jwt.GenerateToken(userID, username, "admin", "admin")
|
||||
|
||||
// 解析 Token
|
||||
// ParseToken 默认 fail-closed:黑名单后端不可检查时拒绝 Token(需 Redis)
|
||||
claims, err := jwt.ParseToken(tokenString)
|
||||
|
||||
// 需显式 fail-open(仅无 Redis 或低安全场景)用:
|
||||
claims, err = jwt.ParseTokenWithBlacklistPolicy(tokenString, jwt.BlacklistFailOpen)
|
||||
|
||||
// 使 Token 失效(使用 JTI,高效)
|
||||
jwt.InvalidateToken(tokenString)
|
||||
|
||||
@@ -240,7 +408,7 @@ if token != nil {
|
||||
cache.ExtendLock(ctx, token, 30*time.Second)
|
||||
|
||||
// 自动续期执行
|
||||
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func() error {
|
||||
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func(ctx context.Context) error {
|
||||
// 长任务自动续期
|
||||
return nil
|
||||
})
|
||||
@@ -250,6 +418,8 @@ err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func()
|
||||
|
||||
### Redis 分布式限流
|
||||
|
||||
> **v1.4.0**:多 App 走各自 Redis 用 `middleware.NewRedisRateLimiter(..., middleware.WithRedisClient(app.RedisClient()))`。
|
||||
|
||||
```go
|
||||
// 内存限流(单实例)
|
||||
r.Use(middleware.CustomRateLimit(100, time.Minute))
|
||||
@@ -320,8 +490,11 @@ err := storage.Delete(path)
|
||||
// 获取文件内容
|
||||
data, err := storage.Get(path)
|
||||
|
||||
// 检查文件是否存在
|
||||
exists := storage.Exists(path)
|
||||
// 检查文件是否存在(不存在 false,nil;后端错误返回 err)
|
||||
ok, err := storage.Exists(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### SSE 流式响应
|
||||
@@ -359,6 +532,11 @@ hub.Broadcast([]byte("广播消息"))
|
||||
|
||||
### 定时任务
|
||||
|
||||
> **v1.4.0**:App 持专属调度器(实例隔离)。**App 管理的任务用 `xlgo.WithCronTask(name, schedule, handler)`
|
||||
> 注册**(蕴含启用 cron,`Init` 时注册+启动,`Shutdown` 时停止)。下方包级 `cron.AddTask`/`cron.Start`
|
||||
> 仅用于 standalone(无 App)或 `Init` 之后;**`Init` 之前**调包级 `cron.AddTask` 会注册到 init 默认
|
||||
> 调度器、被 App swap 丢弃。`app.Scheduler()` 供动态注册与管理。
|
||||
|
||||
```go
|
||||
// 每隔 5 分钟执行
|
||||
cron.AddTask("cleanup", cron.Every(5*time.Minute), func(ctx context.Context) error {
|
||||
@@ -375,6 +553,9 @@ cron.AddTask("weekly", cron.Weekly(time.Monday, 9, 0), weeklyTask)
|
||||
cron.AddTask("every15min", cron.ParseCron("*/15 * * * *"), doSomething)
|
||||
cron.AddTask("monthly", cron.ParseCron("0 0 1 * *"), doSomething) // 每月1号
|
||||
|
||||
// ParseCron 对非法表达式 fail-fast panic;动态输入请用 ParseCronStrict 处理 error。
|
||||
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
|
||||
|
||||
// 启动调度器
|
||||
cron.Start()
|
||||
defer cron.Stop()
|
||||
@@ -421,32 +602,45 @@ match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, pas
|
||||
// JWT 认证(必须登录)
|
||||
r.Use(middleware.AuthRequired())
|
||||
|
||||
// 管理员权限
|
||||
// 自定义用户类型权限
|
||||
r.Use(middleware.RequireUserTypes("tenant_admin", "platform_admin"))
|
||||
|
||||
// 自定义角色权限
|
||||
r.Use(middleware.RequireRoles("owner", "manager"))
|
||||
|
||||
// 自定义复杂权限判断
|
||||
r.Use(middleware.RequireAuth(func(user middleware.AuthUser, c *gin.Context) bool {
|
||||
return user.UserType == "merchant" && user.Role == "owner"
|
||||
}))
|
||||
|
||||
// 默认快捷方法(super_admin/admin/staff 只是框架默认常量)
|
||||
r.Use(middleware.AdminRequired())
|
||||
|
||||
// 超级管理员权限
|
||||
r.Use(middleware.SuperAdminRequired())
|
||||
|
||||
// 员工权限
|
||||
r.Use(middleware.StaffRequired())
|
||||
|
||||
// 任意用户(管理员或员工)
|
||||
r.Use(middleware.AnyUserRequired())
|
||||
|
||||
// 获取用户信息
|
||||
user, ok := middleware.GetAuthUser(c)
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
role := middleware.GetRole(c)
|
||||
userType := middleware.GetUserType(c)
|
||||
```
|
||||
|
||||
### 限流中间件
|
||||
|
||||
> **v1.4.0**:App `Shutdown` 自动 `registry.Stop()`(不再调全局 `StopRateLimiters`,避免误停其他 App)。多 App per-App 计数隔离用 `app.RateLimitRegistry().LoginRateLimit()` 等 App-bound 中间件。
|
||||
|
||||
```go
|
||||
// 登录限流(每分钟 10 次)
|
||||
r.POST("/login", middleware.LoginRateLimit(), handler.Login)
|
||||
r.POST("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"token": "..."})
|
||||
})
|
||||
|
||||
// 上传限流(每分钟 20 次)
|
||||
r.POST("/upload", middleware.UploadRateLimit(), handler.Upload)
|
||||
r.POST("/upload", middleware.UploadRateLimit(), func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"file": "..."})
|
||||
})
|
||||
|
||||
// 自定义限流
|
||||
r.Use(middleware.CustomRateLimit(50, time.Minute))
|
||||
@@ -486,6 +680,16 @@ response.Unauthorized(c, "请先登录")
|
||||
response.NotFound(c, "资源不存在")
|
||||
response.ServerError(c, "服务器错误")
|
||||
response.RateLimit(c)
|
||||
|
||||
// 显式指定 HTTP status(不受 Mode 影响)
|
||||
response.Custom(c, http.StatusBadRequest, response.CodeFail, "参数错误", nil)
|
||||
```
|
||||
|
||||
**响应模式**(v1.1.0):默认 `ModeBusiness`,所有响应 HTTP 200 + 业务码(兼容存量)。切换 `ModeREST` 后,失败响应按错误码映射对应 HTTP status(401/404/429/500...),body 仍带业务码,便于 APM / Prometheus / 网关按 status 区分异常:
|
||||
|
||||
```go
|
||||
response.SetMode(response.ModeREST) // 代码切换
|
||||
// 或在 config.yaml 配置:server.response_mode: rest
|
||||
```
|
||||
|
||||
---
|
||||
@@ -550,14 +754,16 @@ xlgo/
|
||||
├── compress/
|
||||
│ └── compress.go # Gzip/Zip 压缩解压
|
||||
├── config/
|
||||
│ └── config.go # 配置管理(支持热更新)
|
||||
│ ├── config.go # 配置管理(支持热更新、time.Duration 解析)
|
||||
│ └── validate.go # 配置校验(启动期拦截非法配置)
|
||||
├── console/
|
||||
│ └── console.go # 彩色控制台输出
|
||||
├── cron/
|
||||
│ └── cron.go # 定时任务调度
|
||||
├── database/
|
||||
│ ├── mysql.go # MySQL 连接(支持读写分离)
|
||||
│ └── redis.go # Redis 连接
|
||||
│ ├── manager.go # 数据库管理器(主从、Picker、探活自愈、Init/Close/HealthCheck)
|
||||
│ ├── dialect.go # GORM 方言注册表(mysql / postgres,可扩展)
|
||||
│ └── redis.go # Redis 连接管理器(RedisManager)
|
||||
├── handler/
|
||||
│ └── handler.go # 基础处理器(类型安全参数获取)
|
||||
├── jwt/
|
||||
@@ -570,18 +776,22 @@ xlgo/
|
||||
│ ├── cors.go # CORS 跨域中间件
|
||||
│ ├── csrf.go # CSRF 防护中间件
|
||||
│ ├── logger.go # 请求日志中间件
|
||||
│ ├── metrics.go # Prometheus 指标中间件
|
||||
│ ├── ratelimit.go # 限流中间件
|
||||
│ ├── requestid.go # 请求ID中间件
|
||||
│ └── recover.go # Panic恢复中间件
|
||||
│ ├── recover.go # Panic恢复中间件
|
||||
│ └── timeout.go # 请求级超时中间件
|
||||
├── model/
|
||||
│ └── base.go # 基础模型
|
||||
├── repository/
|
||||
│ └── repository.go # 基础仓库(泛型CRUD)
|
||||
├── response/
|
||||
│ ├── response.go # 统一响应格式
|
||||
│ ├── mode.go # 响应模式开关(business / REST)
|
||||
│ └── error.go # 统一错误码
|
||||
├── router/
|
||||
│ └── router.go # 路由注册中心(模块化/版本化)
|
||||
│ ├── router.go # 路由注册中心(模块化/版本化、livez/readyz/health)
|
||||
│ └── metrics.go # Prometheus /metrics 端点
|
||||
├── sse/
|
||||
│ └── sse.go # SSE 流式响应
|
||||
├── storage/
|
||||
@@ -605,8 +815,6 @@ xlgo/
|
||||
│ ├── validator.go # 请求验证器
|
||||
│ ├── password.go # 密码强度验证
|
||||
│ └── hash.go # 密码加密
|
||||
├── wire/
|
||||
│ └── wire.go # 依赖注入
|
||||
└── ws/
|
||||
└── ws.go # WebSocket 支持
|
||||
```
|
||||
@@ -618,7 +826,7 @@ xlgo/
|
||||
### Docker 部署
|
||||
|
||||
```dockerfile
|
||||
FROM golang:1.22-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
@@ -643,50 +851,258 @@ docker run -d -p 8080:8080 xlgo-app:latest
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2.1.0 (2026-04-30)
|
||||
> 完整变更历史见 [CHANGELOG.md](./CHANGELOG.md)。
|
||||
|
||||
- **分布式锁安全增强** - Lua 脚本 + UUID Token,只有持有者能释放锁
|
||||
- **JWT 黑名单优化** - 使用 JTI 替代完整 Token,大幅节省 Redis 内存
|
||||
- **HTTP Client 连接池** - Transport 初始化时创建,连接可复用
|
||||
- **优雅关闭机制** - 监听系统信号,等待请求处理完成
|
||||
- **Redis 分布式限流** - 滑动窗口算法,多实例共享限流状态
|
||||
- **Repository 扩展** - 分页查询、链式查询、批量操作、事务支持
|
||||
- **CORS 配置完善** - 支持配置文件、通配符域名
|
||||
- **日志中间件增强** - 可记录请求体、慢请求警告、敏感字段过滤
|
||||
- **新增测试** - cache、middleware 新增多项测试用例
|
||||
### v1.4.0 (2026-07-12)
|
||||
|
||||
### v2.0.0 (2026-04-30)
|
||||
> **instance+facade 一致性收尾**:cron/storage/cache/ratelimit 实例化(App 持 per-App 实例 + `SwapDefault*` + 包级 facade 代理,照 `database.Manager`/`jwt.Manager` 模式),消除单进程多 App 互相污染;trace 纳入 App 生命周期(`WithTrace`);删除 `WithoutWire` 空函数。`go vet` + `go build` + `go test -race ./...` 全绿。完整变更见 [CHANGELOG.md](./CHANGELOG.md)。
|
||||
|
||||
- **新增工具函数库** - 111 个实用函数(随机数、字符串、时间、转换、文件、URL、验证、加密、HTTP 客户端、UUID)
|
||||
- **新增彩色控制台输出** - console 包支持 Debug/Info/Success/Warn/Error 五级彩色输出
|
||||
- **新增压缩解压** - compress 包支持 Gzip/Zip 压缩解压
|
||||
- **新增键名前缀管理** - cache.K() 自动添加站点前缀,解决多项目共用 Redis 冲突
|
||||
- **新增分布式锁** - cache.Lock/TryLock/WithLock 完整实现
|
||||
- **新增计数器** - cache.Incr/Decr/IncrBy 支持
|
||||
- **新增 RequestID 中间件** - 请求追踪支持
|
||||
- **新增 Recover 中间件** - Panic 恢复
|
||||
- **新增类型安全参数获取** - handler.QueryInt/PathInt/FormInt 等
|
||||
- **新增路由架构** - router 包支持模块化、版本化 API、中间件分组、RESTful CRUD
|
||||
- **新增 AppConfig** - 站点别名、环境判断
|
||||
- **CLI 工具重构** - 模块化结构,模板分离
|
||||
- **单元测试覆盖** - 17 个包有测试(68%覆盖)
|
||||
主要破坏性变更(升级前必读):
|
||||
|
||||
### v1.1.0 (2026-04-29)
|
||||
- **`xlgo.WithoutWire()` 删除**:v1.1.0 wire 包删除后遗留的空 no-op,直接删除调用即可(无行为变化)。
|
||||
- **`cron.AddTask` 在 `App.Init` 之前注册失效**:App 持专属调度器,pre-Init 包级 `cron.AddTask` 注册到 init 默认调度器、被 App swap 丢弃。改用 `xlgo.WithCronTask(name, schedule, handler)`(或 Init 后 `app.Scheduler().AddTask`)。
|
||||
- **`middleware.StopRateLimiters` 语义收窄**:仅停默认 Registry;App `Shutdown` 调 `app` 自己的 `registry.Stop()`,不再误停其他 App 限流器。
|
||||
- **`commitReplacedResources` 不再关闭 `previousDB`/`previousRedis`/`loggerSnapshot`**:多 App 下 previous 可能是另一 App 的活跃资源(致 `redis: client is closed` / logger writer 误杀)。单 App 无影响(init 默认无资源)。
|
||||
- **cache/ratelimit redis 注入**(jwt 模型):`cache.NewRedisCacheWithRedis`/`CacheManager.InitWithRedis`;`middleware.WithRedisClient`。
|
||||
|
||||
- 新增配置热更新支持
|
||||
- 新增数据库读写分离
|
||||
- 新增 CSRF 防护中间件
|
||||
- 新增 SSE 流式响应支持
|
||||
- 新增 WebSocket 支持
|
||||
- 新增定时任务调度器
|
||||
- 新增 CLI 脚手架工具
|
||||
- 新增测试工具包
|
||||
- 新增统一错误码体系
|
||||
- 新增密码加密工具
|
||||
- 新增请求验证器(支持自定义错误消息)
|
||||
- 实现 OSS 存储上传
|
||||
- 优化缓存 DeleteByPattern 使用 SCAN
|
||||
- 修复限流器 goroutine 泄漏问题
|
||||
新增:`WithTrace`/`WithCronTask`;`app.Scheduler()`/`Cache()`/`RedisClient()`/`RateLimitRegistry()`;`cron/storage/cache/middleware` 各 `SwapDefault*`;`config.TraceConfig`;`middleware.RateLimitRegistry` + App-bound 限流中间件;`storage.StorageManager.Close()`。
|
||||
|
||||
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.4.0`(⚠️ 含破坏性变更,详见 CHANGELOG 迁移说明)
|
||||
|
||||
### v1.2.0 (2026-07-04)
|
||||
|
||||
> **破坏性版本**:4 轮对抗性评审(deepseek / GLM / Claude / 终审)收口的全部 CRITICAL/HIGH/MEDIUM 修复 + 主线A 包级可变全局并发治理统一。`go vet` + `go build` + `go test -race ./...` 全绿。完整变更见 [CHANGELOG.md](./CHANGELOG.md)。
|
||||
|
||||
主要破坏性变更(升级前必读):
|
||||
|
||||
- **包级可变全局一律 `atomic.Pointer`**(主线A):`database.DefaultRedis`/`database.DefaultManager`/`storage.DefaultStorage`/`validation.Validator` 由裸指针改为 `atomic.Pointer[T]`。直接读字段调方法需改用 facade(`InitRedis`/`GetDB`/`ValidateStruct` 等)或 `.Load()`;`database.DefaultManager = x` 改 `database.SetDefaultManager(x)`。
|
||||
- **`jwt.DefaultJWT` 包级变量删除** → `GetDefaultJWT()` / `SetDefaultJWTManager()`。
|
||||
- **`repository.FindWhereOrdered`/`FindPageWhereOrdered` 签名**:`args []any` → `args ...any`,`order` 前置于 `query`。
|
||||
- **`response.Response` 的 `Data`/`RequestID` 去 `omitempty`**:两字段在所有响应中恒存在。
|
||||
- **`cache.IsLocked`/`GetLockTTL`/`ForceUnlock`** Redis 不可用改返 `ErrRedisNotReady`(锁操作正确性相关)。
|
||||
- **`config.Load()` 返回深拷贝**:新增 `(*Config).Clone()`;`Get()` 仍返只读指针(热路径零分配)。
|
||||
- **`handler.GetPage` 加 `page` 上限 10000**;`utils.EqualsIgnoreCase` 改 `strings.EqualFold`;`utils.ReadFile` 去 TOCTOU 前置检查(错误改 `errors.Is(err, os.ErrNotExist)`)。
|
||||
|
||||
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.2.0`(⚠️ 含破坏性变更,详见 CHANGELOG 迁移说明)
|
||||
|
||||
### v1.1.1 (2026-06-23)
|
||||
|
||||
> v1.1.0 的补丁发布:补 `ServerConfig.Host` 绑定地址、面向用户文案统一中文、修正 README 过时/错误描述(含会致启动失败的配置示例)。
|
||||
|
||||
- **ServerConfig.Host** - `server.host` 控制监听地址,空=所有接口(兼容),`127.0.0.1`=仅本机,内网 IP=绑定指定网卡
|
||||
- **文案统一中文** - recover/logger/metrics 等面向用户文案统一中文(保留协议字段名、Prometheus metric Name、MySQL 错误串匹配等英文)
|
||||
- **README 修正** - 删 wire 段、修配置示例(secret≥32 字节 + expire 改 Duration)、补 v1.1.0 新文件、重写首段介绍
|
||||
|
||||
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.1.1`(无破坏性变更)
|
||||
|
||||
### v1.1.0 (2026-06-23)
|
||||
|
||||
> 本版本定位为 **HA & Manager 化 release**:高可用与生产就绪改进 + 组件 Manager 化。含少量破坏性变更,升级前请阅读 [CHANGELOG 升级说明](./CHANGELOG.md#升级说明)。
|
||||
|
||||
- **组件 Manager 化** - storage/cache/redis/jwt/logger 五组件新增 `XxxManager` + `SetDefaultXxxManager`,包级 facade 保留兼容,支持多实例与测试注入
|
||||
- **Lifecycle Hooks** - `WithHook(Hook{OnInit/OnStart/OnReady/OnStop})` 生命周期钩子
|
||||
- **App.Go** - 受管理的后台 goroutine,Shutdown 时 cancel + 等待退出
|
||||
- **Server 配置化** - `server` 新增 timeout/TLS/unix_socket/response_mode 等字段
|
||||
- **JWTConfig time.Duration** - `jwt.expire` 用 `"24h"`,新增 issuer/algorithm
|
||||
- **Config Validate** - 启动期校验配置(端口/密钥/必填字段),错误前置
|
||||
- **response REST 模式** - `SetMode(ModeREST)` 按错误码映射 HTTP status
|
||||
- **livez/readyz** - K8s 友好的存活性/就绪性探针
|
||||
- **Prometheus metrics** - `/metrics` + `middleware.Metrics()` 采集请求指标
|
||||
- **请求级 Timeout** - `middleware.Timeout(d)` 级联取消下游
|
||||
- **依赖健康自愈** - 主库探活 + replica 健康剔除,`/readyz` 联动 503
|
||||
- **RequestID 默认装入** - 每个响应/panic 日志都带 request_id
|
||||
|
||||
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.1.0`(⚠️ 含破坏性变更,见升级说明)
|
||||
|
||||
### v1.0.4 (2026-06-22)
|
||||
|
||||
> 本版本定位为 **DX & Docs release**:开发体验与文档改进,无破坏性 API 变更。
|
||||
|
||||
- **CLI 多模板** - `xlgo new --template minimal/api/fullstack`,支持轻量 HTTP / 标准业务 / 全组件三种脚手架
|
||||
- **examples/ 目录** - 新增 `examples/minimal`(无依赖可跑)与 `examples/full`(mysql+redis+jwt+user CRUD)可运行示例
|
||||
- **模块路径文档** - 明确说明 module path `xlgo-core` 与 package name `xlgo` 的关系,补完整 import 示例(保留 `-core`,xlgo 多产品系列)
|
||||
- **Without* 定位** - 文档化 `Without*` Option 主要用于 `NewFullStack` 后排除单项
|
||||
- **文档结构** - 规划文档归档到 `docs/plans/`,新增 `docs/README.md` 索引
|
||||
|
||||
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.0.4`(无破坏性变更)
|
||||
|
||||
### v1.0.3 (2026-06-22)
|
||||
|
||||
> 本版本定位为 **bug fix release**:收口 v1.0.2 引入的破坏性清理,并修复 4 个轻量 bug + 依赖复查。完整说明见 [CHANGELOG.md#unreleased](./CHANGELOG.md#unreleased)。
|
||||
|
||||
#### 🐛 Fixed — JWT JTI 生成忽略 `rand.Read` 错误
|
||||
|
||||
`generateJTI()` 丢弃 `crypto/rand.Read` 的 error,失败时会基于全零字节生成 JTI,导致所有 token 的 JTI 相同、黑名单机制失效。改为 `(string, error)` 并在 `GenerateToken` / `GenerateTokenWithCustomExpiry` 传播错误。
|
||||
|
||||
#### 🐛 Fixed — `QueryBuilder.Page` 统计行数被残留 Limit 截断
|
||||
|
||||
`Page()` 复制查询做 Count 时未清除残留 `Limit`/`Offset`,调用方先 `.Limit(n)` 再 `.Page(...)` 会让 Count 被包成子查询,返回 `total` 被截断为 ≤ n。countDB 改为 `.Limit(-1).Offset(-1)` 清除残留条件。
|
||||
|
||||
#### 🐛 Fixed — OSS / 本地存储文件名冲突
|
||||
|
||||
4 处上传路径仅用 `time.Now().UnixNano()` 命名,同纳秒并发上传会撞名覆盖。新增 `uniqueFilename(now, ext)`(`<unixNano>-<8字节crypto/rand hex>.<ext>`),4 处统一改用。
|
||||
|
||||
#### 🐛 Fixed — 数据库重试策略对不可恢复错误无效
|
||||
|
||||
`InitDB` 对认证失败(`Access denied`)、未知数据库(`Unknown database`)、非法 DSN、未注册驱动等配置类错误也退避重试 5 次,白白延迟 31 秒。新增 `isTransientDBError`,这类错误首次出现即返回;网络类错误仍正常重试。
|
||||
|
||||
#### 📦 Dependencies — `go mod tidy` + 安全补丁升级
|
||||
|
||||
- `go mod tidy` 补全 postgres 方言传递依赖(`jackc/pgx` 家族、`golang.org/x/sync`),`gorm.io/driver/postgres` 提升为直接依赖
|
||||
- 安全补丁升级(无 API 变更):`golang.org/x/crypto` v0.49→v0.53、`golang-jwt/jwt/v5` v5.2.1→v5.3.1、`gorilla/websocket` v1.5.1→v1.5.3
|
||||
- 暂缓升级(留待 v1.0.4 / v1.1):`gin` / `validator` / `gorm` / `aliyun-oss-go-sdk` v2→v3(major 破坏性)等跨版本升级
|
||||
|
||||
#### ⚠️ Breaking — 清理 v1.0.2 兼容别名(database 包)
|
||||
|
||||
```go
|
||||
// ❌ 移除
|
||||
database.InitMySQL(cfg)
|
||||
database.InitMySQLWithReplicas(cfg, replicas)
|
||||
(*Manager).InitMySQL / InitMySQLWithReplicas
|
||||
|
||||
// ✅ 改用(v1.0.2 起就是正式 API,驱动由 cfg.Database.Driver 决定)
|
||||
database.InitDB(cfg)
|
||||
database.InitDBWithReplicas(cfg, replicas)
|
||||
```
|
||||
|
||||
xlgo 仍是早期框架,趁此一次彻底清理 v1.0.2 临时保留的别名,避免长期累积技术债。
|
||||
|
||||
#### 🗑️ Removed — 删除死代码 `database.DBResolver`
|
||||
|
||||
`database.DBResolver.BeforeQuery` 从未被注册到 GORM callback chain,属于纯死代码。文档曾暗示的"自动读写分离"实际从未生效——读写分离一直依赖业务侧显式调用 `database.UseMaster(ctx)` / `database.UseReplica(ctx)`。
|
||||
|
||||
需要 callback 级自动路由的用户请直接接入官方 [`gorm.io/plugin/dbresolver`](https://github.com/go-gorm/dbresolver)。
|
||||
|
||||
#### 🔄 Changed — 文件重命名 `database/mysql.go → database/manager.go`
|
||||
|
||||
文件内容已与 MySQL 解耦(v1.0.2 引入可插拔方言注册表后),继续叫 `mysql.go` 误导。**导入路径无变化**,公开 API 全部保留。
|
||||
|
||||
#### ✨ Added — console 包显式 level 控制
|
||||
|
||||
`console` 包新增显式级别屏蔽能力:
|
||||
|
||||
```go
|
||||
console.SetLevel(console.LevelWarn) // 只看 Warn / Error
|
||||
console.SetLevel(console.LevelSilent) // 完全静默
|
||||
```
|
||||
|
||||
**定位明确**:console 是**开发期彩色 stdout 工具**(跟 `fmt.Println` 同级),**不写文件、不感知环境**。业务可观测信息请使用 `logger`。框架不会自动根据 `app.env` 切换级别——选择权完全在调用方,避免"dev / prod 行为不一致"的隐式陷阱。
|
||||
|
||||
完整对比表见 [GUIDE.md §3.3](./GUIDE.md#33-彩色控制台输出)。
|
||||
|
||||
#### 🐛 Fixed — Logger 重复写入修复
|
||||
|
||||
修复通用 `Logger` 把 `apiCore` 与 `dbCore` 都 Tee 进来导致**每条日志写三份**的 bug。
|
||||
|
||||
- `Logger`(通用)→ `logs/app.log` + console
|
||||
- `APILog()` → `logs/api.log` + console
|
||||
- `DBLog()` → `logs/database.log` + console
|
||||
- 互不串扰,磁盘体积砍掉 2/3
|
||||
|
||||
**新增**:`logger.Close()` 显式关闭文件句柄,`App.Shutdown` 已自动调用;`Init(nil)` 改为返回 error 而非 panic;生产默认级别从 `Warn` 调整为 `Info`。
|
||||
|
||||
**升级注意**:日志目录会新增 `logs/app.log` 文件(之前通用日志被串写进了 `api.log`/`database.log`),运维采集脚本如有需要请补上。
|
||||
|
||||
#### 🔒 Security — CORS 中间件修复
|
||||
|
||||
修复 CORS 中间件多个安全与规范遵守问题:
|
||||
|
||||
- **`Access-Control-Allow-Credentials` 永远是 `true`** — 旧实现 `if/else` 两个分支都设了 `"true"`,导致即使配置 `AllowCredentials=false` 也会发送凭证头
|
||||
- **`*` + `credentials: true` 的规范违规** — 同时发送会被浏览器拒绝;修复后此场景回显具体 Origin
|
||||
- **缺失 `Vary: Origin`** — 防止 CDN / 网关把 A 用户的 CORS 响应缓存给 B 用户
|
||||
- 非白名单 Origin 不再被回显,防反射型 CORS 漏洞
|
||||
|
||||
**升级影响**:如果你之前依赖"默认允许凭证"的隐式行为,需要在配置里显式启用:
|
||||
|
||||
```yaml
|
||||
cors:
|
||||
allowed_origins: ["https://your-frontend.example"]
|
||||
allow_credentials: true
|
||||
```
|
||||
|
||||
#### ⚠️ Breaking — 错误码体系重构
|
||||
|
||||
修复 `CodeSuccess` 与 `CodeInvalidParams` 撞码的生产级 bug(两者都等于 `1`,导致业务错误响应被前端误判为成功)。
|
||||
|
||||
**数值变更**:
|
||||
|
||||
| 常量 | 旧值 | 新值 |
|
||||
|---|---|---|
|
||||
| `response.CodeSuccess` | `1` | **`0`** |
|
||||
| `response.CodeFail` | `0` | **`1`** |
|
||||
|
||||
**移除**:
|
||||
|
||||
- `response.CodeInvalidParams`(与 `CodeSuccess` 撞码,且参数错误码应由业务自定义)
|
||||
- `response.ErrInvalidParams`
|
||||
|
||||
**迁移指南**:
|
||||
|
||||
```go
|
||||
// ❌ 旧代码(编译失败)
|
||||
response.FailWithError(c, response.ErrInvalidParams)
|
||||
|
||||
// ✅ 推荐:业务侧自定义错误码
|
||||
var ErrInvalidParams = response.NewError(40001, "参数错误")
|
||||
response.FailWithError(c, ErrInvalidParams)
|
||||
|
||||
// ✅ 或直接使用通用失败响应
|
||||
response.Fail(c, "用户名格式错误")
|
||||
```
|
||||
|
||||
**前端**:`if (resp.code === 1)` → `if (resp.code === 0)`。
|
||||
|
||||
新增 `_errorCodeUniquenessGuard` 编译期防撞码 map,任何后续 `Code*` 常量重复都会在 `go build` 阶段直接报错。
|
||||
|
||||
详细迁移说明见 [CHANGELOG.md](./CHANGELOG.md)。
|
||||
|
||||
### v1.0.2 (2026-06-20)
|
||||
|
||||
#### 数据库
|
||||
- **可插拔方言注册表** - 新增 `database.RegisterDialect` / `LookupDialect` / `RegisteredDialects`,应用可一次注册即让 `database.driver: <name>` 生效,DSN 构建器同步登记到 `config` 包;内置 `mysql` 与 `postgres`(含 `postgresql`、`pg` 别名),未注册驱动回退 MySQL
|
||||
- **多数据库驱动** - `database.driver` 支持 `mysql`(默认)与 `postgres`,新增 `database.InitDB` / `InitDBWithReplicas`(v1.0.3 起原 `InitMySQL*` 别名已移除)
|
||||
- **数据库管理器** - 引入实例化 `database.Manager` 持有主从连接,提供 `Master/Replica/FromContext/HealthCheck` 等方法
|
||||
- **从库选择策略** - 新增 `ReplicaPicker` 接口与 `RoundRobinPicker` / `RandomPicker` 实现,可通过 `SetReplicaPicker` 自定义
|
||||
- **私有上下文键** - `db_mode` 字符串键替换为私有 `dbModeContextKey{}` 类型,避免上下文键名冲突
|
||||
- **DSN 构建器注册表** - `config` 包新增 `RegisterDSNBuilder` / `LookupDSNBuilder` / `RegisteredDrivers`,`DatabaseConfig.DSN()` 改为查注册表,自定义 `CustomDSN` 优先
|
||||
|
||||
#### App 启动流程
|
||||
- **轻量默认** - `xlgo.New()` 默认不再初始化 MySQL / Redis / Storage,也不注册 `/health` 与 `/swagger/*`;通过 Option 显式启用
|
||||
- **组件 Option** - 新增 `WithLogger / WithMySQL / WithRedis / WithStorage / WithHealthRoutes / WithSwaggerRoutes / WithDefaultRoutes / WithAutoMigrate` 及对应 `WithoutXxx` 关闭项(注:`WithWire` 曾在此版本新增,已于 v1.1.0 随 wire 包删除而移除)
|
||||
- **迁移控制** - 新增 `Migrator` 类型与 `WithMigrator / WithModels`,注册时自动开启 `WithAutoMigrate`,`WithoutAutoMigrate` 可显式关闭;不再强制调用空的 `database.AutoMigrate()`
|
||||
- **batteries-included** - 新增 `WithFullStack` / `NewFullStack` / `RunFullStack`,一键启用全部默认组件
|
||||
- **错误传播** - 框架初始化失败一律 `return error`,不再在框架内部直接 `Fatalf` 退出进程
|
||||
|
||||
#### 权限中间件
|
||||
- **去业务化** - `super_admin/admin/staff` 调整为默认常量而非固定业务模型
|
||||
- **通用能力** - 新增 `AuthUser` 结构体、`GetAuthUser`、`RequireUserTypes / RequireRoles / RequireAuth`,旧的 `AdminRequired/SuperAdminRequired/StaffRequired/AnyUserRequired` 改为基于通用能力实现
|
||||
|
||||
#### 配置管理
|
||||
- **实例化 Manager** - 新增 `config.Manager`,提供 `Load / LoadWithWatch / Reload / RegisterCallback / Get / GetViper / Set` 等方法,支持多实例与重复加载
|
||||
- **App 持有 Manager** - `WithConfigPath` 创建 App 私有 manager 并通过新增的 `config.SetDefaultManager` 推为全局默认,使 `config.Get / GetString` 等便捷函数仍然可用
|
||||
- **修复** - 修复 `WithConfigPath` 此前的空实现问题
|
||||
|
||||
#### 健康检查与默认路由
|
||||
- **拆分注册** - `router` 包新增 `RegisterHealthRoute` / `RegisterSwaggerRoutes`,原 `RegisterDefaultRoutes` 改为组合调用
|
||||
- **检查项与状态** - `/health` 支持注册 `HealthCheck`,失败返回 HTTP 503 与 `{"status":"error", "checks":{...}}`
|
||||
- **App 集成** - 启用 MySQL / Redis 时自动追加对应健康检查项,可通过 `WithHealthCheck` 注册自定义检查
|
||||
|
||||
#### 资源关闭
|
||||
- **Shutdown 修复** - 改为 `database.CloseAll()` 关闭主从连接,并使用 `errors.Join` 聚合限流器、日志、Redis 等组件的关闭错误
|
||||
- **Storage 可选化** - 未初始化 Storage 时返回 `ErrStorageNotInitialized`,不再 nil panic
|
||||
|
||||
#### 文档与 CLI
|
||||
- **CLI 修复** - `xlgo version` 更新为 v1.0.2,脚手架模板改为依赖 `github.com/EthanCodeCraft/xlgo-core` 并在注释中提示 Swagger / MySQL / Redis / FullStack 的开启方式
|
||||
- **GUIDE 同步** - 1.3 最简示例、3.2 日志注释、8.4.7 默认路由、9.2 用户信息获取均更新为 v1.0.2 行为
|
||||
- **历史日志修正** - 修复此前 README 中错误的 v2.0.0 / v2.1.0 更新日志表述
|
||||
|
||||
### v1.0.1 (2026-04-30)
|
||||
|
||||
- 新增工具函数库、彩色控制台输出、压缩解压、RequestID、Recover 中间件
|
||||
- 新增缓存键名前缀、分布式锁、计数器、Redis 分布式限流
|
||||
- 增强 JWT 黑名单、Repository、CORS、日志中间件和优雅关闭能力
|
||||
- 新增路由架构,支持模块化、版本化 API、中间件分组和 RESTful CRUD
|
||||
- 完善配置热更新、数据库读写分离、CSRF、SSE、WebSocket、定时任务、CLI、测试工具和统一错误码
|
||||
|
||||
### v1.0.0 (2024-04)
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/cache"
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
)
|
||||
|
||||
func splitAddr(t *testing.T, addr string) (string, int) {
|
||||
t.Helper()
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort %q: %v", addr, err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi port %q: %v", portStr, err)
|
||||
}
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
|
||||
// TestAppCacheMultiAppRedisIsolation 固化 Phase 4 核心契约:单进程多 App 时,每个 App 的
|
||||
// 缓存走自己的 redisManager.Client(),互不串越。修复前 cache 硬编码 database.GetRedis()
|
||||
// (全局),App B swap 后 App A 的缓存会读到 App B 的 redis。
|
||||
//
|
||||
// 用两个 miniredis 实例分别作 App A/B 的 Redis 后端,断言各自 cache 只写自己的实例、
|
||||
// 互读对方的 key 为 miss。appA.Cache()/appB.Cache() 绕过全局 facade 直取 App 实例。
|
||||
func TestAppCacheMultiAppRedisIsolation(t *testing.T) {
|
||||
mrA := miniredis.RunT(t)
|
||||
mrB := miniredis.RunT(t)
|
||||
hostA, portA := splitAddr(t, mrA.Addr())
|
||||
hostB, portB := splitAddr(t, mrB.Addr())
|
||||
|
||||
cfgA := testConfig(18120)
|
||||
cfgA.Redis = config.RedisConfig{Host: hostA, Port: portA}
|
||||
cfgB := testConfig(18121)
|
||||
cfgB.Redis = config.RedisConfig{Host: hostB, Port: portB}
|
||||
|
||||
appA := xlgo.New(xlgo.WithConfig(cfgA), xlgo.WithRedis())
|
||||
if err := appA.Init(); err != nil {
|
||||
t.Fatalf("appA Init: %v", err)
|
||||
}
|
||||
defer appA.Shutdown()
|
||||
appB := xlgo.New(xlgo.WithConfig(cfgB), xlgo.WithRedis())
|
||||
if err := appB.Init(); err != nil {
|
||||
t.Fatalf("appB Init: %v", err)
|
||||
}
|
||||
defer appB.Shutdown()
|
||||
|
||||
if appA.Cache() == nil || appB.Cache() == nil {
|
||||
t.Fatal("app.Cache() = nil after Init with WithRedis")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// A 写 keyA -> 仅落 miniredisA
|
||||
if err := appA.Cache().Set(ctx, "keyA", "valA", time.Minute); err != nil {
|
||||
t.Fatalf("appA cache Set: %v", err)
|
||||
}
|
||||
if !mrA.Exists("keyA") {
|
||||
t.Error("keyA not in miniredisA (appA redis)")
|
||||
}
|
||||
if mrB.Exists("keyA") {
|
||||
t.Error("keyA leaked into miniredisB (appB redis) -- cache not isolated")
|
||||
}
|
||||
|
||||
// B 写 keyB -> 仅落 miniredisB
|
||||
if err := appB.Cache().Set(ctx, "keyB", "valB", time.Minute); err != nil {
|
||||
t.Fatalf("appB cache Set: %v", err)
|
||||
}
|
||||
if !mrB.Exists("keyB") {
|
||||
t.Error("keyB not in miniredisB (appB redis)")
|
||||
}
|
||||
if mrA.Exists("keyB") {
|
||||
t.Error("keyB leaked into miniredisA (appA redis) -- cache not isolated")
|
||||
}
|
||||
|
||||
// 互读对方 key -> miss(各自走自己的 redis)
|
||||
var got string
|
||||
if ok, err := appA.Cache().Get(ctx, "keyB", &got); ok || err != nil {
|
||||
t.Errorf("appA cache should miss keyB (different redis): ok=%v err=%v", ok, err)
|
||||
}
|
||||
if ok, err := appB.Cache().Get(ctx, "keyA", &got); ok || err != nil {
|
||||
t.Errorf("appB cache should miss keyA (different redis): ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
// 各自读自己的 key -> hit
|
||||
if ok, err := appA.Cache().Get(ctx, "keyA", &got); err != nil || !ok || got != "valA" {
|
||||
t.Errorf("appA cache Get keyA = %v %q err=%v (want true valA)", ok, got, err)
|
||||
}
|
||||
if ok, err := appB.Cache().Get(ctx, "keyB", &got); err != nil || !ok || got != "valB" {
|
||||
t.Errorf("appB cache Get keyB = %v %q err=%v (want true valB)", ok, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppCacheRollbackOnInitFailure 固化 Phase 4 回滚:Init 失败后全局默认 CacheManager
|
||||
// 必须恢复为 Init 前的实例。
|
||||
func TestAppCacheRollbackOnInitFailure(t *testing.T) {
|
||||
preInit := cache.NewCacheManager()
|
||||
saved := cache.SwapDefaultCacheManager(preInit) // saved = init 默认;preInit 现为全局
|
||||
defer cache.SwapDefaultCacheManager(saved)
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
host, port := splitAddr(t, mr.Addr())
|
||||
cfg := testConfig(18122)
|
||||
cfg.Redis = config.RedisConfig{Host: host, Port: port}
|
||||
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(cfg),
|
||||
xlgo.WithRedis(),
|
||||
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
|
||||
)
|
||||
if err := app.Init(); err == nil {
|
||||
_ = app.Shutdown()
|
||||
t.Fatal("expected Init to fail via OnInit hook")
|
||||
}
|
||||
|
||||
if cache.GetDefaultCache() != preInit {
|
||||
t.Fatal("rollback did not restore preInit cache manager as global default")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// TestAppShutdownStopsConfigWatcher_Mconfig3 固化 M-config-3:App.Shutdown 须停止其
|
||||
// configManager 的热重载 watcher,避免关闭后遗留监听 goroutine(违反 C7 生命周期契约)。
|
||||
//
|
||||
// Init 后 resolveConfig 把 a.configManager 提升为全局默认,故经包级 config.StartWatcher
|
||||
// 在其上启动 watcher(等价于用户对 configManager 调 LoadWithWatch/StartWatcher);
|
||||
// Shutdown 后断言 watchLoop goroutine 已退出。
|
||||
func TestAppShutdownStopsConfigWatcher_Mconfig3(t *testing.T) {
|
||||
dir := filepath.Join(os.TempDir(), "xlgo_app_mconfig3")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte("app:\n name: m3\n env: dev\nserver:\n port: 18080\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
app := xlgo.New(xlgo.WithConfigPath(cfgPath))
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
// 经全局默认(=a.configManager)启动 watcher,模拟用户开启热重载
|
||||
if err := config.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher: %v", err)
|
||||
}
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
time.Sleep(120 * time.Millisecond) // 等 watchLoop 就绪
|
||||
before := runtime.NumGoroutine()
|
||||
|
||||
if err := app.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
|
||||
// watchLoop goroutine 应随 Shutdown 退出
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runtime.NumGoroutine() < before {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if after := runtime.NumGoroutine(); after >= before {
|
||||
t.Errorf("App.Shutdown 未停止 config watcher(M-config-3): before=%d after=%d", before, after)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/cron"
|
||||
)
|
||||
|
||||
// TestAppCronSchedulerInstance 固化 Phase 3:WithCronTask 把任务注册到 App 专属调度器,
|
||||
// app.Scheduler() 返回该实例且含注册的任务。
|
||||
func TestAppCronSchedulerInstance(t *testing.T) {
|
||||
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
|
||||
defer cron.SwapDefaultScheduler(saved)
|
||||
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18112)),
|
||||
xlgo.WithCronTask("my-task", cron.Every(time.Hour), func(context.Context) error { return nil }),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
defer app.Shutdown()
|
||||
|
||||
s := app.Scheduler()
|
||||
if s == nil {
|
||||
t.Fatal("app.Scheduler() = nil after Init with WithCronTask")
|
||||
}
|
||||
found := false
|
||||
for _, tk := range s.ListTasks() {
|
||||
if tk.Name == "my-task" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("WithCronTask task not registered on App scheduler")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppCronRollbackRestoresDefault 固化 Phase 3 回滚:Init 失败后全局默认调度器
|
||||
// 必须恢复为 Init 前的实例(不含 App 注册的任务),而非残留 App 的调度器。
|
||||
func TestAppCronRollbackRestoresDefault(t *testing.T) {
|
||||
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
|
||||
defer cron.SwapDefaultScheduler(saved)
|
||||
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18113)),
|
||||
xlgo.WithCronTask("rollback-task", cron.Every(time.Hour), func(context.Context) error { return nil }),
|
||||
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
|
||||
)
|
||||
if err := app.Init(); err == nil {
|
||||
_ = app.Shutdown()
|
||||
t.Fatal("expected Init to fail via OnInit hook")
|
||||
}
|
||||
|
||||
// 回滚后全局默认不应含 App 的 "rollback-task"
|
||||
for _, tk := range cron.GetScheduler().ListTasks() {
|
||||
if tk.Name == "rollback-task" {
|
||||
t.Fatal("rollback did not restore global default scheduler (App task still on global)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppCronMultiAppIsolation 固化 Phase 3 核心契约:单进程多 App 时,App A 的 Shutdown
|
||||
// 只停 A 自己的调度器,不影响 App B 的调度器继续运行(修复前 cron 是全局单例,A.Shutdown
|
||||
// 会经 cron.StopGlobalWithTimeout 把 B 的调度器也停了)。
|
||||
//
|
||||
// 调度器内部 ticker 为 1s,故任务每 1s tick 触发一次(schedule=Every(1ms) 仅决定 due 判定,
|
||||
// 实际 spawn 受 1s ticker 节拍)。测试因此需要跨 1s tick 观测。
|
||||
func TestAppCronMultiAppIsolation(t *testing.T) {
|
||||
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
|
||||
defer cron.SwapDefaultScheduler(saved)
|
||||
|
||||
var ranA, ranB atomic.Int32
|
||||
appA := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18110)),
|
||||
xlgo.WithCronTask("taskA", cron.Every(time.Millisecond), func(context.Context) error {
|
||||
ranA.Add(1)
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
appB := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18111)),
|
||||
xlgo.WithCronTask("taskB", cron.Every(time.Millisecond), func(context.Context) error {
|
||||
ranB.Add(1)
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err := appA.Init(); err != nil {
|
||||
t.Fatalf("appA Init: %v", err)
|
||||
}
|
||||
if err := appB.Init(); err != nil {
|
||||
t.Fatalf("appB Init: %v", err)
|
||||
}
|
||||
defer appB.Shutdown()
|
||||
|
||||
// 轮询等第一个 1s tick:两个调度器都应各跑至少一次(deadline 3s,容忍慢机/CI 漂移)
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if ranA.Load() > 0 && ranB.Load() > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
aAtTick := ranA.Load()
|
||||
bAtTick := ranB.Load()
|
||||
if aAtTick == 0 || bAtTick == 0 {
|
||||
t.Fatalf("expected both schedulers ran at least once: A=%d B=%d", aAtTick, bAtTick)
|
||||
}
|
||||
|
||||
// 停 App A
|
||||
if err := appA.Shutdown(); err != nil {
|
||||
t.Fatalf("appA Shutdown: %v", err)
|
||||
}
|
||||
|
||||
// 轮询等 B 再跑一次(deadline 3s),证明 B 调度器未被 A.Shutdown 停掉
|
||||
deadline = time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if ranB.Load() > bAtTick {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if ranA.Load() != aAtTick {
|
||||
t.Errorf("App A scheduler ran after its Shutdown (not isolated): before=%d after=%d", aAtTick, ranA.Load())
|
||||
}
|
||||
if ranB.Load() <= bAtTick {
|
||||
t.Errorf("App B scheduler did not continue after App A Shutdown (not isolated): before=%d after=%d", bAtTick, ranB.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
)
|
||||
|
||||
func TestAppGoWaitsOnShutdown(t *testing.T) {
|
||||
app := xlgo.New()
|
||||
|
||||
started := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
app.Go(func(ctx context.Context) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
// 模拟收尾
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
close(exited)
|
||||
})
|
||||
|
||||
<-started
|
||||
|
||||
// Shutdown 应 cancel ctx 并等待 goroutine 退出
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- app.Shutdown() }()
|
||||
select {
|
||||
case <-exited:
|
||||
// goroutine 在 cancel 后退出,符合预期
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("App.Go goroutine did not exit after Shutdown cancel")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("Shutdown returned error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Shutdown did not return")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// appHungDriver 是测试用 database/sql 驱动,Conn.Ping 阻塞到 ctx 取消,模拟挂起 DB。
|
||||
// 与 database 包内部的 hungDriver 同构(app_test 为外部包,需自带一份)。
|
||||
type appHungDriver struct{}
|
||||
|
||||
func (appHungDriver) Open(string) (driver.Conn, error) { return appHungConn{}, nil }
|
||||
|
||||
type appHungConn struct{}
|
||||
|
||||
func (appHungConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("hung: not implemented") }
|
||||
func (appHungConn) Close() error { return nil }
|
||||
func (appHungConn) Begin() (driver.Tx, error) { return nil, errors.New("hung: not implemented") }
|
||||
|
||||
// Ping 阻塞到 ctx 取消(实现 driver.Pinger,方法名 Ping 而非 PingContext)。
|
||||
func (appHungConn) Ping(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
var appHungRegisterOnce sync.Once
|
||||
|
||||
func registerAppHungDialect() {
|
||||
appHungRegisterOnce.Do(func() {
|
||||
sql.Register("xlgo_app_hung_hdb1", appHungDriver{})
|
||||
database.RegisterDialect(database.DialectSpec{
|
||||
Name: "app_hung_hdb1",
|
||||
Dialector: func(string) gorm.Dialector {
|
||||
db, _ := sql.Open("xlgo_app_hung_hdb1", "")
|
||||
return appHungDialector{sqlDB: db}
|
||||
},
|
||||
DSN: func(*config.DatabaseConfig) string { return "app_hung://" },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// appHungDialector 让 gorm.Open 成功并注入挂起 *sql.DB,使 initDB 的 pingWithTimeout 触发。
|
||||
type appHungDialector struct{ sqlDB *sql.DB }
|
||||
|
||||
func (d appHungDialector) Name() string { return "app_hung_hdb1" }
|
||||
func (d appHungDialector) Initialize(db *gorm.DB) error { db.Config.ConnPool = d.sqlDB; return nil }
|
||||
func (d appHungDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
|
||||
func (d appHungDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
func (d appHungDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
func (d appHungDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
|
||||
func (d appHungDialector) QuoteTo(w clause.Writer, s string) { _, _ = w.WriteString(s) }
|
||||
func (d appHungDialector) Explain(s string, _ ...any) string { return s }
|
||||
|
||||
// TestAppInitHungDBBounded_Hdb1 端到端回归 H-db-1(App 级闭环):
|
||||
// App.Init 启用 MySQL 且 DB 挂起时,initDB 的 pingWithTimeout 3s 失败 -> InitDB 返回错误
|
||||
// -> App.Init 失败(failAfterInit 回滚)。全程有界,不因挂起 DB 无限卡死 App.Init。
|
||||
// 修复前 gorm.Open 的 automatic Ping() 在 pingWithTimeout 之前就无限阻塞 -> App.Init 永久 hang。
|
||||
// 同时验证 App.Init 失败后无 goroutine 泄漏(closeResources 正常)。
|
||||
func TestAppInitHungDBBounded_Hdb1(t *testing.T) {
|
||||
registerAppHungDialect()
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{Env: "test"},
|
||||
Server: config.ServerConfig{Port: 18091},
|
||||
Database: config.DatabaseConfig{
|
||||
Driver: "app_hung_hdb1",
|
||||
Host: "localhost", Port: 3306, User: "u", Password: "p", Name: "n",
|
||||
},
|
||||
}
|
||||
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithMySQL())
|
||||
|
||||
done := make(chan error, 1)
|
||||
start := time.Now()
|
||||
go func() { done <- app.Init() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatalf("挂起 DB 下 App.Init 应失败,got nil")
|
||||
}
|
||||
// initDB pingWithTimeout 3s 失败后重试 5 次(每次 ping 3s + retryDelay 1s 起步翻倍),
|
||||
// 全跑约 5*4s=20s+。放宽到 35s 上限。关键是不触发下方 45s watchdog 的"无限阻塞"。
|
||||
if elapsed > 35*time.Second {
|
||||
t.Fatalf("App.Init 耗时 %v 超过 35s 上限(H-db-1:initDB ping 应被 3s 约束,重试 5 次应 ~20s)", elapsed)
|
||||
}
|
||||
case <-time.After(45 * time.Second):
|
||||
t.Fatalf("App.Init 在挂起 DB 上无限阻塞(H-db-1:gorm.Open automatic ping 或 initDB ping 未被超时约束)")
|
||||
}
|
||||
|
||||
// App.Init 失败后应能正常 Shutdown(failAfterInit 已回滚资源,Shutdown 幂等)。
|
||||
shutdownDone := make(chan error, 1)
|
||||
go func() { shutdownDone <- app.Shutdown() }()
|
||||
select {
|
||||
case err := <-shutdownDone:
|
||||
if err != nil {
|
||||
t.Logf("App.Shutdown after failed Init returned err: %v(可接受,资源已回滚)", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("App.Shutdown 在 Init 失败后无限阻塞(closeResources 未正常收口)")
|
||||
}
|
||||
|
||||
// rootCtx 已被 failAfterInit cancel,无残留 goroutine(StartProbing 未启动因 Init 失败)。
|
||||
// 此处不直接断言 goroutine 数(受测试 runtime 噪声影响),靠 Shutdown 有界退出间接证明。
|
||||
_ = context.Background()
|
||||
}
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/cron"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestAppGoAddWaitRace_M1 回归(须 -race):App.Go 的 wg.Add 与 Shutdown 的 wg.Wait 并发,
|
||||
// 必须由 lifecycleMu 串行化(Add happens-before Wait),否则违反 WaitGroup 契约。
|
||||
// 修复前:-race 报 wg.Add/wg.Wait data race 或 "negative WaitGroup counter" panic。
|
||||
func TestAppGoAddWaitRace_M1(t *testing.T) {
|
||||
const M = 20
|
||||
const iters = 20
|
||||
for iter := 0; iter < iters; iter++ {
|
||||
app := xlgo.New()
|
||||
var spawned, exited atomic.Int32
|
||||
gate := make(chan struct{})
|
||||
var goDone sync.WaitGroup
|
||||
goDone.Add(M)
|
||||
for i := 0; i < M; i++ {
|
||||
go func() {
|
||||
<-gate
|
||||
app.Go(func(ctx context.Context) {
|
||||
spawned.Add(1)
|
||||
<-ctx.Done()
|
||||
exited.Add(1)
|
||||
})
|
||||
goDone.Done()
|
||||
}()
|
||||
}
|
||||
shutCh := make(chan error, 1)
|
||||
go func() {
|
||||
<-gate
|
||||
shutCh <- app.Shutdown()
|
||||
}()
|
||||
close(gate)
|
||||
goDone.Wait()
|
||||
err := <-shutCh
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d: Shutdown error: %v", iter, err)
|
||||
}
|
||||
sp, ex := spawned.Load(), exited.Load()
|
||||
if sp != ex {
|
||||
t.Fatalf("iter %d: spawned %d != exited %d (goroutine leak / Add-after-Wait)", iter, sp, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppGoRefusedAfterShutdown_M1:Shutdown 后 Go() 必须拒绝(state>=Stopping),不 spawn。
|
||||
func TestAppGoRefusedAfterShutdown_M1(t *testing.T) {
|
||||
app := xlgo.New(xlgo.WithConfig(testConfig(18102)))
|
||||
if err := app.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
ran := make(chan struct{}, 1)
|
||||
app.Go(func(context.Context) { ran <- struct{}{} })
|
||||
time.Sleep(50 * time.Millisecond) // 给可能被 spawn 的 goroutine 时间发送
|
||||
select {
|
||||
case <-ran:
|
||||
t.Fatal("Go ran after Shutdown — should be refused (state=Stopping)")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppInitAfterShutdownRejected_M1(Edge 1):Shutdown 后再 Init 必须返 ErrAppClosed。
|
||||
func TestAppInitAfterShutdownRejected_M1(t *testing.T) {
|
||||
app := xlgo.New(xlgo.WithConfig(testConfig(18101)))
|
||||
if err := app.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
if err := app.Init(); !errors.Is(err, xlgo.ErrAppClosed) {
|
||||
t.Fatalf("Init after Shutdown err = %v, want ErrAppClosed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppShutdownIdempotent_M1:并发 Shutdown 幂等,OnStop 仅执行一次。
|
||||
func TestAppShutdownIdempotent_M1(t *testing.T) {
|
||||
var stopCount atomic.Int32
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18100)),
|
||||
xlgo.WithHook(xlgo.Hook{Name: "count-stop", OnStop: func(*xlgo.App) error { stopCount.Add(1); return nil }}),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); _ = app.Shutdown() }()
|
||||
go func() { defer wg.Done(); _ = app.Shutdown() }()
|
||||
wg.Wait()
|
||||
if stopCount.Load() != 1 {
|
||||
t.Fatalf("OnStop called %d times, want 1 (idempotent)", stopCount.Load())
|
||||
}
|
||||
if err := app.Shutdown(); err != nil {
|
||||
t.Fatalf("third Shutdown error: %v", err)
|
||||
}
|
||||
if stopCount.Load() != 1 {
|
||||
t.Fatalf("OnStop called %d times after 3rd, want 1", stopCount.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppInitFailureRollsBackCron_M1(Edge 3 + 资源回滚):Init 失败(OnInit hook 报错)时,
|
||||
// 已 cron.Start() 的全局调度器必须被 failAfterInit 停止,canary 任务不再触发。
|
||||
// 修复前:Init 失败不停 cron → canary 在下个 1s tick 跑 → ran > 0。
|
||||
// TestAppShutdownTimeoutCoversOnStop_M1 回归:OnStop 必须受 server.shutdown_timeout 约束。
|
||||
// 修复前:OnStop 同步执行且不看超时,阻塞 hook 会拖死 Shutdown。
|
||||
func TestAppShutdownTimeoutCoversOnStop_M1(t *testing.T) {
|
||||
cfg := testConfig(18105)
|
||||
cfg.Server.ShutdownTimeout = 30 * time.Millisecond
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(cfg),
|
||||
xlgo.WithHook(xlgo.Hook{Name: "slow-stop", OnStop: func(*xlgo.App) error {
|
||||
<-release
|
||||
return nil
|
||||
}}),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
err := app.Shutdown()
|
||||
if err == nil || !strings.Contains(err.Error(), "OnStop hook") {
|
||||
t.Fatalf("Shutdown err = %v, want OnStop timeout error", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("Shutdown took %s, OnStop timeout did not bound shutdown", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithConfigClonesAndValidates_M1 回归:WithConfig 捕获私有快照,并在 Init 时校验。
|
||||
func TestWithConfigClonesAndValidates_M1(t *testing.T) {
|
||||
cfg := testConfig(18106)
|
||||
app := xlgo.New(xlgo.WithConfig(cfg))
|
||||
cfg.Server.Port = -1 // 不应污染 App 内部快照
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init with cloned config failed after caller mutation: %v", err)
|
||||
}
|
||||
if err := app.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
|
||||
bad := testConfig(-1)
|
||||
err := xlgo.New(xlgo.WithConfig(bad)).Init()
|
||||
if err == nil || !strings.Contains(err.Error(), "配置校验失败") {
|
||||
t.Fatalf("Init with invalid WithConfig err = %v, want validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppInitFailureRollsBackCron_M1(t *testing.T) {
|
||||
cron.StopGlobalWithTimeout(time.Second) // 清前序残留
|
||||
t.Cleanup(func() {
|
||||
cron.GetScheduler().RemoveTask("canary-m1")
|
||||
cron.StopGlobalWithTimeout(time.Second)
|
||||
})
|
||||
|
||||
var ran atomic.Int32
|
||||
// Phase 3:canary 注册到 App 专属调度器(WithCronTask),Init 失败时 stopCron 必须停掉它。
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18104)),
|
||||
xlgo.WithCronTask("canary-m1", cron.Every(time.Millisecond), func(context.Context) error {
|
||||
ran.Add(1)
|
||||
return nil
|
||||
}),
|
||||
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom-init") }}),
|
||||
)
|
||||
err := app.Init()
|
||||
if err == nil || !strings.Contains(err.Error(), "boom-init") {
|
||||
t.Fatalf("Init err = %v, want boom-init", err)
|
||||
}
|
||||
|
||||
time.Sleep(1300 * time.Millisecond) // 跨一个 1s ticker
|
||||
if ran.Load() != 0 {
|
||||
t.Fatalf("canary ran %d times after Init failure — cron not stopped by rollback", ran.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppGoRefusedAfterInitFailure_M1:Init 失败后 failAfterInit 置 state=Stopping,
|
||||
// 后续 Go() 拒绝;先前的 Go goroutine 因 rootCtx cancel 退出(不泄漏)。
|
||||
func TestAppGoRefusedAfterInitFailure_M1(t *testing.T) {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18103)),
|
||||
xlgo.WithMigrator(func(*gorm.DB) error { return nil }), // 无 WithMySQL → Init 确定性失败
|
||||
)
|
||||
firstDone := make(chan struct{})
|
||||
app.Go(func(ctx context.Context) { <-ctx.Done(); close(firstDone) })
|
||||
|
||||
if err := app.Init(); err == nil {
|
||||
t.Fatal("expected Init error")
|
||||
}
|
||||
select {
|
||||
case <-firstDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("first Go goroutine did not exit after Init failure (rootCtx not cancelled)")
|
||||
}
|
||||
|
||||
ran := make(chan struct{}, 1)
|
||||
app.Go(func(context.Context) { ran <- struct{}{} })
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
select {
|
||||
case <-ran:
|
||||
t.Fatal("Go ran after Init failure — should be refused (state=Stopping)")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppInitFailureDoesNotCloseUnownedGlobals_M1 回归:一个尚未成功初始化任何资源的
|
||||
// App Init 失败时,不得关闭进程里已有的全局 logger/db/redis 等资源。
|
||||
func TestAppInitFailureDoesNotCloseUnownedGlobals_M1(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := logger.Init(&config.Config{
|
||||
App: config.AppConfig{Env: "test"},
|
||||
Server: config.ServerConfig{Mode: "development"},
|
||||
Log: config.LogConfig{Dir: dir, MaxSize: 1, MaxBackups: 1, MaxAge: 1},
|
||||
}); err != nil {
|
||||
t.Fatalf("logger.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = logger.Close() })
|
||||
|
||||
missingConfig := filepath.Join(t.TempDir(), "missing.yaml")
|
||||
if err := xlgo.New(xlgo.WithConfigPath(missingConfig)).Init(); err == nil {
|
||||
t.Fatal("expected Init error without config")
|
||||
}
|
||||
|
||||
const mark = "M1_UNOWNED_LOGGER_STILL_ACTIVE"
|
||||
logger.Info(mark)
|
||||
_ = logger.Sync()
|
||||
data, err := os.ReadFile(filepath.Join(dir, "app.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("read app.log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), mark) {
|
||||
t.Fatal("unowned global logger was closed by failed App.Init")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppInitShutdownConcurrentSerialization_M1 直接覆盖 initMu 串行化 doInit/doShutdown
|
||||
// (Edge 2):Init 持 initMu 期间(OnInit hook 阻塞),并发 Shutdown 必须阻塞等 Init 释放,
|
||||
// 不能与 doInit 并发改资源。确定性逻辑测试,不依赖 -race 概率。
|
||||
func TestAppInitFailureRestoresReplacedLogger_M1(t *testing.T) {
|
||||
oldMgr := logger.NewLogManager()
|
||||
oldDir := t.TempDir()
|
||||
if err := oldMgr.Init(&config.Config{
|
||||
App: config.AppConfig{Env: "test"},
|
||||
Server: config.ServerConfig{Mode: "development"},
|
||||
Log: config.LogConfig{Dir: oldDir, MaxSize: 1, MaxBackups: 1, MaxAge: 1},
|
||||
}); err != nil {
|
||||
t.Fatalf("old logger Init: %v", err)
|
||||
}
|
||||
logger.SetDefaultLogManager(oldMgr)
|
||||
t.Cleanup(func() {
|
||||
_ = logger.Close()
|
||||
logger.SetDefaultLogManager(logger.NewLogManager())
|
||||
})
|
||||
|
||||
cfg := testConfig(18105)
|
||||
cfg.Log.Dir = t.TempDir()
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(cfg),
|
||||
xlgo.WithLogger(),
|
||||
xlgo.WithHook(xlgo.Hook{Name: "fail-after-logger", OnInit: func(*xlgo.App) error {
|
||||
return errors.New("boom-after-logger")
|
||||
}}),
|
||||
)
|
||||
err := app.Init()
|
||||
if err == nil || !strings.Contains(err.Error(), "boom-after-logger") {
|
||||
t.Fatalf("Init err = %v, want boom-after-logger", err)
|
||||
}
|
||||
if got := logger.GetDefaultLogManager(); got != oldMgr {
|
||||
t.Fatalf("default logger manager was not restored after Init failure: got %p want %p", got, oldMgr)
|
||||
}
|
||||
|
||||
const mark = "M1_RESTORED_LOGGER_STILL_ACTIVE"
|
||||
logger.Info(mark)
|
||||
_ = logger.Sync()
|
||||
data, err := os.ReadFile(filepath.Join(oldDir, "app.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("read old app.log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), mark) {
|
||||
t.Fatal("restored logger did not write to old app.log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppInitShutdownConcurrentSerialization_M1(t *testing.T) {
|
||||
initStarted := make(chan struct{})
|
||||
releaseInit := make(chan struct{})
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18200)),
|
||||
xlgo.WithHook(xlgo.Hook{
|
||||
Name: "block-init",
|
||||
OnInit: func(*xlgo.App) error {
|
||||
close(initStarted)
|
||||
<-releaseInit
|
||||
return nil
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
initDone := make(chan error, 1)
|
||||
go func() { initDone <- app.Init() }()
|
||||
|
||||
<-initStarted // OnInit 在 doInit 末尾运行 → 此时 Init 持 initMu
|
||||
|
||||
// 并发 Shutdown:必须阻塞等 initMu,不能在 Init 完成前返回。
|
||||
shutDone := make(chan error, 1)
|
||||
go func() { shutDone <- app.Shutdown() }()
|
||||
|
||||
select {
|
||||
case <-shutDone:
|
||||
t.Fatal("Shutdown returned before Init finished — initMu not serializing doInit/doShutdown")
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
// 期望:Shutdown 仍阻塞等 initMu
|
||||
}
|
||||
|
||||
close(releaseInit) // 让 OnInit 返回 → doInit 完成 → Init 释放 initMu
|
||||
|
||||
select {
|
||||
case err := <-initDone:
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Init did not return after release")
|
||||
}
|
||||
select {
|
||||
case <-shutDone:
|
||||
// Shutdown 在 Init 后获得 initMu 并完成
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Shutdown did not return after Init finished — initMu not released?")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppStartServerListenFailureSkipsOnReady_M1 回归:监听失败时不得先执行 OnReady。
|
||||
// 修复前 StartServer 在 goroutine 内 ListenAndServe,主 goroutine 立即跑 OnReady;
|
||||
// 端口被占用时仍可能先触发 ready 副作用,再返回启动失败。
|
||||
func TestAppStartServerListenFailureSkipsOnReady_M1(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("occupy port: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
cfg := testConfig(port)
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
|
||||
var ready atomic.Int32
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(cfg),
|
||||
xlgo.WithHook(xlgo.Hook{
|
||||
Name: "ready-must-not-run",
|
||||
OnReady: func(*xlgo.App) error {
|
||||
ready.Add(1)
|
||||
return nil
|
||||
},
|
||||
}),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
err = app.StartServer()
|
||||
if err == nil || !strings.Contains(err.Error(), "服务器监听失败") {
|
||||
t.Fatalf("StartServer err = %v, want listen failure", err)
|
||||
}
|
||||
if ready.Load() != 0 {
|
||||
t.Fatalf("OnReady ran %d times despite listen failure", ready.Load())
|
||||
}
|
||||
_ = app.Shutdown()
|
||||
}
|
||||
|
||||
// TestAppStartServerTLSFailureSkipsOnReady_M1 回归:TLS 证书装配失败也不得触发 OnReady。
|
||||
func TestAppStartServerTLSFailureSkipsOnReady_M1(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve port: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
if err := ln.Close(); err != nil {
|
||||
t.Fatalf("release port: %v", err)
|
||||
}
|
||||
|
||||
cfg := testConfig(port)
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
cfg.Server.TLS.Enabled = true
|
||||
cfg.Server.TLS.CertFile = filepath.Join(t.TempDir(), "missing.crt")
|
||||
cfg.Server.TLS.KeyFile = filepath.Join(t.TempDir(), "missing.key")
|
||||
|
||||
var ready atomic.Int32
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(cfg),
|
||||
xlgo.WithHook(xlgo.Hook{
|
||||
Name: "tls-ready-must-not-run",
|
||||
OnReady: func(*xlgo.App) error {
|
||||
ready.Add(1)
|
||||
return nil
|
||||
},
|
||||
}),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
err = app.StartServer()
|
||||
if err == nil || !strings.Contains(err.Error(), "服务器 TLS 配置无效") {
|
||||
t.Fatalf("StartServer err = %v, want TLS configuration failure", err)
|
||||
}
|
||||
if ready.Load() != 0 {
|
||||
t.Fatalf("OnReady ran %d times despite TLS failure", ready.Load())
|
||||
}
|
||||
_ = app.Shutdown()
|
||||
}
|
||||
|
||||
// TestAppStartServerUnixSocketListens_M1 回归:server.unix_socket 必须真的通过
|
||||
// net.Listen("unix", path) 监听,而不是把 path 塞给 TCP ListenAndServe 的 Addr。
|
||||
func TestAppStartServerUnixSocketListens_M1(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows unix socket support varies by environment")
|
||||
}
|
||||
|
||||
sock := filepath.Join(t.TempDir(), "xlgo.sock")
|
||||
cfg := testConfig(0)
|
||||
cfg.Server.UnixSocket = sock
|
||||
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(cfg),
|
||||
xlgo.WithHook(xlgo.Hook{
|
||||
Name: "dial-unix-ready",
|
||||
OnReady: func(*xlgo.App) error {
|
||||
conn, err := net.DialTimeout("unix", sock, time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.Close()
|
||||
return errors.New("stop after unix socket ready")
|
||||
},
|
||||
}),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
err := app.StartServer()
|
||||
if err == nil || !strings.Contains(err.Error(), "stop after unix socket ready") {
|
||||
t.Fatalf("StartServer err = %v, want OnReady sentinel after unix dial", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestAppRateLimitPerAppCountingIsolation 固化 #1 修复:app.RateLimitRegistry().LoginRateLimit()
|
||||
// 返回的中间件捕获 App 自己的 Registry,多 App 下 per-App 计数隔离。修复前包级 LoginRateLimit()
|
||||
// 解析全局默认(多 App 仅最后 Init 的 App),App A 用满额度后 App B 首请求即被拒。
|
||||
//
|
||||
// 验证:App A 用满 10/10 后第 11 次 429;App B 同 IP 仍可 10 次 200、第 11 次 429(独立计数)。
|
||||
func TestAppRateLimitPerAppCountingIsolation(t *testing.T) {
|
||||
// REST 模式:限流映射 HTTP 429
|
||||
cfgA := testConfig(18140)
|
||||
cfgA.Server.ResponseMode = "rest"
|
||||
cfgB := testConfig(18141)
|
||||
cfgB.Server.ResponseMode = "rest"
|
||||
|
||||
appA := xlgo.New(xlgo.WithConfig(cfgA))
|
||||
if appA.RateLimitRegistry() == nil {
|
||||
t.Fatal("appA.RateLimitRegistry() = nil before Init (should be pre-created in New)")
|
||||
}
|
||||
if err := appA.Init(); err != nil {
|
||||
t.Fatalf("appA Init: %v", err)
|
||||
}
|
||||
defer appA.Shutdown()
|
||||
// app-bound 中间件:捕获 appA 的 Registry,不查全局默认
|
||||
appA.GetRouter().GET("/login", appA.RateLimitRegistry().LoginRateLimit(), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
appB := xlgo.New(xlgo.WithConfig(cfgB))
|
||||
if err := appB.Init(); err != nil {
|
||||
t.Fatalf("appB Init: %v", err)
|
||||
}
|
||||
defer appB.Shutdown()
|
||||
appB.GetRouter().GET("/login", appB.RateLimitRegistry().LoginRateLimit(), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
req := func(h http.Handler) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
r.RemoteAddr = "192.0.2.7:1234" // 两 App 用同一 IP
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// App A 用满 10 次(200),第 11 次 429
|
||||
for i := 0; i < 10; i++ {
|
||||
if w := req(appA.GetRouter()); w.Code != http.StatusOK {
|
||||
t.Fatalf("appA request %d: got %d, want 200", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
if w := req(appA.GetRouter()); w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("appA 11th: got %d, want 429 (limit reached)", w.Code)
|
||||
}
|
||||
|
||||
// App B 同 IP:应独立计数,10 次 200、第 11 次 429。
|
||||
// 包级 facade 下 B 会共享 A 的 loginLimiter(A 已用满)-> 首请求即 429,本测试即暴露该回归。
|
||||
for i := 0; i < 10; i++ {
|
||||
if w := req(appB.GetRouter()); w.Code != http.StatusOK {
|
||||
t.Fatalf("appB request %d: got %d, want 200 (per-App isolation: B should not share A's count)", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
if w := req(appB.GetRouter()); w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("appB 11th: got %d, want 429 (B independent limit reached)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppRateLimitRegistryCustomNoLeakOnRollback 固化 #2 修复路径:app-bound CustomRateLimit
|
||||
// 的 limiter 登记在 App 的 Registry 上,Init 失败回滚时由 registry.Stop() 收口,不泄漏。
|
||||
// 验证:注册 CustomRateLimit 任务后 Init 失败,rollback 调 registry.Stop(customLimiters 被清)。
|
||||
func TestAppRateLimitRegistryCustomNoLeakOnRollback(t *testing.T) {
|
||||
cfg := testConfig(18142)
|
||||
app := xlgo.New(xlgo.WithConfig(cfg),
|
||||
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
|
||||
)
|
||||
// app-bound CustomRateLimit(首请求才创建 limiter,但 Init 会失败故不会触发请求;
|
||||
// 此用例固化 app.RateLimitRegistry() 在 pre-Init 可用、Init 失败回滚不 panic)
|
||||
app.GetRouter().GET("/c", app.RateLimitRegistry().CustomRateLimit(5, time.Minute), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
if err := app.Init(); err == nil {
|
||||
_ = app.Shutdown()
|
||||
t.Fatal("expected Init to fail via OnInit hook")
|
||||
}
|
||||
// Init 失败后 rollbackReplacedResources 已 Stop App 的 Registry 并把 a.rateLimitRegistry 置 nil,
|
||||
// 故 app.RateLimitRegistry() 返回 nil(rollback 已收口,无泄漏、无 panic)。
|
||||
if app.RateLimitRegistry() != nil {
|
||||
t.Fatal("a.rateLimitRegistry should be nil after Init-failure rollback")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestAppRateLimitMultiAppShutdownIsolation 固化 Phase 5 核心契约:单进程多 App 时,
|
||||
// App A 的 Shutdown 不得重置/停止 App B 的限流器。修复前 App.Shutdown 调全局
|
||||
// middleware.StopRateLimiters,把全局 loginLimiter 置 nil,App B 下次请求会懒创建
|
||||
// 新 limiter(计数清零)--稳态客户端借 App A 的 Shutdown 窗口绕过限流。
|
||||
//
|
||||
// 验证:App B 的 /login 已用 10/10 次后,App A.Shutdown,第 11 次仍应 429(计数保留)。
|
||||
// 修复前第 11 次会 200(计数被重置)。
|
||||
func TestAppRateLimitMultiAppShutdownIsolation(t *testing.T) {
|
||||
saved := middleware.SwapDefaultRateLimitRegistry(middleware.NewRateLimitRegistry())
|
||||
defer middleware.SwapDefaultRateLimitRegistry(saved)
|
||||
|
||||
// 用 REST 响应模式:限流映射 HTTP 429(business 模式下是 200+业务码 429,不便断言)
|
||||
cfgA := testConfig(18130)
|
||||
cfgA.Server.ResponseMode = "rest"
|
||||
cfgB := testConfig(18131)
|
||||
cfgB.Server.ResponseMode = "rest"
|
||||
|
||||
appA := xlgo.New(xlgo.WithConfig(cfgA))
|
||||
if err := appA.Init(); err != nil {
|
||||
t.Fatalf("appA Init: %v", err)
|
||||
}
|
||||
appA.GetRouter().GET("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
appB := xlgo.New(xlgo.WithConfig(cfgB))
|
||||
if err := appB.Init(); err != nil {
|
||||
t.Fatalf("appB Init: %v", err)
|
||||
}
|
||||
defer appB.Shutdown()
|
||||
appB.GetRouter().GET("/login", middleware.LoginRateLimit(), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// 10 次请求 App B 的 /login(限流 10/min,同一 IP)-> 全 200
|
||||
for i := 0; i < 10; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
appB.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("request %d: got %d, want 200 (under limit)", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 第 11 次(不经 A.Shutdown)应 429 -- 先确认限流器确实计数到上限(排除 IP/计数 bug)
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
appB.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("sanity: 11th without shutdown got %d, want 429 (limiter not counting?)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// App A Shutdown -- 不得重置/停止 App B 的 login limiter
|
||||
if err := appA.Shutdown(); err != nil {
|
||||
t.Fatalf("appA Shutdown: %v", err)
|
||||
}
|
||||
|
||||
// 第 12 次请求 App B 的 /login -> 应仍 429(计数保留在 10,registryB 未被 A.Shutdown 重置)。
|
||||
// 修复前 A.Shutdown 调 StopRateLimiters 把全局 loginLimiter 置 nil,下次请求懒创建新 limiter(计数清零)-> 200。
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
appB.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("12th request after appA Shutdown: got %d, want 429 (limiter count should be preserved, not reset by appA.Shutdown)", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppRateLimitRollbackOnInitFailure 固化 Phase 5 回滚:Init 失败后全局默认 Registry
|
||||
// 必须恢复为 Init 前的实例。
|
||||
func TestAppRateLimitRollbackOnInitFailure(t *testing.T) {
|
||||
preInit := middleware.NewRateLimitRegistry()
|
||||
saved := middleware.SwapDefaultRateLimitRegistry(preInit)
|
||||
defer middleware.SwapDefaultRateLimitRegistry(saved)
|
||||
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18132)),
|
||||
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
|
||||
)
|
||||
if err := app.Init(); err == nil {
|
||||
_ = app.Shutdown()
|
||||
t.Fatal("expected Init to fail via OnInit hook")
|
||||
}
|
||||
|
||||
if middleware.GetDefaultRateLimitRegistry() != preInit {
|
||||
t.Fatal("rollback did not restore preInit rate limit registry as global default")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/storage"
|
||||
)
|
||||
|
||||
// localStorageCfg 构造一个本地存储配置(tmpdir 由调用方提供)。
|
||||
func localStorageCfg(port int, dir string) *config.Config {
|
||||
cfg := testConfig(port)
|
||||
cfg.Storage = config.StorageConfig{
|
||||
Driver: "local",
|
||||
Local: config.LocalStorageConfig{Path: dir},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestAppWithStorageSwapsDefault 固化 Phase 2 契约:App.Init(WithStorage) 必须把 App 的
|
||||
// StorageManager 提升为全局默认。证据:Init 前 GetStorage()=nil(空 manager),Init 后非 nil。
|
||||
func TestAppWithStorageSwapsDefault(t *testing.T) {
|
||||
// 用空 manager 作"Init 前"默认,确保 GetStorage() 起点 nil
|
||||
saved := storage.SwapDefaultStorageManager(storage.NewStorageManager())
|
||||
defer storage.SwapDefaultStorageManager(saved)
|
||||
|
||||
if s := storage.GetStorage(); s != nil {
|
||||
t.Fatalf("precondition: GetStorage() should be nil, got %T", s)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
app := xlgo.New(xlgo.WithConfig(localStorageCfg(18094, dir)), xlgo.WithStorage())
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
defer app.Shutdown()
|
||||
|
||||
if s := storage.GetStorage(); s == nil {
|
||||
t.Fatal("storage.GetStorage() = nil after App.Init with WithStorage(未把 App manager 提升为全局默认)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppStorageRollbackOnInitFailure 固化 Phase 2 回滚契约:Init 在 storage 之后失败时,
|
||||
// rollbackReplacedResources 必须把全局默认 swap 回 Init 前的旧 manager。
|
||||
// 用 OnInit hook 触发失败(OnInit 在 storage init 之后、commitReplacedResources 之前)。
|
||||
func TestAppStorageRollbackOnInitFailure(t *testing.T) {
|
||||
saved := storage.SwapDefaultStorageManager(storage.NewStorageManager())
|
||||
defer storage.SwapDefaultStorageManager(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(localStorageCfg(18095, dir)),
|
||||
xlgo.WithStorage(),
|
||||
xlgo.WithHook(xlgo.Hook{
|
||||
Name: "force-fail",
|
||||
OnInit: func(*xlgo.App) error { return errors.New("boom") },
|
||||
}),
|
||||
)
|
||||
|
||||
if err := app.Init(); err == nil {
|
||||
_ = app.Shutdown()
|
||||
t.Fatal("expected Init to fail via OnInit hook")
|
||||
}
|
||||
|
||||
// 回滚后全局默认应恢复为 saved(空 manager)-> GetStorage() 返回 nil
|
||||
if s := storage.GetStorage(); s != nil {
|
||||
t.Errorf("Init 失败回滚未恢复 storage 全局默认: GetStorage()=%T", s)
|
||||
}
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/router"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func testConfig(port int) *config.Config {
|
||||
return &config.Config{
|
||||
App: config.AppConfig{Env: "test"},
|
||||
Server: config.ServerConfig{Port: port, Mode: "development"},
|
||||
Log: config.LogConfig{Dir: filepath.Join(os.TempDir(), "xlgo_app_test_logs"), MaxSize: 1, MaxBackups: 1, MaxAge: 1},
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, name string, port int) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, name)
|
||||
content := "app:\n env: test\nserver:\n port: " + strconv.Itoa(port) + "\n mode: development\nlog:\n dir: " + filepath.ToSlash(filepath.Join(dir, "logs")) + "\n max_size: 1\n max_backups: 1\n max_age: 1\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestAppWithConfigPath(t *testing.T) {
|
||||
path := writeConfig(t, "config.yaml", 18081)
|
||||
app := xlgo.New(xlgo.WithConfigPath(path))
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
if app.GetServer() != nil {
|
||||
t.Fatal("Init should not start server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppWithConfigNoDefaultRoutes(t *testing.T) {
|
||||
app := xlgo.New(xlgo.WithConfig(testConfig(18082)))
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
app.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected /health 404 without health routes, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppWithHealthRoutes(t *testing.T) {
|
||||
app := xlgo.New(xlgo.WithConfig(testConfig(18083)), xlgo.WithHealthRoutes())
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
app.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected /health 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppWithHealthCheckFailure(t *testing.T) {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18084)),
|
||||
xlgo.WithHealthRoutes(),
|
||||
xlgo.WithHealthCheck("custom", func(_ context.Context) error { return errors.New("down") }),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
app.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected /health 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppMigratorRequiresMySQL(t *testing.T) {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18085)),
|
||||
xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }),
|
||||
)
|
||||
err := app.Init()
|
||||
if err == nil || !strings.Contains(err.Error(), "未启用 MySQL") {
|
||||
t.Fatalf("expected mysql disabled migrator error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRoutesModule(t *testing.T) {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18086)),
|
||||
xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) {
|
||||
r.GET("/hello", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"hello": "world"}) })
|
||||
})),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/hello", nil)
|
||||
app.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected module route 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppWithoutDefaultRoutesOverrides(t *testing.T) {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18087)),
|
||||
xlgo.WithDefaultRoutes(),
|
||||
xlgo.WithoutDefaultRoutes(),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
app.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected /health 404 after WithoutDefaultRoutes, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppWithSwaggerRoutesOnly 验证可以单独启用 Swagger 而不开启 health。
|
||||
func TestAppWithSwaggerRoutesOnly(t *testing.T) {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18088)),
|
||||
xlgo.WithSwaggerRoutes(),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
|
||||
// /health 不应注册
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
app.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected /health 404 when only Swagger enabled, got %d", w.Code)
|
||||
}
|
||||
|
||||
// /swagger/* 应注册(具体子路径返回什么取决于 swag 文档是否生成,这里只验证未 404)
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil)
|
||||
app.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Fatalf("expected /swagger/index.html to be registered, got 404")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppWithoutAutoMigrateOverride 验证 WithoutAutoMigrate 能覆盖
|
||||
// 之前通过 WithMigrator 隐式开启的迁移,不再要求 MySQL 已启用。
|
||||
func TestAppWithoutAutoMigrateOverride(t *testing.T) {
|
||||
migratorCalled := false
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18089)),
|
||||
xlgo.WithMigrator(func(_ *gorm.DB) error {
|
||||
migratorCalled = true
|
||||
return nil
|
||||
}),
|
||||
xlgo.WithoutAutoMigrate(),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init should succeed when AutoMigrate is disabled, got %v", err)
|
||||
}
|
||||
if migratorCalled {
|
||||
t.Fatal("migrator must not be called when WithoutAutoMigrate is applied")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppDefaultsLightweight 验证 v1.0.2 起 xlgo.New 默认是轻量应用:
|
||||
// 不启 MySQL/Redis/Storage、不注册 health/swagger 路由。
|
||||
func TestAppDefaultsLightweight(t *testing.T) {
|
||||
app := xlgo.New(xlgo.WithConfig(testConfig(18090)))
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
|
||||
r := app.GetRouter()
|
||||
|
||||
for _, path := range []string{"/health", "/swagger/index.html"} {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected %s 404 by default, got %d", path, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 默认未注册 MySQL/Redis 健康检查项,故 healthChecks 应为空
|
||||
// 这里通过启用 health 路由后只返回 status:ok 来间接验证
|
||||
app2 := xlgo.New(xlgo.WithConfig(testConfig(18091)), xlgo.WithHealthRoutes())
|
||||
if err := app2.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
app2.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected /health 200 with no checks, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppWithConfigPathDrivesManager 验证 WithConfigPath 真正驱动 App 自己的
|
||||
// config.Manager,并把它推到全局 default,使 config.Get 能取到加载后的配置。
|
||||
func TestAppWithConfigPathDrivesManager(t *testing.T) {
|
||||
path := writeConfig(t, "config_drive.yaml", 18092)
|
||||
app := xlgo.New(xlgo.WithConfigPath(path))
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
t.Fatal("config.Get returned nil after WithConfigPath; manager not promoted to default")
|
||||
}
|
||||
if cfg.Server.Port != 18092 {
|
||||
t.Fatalf("expected port 18092 from loaded config, got %d", cfg.Server.Port)
|
||||
}
|
||||
if config.GetInt("server.port") != 18092 {
|
||||
t.Fatalf("expected GetInt(server.port)=18092, got %d", config.GetInt("server.port"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppMetricsInstrumentsRegistryRoutes_H8c 端到端验证 H8c:经注册中心注册的业务路由
|
||||
// 被指标中间件采集,不依赖 RegisterMetricsRoute 的调用顺序。修复前 RegisterMetricsRoute
|
||||
// 用 r.Use 仅采集其后注册的路由;修复后采集中间件在 Apply 内作首个全局中间件装入。
|
||||
func TestAppMetricsInstrumentsRegistryRoutes_H8c(t *testing.T) {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18093)),
|
||||
xlgo.WithMetricsRoute(),
|
||||
xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) {
|
||||
r.GET("/h8c-biz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
|
||||
})),
|
||||
)
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init error: %v", err)
|
||||
}
|
||||
|
||||
r := app.GetRouter()
|
||||
for i := 0; i < 2; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8c-biz", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("/h8c-biz status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取 /metrics,断言 /h8c-biz 路由被采集(route 标签存在)。
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("/metrics status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `route="/h8c-biz"`) {
|
||||
t.Fatalf("metrics output should contain route=\"/h8c-biz\" series, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// freePort 返回一个当前空闲的 TCP 端口。
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("freePort: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// TestAppOnReadyFailureReleasesPort H-1 回归:OnReady 失败时 Run 应返回错误,
|
||||
// 且 HTTP 端口被释放(监听 goroutine 不泄漏)。
|
||||
// 修复前 OnReady 失败直接 return,监听 goroutine 仍阻塞在 ListenAndServe,
|
||||
// 端口不释放、goroutine 泄漏。
|
||||
func TestAppOnReadyFailureReleasesPort(t *testing.T) {
|
||||
port := freePort(t)
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(port)),
|
||||
xlgo.WithHook(xlgo.Hook{
|
||||
Name: "fail-on-ready",
|
||||
OnReady: func(*xlgo.App) error { return errors.New("boom") },
|
||||
}),
|
||||
)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- app.Run() }()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("expected OnReady failure error, got %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Run did not return after OnReady failure (server goroutine leaked?)")
|
||||
}
|
||||
|
||||
// 端口应已释放:能重新 bind。容忍 TIME_WAIT 短暂残留。
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
|
||||
if err == nil {
|
||||
l.Close()
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("port %d not released 3s after Run returned: %v (server goroutine leaked)", port, err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppInitFailureCancelsGoGoroutine H-2 回归:App.Go 启动的 goroutine 在 Init 失败后
|
||||
// 应因 rootCtx cancel 而退出,不泄漏。
|
||||
// 修复前 Init 失败不 cancel rootCtx,goroutine 永久阻塞在 ctx.Done()。
|
||||
func TestAppInitFailureCancelsGoGoroutine(t *testing.T) {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfig(testConfig(18087)),
|
||||
// WithMigrator 无 WithMySQL → Init 确定性失败("未启用 MySQL")
|
||||
xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }),
|
||||
)
|
||||
|
||||
ctxDone := make(chan struct{})
|
||||
app.Go(func(ctx context.Context) {
|
||||
<-ctx.Done()
|
||||
close(ctxDone)
|
||||
})
|
||||
|
||||
if err := app.Init(); err == nil {
|
||||
t.Fatal("expected Init error")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctxDone:
|
||||
// 好:goroutine 在 Init 失败后退出
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("App.Go goroutine did not exit after Init failure (rootCtx not cancelled, leaked)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/trace"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestWithTraceInitErrorPropagated 固化 Phase 1 契约:WithTrace 启用后 doInit 必须调
|
||||
// trace.Init 并把错误向上传播。用非法 ExporterType 触发 trace.Init 失败(config.Validate
|
||||
// 只校验 SampleRatio,故 bogus exporter 能过 Validate、在 trace.Init 失败,证明是 App 主动调 Init)。
|
||||
func TestWithTraceInitErrorPropagated(t *testing.T) {
|
||||
cfg := testConfig(18090)
|
||||
cfg.Trace.Enabled = true
|
||||
cfg.Trace.ExporterType = "bogus-exporter"
|
||||
|
||||
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
|
||||
err := app.Init()
|
||||
if err == nil {
|
||||
_ = app.Shutdown()
|
||||
t.Fatal("expected Init to fail with bogus trace exporter, got nil")
|
||||
}
|
||||
// "初始化 trace 失败" 包裹 trace.Init 的 "不支持的导出器类型"
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "trace") && !strings.Contains(msg, "导出器") {
|
||||
t.Fatalf("expected trace init error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithTraceNoopMiddlewareDoesNotBreakRequest 固化:WithTrace + Enabled=false(默认)
|
||||
// 时 trace.Init 安装 Noop tracer,trace.Middleware 装入链后不应破坏正常请求。
|
||||
func TestWithTraceNoopMiddlewareDoesNotBreakRequest(t *testing.T) {
|
||||
cfg := testConfig(18091)
|
||||
// Trace.Enabled 保持默认 false -> noop
|
||||
|
||||
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
defer app.Shutdown()
|
||||
|
||||
// Init 后(middleware 链已装入)注册路由,确保该路由经过 trace 中间件。
|
||||
app.GetRouter().GET("/ping", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
app.GetRouter().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("trace noop middleware broke request: got status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithTraceShutdownReleasesExporterGoroutine 固化 Phase 1 生命周期闭环:
|
||||
// WithTrace + Enabled=true + stdout exporter,Init 后 BatchSpanProcessor 后台 goroutine
|
||||
// 在运行;Shutdown 必须调 trace.Close 让其退出,否则 goroutine 泄漏(H-14 修了 Close 幂等,
|
||||
// 但 App 此前从未调 Close -- 本测试断言 App.Shutdown 确实调了)。
|
||||
//
|
||||
// 不发任何请求 -> stdout exporter 不输出 -> 无测试输出污染;仅观测 goroutine 计数。
|
||||
func TestWithTraceShutdownReleasesExporterGoroutine(t *testing.T) {
|
||||
cfg := testConfig(18092)
|
||||
cfg.Trace.Enabled = true
|
||||
cfg.Trace.ExporterType = "stdout"
|
||||
|
||||
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
|
||||
|
||||
baseline := runtime.NumGoroutine()
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
afterInit := runtime.NumGoroutine()
|
||||
if afterInit <= baseline {
|
||||
t.Logf("note: afterInit=%d baseline=%d (batch goroutine delta not detected; test may be noisy)", afterInit, baseline)
|
||||
}
|
||||
|
||||
if err := app.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
|
||||
// trace.Close 的 provider.Shutdown 异步停止 batch goroutine,轮询等待回归 baseline。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runtime.NumGoroutine() <= baseline {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if after := runtime.NumGoroutine(); after > baseline {
|
||||
t.Errorf("trace.Close 未释放 exporter goroutine(App.Shutdown 疑未调 trace.Close): baseline=%d after=%d", baseline, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithTraceEnabledStdoutInitOK 固化:WithTrace + Enabled=true + stdout 是合法组合,
|
||||
// trace.Init 成功,App.Init 成功;二次 trace.Close 幂等(H-14)。
|
||||
func TestWithTraceEnabledStdoutInitOK(t *testing.T) {
|
||||
cfg := testConfig(18093)
|
||||
cfg.Trace.Enabled = true
|
||||
cfg.Trace.ExporterType = "stdout"
|
||||
|
||||
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithTrace())
|
||||
if err := app.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
if err := app.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
// Shutdown 已调 trace.Close;再调一次必须幂等不报错(H-14 契约)
|
||||
if err := trace.Close(context.Background()); err != nil {
|
||||
t.Fatalf("trace.Close after Shutdown should be idempotent: %v", err)
|
||||
}
|
||||
}
|
||||
Vendored
+162
-46
@@ -3,68 +3,90 @@ package cache
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CacheService 缓存服务接口
|
||||
type CacheService interface {
|
||||
// Get 获取缓存值,如果存在则反序列化到 dest 并返回 true
|
||||
Get(ctx context.Context, key string, dest any) bool
|
||||
// Get 获取缓存值,命中则反序列化到 dest 并返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未就绪、命令错误或反序列化失败返回 (false, err)。调用方须显式处理错误。
|
||||
Get(ctx context.Context, key string, dest any) (bool, error)
|
||||
// Set 设置缓存值
|
||||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||||
// Delete 删除缓存
|
||||
Delete(ctx context.Context, key string) error
|
||||
// DeleteByPattern 按模式删除缓存
|
||||
DeleteByPattern(ctx context.Context, pattern string) error
|
||||
// Exists 检查缓存是否存在
|
||||
Exists(ctx context.Context, key string) bool
|
||||
// Exists 检查缓存是否存在;未命中返回 (false, nil),后端错误返回 (false, err)。
|
||||
Exists(ctx context.Context, key string) (bool, error)
|
||||
}
|
||||
|
||||
// redisCache Redis 缓存实现
|
||||
// redisCache Redis 缓存实现。
|
||||
//
|
||||
// 不在构造时快照 redis.Client(M12 修复:原 NewRedisCache 构造时取 database.GetRedis(),
|
||||
// 若在 database.InitRedis 之前构造则永久 nil、即使后续 Redis 就绪也是 no-op)。
|
||||
// 改为每次操作实时取 redis 客户端,使"先构造后 Init Redis"的顺序也能正确工作。
|
||||
//
|
||||
// Phase 4:持有可选的注入 client(NewRedisCacheWithRedis),注入优先、否则回退全局
|
||||
// database.GetRedis()(照 jwt.TokenBlacklist.redisClient 模型)。App 经 InitWithRedis
|
||||
// 注入 per-App redisManager.Client(),使缓存走 App 自己的 Redis 而非全局(修复跨 App 串越)。
|
||||
type redisCache struct {
|
||||
client *redis.Client
|
||||
injectedClient *redis.Client
|
||||
}
|
||||
|
||||
// NewRedisCache 创建 Redis 缓存实例
|
||||
// client 返回缓存使用的 Redis 客户端:注入优先,否则回退全局 database.GetRedis()。
|
||||
func (c *redisCache) client() *redis.Client {
|
||||
if c != nil && c.injectedClient != nil {
|
||||
return c.injectedClient
|
||||
}
|
||||
return database.GetRedis()
|
||||
}
|
||||
|
||||
// NewRedisCache 创建 Redis 缓存实例(懒取全局 Redis,兼容 standalone 用法)。
|
||||
func NewRedisCache() CacheService {
|
||||
return &redisCache{
|
||||
client: database.RedisClient,
|
||||
}
|
||||
return &redisCache{}
|
||||
}
|
||||
|
||||
// Get 获取缓存值
|
||||
func (c *redisCache) Get(ctx context.Context, key string, dest any) bool {
|
||||
if c.client == nil {
|
||||
return false
|
||||
// NewRedisCacheWithRedis 创建使用指定 Redis 客户端的缓存实例(多 Redis/测试隔离)。
|
||||
func NewRedisCacheWithRedis(client *redis.Client) CacheService {
|
||||
return &redisCache{injectedClient: client}
|
||||
}
|
||||
|
||||
// Get 获取缓存值。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未就绪返回 (false, ErrRedisNotReady);Redis 命令错误或反序列化失败返回 (false, err)。
|
||||
func (c *redisCache) Get(ctx context.Context, key string, dest any) (bool, error) {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
|
||||
val, err := c.client.Get(ctx, key).Result()
|
||||
val, err := cli.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
if err != redis.Nil {
|
||||
logger.Warn("缓存获取失败", zap.String("key", key), zap.Error(err))
|
||||
if err == redis.Nil {
|
||||
return false, nil
|
||||
}
|
||||
return false
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(val), dest); err != nil {
|
||||
logger.Warn("缓存反序列化失败", zap.String("key", key), zap.Error(err))
|
||||
return false
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Set 设置缓存值
|
||||
func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
|
||||
if c.client == nil {
|
||||
return nil // Redis 未启用,跳过缓存
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
data, err := json.Marshal(value)
|
||||
@@ -73,7 +95,7 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.client.Set(ctx, key, data, ttl).Err(); err != nil {
|
||||
if err := cli.Set(ctx, key, data, ttl).Err(); err != nil {
|
||||
logger.Warn("缓存设置失败", zap.String("key", key), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
@@ -83,11 +105,12 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du
|
||||
|
||||
// Delete 删除缓存
|
||||
func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
if c.client == nil {
|
||||
return nil
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
if err := c.client.Del(ctx, key).Err(); err != nil {
|
||||
if err := cli.Del(ctx, key).Err(); err != nil {
|
||||
logger.Warn("缓存删除失败", zap.String("key", key), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
@@ -97,8 +120,9 @@ func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
|
||||
// DeleteByPattern 按模式删除缓存(使用 SCAN 避免阻塞 Redis)
|
||||
func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error {
|
||||
if c.client == nil {
|
||||
return nil
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
var cursor uint64
|
||||
@@ -106,7 +130,7 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
|
||||
|
||||
for {
|
||||
// 使用 SCAN 命令迭代查找匹配的键
|
||||
keys, nextCursor, err := c.client.Scan(ctx, cursor, pattern, 100).Result()
|
||||
keys, nextCursor, err := cli.Scan(ctx, cursor, pattern, 100).Result()
|
||||
if err != nil {
|
||||
logger.Warn("缓存键扫描失败", zap.String("pattern", pattern), zap.Error(err))
|
||||
return err
|
||||
@@ -114,7 +138,7 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
|
||||
|
||||
// 删除找到的键
|
||||
if len(keys) > 0 {
|
||||
if err := c.client.Del(ctx, keys...).Err(); err != nil {
|
||||
if err := cli.Del(ctx, keys...).Err(); err != nil {
|
||||
logger.Warn("缓存批量删除失败", zap.Strings("keys", keys), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
@@ -137,27 +161,119 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists 检查缓存是否存在
|
||||
func (c *redisCache) Exists(ctx context.Context, key string) bool {
|
||||
if c.client == nil {
|
||||
return false
|
||||
// Exists 检查缓存是否存在。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未就绪返回 (false, ErrRedisNotReady);命令错误返回 (false, err)。
|
||||
func (c *redisCache) Exists(ctx context.Context, key string) (bool, error) {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
|
||||
return c.client.Exists(ctx, key).Val() > 0
|
||||
n, err := cli.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// 全局缓存实例
|
||||
var globalCache CacheService
|
||||
// CacheManager 缓存管理器(#10)。照 database.Manager 模式:
|
||||
// 实例化 + DefaultCache 全局默认 + 包级 facade 代理,支持测试注入 mock 实现。
|
||||
type CacheManager struct {
|
||||
mu sync.Mutex
|
||||
svc CacheService
|
||||
}
|
||||
|
||||
// defaultCachePtr 全局默认缓存管理器(CK3 修复:atomic.Pointer 替代裸指针)。
|
||||
var defaultCachePtr atomic.Pointer[CacheManager]
|
||||
|
||||
func init() {
|
||||
defaultCachePtr.Store(NewCacheManager())
|
||||
}
|
||||
|
||||
// NewCacheManager 创建缓存管理器实例。
|
||||
func NewCacheManager() *CacheManager { return &CacheManager{} }
|
||||
|
||||
// GetDefaultCache 返回全局默认缓存管理器(并发安全,CK3 修复)。
|
||||
func GetDefaultCache() *CacheManager {
|
||||
return defaultCachePtr.Load()
|
||||
}
|
||||
|
||||
// SetDefaultCacheManager 提升指定 CacheManager 为全局默认(atomic 置换,并发安全)。
|
||||
func SetDefaultCacheManager(m *CacheManager) {
|
||||
if m != nil {
|
||||
defaultCachePtr.Store(m)
|
||||
}
|
||||
}
|
||||
|
||||
// SwapDefaultCacheManager 将指定 CacheManager 置为全局默认,返回被替换的旧 Manager。
|
||||
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存(照
|
||||
// SwapDefaultRedisManager / SwapDefaultStorageManager 模式)。nil 被忽略,返回当前默认。
|
||||
func SwapDefaultCacheManager(m *CacheManager) *CacheManager {
|
||||
if m == nil {
|
||||
return defaultCachePtr.Load()
|
||||
}
|
||||
return defaultCachePtr.Swap(m)
|
||||
}
|
||||
|
||||
// Init 初始化缓存服务(基于 DefaultRedis 的客户端)。
|
||||
func (m *CacheManager) Init() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.svc = NewRedisCache()
|
||||
}
|
||||
|
||||
// InitWithRedis 用指定 Redis 客户端初始化缓存服务(多 Redis/测试隔离)。
|
||||
func (m *CacheManager) InitWithRedis(client *redis.Client) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.svc = NewRedisCacheWithRedis(client)
|
||||
}
|
||||
|
||||
// Set 设置缓存服务实现(用于注入 mock 或自定义实现)。
|
||||
func (m *CacheManager) Set(svc CacheService) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.svc = svc
|
||||
}
|
||||
|
||||
// Get 返回缓存服务(未初始化时延迟初始化)。
|
||||
func (m *CacheManager) Get() CacheService {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.svc == nil {
|
||||
m.svc = NewRedisCache()
|
||||
}
|
||||
return m.svc
|
||||
}
|
||||
|
||||
// --- 包级 facade(代理到 GetDefaultCache(),CK3 修复) ---
|
||||
|
||||
// Init 初始化全局缓存实例
|
||||
func Init() {
|
||||
globalCache = NewRedisCache()
|
||||
GetDefaultCache().Init()
|
||||
}
|
||||
|
||||
// GetCache 获取全局缓存实例
|
||||
func GetCache() CacheService {
|
||||
if globalCache == nil {
|
||||
Init()
|
||||
}
|
||||
return globalCache
|
||||
return GetDefaultCache().Get()
|
||||
}
|
||||
|
||||
// Get 获取全局缓存值。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// 缓存未初始化或 Redis 错误返回 (false, err)。
|
||||
func Get(ctx context.Context, key string, dest any) (bool, error) {
|
||||
svc := GetCache()
|
||||
if svc == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
return svc.Get(ctx, key, dest)
|
||||
}
|
||||
|
||||
// Exists 检查全局缓存是否存在。未命中返回 (false, nil);缓存未初始化或
|
||||
// Redis 错误返回 (false, err)。
|
||||
func Exists(ctx context.Context, key string) (bool, error) {
|
||||
svc := GetCache()
|
||||
if svc == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
return svc.Exists(ctx, key)
|
||||
}
|
||||
|
||||
Vendored
+199
@@ -0,0 +1,199 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func setupM10MiniRedis(t *testing.T) *miniredis.Miniredis {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
database.SetTestRedisClient(client)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
return mr
|
||||
}
|
||||
|
||||
func TestM10CacheWritesReturnRedisNotReady(t *testing.T) {
|
||||
database.SetTestRedisClient(nil)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
|
||||
c := &redisCache{}
|
||||
ctx := context.Background()
|
||||
|
||||
if err := c.Set(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Set without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if err := c.Delete(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Delete without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if err := c.DeleteByPattern(ctx, "k:*"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("DeleteByPattern without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10RawHelpersReturnRedisNotReady(t *testing.T) {
|
||||
database.SetTestRedisClient(nil)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := Incr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Incr without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := IncrBy(ctx, "k", 2); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("IncrBy without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := Decr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Decr without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := GetTTL(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("GetTTL without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := SetExpire(ctx, "k", time.Minute); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("SetExpire without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := GetRaw(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("GetRaw without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if err := SetRaw(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("SetRaw without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10ExistsReturnsBackendErrors(t *testing.T) {
|
||||
setupM10MiniRedis(t)
|
||||
c := &redisCache{}
|
||||
ctx := context.Background()
|
||||
|
||||
exists, err := c.Exists(ctx, "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("Exists missing err = %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatal("Exists missing = true, want false")
|
||||
}
|
||||
|
||||
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
|
||||
t.Fatalf("Set present: %v", err)
|
||||
}
|
||||
exists, err = c.Exists(ctx, "present")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("Exists present = %v, err=%v; want true,nil", exists, err)
|
||||
}
|
||||
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
if exists, err = c.Exists(canceled, "present"); err == nil || exists {
|
||||
t.Fatalf("Exists canceled = %v, err=%v; want false,error", exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10GetReturnsBackendAndDecodeErrors(t *testing.T) {
|
||||
setupM10MiniRedis(t)
|
||||
c := &redisCache{}
|
||||
ctx := context.Background()
|
||||
|
||||
var got string
|
||||
ok, err := c.Get(ctx, "missing", &got)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("Get missing = %v, err=%v; want false,nil", ok, err)
|
||||
}
|
||||
|
||||
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
|
||||
t.Fatalf("Set present: %v", err)
|
||||
}
|
||||
ok, err = c.Get(ctx, "present", &got)
|
||||
if err != nil || !ok || got != "value" {
|
||||
t.Fatalf("Get present = %v, %q, err=%v; want true,value,nil", ok, got, err)
|
||||
}
|
||||
|
||||
if err := c.client().Set(ctx, "bad-json", "{", time.Minute).Err(); err != nil {
|
||||
t.Fatalf("Set bad-json: %v", err)
|
||||
}
|
||||
ok, err = c.Get(ctx, "bad-json", &got)
|
||||
if err == nil || ok {
|
||||
t.Fatalf("Get bad-json = %v, err=%v; want false,error", ok, err)
|
||||
}
|
||||
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
ok, err = c.Get(canceled, "present", &got)
|
||||
if err == nil || ok {
|
||||
t.Fatalf("Get canceled = %v, err=%v; want false,error", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10PackageExistsReturnsBackendErrors(t *testing.T) {
|
||||
setupM10MiniRedis(t)
|
||||
orig := GetDefaultCache()
|
||||
t.Cleanup(func() { SetDefaultCacheManager(orig) })
|
||||
SetDefaultCacheManager(NewCacheManager())
|
||||
Init()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
|
||||
t.Fatalf("Set through facade: %v", err)
|
||||
}
|
||||
exists, err := Exists(ctx, "present")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("facade Exists = %v, err=%v; want true,nil", exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10PackageGetReturnsBackendErrors(t *testing.T) {
|
||||
setupM10MiniRedis(t)
|
||||
orig := GetDefaultCache()
|
||||
t.Cleanup(func() { SetDefaultCacheManager(orig) })
|
||||
SetDefaultCacheManager(NewCacheManager())
|
||||
Init()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
|
||||
t.Fatalf("Set through facade: %v", err)
|
||||
}
|
||||
var got string
|
||||
ok, err := Get(ctx, "present", &got)
|
||||
if err != nil || !ok || got != "value" {
|
||||
t.Fatalf("facade Get = %v, %q, err=%v; want true,value,nil", ok, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestM10CustomCacheServiceCompile asserts a user-provided CacheService
|
||||
// implementation compiles against the (bool, error) contract. This is a
|
||||
// compile-time guard: if the interface changes again, this stops building.
|
||||
type customCacheSvc struct{}
|
||||
|
||||
func (customCacheSvc) Get(ctx context.Context, key string, dest any) (bool, error) { return false, nil }
|
||||
func (customCacheSvc) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (customCacheSvc) Delete(ctx context.Context, key string) error { return nil }
|
||||
func (customCacheSvc) DeleteByPattern(ctx context.Context, p string) error { return nil }
|
||||
func (customCacheSvc) Exists(ctx context.Context, key string) (bool, error) { return false, nil }
|
||||
|
||||
func TestM10CustomCacheServiceCompiles(t *testing.T) {
|
||||
var _ CacheService = customCacheSvc{}
|
||||
}
|
||||
|
||||
// TestM10RedisNotReadyOnGetExists asserts the Redis-not-ready error is
|
||||
// surfaced (not swallowed) when no backend is configured.
|
||||
func TestM10RedisNotReadyOnGetExists(t *testing.T) {
|
||||
database.SetTestRedisClient(nil)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
c := &redisCache{}
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := c.Get(ctx, "k", new(string)); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Get without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := c.Exists(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Exists without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
}
|
||||
Vendored
+69
@@ -0,0 +1,69 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// TestRedisCacheInjectedClientPriority 固化 Phase 4 核心契约(jwt 模型):
|
||||
// redisCache.client() 注入优先、全局兜底。注入的 client 即使与 database.GetRedis()
|
||||
// 不同也必须用注入的--这是多 App 缓存隔离的基础。
|
||||
func TestRedisCacheInjectedClientPriority(t *testing.T) {
|
||||
// 全局 redis = clientG
|
||||
mrG := miniredis.RunT(t)
|
||||
clientG := redis.NewClient(&redis.Options{Addr: mrG.Addr()})
|
||||
t.Cleanup(func() { _ = clientG.Close() })
|
||||
origGlobal := database.SetTestRedisClient(clientG)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(origGlobal) })
|
||||
|
||||
// 注入 clientA(与全局 clientG 不同实例)
|
||||
mrA := miniredis.RunT(t)
|
||||
clientA := redis.NewClient(&redis.Options{Addr: mrA.Addr()})
|
||||
t.Cleanup(func() { _ = clientA.Close() })
|
||||
|
||||
// 注入优先
|
||||
injected := &redisCache{injectedClient: clientA}
|
||||
if got := injected.client(); got != clientA {
|
||||
t.Fatalf("injected redisCache.client() = %v, want clientA (注入优先)", got)
|
||||
}
|
||||
|
||||
// 未注入 -> 回退全局 clientG
|
||||
fallback := &redisCache{}
|
||||
if got := fallback.client(); got != clientG {
|
||||
t.Fatalf("fallback redisCache.client() = %v, want clientG (全局兜底)", got)
|
||||
}
|
||||
|
||||
// NewRedisCacheWithRedis 构造的实例持注入 client
|
||||
svc := NewRedisCacheWithRedis(clientA)
|
||||
rc, ok := svc.(*redisCache)
|
||||
if !ok {
|
||||
t.Fatalf("NewRedisCacheWithRedis returned %T, want *redisCache", svc)
|
||||
}
|
||||
if rc.client() != clientA {
|
||||
t.Fatalf("NewRedisCacheWithRedis client = %v, want clientA", rc.client())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapDefaultCacheManagerReturnsOld 固化 Phase 4:Swap 返回被替换的旧 Manager 且不关闭。
|
||||
func TestSwapDefaultCacheManagerReturnsOld(t *testing.T) {
|
||||
orig := defaultCachePtr.Load()
|
||||
defer SwapDefaultCacheManager(orig)
|
||||
|
||||
first := NewCacheManager()
|
||||
if prev := SwapDefaultCacheManager(first); prev != orig {
|
||||
t.Fatalf("Swap returned %v, want orig", prev)
|
||||
}
|
||||
second := NewCacheManager()
|
||||
if returned := SwapDefaultCacheManager(second); returned != first {
|
||||
t.Fatalf("Swap returned %v, want first", returned)
|
||||
}
|
||||
if defaultCachePtr.Load() != second {
|
||||
t.Fatal("Swap did not install second as default")
|
||||
}
|
||||
if got := SwapDefaultCacheManager(nil); got != second {
|
||||
t.Fatalf("Swap(nil) = %v, want second (current default)", got)
|
||||
}
|
||||
}
|
||||
Vendored
+68
-58
@@ -3,16 +3,16 @@ package cache
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// KeyBuilder 缓存键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 多项目共用 Redis 时,通过前缀避免 key 冲突
|
||||
// KeyBuilder 缓存键名构建器(CK2 修复:mu 保护并发访问)。
|
||||
// 使用场景: 多个小项目共用一台 Redis 服务器,每个项目设置不同前缀
|
||||
type KeyBuilder struct {
|
||||
mu sync.Mutex
|
||||
prefix string // 项目/站点前缀,如 "site_a"
|
||||
_separator string // 分隔符,默认 ":"
|
||||
_cacheType string // 缓存类型标识,如 "cache"
|
||||
@@ -22,38 +22,39 @@ type KeyBuilder struct {
|
||||
type KeyBuilderOption func(*KeyBuilder)
|
||||
|
||||
// WithPrefix 设置前缀(项目/站点别名)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 核心配置,用于区分不同项目
|
||||
// 示例: WithPrefix("site_a") -> 所有 key 自动添加 "site_a" 前缀
|
||||
func WithPrefix(prefix string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
if kb == nil {
|
||||
return
|
||||
}
|
||||
kb.prefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
// WithSeparator 设置分隔符
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 可自定义分隔符,适应不同命名风格
|
||||
// 示例: WithSeparator(":") -> "site_a:user:1"
|
||||
func WithSeparator(separator string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
if kb == nil {
|
||||
return
|
||||
}
|
||||
kb._separator = separator
|
||||
}
|
||||
}
|
||||
|
||||
// WithCacheType 设置缓存类型标识
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 区分缓存用途,便于管理
|
||||
// 示例: WithCacheType("session") -> "session_site_a_user:1"
|
||||
// 示例: WithCacheType("session") -> "session:site_a:user:1"
|
||||
func WithCacheType(cacheType string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
if kb == nil {
|
||||
return
|
||||
}
|
||||
kb._cacheType = cacheType
|
||||
}
|
||||
}
|
||||
|
||||
// NewKeyBuilder 创建键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 链式配置,灵活易用
|
||||
func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
|
||||
kb := &KeyBuilder{
|
||||
prefix: "",
|
||||
@@ -61,17 +62,20 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
|
||||
_cacheType: "cache",
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt == nil {
|
||||
continue
|
||||
}
|
||||
opt(kb)
|
||||
}
|
||||
return kb
|
||||
}
|
||||
|
||||
// Build 构建完整键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动拼接前缀,避免手动拼接错误
|
||||
// Build 构建完整键名(CK2 修复:mu 读保护)。
|
||||
// 格式: {cacheType}{separator}{prefix}{separator}{key}
|
||||
// 示例: kb.Build("user:1") -> "cache_site_a_user:1"
|
||||
// 示例: kb.Build("user:1") -> "cache:site_a:user:1"
|
||||
func (kb *KeyBuilder) Build(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{}
|
||||
if kb._cacheType != "" {
|
||||
parts = append(parts, kb._cacheType)
|
||||
@@ -83,11 +87,11 @@ func (kb *KeyBuilder) Build(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildTemp 构建临时缓存键名(带过期时间)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 区分临时和永久缓存,便于批量管理
|
||||
// BuildTemp 构建临时缓存键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "temp{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildTemp(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"temp"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -96,11 +100,11 @@ func (kb *KeyBuilder) BuildTemp(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildPerm 构建永久缓存键名(不带过期时间)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 永久存储的数据使用不同标识
|
||||
// BuildPerm 构建永久缓存键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "perm{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildPerm(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"perm"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -109,11 +113,11 @@ func (kb *KeyBuilder) BuildPerm(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildLock 构建分布式锁键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 锁使用独立命名空间,避免与业务缓存冲突
|
||||
// BuildLock 构建分布式锁键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "lock{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildLock(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"lock"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -122,11 +126,11 @@ func (kb *KeyBuilder) BuildLock(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildCounter 构建计数器键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 计数器使用独立命名空间
|
||||
// BuildCounter 构建计数器键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "counter{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildCounter(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"counter"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -135,11 +139,11 @@ func (kb *KeyBuilder) BuildCounter(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildSession 构建会话键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 会话缓存统一管理
|
||||
// BuildSession 构建会话键名(CK2 修复:mu 读保护)。
|
||||
// 格式: "session{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildSession(key string) string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
parts := []string{"session"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
@@ -148,34 +152,37 @@ func (kb *KeyBuilder) BuildSession(key string) string {
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildPattern 构建匹配模式(用于 SCAN/Keys)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 批量查询/删除时使用
|
||||
// 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*"
|
||||
// BuildPattern 构建匹配模式(用于 SCAN/Keys)(CK2 修复:mu 读保护)。
|
||||
// 示例: kb.BuildPattern("user:*") -> "cache:site_a:user:*"
|
||||
func (kb *KeyBuilder) BuildPattern(pattern string) string {
|
||||
return kb.Build(pattern)
|
||||
}
|
||||
|
||||
// GetPrefix 获取当前前缀
|
||||
// GetPrefix 获取当前前缀(CK2 修复:mu 读保护)。
|
||||
func (kb *KeyBuilder) GetPrefix() string {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
return kb.prefix
|
||||
}
|
||||
|
||||
// SetPrefix 动态设置前缀
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 运行时可切换前缀,适用于多租户场景
|
||||
// SetPrefix 动态设置前缀(CK2 修复:mu 写保护,并发安全)。
|
||||
func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder {
|
||||
kb.mu.Lock()
|
||||
defer kb.mu.Unlock()
|
||||
kb.prefix = prefix
|
||||
return kb
|
||||
}
|
||||
|
||||
// ===== 全局键名构建器 =====
|
||||
|
||||
var globalKeyBuilder *KeyBuilder
|
||||
// globalKeyBuilder 全局构建器,受 globalKBMu 保护;globalKBOnce 保证自动初始化只执行一次(M13)。
|
||||
var (
|
||||
globalKeyBuilder *KeyBuilder
|
||||
globalKBMu sync.RWMutex
|
||||
globalKBOnce sync.Once
|
||||
)
|
||||
|
||||
// InitKeyBuilder 初始化全局键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 应用启动时配置,全局统一命名
|
||||
// 参数: prefix 站点别名,如果为空则自动从配置读取
|
||||
// 示例:
|
||||
//
|
||||
@@ -192,12 +199,13 @@ func InitKeyBuilder(prefix string, opts ...KeyBuilderOption) {
|
||||
}
|
||||
|
||||
opts = append([]KeyBuilderOption{WithPrefix(prefix)}, opts...)
|
||||
globalKeyBuilder = NewKeyBuilder(opts...)
|
||||
kb := NewKeyBuilder(opts...)
|
||||
globalKBMu.Lock()
|
||||
globalKeyBuilder = kb
|
||||
globalKBMu.Unlock()
|
||||
}
|
||||
|
||||
// AutoInitKeyBuilder 自动从配置初始化键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 无需手动传入参数,完全依赖配置文件
|
||||
// 配置示例:
|
||||
//
|
||||
// app:
|
||||
@@ -207,20 +215,23 @@ func AutoInitKeyBuilder(opts ...KeyBuilderOption) {
|
||||
InitKeyBuilder("", opts...)
|
||||
}
|
||||
|
||||
// GetKeyBuilder 获取全局键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 如果未初始化,自动从配置创建
|
||||
// GetKeyBuilder 获取全局键名构建器,未初始化时用 sync.Once 自动初始化一次(M13)。
|
||||
func GetKeyBuilder() *KeyBuilder {
|
||||
if globalKeyBuilder == nil {
|
||||
// 自动从配置初始化
|
||||
AutoInitKeyBuilder()
|
||||
}
|
||||
globalKBOnce.Do(func() {
|
||||
// 仅在仍为 nil 时自动初始化(已由 InitKeyBuilder 设置则跳过)。
|
||||
globalKBMu.RLock()
|
||||
kb := globalKeyBuilder
|
||||
globalKBMu.RUnlock()
|
||||
if kb == nil {
|
||||
AutoInitKeyBuilder()
|
||||
}
|
||||
})
|
||||
globalKBMu.RLock()
|
||||
defer globalKBMu.RUnlock()
|
||||
return globalKeyBuilder
|
||||
}
|
||||
|
||||
// K 快捷构建键名(使用全局构建器)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 简短易用,减少代码量
|
||||
// 示例: cache.K("user:1") -> 自动添加前缀
|
||||
func K(key string) string {
|
||||
return GetKeyBuilder().Build(key)
|
||||
@@ -254,15 +265,14 @@ func KSession(key string) string {
|
||||
// ===== 带键名构建器的缓存操作 =====
|
||||
|
||||
// SetWithPrefix 带前缀的缓存设置
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动添加前缀,简化调用
|
||||
func SetWithPrefix(ctx context.Context, key string, value any, ttl time.Duration, prefix string) error {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return GetCache().Set(ctx, kb.Build(key), value, ttl)
|
||||
}
|
||||
|
||||
// GetWithPrefix 带前缀的缓存获取
|
||||
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) bool {
|
||||
// GetWithPrefix 带前缀的缓存获取。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 错误或反序列化失败返回 (false, err)。
|
||||
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) (bool, error) {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return GetCache().Get(ctx, kb.Build(key), dest)
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGlobalKeyBuilderConcurrentInitGet_M13:并发 InitKeyBuilder/GetKeyBuilder/K 不触发 data race
|
||||
// 且不 nil-panic(M13:sync.Once + RWMutex 保护全局构建器)。须配合 -race 运行。
|
||||
func TestGlobalKeyBuilderConcurrentInitGet_M13(t *testing.T) {
|
||||
// 预置一个已知前缀,避免依赖 config.Get。
|
||||
InitKeyBuilder("race_site")
|
||||
t.Cleanup(func() { globalKBMu.Lock(); globalKeyBuilder = nil; globalKBMu.Unlock() })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); InitKeyBuilder("race_site") }()
|
||||
go func() { defer wg.Done(); _ = GetKeyBuilder() }()
|
||||
go func() { defer wg.Done(); _ = K("user:1") }()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 最终 GetKeyBuilder 非 nil,K 返回带前缀键。
|
||||
if GetKeyBuilder() == nil {
|
||||
t.Fatal("GetKeyBuilder nil after concurrent init")
|
||||
}
|
||||
if got := K("user:1"); got == "user:1" {
|
||||
t.Errorf("K returned unprefixed key %q (prefix not applied)", got)
|
||||
}
|
||||
}
|
||||
Vendored
+12
-1
@@ -62,6 +62,17 @@ func TestKeyBuilderNoPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderNilOptionIgnored(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(nil, cache.WithPrefix("site"))
|
||||
if got := kb.Build("user:1"); got != "cache:site:user:1" {
|
||||
t.Fatalf("Build with nil option = %q, want cache:site:user:1", got)
|
||||
}
|
||||
|
||||
cache.WithPrefix("ignored")(nil)
|
||||
cache.WithSeparator("_")(nil)
|
||||
cache.WithCacheType("ignored")(nil)
|
||||
}
|
||||
|
||||
func TestKeyBuilderSetPrefix(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(cache.WithPrefix("site_a"))
|
||||
|
||||
@@ -211,4 +222,4 @@ func contains(s, sub string) bool {
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+200
-88
@@ -3,6 +3,7 @@ package cache
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
@@ -11,12 +12,47 @@ import (
|
||||
|
||||
// 分布式锁错误
|
||||
var (
|
||||
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
|
||||
ErrLockExpired = errors.New("锁已过期")
|
||||
ErrRedisNotReady = errors.New("Redis 未初始化")
|
||||
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
|
||||
ErrLockExpired = errors.New("锁已过期")
|
||||
ErrRedisNotReady = errors.New("Redis 未初始化")
|
||||
// ErrLockNotAcquired 表示锁被其它客户端持有,业务函数未执行。
|
||||
ErrLockNotAcquired = errors.New("未获取到锁")
|
||||
// ErrInvalidLockTTL 表示锁 TTL 小于 Redis PX 支持的 1ms 粒度或非正。
|
||||
ErrInvalidLockTTL = errors.New("锁 TTL 必须大于等于 1ms")
|
||||
// ErrInvalidLockRetryInterval 表示重试或续期间隔非法。
|
||||
ErrInvalidLockRetryInterval = errors.New("锁重试/续期间隔必须大于 0")
|
||||
// ErrLockUnexpectedResult Lua 脚本返回了非预期的结果类型(C1b:裸类型断言防护)。
|
||||
ErrLockUnexpectedResult = errors.New("锁脚本返回非预期结果")
|
||||
// ErrLockFuncNil 表示分布式锁业务函数为空。
|
||||
ErrLockFuncNil = errors.New("锁业务函数不能为空")
|
||||
)
|
||||
|
||||
// LockToken 锁令牌(用于安全释放锁)
|
||||
// toInt64 将 Lua 脚本返回值安全断言为 int64(C1b:禁止裸断言 panic)。
|
||||
// go-redis 对整数返回 int64,但 nil/错误响应下可能为其他类型。
|
||||
func toInt64(v any) (int64, error) {
|
||||
n, ok := v.(int64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("脚本返回类型 %T: %w", v, ErrLockUnexpectedResult)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func ttlMillis(ttl time.Duration) (int64, error) {
|
||||
if ttl < time.Millisecond {
|
||||
return 0, ErrInvalidLockTTL
|
||||
}
|
||||
return int64(ttl / time.Millisecond), nil
|
||||
}
|
||||
|
||||
// LockToken 锁令牌(用于安全释放锁)。
|
||||
//
|
||||
// 安全说明(C1d 设计局限):Token 是随机 UUID(非单调递增的 fencing token)。
|
||||
// 本实现基于 Redis SET PX + Lua CAS,保证"持有者才能解锁/续期",但**无法防 TTL 到期后的
|
||||
// 双 worker 并发**:若 worker A 因 GC/网络停滞超过 TTL,锁过期后 worker B 获得锁,
|
||||
// A 恢复后仍可能写过期数据。完整的 fencing token 防护需:① 用 Redis INCR 生成单调 token,
|
||||
// ② 下游存储层(DB/外部服务)记录已见最大 token 并拒绝旧 token 写入。
|
||||
// 框架无法单方面保证②,需下游配合,故本类型仅提供 UUID token。对 TTL 到期敏感的场景,
|
||||
// 请确保 ttl >> 业务最长执行时间,或下游实现 fencing token 校验。
|
||||
type LockToken struct {
|
||||
Key string // 锁的键名
|
||||
Token string // 锁的唯一标识(UUID)
|
||||
@@ -58,24 +94,30 @@ end
|
||||
`
|
||||
|
||||
// NewLock 创建分布式锁
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 UUID 作为 Token,保证锁的安全释放
|
||||
// 参数: key 锁名称,ttl 锁定时长
|
||||
// 返回: LockToken 用于后续解锁或续期
|
||||
func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, error) {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil, ErrRedisNotReady
|
||||
}
|
||||
|
||||
token := utils.UUID()
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
|
||||
ttlMs, err := ttlMillis(ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.(int64) == 1 {
|
||||
result, err := rdb.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n, err := toInt64(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 1 {
|
||||
return &LockToken{Key: key, Token: token}, nil
|
||||
}
|
||||
|
||||
@@ -93,10 +135,9 @@ func Lock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
}
|
||||
|
||||
// Unlock 安全释放锁
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 Lua 脚本保证只有锁的持有者才能释放
|
||||
func Unlock(ctx context.Context, token *LockToken) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
@@ -104,12 +145,16 @@ func Unlock(ctx context.Context, token *LockToken) error {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
|
||||
result, err := rdb.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.(int64) == 0 {
|
||||
n, err := toInt64(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
@@ -119,18 +164,18 @@ func Unlock(ctx context.Context, token *LockToken) error {
|
||||
// UnlockByKey 按键名释放锁(不安全,仅用于旧代码兼容)
|
||||
// 注意: 此函数不检查 Token,任何客户端都能释放锁
|
||||
func UnlockByKey(ctx context.Context, key string) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
return rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ExtendLock 续期锁
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 长任务执行时防止锁过期被其他客户端抢占
|
||||
// 参数: token 锁令牌,ttl 新的过期时间
|
||||
func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error {
|
||||
if database.RedisClient == nil {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
@@ -138,24 +183,32 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
|
||||
ttlMs, err := ttlMillis(ttl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.(int64) == 0 {
|
||||
result, err := rdb.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n, err := toInt64(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TryLock 尝试获取锁,失败时等待重试
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 高并发场景常用,避免立即失败
|
||||
// TryLock 尝试获取锁,失败时等待重试。重试等待响应 ctx 取消(C1c 修复)。
|
||||
func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval time.Duration, maxRetry int) (*LockToken, error) {
|
||||
if retryInterval <= 0 {
|
||||
return nil, ErrInvalidLockRetryInterval
|
||||
}
|
||||
for i := 0; i < maxRetry; i++ {
|
||||
token, err := NewLock(ctx, key, ttl)
|
||||
if err != nil {
|
||||
@@ -164,46 +217,71 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t
|
||||
if token != nil {
|
||||
return token, nil
|
||||
}
|
||||
time.Sleep(retryInterval)
|
||||
// 响应 ctx 取消,避免最长阻塞 maxRetry*retryInterval(C1c:禁止 time.Sleep 无视 ctx)。
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(retryInterval):
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
return nil, ErrLockNotAcquired
|
||||
}
|
||||
|
||||
// WithLock 使用分布式锁执行函数(自动管理锁)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动获取、续期、释放锁,避免忘记释放
|
||||
// WithLock 使用分布式锁执行函数(自动管理锁)。
|
||||
// 参数: key 锁名称,ttl 锁定时长,fn 业务函数
|
||||
// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend
|
||||
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() error) error {
|
||||
// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend。
|
||||
//
|
||||
// 解锁用独立 Background ctx(C1a 一致性修复):fn 返回或 panic 后,原 ctx 可能已被
|
||||
// 调用方取消,用其解锁会失败导致锁泄漏到 TTL。fn panic 时 defer 也保证解锁执行。
|
||||
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) error) error {
|
||||
if fn == nil {
|
||||
return ErrLockFuncNil
|
||||
}
|
||||
token, err := NewLock(ctx, key, ttl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil {
|
||||
return nil // 未获取到锁,跳过执行
|
||||
return ErrLockNotAcquired
|
||||
}
|
||||
defer Unlock(ctx, token)
|
||||
defer func() {
|
||||
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = Unlock(unlockCtx, token)
|
||||
}()
|
||||
|
||||
return fn()
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 长任务执行时自动续期,防止锁过期
|
||||
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)。
|
||||
// 参数: key 锁名称,initialTTL 初始锁定时长,extendInterval 续期间隔,fn 业务函数
|
||||
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func() error) error {
|
||||
//
|
||||
// 并发安全说明(C1a 修复):续期 goroutine 与父用"父关停 + 子 ack"双 channel 协调——
|
||||
// 父用 close(stop) 通知子退出(close 由唯一所有者执行,安全),子用 close(finished) ack。
|
||||
// 避免旧实现 done 无缓冲 + 子 defer close(done) + 父 done<-struct{}{} 的 send-on-closed panic
|
||||
// (ctx 取消或 ExtendLock 失败时 done 已 closed,父再 send 即 panic,Unlock 不执行、锁泄漏到 TTL)。
|
||||
// Unlock 用 context.Background() 派生超时,避免原 ctx 已取消致 Unlock 失败再泄漏。
|
||||
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func(context.Context) error) error {
|
||||
if fn == nil {
|
||||
return ErrLockFuncNil
|
||||
}
|
||||
if extendInterval <= 0 {
|
||||
return ErrInvalidLockRetryInterval
|
||||
}
|
||||
token, err := NewLock(ctx, key, initialTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil {
|
||||
return nil // 未获取到锁,跳过执行
|
||||
return ErrLockNotAcquired
|
||||
}
|
||||
|
||||
// 启动续期协程
|
||||
done := make(chan struct{})
|
||||
// 父关停信号(仅父 close)与子 ack 信号(仅子 close)。
|
||||
stop := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
defer close(finished) // 子退出时 ack,父等待 finished
|
||||
ticker := time.NewTicker(extendInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -211,106 +289,140 @@ func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duratio
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-done:
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 续期锁(每次续期为 initialTTL)
|
||||
// 续期锁(每次续期为 initialTTL)。续期失败则停止续期,fn 应尽快结束。
|
||||
if err := ExtendLock(ctx, token, initialTTL); err != nil {
|
||||
return // 续期失败,停止续期
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 执行业务函数
|
||||
err = fn()
|
||||
// defer 兜底:fn panic 时也要停止续期 goroutine 并释放锁(C1a panic 路径修复)。
|
||||
// 无 defer 时 fn panic 会导致 close(stop) 不执行 → 续期 goroutine 永久泄漏,且 Unlock 不执行 → 锁泄漏到 TTL。
|
||||
defer func() {
|
||||
close(stop)
|
||||
<-finished
|
||||
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = Unlock(unlockCtx, token)
|
||||
}()
|
||||
|
||||
// 停止续期并释放锁
|
||||
done <- struct{}{}
|
||||
Unlock(ctx, token)
|
||||
// 执行业务函数。fn 必须接收 ctx,避免请求取消后业务 loader/DB/HTTP 调用继续运行。
|
||||
err = fn(ctx)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// IsLocked 检查锁是否被占用(不获取锁)
|
||||
// IsLocked 检查锁是否被占用(不获取锁)。
|
||||
//
|
||||
// M-E 修复(失败语义统一):Redis 未初始化时返回 (false, ErrRedisNotReady) 而非 (false, nil)——
|
||||
// 后者与"锁确实未被占用"不可区分,调用方可能误以为可获取锁而进入临界区(正确性 bug)。
|
||||
// 调用方应 errors.Is(err, ErrRedisNotReady) 区分"Redis 不可用"与"锁未占用"。
|
||||
// L-G 修复:用 .Result() 显式返回 Redis 错误(原 .Val() 吞错致故障被当"未占用")。
|
||||
//
|
||||
// 契约说明:锁操作有正确性影响(无锁进入临界区 = bug),故 Redis 不可用时显式返错;
|
||||
// 与 cache.Get/Set 等数据操作(性能层、best-effort 静默 no-op)区分。
|
||||
func IsLocked(ctx context.Context, key string) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
return false, nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.Exists(ctx, key).Val() > 0, nil
|
||||
n, err := rdb.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// GetLockTTL 获取锁的剩余过期时间
|
||||
// GetLockTTL 获取锁的剩余过期时间。
|
||||
//
|
||||
// M-E 修复:Redis 未初始化时返回 (0, ErrRedisNotReady) 而非 (0, nil),调用方可区分
|
||||
// "Redis 不可用"与"锁不存在/已过期"(后者 TTL=-1/-2)。
|
||||
func GetLockTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.TTL(ctx, key).Result()
|
||||
return rdb.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// ForceUnlock 强制释放锁(危险操作,仅用于管理场景)
|
||||
// 注意: 此函数不检查 Token,强制删除锁
|
||||
//
|
||||
// M-E 修复:Redis 未初始化时返回 ErrRedisNotReady 而非 nil——原 nil 让调用方误以为
|
||||
// 已解锁成功、实则从未操作。管理脚本应据此重试或告警,而非假设成功。
|
||||
func ForceUnlock(ctx context.Context, key string) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
return rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ===== 计数器操作 =====
|
||||
|
||||
// Incr 自增计数器
|
||||
func Incr(ctx context.Context, key string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.Incr(ctx, key).Result()
|
||||
return rdb.Incr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// IncrBy 指定增量自增
|
||||
func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.IncrBy(ctx, key, value).Result()
|
||||
return rdb.IncrBy(ctx, key, value).Result()
|
||||
}
|
||||
|
||||
// Decr 自减计数器
|
||||
func Decr(ctx context.Context, key string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.Decr(ctx, key).Result()
|
||||
return rdb.Decr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// GetTTL 获取键的剩余过期时间
|
||||
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.TTL(ctx, key).Result()
|
||||
return rdb.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetExpire 设置键的过期时间
|
||||
func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
return false, nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.Expire(ctx, key, ttl).Result()
|
||||
return rdb.Expire(ctx, key, ttl).Result()
|
||||
}
|
||||
|
||||
// GetRaw 获取原始字符串值(不反序列化)
|
||||
func GetRaw(ctx context.Context, key string) (string, error) {
|
||||
if database.RedisClient == nil {
|
||||
return "", nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return "", ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.Get(ctx, key).Result()
|
||||
return rdb.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetRaw 设置原始值(不序列化)
|
||||
func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
return database.RedisClient.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
return rdb.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
Vendored
+413
@@ -0,0 +1,413 @@
|
||||
package cache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/cache"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// setupMiniRedis 启动一个 miniredis 实例并把它注入 database 内部,返回清理函数。
|
||||
func setupMiniRedis(t *testing.T) *miniredis.Miniredis {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
// 通过 SetTestRedisClient 注入 miniredis 客户端到 database 包的内部 redisClient
|
||||
database.SetTestRedisClient(client)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
return mr
|
||||
}
|
||||
|
||||
// ===== C1a:WithLockAutoExtend 不 panic / 不泄漏锁 =====
|
||||
|
||||
// 回归 C1a:ctx 取消时 WithLockAutoExtend 不 send-on-closed panic,且锁被释放。
|
||||
// 旧实现:ctx 取消时子 goroutine defer close(done),父 fn() 后 done<-struct{}{} 即 panic,
|
||||
// Unlock 不执行,锁泄漏到 TTL。
|
||||
func TestWithLockAutoExtendCtxCancelNoPanic(t *testing.T) {
|
||||
mr := setupMiniRedis(t)
|
||||
_ = mr
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
key := "c1a:cancel"
|
||||
|
||||
ran := make(chan struct{})
|
||||
var panicked any
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { panicked = recover() }()
|
||||
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(runCtx context.Context) error {
|
||||
close(ran)
|
||||
// 阻塞直到 ctx 被取消,模拟长任务。
|
||||
<-runCtx.Done()
|
||||
return runCtx.Err()
|
||||
})
|
||||
// WithLockAutoExtend 返回 fn 的错误(ctx.Err),不应 panic。
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("unexpected err: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等 fn 启动 + 至少一次续期 ticker,再取消 ctx,制造最大竞态窗口。
|
||||
<-ran
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
if panicked != nil {
|
||||
t.Fatalf("WithLockAutoExtend panicked on ctx cancel (C1a send-on-closed): %v", panicked)
|
||||
}
|
||||
// 锁必须被释放(Unlock 用 Background ctx 执行)。
|
||||
locked, err := cache.IsLocked(context.Background(), key)
|
||||
if err != nil {
|
||||
t.Fatalf("IsLocked: %v", err)
|
||||
}
|
||||
if locked {
|
||||
t.Error("lock leaked after ctx cancel (Unlock not executed)")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C1a:fn 正常返回时锁被释放,无 panic。
|
||||
func TestWithLockAutoExtendNormalRelease(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
key := "c1a:normal"
|
||||
|
||||
called := false
|
||||
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLockAutoExtend: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("fn not called")
|
||||
}
|
||||
locked, _ := cache.IsLocked(ctx, key)
|
||||
if locked {
|
||||
t.Error("lock not released after normal fn return")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C1a:fn 执行超过续期间隔,锁被续期不丢失(续期 goroutine 工作)。
|
||||
func TestWithLockAutoExtendExtendsLock(t *testing.T) {
|
||||
mr := setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
key := "c1a:extend"
|
||||
|
||||
// initialTTL=500ms,extendInterval=100ms。fn 执行 800ms,期间应多次续期。
|
||||
// 若续期失效,锁会在 500ms 过期,另一 worker 可获取。
|
||||
err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func(context.Context) error {
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLockAutoExtend: %v", err)
|
||||
}
|
||||
|
||||
// 期间另一 worker 应无法获取锁(续期生效)。
|
||||
// 此断言在 fn 执行中验证更准;这里用 fn 返回后锁已释放验证基础闭环。
|
||||
locked, _ := cache.IsLocked(ctx, key)
|
||||
if locked {
|
||||
t.Error("lock not released after fn")
|
||||
}
|
||||
_ = mr
|
||||
}
|
||||
|
||||
// 回归 C1a:fn 执行中另一 worker 拿不到锁(续期保活)。
|
||||
func TestWithLockAutoExtendBlocksContender(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
key := "c1a:block"
|
||||
|
||||
fnStarted := make(chan struct{})
|
||||
fnDone := make(chan struct{})
|
||||
lockDone := make(chan struct{})
|
||||
var contenderGotLock atomic.Bool
|
||||
|
||||
go func() {
|
||||
defer close(lockDone)
|
||||
cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func(context.Context) error {
|
||||
close(fnStarted)
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
close(fnDone)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
<-fnStarted
|
||||
// 期间尝试获取锁,应失败(nil)。
|
||||
token, err := cache.NewLock(ctx, key, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLock contender: %v", err)
|
||||
}
|
||||
if token != nil {
|
||||
contenderGotLock.Store(true)
|
||||
_ = cache.Unlock(ctx, token)
|
||||
}
|
||||
<-fnDone
|
||||
<-lockDone
|
||||
|
||||
if contenderGotLock.Load() {
|
||||
t.Error("contender acquired lock while auto-extend active (extend failed)")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C1a:fn panic 时锁仍被释放、续期 goroutine 不泄漏(defer 兜底)。
|
||||
// 旧实现(无 defer)fn panic → close(stop) 不执行 → 续期 goroutine 永久泄漏 + Unlock 不执行 → 锁泄漏。
|
||||
func TestWithLockAutoExtendFnPanicReleasesLock(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
key := "c1a:panic"
|
||||
|
||||
var panicked any
|
||||
func() {
|
||||
defer func() { panicked = recover() }()
|
||||
_ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
|
||||
time.Sleep(150 * time.Millisecond) // 让续期 ticker 至少触发一次
|
||||
panic("boom")
|
||||
})
|
||||
}()
|
||||
|
||||
if panicked == nil {
|
||||
t.Fatal("expected fn panic to propagate")
|
||||
}
|
||||
|
||||
// 锁必须被释放(defer Unlock 执行)。
|
||||
locked, _ := cache.IsLocked(ctx, key)
|
||||
if locked {
|
||||
t.Error("lock leaked after fn panic (defer Unlock not executed)")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C1a/HIGH:WithLock 在 ctx 取消后仍能解锁(Unlock 用 Background ctx)。
|
||||
// 旧实现 defer Unlock(ctx, token) 用原 ctx,ctx 取消致 Unlock 失败、锁泄漏到 TTL。
|
||||
func TestWithLockCtxCancelReleasesLock(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
key := "c1a:withlock"
|
||||
|
||||
fnStarted := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_ = cache.WithLock(ctx, key, 10*time.Second, func(runCtx context.Context) error {
|
||||
close(fnStarted)
|
||||
<-runCtx.Done()
|
||||
return runCtx.Err()
|
||||
})
|
||||
}()
|
||||
|
||||
<-fnStarted
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
locked, _ := cache.IsLocked(context.Background(), key)
|
||||
if locked {
|
||||
t.Error("lock leaked after ctx cancel (WithLock Unlock should use Background ctx)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLockPassesContextToFunction(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
key := "m10:ctx-aware"
|
||||
|
||||
var seen context.Context
|
||||
err := cache.WithLock(ctx, key, time.Second, func(runCtx context.Context) error {
|
||||
seen = runCtx
|
||||
cancel()
|
||||
<-runCtx.Done()
|
||||
return runCtx.Err()
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("WithLock ctx-aware err = %v, want context.Canceled", err)
|
||||
}
|
||||
if seen != ctx {
|
||||
t.Fatal("WithLock 应把调用方 ctx 传入业务函数")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLockRejectsNilFunction(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := cache.WithLock(ctx, "m10:nil-fn", time.Second, nil); !errors.Is(err, cache.ErrLockFuncNil) {
|
||||
t.Fatalf("WithLock nil fn err = %v, want ErrLockFuncNil", err)
|
||||
}
|
||||
if err := cache.WithLockAutoExtend(ctx, "m10:nil-fn-auto", time.Second, 100*time.Millisecond, nil); !errors.Is(err, cache.ErrLockFuncNil) {
|
||||
t.Fatalf("WithLockAutoExtend nil fn err = %v, want ErrLockFuncNil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== C1b:类型断言不 panic =====
|
||||
|
||||
// 回归 C1b:NewLock/Unlock/ExtendLock 在正常路径返回正确结果(Lua 脚本返 int64)。
|
||||
// 此用例锁定正常路径不被 toInt64 改坏;裸断言 panic 路径需构造非 int64 返回,
|
||||
// miniredis Lua 恒返整数,故 panic 路径由 toInt64 的 comma-ok 防护(代码审查保证)。
|
||||
func TestLockUnlockExtendCycle(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
key := "c1b:cycle"
|
||||
|
||||
// 加锁
|
||||
token, err := cache.NewLock(ctx, key, 5*time.Second)
|
||||
if err != nil || token == nil {
|
||||
t.Fatalf("NewLock: err=%v token=%v", err, token)
|
||||
}
|
||||
// 重复加锁应失败(返回 nil token)
|
||||
t2, err := cache.NewLock(ctx, key, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("second NewLock err: %v", err)
|
||||
}
|
||||
if t2 != nil {
|
||||
t.Error("second NewLock should return nil token (lock held)")
|
||||
_ = cache.Unlock(ctx, t2)
|
||||
}
|
||||
// 续期
|
||||
if err := cache.ExtendLock(ctx, token, 5*time.Second); err != nil {
|
||||
t.Errorf("ExtendLock: %v", err)
|
||||
}
|
||||
// 用错误 token 解锁应失败
|
||||
wrong := &cache.LockToken{Key: key, Token: "wrong-token"}
|
||||
if err := cache.Unlock(ctx, wrong); !errors.Is(err, cache.ErrLockNotHeld) {
|
||||
t.Errorf("Unlock wrong token err = %v, want ErrLockNotHeld", err)
|
||||
}
|
||||
// 正确解锁
|
||||
if err := cache.Unlock(ctx, token); err != nil {
|
||||
t.Errorf("Unlock: %v", err)
|
||||
}
|
||||
// 解锁后另一方可获取
|
||||
t3, err := cache.NewLock(ctx, key, 5*time.Second)
|
||||
if err != nil || t3 == nil {
|
||||
t.Fatalf("NewLock after unlock: err=%v token=%v", err, t3)
|
||||
}
|
||||
_ = cache.Unlock(ctx, t3)
|
||||
}
|
||||
|
||||
// ===== C1c:TryLock 响应 ctx 取消 =====
|
||||
|
||||
// 回归 C1c:TryLock 在 ctx 取消时立即返回,不阻塞 maxRetry*retryInterval。
|
||||
// 旧实现 time.Sleep 不响应 ctx,取消后仍要等满所有重试。
|
||||
func TestTryLockRespectsCtxCancel(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
// 先占用锁,使 TryLock 必然重试。
|
||||
holder, err := cache.NewLock(context.Background(), "c1c:try", 10*time.Second)
|
||||
if err != nil || holder == nil {
|
||||
t.Fatalf("setup holder: err=%v token=%v", err, holder)
|
||||
}
|
||||
defer cache.Unlock(context.Background(), holder)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// retryInterval=200ms,maxRetry=10 → 旧实现取消后最长阻塞 2s。
|
||||
start := time.Now()
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
token, err := cache.TryLock(ctx, "c1c:try", 5*time.Second, 200*time.Millisecond, 10)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("TryLock err = %v, want context.Canceled", err)
|
||||
}
|
||||
if token != nil {
|
||||
t.Error("TryLock should return nil token (held by other)")
|
||||
_ = cache.Unlock(context.Background(), token)
|
||||
}
|
||||
// 取消应在 ~150ms 后返回,远小于 2s。
|
||||
if elapsed > 1*time.Second {
|
||||
t.Errorf("TryLock took %v, should return shortly after ctx cancel (C1c: time.Sleep ignored ctx)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockRejectsSubMillisecondTTL(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if token, err := cache.NewLock(ctx, "m10:ttl", time.Nanosecond); !errors.Is(err, cache.ErrInvalidLockTTL) || token != nil {
|
||||
t.Fatalf("NewLock sub-ms ttl token=%v err=%v, want ErrInvalidLockTTL", token, err)
|
||||
}
|
||||
|
||||
token, err := cache.NewLock(ctx, "m10:ttl-valid", time.Second)
|
||||
if err != nil || token == nil {
|
||||
t.Fatalf("NewLock valid setup token=%v err=%v", token, err)
|
||||
}
|
||||
defer cache.Unlock(ctx, token)
|
||||
|
||||
if err := cache.ExtendLock(ctx, token, 0); !errors.Is(err, cache.ErrInvalidLockTTL) {
|
||||
t.Errorf("ExtendLock zero ttl err = %v, want ErrInvalidLockTTL", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLockAutoExtendRejectsNonPositiveInterval(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
called := false
|
||||
|
||||
err := cache.WithLockAutoExtend(context.Background(), "m10:interval", time.Second, 0, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, cache.ErrInvalidLockRetryInterval) {
|
||||
t.Fatalf("WithLockAutoExtend zero interval err = %v, want ErrInvalidLockRetryInterval", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("WithLockAutoExtend should not run fn with invalid interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLockHeldReturnsErrLockNotAcquired(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
key := "m10:not-acquired"
|
||||
|
||||
holder, err := cache.NewLock(ctx, key, time.Second)
|
||||
if err != nil || holder == nil {
|
||||
t.Fatalf("setup holder token=%v err=%v", holder, err)
|
||||
}
|
||||
defer cache.Unlock(ctx, holder)
|
||||
|
||||
called := false
|
||||
err = cache.WithLock(ctx, key, time.Second, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, cache.ErrLockNotAcquired) {
|
||||
t.Fatalf("WithLock held err = %v, want ErrLockNotAcquired", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("WithLock should not run fn when lock is held")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryLockExhaustedReturnsErrLockNotAcquired(t *testing.T) {
|
||||
setupMiniRedis(t)
|
||||
ctx := context.Background()
|
||||
key := "m10:try-exhausted"
|
||||
|
||||
holder, err := cache.NewLock(ctx, key, time.Second)
|
||||
if err != nil || holder == nil {
|
||||
t.Fatalf("setup holder token=%v err=%v", holder, err)
|
||||
}
|
||||
defer cache.Unlock(ctx, holder)
|
||||
|
||||
token, err := cache.TryLock(ctx, key, time.Second, time.Millisecond, 2)
|
||||
if !errors.Is(err, cache.ErrLockNotAcquired) {
|
||||
t.Fatalf("TryLock exhausted err = %v, want ErrLockNotAcquired", err)
|
||||
}
|
||||
if token != nil {
|
||||
t.Fatalf("TryLock exhausted token = %v, want nil", token)
|
||||
}
|
||||
}
|
||||
Vendored
+26
-22
@@ -2,6 +2,7 @@ package cache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -32,50 +33,53 @@ func TestLockErrors(t *testing.T) {
|
||||
t.Errorf("Unlock with nil token should return ErrLockNotHeld or ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
|
||||
// Test IsLocked without Redis (should return false)
|
||||
// Test IsLocked without Redis — M-E:应返回 (false, ErrRedisNotReady),
|
||||
// 让调用方可区分"Redis 不可用"与"锁未占用"(原 (false,nil) 与"未占用"不可区分)。
|
||||
locked, err := cache.IsLocked(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("IsLocked error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("IsLocked without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if locked {
|
||||
t.Error("IsLocked should return false without Redis")
|
||||
}
|
||||
|
||||
// Test GetLockTTL without Redis
|
||||
// Test GetLockTTL without Redis — M-E:应返回 (0, ErrRedisNotReady)。
|
||||
ttl, err := cache.GetLockTTL(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("GetLockTTL error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("GetLockTTL without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if ttl != 0 {
|
||||
t.Error("GetLockTTL should return 0 without Redis")
|
||||
}
|
||||
|
||||
if err := cache.UnlockByKey(ctx, "test_key"); !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("UnlockByKey without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrDecr(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test Incr without Redis
|
||||
// Redis 未初始化时应显式返回 ErrRedisNotReady,避免调用方误判为计数器值为 0。
|
||||
n, err := cache.Incr(ctx, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("Incr error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("Incr without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("Incr should return 0 without Redis")
|
||||
}
|
||||
|
||||
// Test IncrBy
|
||||
n, err = cache.IncrBy(ctx, "counter", 10)
|
||||
if err != nil {
|
||||
t.Errorf("IncrBy error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("IncrBy without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("IncrBy should return 0 without Redis")
|
||||
}
|
||||
|
||||
// Test Decr
|
||||
n, err = cache.Decr(ctx, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("Decr error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("Decr without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("Decr should return 0 without Redis")
|
||||
@@ -86,8 +90,8 @@ func TestSetExpire(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
ok, err := cache.SetExpire(ctx, "test_key", time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("SetExpire error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("SetExpire without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("SetExpire should return false without Redis")
|
||||
@@ -99,14 +103,14 @@ func TestGetRawSetRaw(t *testing.T) {
|
||||
|
||||
// Test SetRaw
|
||||
err := cache.SetRaw(ctx, "test_key", "test_value", time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("SetRaw error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("SetRaw without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
|
||||
// Test GetRaw
|
||||
val, err := cache.GetRaw(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("GetRaw error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("GetRaw without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if val != "" {
|
||||
t.Error("GetRaw should return empty string without Redis")
|
||||
@@ -144,4 +148,4 @@ func TestKFunctions(t *testing.T) {
|
||||
if sessionKey == "" {
|
||||
t.Error("KSession should not return empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+226
-73
@@ -9,38 +9,78 @@ import (
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
)
|
||||
|
||||
func createProject(name string) {
|
||||
func createProject(name string) error {
|
||||
// P1 #21:校验项目名,拒绝路径穿越与非法 Go 包名。
|
||||
if err := validateProjectName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
// #nosec G703 -- name was validated by validateProjectName: no path separators, no "..", no leading dot, Go identifier only.
|
||||
if _, err := os.Stat(name); !os.IsNotExist(err) {
|
||||
fmt.Printf("目录 %s 已存在\n", name)
|
||||
return
|
||||
return fmt.Errorf("目录 %s 已存在", name)
|
||||
}
|
||||
|
||||
// 创建目录结构
|
||||
dirs := []string{
|
||||
name,
|
||||
name + "/config",
|
||||
name + "/handler",
|
||||
name + "/model",
|
||||
name + "/repository",
|
||||
name + "/service",
|
||||
name + "/middleware",
|
||||
name + "/public",
|
||||
name + "/logs",
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fmt.Printf("创建目录失败: %s\n", err)
|
||||
return
|
||||
// 解析 --template 与 --module 参数(默认 template=api)
|
||||
tmplName := "api"
|
||||
module := name
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--template", "-t":
|
||||
if i+1 >= len(args) {
|
||||
return fmt.Errorf("%s 缺少参数值", args[i])
|
||||
}
|
||||
tmplName = args[i+1]
|
||||
i++
|
||||
case "--module", "-m":
|
||||
if i+1 >= len(args) {
|
||||
return fmt.Errorf("%s 缺少参数值", args[i])
|
||||
}
|
||||
module = args[i+1]
|
||||
i++
|
||||
default:
|
||||
// P1 #21:未知参数显式报错,不再静默忽略。
|
||||
return fmt.Errorf("未知参数: %s", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模块路径
|
||||
module := name
|
||||
if len(os.Args) > 3 && os.Args[3] == "--module" && len(os.Args) > 4 {
|
||||
module = os.Args[4]
|
||||
// P1 #21:校验 module 路径,避免模板元字符 {{ }} 经 Sprintf 进 go.mod 后触发 Parse 报错。
|
||||
if err := validateModulePath(module); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 校验模板名
|
||||
switch tmplName {
|
||||
case "minimal", "api", "fullstack":
|
||||
// ok
|
||||
default:
|
||||
return fmt.Errorf("未知模板: %s(可选: minimal / api / fullstack)", tmplName)
|
||||
}
|
||||
|
||||
// minimal 模板目录结构最小化;api/fullstack 含完整分层目录
|
||||
var dirs []string
|
||||
dirs = append(dirs, name, name+"/public", name+"/logs")
|
||||
if tmplName != "minimal" {
|
||||
dirs = append(dirs,
|
||||
name+"/config",
|
||||
name+"/handler",
|
||||
name+"/model",
|
||||
name+"/repository",
|
||||
name+"/service",
|
||||
name+"/middleware",
|
||||
)
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
// #nosec G301,G703 -- dir is built from validated project name plus fixed scaffold subdirectories; 0755 is intentional for generated project dirs.
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
// #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back.
|
||||
_ = os.RemoveAll(name) // P1 #21:清理半成品
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
caser := cases.Title(language.English)
|
||||
@@ -52,68 +92,169 @@ func createProject(name string) {
|
||||
Year: time.Now().Year(),
|
||||
}
|
||||
|
||||
// 按模板选择 main.go 与 config.yaml
|
||||
var mainTmpl, configTmpl string
|
||||
switch tmplName {
|
||||
case "minimal":
|
||||
mainTmpl, configTmpl = templates.MainMinimal, templates.ConfigMinimal
|
||||
case "fullstack":
|
||||
mainTmpl, configTmpl = templates.MainFull, templates.ConfigFull
|
||||
default: // api
|
||||
mainTmpl, configTmpl = templates.Main, templates.Config
|
||||
}
|
||||
|
||||
// 创建文件
|
||||
files := map[string]string{
|
||||
name + "/main.go": templates.Main,
|
||||
name + "/config.yaml": templates.Config,
|
||||
name + "/go.mod": fmt.Sprintf(templates.GoMod, module),
|
||||
name + "/Makefile": templates.Makefile,
|
||||
name + "/.gitignore": templates.Gitignore,
|
||||
name + "/handler/home.go": templates.Handler,
|
||||
name + "/main.go": mainTmpl,
|
||||
name + "/config.yaml": configTmpl,
|
||||
name + "/go.mod": fmt.Sprintf(templates.GoMod, module, xlgo.Version),
|
||||
name + "/Makefile": templates.Makefile,
|
||||
name + "/.gitignore": templates.Gitignore,
|
||||
}
|
||||
// api/fullstack 模板带示例 handler
|
||||
if tmplName != "minimal" {
|
||||
files[name+"/handler/home.go"] = templates.Handler
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
tmpl, err := template.New(path).Parse(content)
|
||||
if err != nil {
|
||||
fmt.Printf("解析模板失败: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
fmt.Printf("创建文件失败: %s\n", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := tmpl.Execute(file, data); err != nil {
|
||||
fmt.Printf("写入文件失败: %s\n", err)
|
||||
return
|
||||
if err := renderTemplateFile(path, content, data); err != nil {
|
||||
// #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back.
|
||||
_ = os.RemoveAll(name) // P1 #21:部分失败回滚,避免留下半成品项目
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("✓ 项目 %s 创建成功\n", name)
|
||||
fmt.Printf("✓ 项目 %s 创建成功(模板: %s)\n", name, tmplName)
|
||||
fmt.Println("\n下一步:")
|
||||
fmt.Printf(" cd %s\n", name)
|
||||
fmt.Println(" go mod tidy")
|
||||
fmt.Println(" go run main.go")
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeFile(fileType, name string) {
|
||||
// renderTemplateFile 解析并渲染单个模板文件,每次调用显式关闭句柄
|
||||
// (P1 #21:不在循环内 defer 累积句柄,且 Close 错误纳入返回)。
|
||||
func renderTemplateFile(path, content string, data TemplateData) (retErr error) {
|
||||
tmpl, err := template.New(path).Parse(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析模板 %s 失败: %w", path, err)
|
||||
}
|
||||
// #nosec G304 -- path is from createProject's files map built from validated project name plus fixed filenames.
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件 %s 失败: %w", path, err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := file.Close(); cerr != nil && retErr == nil {
|
||||
retErr = fmt.Errorf("关闭文件 %s 失败: %w", path, cerr)
|
||||
}
|
||||
}()
|
||||
if err := tmpl.Execute(file, data); err != nil {
|
||||
return fmt.Errorf("写入文件 %s 失败: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateProjectName 校验项目名(P1 #21):非空、无路径分隔符/.. /前导点,
|
||||
// 且可派生为合法 Go 包名(须以字母开头,仅含字母/数字/下划线)。
|
||||
func validateProjectName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("项目名不能为空")
|
||||
}
|
||||
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return fmt.Errorf("项目名不能包含路径分隔符或 ..(防止在预期目录外创建文件): %q", name)
|
||||
}
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return fmt.Errorf("项目名不能以 . 开头: %q", name)
|
||||
}
|
||||
if !isValidGoIdentifier(name) {
|
||||
return fmt.Errorf("项目名 %q 无法生成合法 Go 包名:须以字母开头,仅含字母、数字、下划线", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidGoIdentifier 判断 s 是否为脚手架接受的 ASCII 标识符:字母开头,后续允许字母、数字、下划线。
|
||||
func isValidGoIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
isNum := r >= '0' && r <= '9'
|
||||
if i == 0 {
|
||||
if !isLetter {
|
||||
return false
|
||||
}
|
||||
} else if !isLetter && !isNum && r != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validateModulePath 校验 --module(P1 #21):非空、不含模板元字符与空白。
|
||||
func validateModulePath(module string) error {
|
||||
if module == "" {
|
||||
return fmt.Errorf("模块路径不能为空")
|
||||
}
|
||||
if strings.Contains(module, "{{") || strings.Contains(module, "}}") {
|
||||
return fmt.Errorf("模块路径不能包含模板元字符 {{ }}: %q", module)
|
||||
}
|
||||
if strings.ContainsAny(module, " \t\r\n") {
|
||||
return fmt.Errorf("模块路径不能包含空白字符: %q", module)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeFile(fileType, name string) error {
|
||||
if err := validateMakeName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
caser := cases.Title(language.English)
|
||||
nameTitle := caser.String(name)
|
||||
nameTitle := makeNameTitle(name)
|
||||
|
||||
switch fileType {
|
||||
case "handler":
|
||||
createHandler(name, nameTitle)
|
||||
return createHandler(name, nameTitle)
|
||||
case "repository":
|
||||
createRepository(name, nameTitle)
|
||||
return createRepository(name, nameTitle)
|
||||
case "model":
|
||||
createModel(name, nameTitle)
|
||||
return createModel(name, nameTitle)
|
||||
case "service":
|
||||
createService(name, nameTitle)
|
||||
return createService(name, nameTitle)
|
||||
default:
|
||||
fmt.Printf("未知类型: %s\n", fileType)
|
||||
fmt.Println("可用类型: handler, repository, model, service")
|
||||
return fmt.Errorf("未知类型: %s(可用类型: handler, repository, model, service)", fileType)
|
||||
}
|
||||
}
|
||||
|
||||
func createHandler(name, nameTitle string) {
|
||||
// validateMakeName 校验 xlgo make 的资源名,避免路径穿越或生成不可编译的 Go 代码。
|
||||
func validateMakeName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("名称不能为空")
|
||||
}
|
||||
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name)
|
||||
}
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return fmt.Errorf("名称不能以 . 开头: %q", name)
|
||||
}
|
||||
if !isValidGoIdentifier(name) {
|
||||
return fmt.Errorf("名称 %q 必须是合法标识符:须以字母开头,仅含字母、数字、下划线", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeNameTitle 把 snake_case 名称转为导出的 CamelCase 类型名。
|
||||
func makeNameTitle(name string) string {
|
||||
caser := cases.Title(language.English)
|
||||
nameTitle := caser.String(strings.ReplaceAll(name, "_", " "))
|
||||
return strings.ReplaceAll(nameTitle, " ", "")
|
||||
}
|
||||
|
||||
func createHandler(name, nameTitle string) error {
|
||||
path := fmt.Sprintf("handler/%s.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
return fmt.Errorf("文件 %s 已存在", path)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.HandlerMake,
|
||||
@@ -125,48 +266,56 @@ func createHandler(name, nameTitle string) {
|
||||
nameTitle, name, name, nameTitle,
|
||||
nameTitle, name, name, nameTitle,
|
||||
)
|
||||
content = replaceModuleImports(content)
|
||||
|
||||
writeFile(path, content)
|
||||
if err := writeFile(path, content); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ 创建处理器: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createRepository(name, nameTitle string) {
|
||||
func createRepository(name, nameTitle string) error {
|
||||
path := fmt.Sprintf("repository/%s_repository.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
return fmt.Errorf("文件 %s 已存在", path)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.RepositoryMake,
|
||||
nameTitle, name, nameTitle, nameTitle,
|
||||
nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle,
|
||||
nameTitle, nameTitle, nameTitle,
|
||||
nameTitle, nameTitle,
|
||||
)
|
||||
content = replaceModuleImports(content)
|
||||
|
||||
writeFile(path, content)
|
||||
if err := writeFile(path, content); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ 创建仓库: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createModel(name, nameTitle string) {
|
||||
func createModel(name, nameTitle string) error {
|
||||
path := fmt.Sprintf("model/%s.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
return fmt.Errorf("文件 %s 已存在", path)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.ModelMake,
|
||||
nameTitle, name, nameTitle, nameTitle, name,
|
||||
)
|
||||
|
||||
writeFile(path, content)
|
||||
if err := writeFile(path, content); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ 创建模型: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createService(name, nameTitle string) {
|
||||
func createService(name, nameTitle string) error {
|
||||
path := fmt.Sprintf("service/%s_service.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
return fmt.Errorf("文件 %s 已存在", path)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.ServiceMake,
|
||||
@@ -177,7 +326,11 @@ func createService(name, nameTitle string) {
|
||||
nameTitle, nameTitle,
|
||||
nameTitle, nameTitle,
|
||||
)
|
||||
content = replaceModuleImports(content)
|
||||
|
||||
writeFile(path, content)
|
||||
if err := writeFile(path, content); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ 创建服务: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMakeNameRejectsUnsafeOrInvalidNames(t *testing.T) {
|
||||
tests := []string{
|
||||
"",
|
||||
"../user",
|
||||
`..\user`,
|
||||
".user",
|
||||
"my-thing",
|
||||
"123user",
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt, func(t *testing.T) {
|
||||
if err := validateMakeName(tt); err == nil {
|
||||
t.Fatalf("名称 %q 应返回错误", tt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeFileAcceptsSnakeCaseIdentifier(t *testing.T) {
|
||||
oldWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("获取当前目录失败: %v", err)
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatalf("切换测试目录失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chdir(oldWd); err != nil {
|
||||
t.Fatalf("恢复测试目录失败: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := makeFile("model", "user_profile"); err != nil {
|
||||
t.Fatalf("生成模型失败: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(tmp, "model", "user_profile.go")
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读取生成文件失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "type UserProfile struct") {
|
||||
t.Fatalf("生成类型名不符合预期:\n%s", content)
|
||||
}
|
||||
}
|
||||
+27
-11
@@ -3,21 +3,30 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
)
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(`xlgo - Go Web 框架脚手架工具
|
||||
|
||||
用法:
|
||||
xlgo new <项目名> 创建新项目
|
||||
xlgo make handler <名称> 创建处理器
|
||||
xlgo new <项目名> [--template <模板>] [--module <模块路径>] 创建新项目
|
||||
xlgo make handler <名称> 创建处理器
|
||||
xlgo make repository <名称> 创建仓库
|
||||
xlgo make model <名称> 创建模型
|
||||
xlgo make service <名称> 创建服务
|
||||
xlgo version 显示版本号
|
||||
xlgo version 显示版本号
|
||||
|
||||
模板 (xlgo new --template <名称>):
|
||||
minimal 轻量 HTTP 服务,不依赖 MySQL/Redis(默认入门)
|
||||
api 标准业务 API,含 MySQL/Redis/JWT 与 handler/model/repository/service 分层(默认)
|
||||
fullstack 全组件,一键启用 MySQL/Redis/Storage/Swagger/AutoMigrate
|
||||
|
||||
示例:
|
||||
xlgo new myapp
|
||||
xlgo new myapp --template minimal
|
||||
xlgo new myapp --template fullstack --module github.com/me/myapp
|
||||
xlgo make handler user
|
||||
xlgo make repository user`)
|
||||
}
|
||||
@@ -30,24 +39,31 @@ func main() {
|
||||
|
||||
command := os.Args[1]
|
||||
|
||||
// P1 #21:命令执行失败时以非零码退出,便于 `xlgo new x && cd x` 等脚本/CI 正确中断。
|
||||
switch command {
|
||||
case "new":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("用法: xlgo new <项目名>")
|
||||
return
|
||||
fmt.Fprintln(os.Stderr, "用法: xlgo new <项目名>")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := createProject(os.Args[2]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
createProject(os.Args[2])
|
||||
|
||||
case "make":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("用法: xlgo make <类型> <名称>")
|
||||
fmt.Println("类型: handler, repository, model, service")
|
||||
return
|
||||
fmt.Fprintln(os.Stderr, "用法: xlgo make <类型> <名称>")
|
||||
fmt.Fprintln(os.Stderr, "类型: handler, repository, model, service")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := makeFile(os.Args[2], os.Args[3]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
makeFile(os.Args[2], os.Args[3])
|
||||
|
||||
case "version":
|
||||
fmt.Println("xlgo v1.0.0")
|
||||
fmt.Printf("xlgo v%s\n", xlgo.Version)
|
||||
|
||||
default:
|
||||
printUsage()
|
||||
|
||||
+227
-139
@@ -2,8 +2,12 @@ package main
|
||||
|
||||
// Templates 存放所有代码生成模板
|
||||
var templates = struct {
|
||||
Main string
|
||||
Config string
|
||||
Main string // api 模板:标准业务 API(mysql+redis+jwt+分层)
|
||||
MainMinimal string // minimal 模板:轻量 HTTP,无外部依赖
|
||||
MainFull string // fullstack 模板:全组件
|
||||
Config string // api 模板配置
|
||||
ConfigMinimal string
|
||||
ConfigFull string
|
||||
GoMod string
|
||||
Makefile string
|
||||
Gitignore string
|
||||
@@ -18,26 +22,15 @@ var templates = struct {
|
||||
Main: `package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/EthanCodeCraft/xlgo-core/router"
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
|
||||
"{{.Module}}/config"
|
||||
"{{.Module}}/database"
|
||||
"{{.Module}}/handler"
|
||||
"{{.Module}}/logger"
|
||||
"{{.Module}}/middleware"
|
||||
"{{.Module}}/storage"
|
||||
"{{.Module}}/validation"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
@@ -49,104 +42,28 @@ func init() {
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// 加载配置
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("加载配置失败: %v\n", err)
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfigPath(configPath),
|
||||
xlgo.WithLogger(),
|
||||
xlgo.WithHealthRoutes(),
|
||||
// 如需 Swagger 文档:xlgo.WithSwaggerRoutes() 或 xlgo.WithDefaultRoutes()
|
||||
// 如需 MySQL/Redis/Storage:xlgo.WithMySQL() / xlgo.WithRedis() / xlgo.WithStorage()
|
||||
// 一键启用全部默认组件:使用 xlgo.NewFullStack(...) 替代 xlgo.New(...)
|
||||
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
|
||||
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
|
||||
)
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Printf("启动失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 初始化日志
|
||||
if err := logger.Init(cfg); err != nil {
|
||||
fmt.Printf("初始化日志失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer logger.Sync()
|
||||
|
||||
// 初始化数据库
|
||||
if err := database.InitMySQL(cfg); err != nil {
|
||||
logger.Fatalf("初始化 MySQL 失败: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// 初始化 Redis
|
||||
if err := database.InitRedis(cfg); err != nil {
|
||||
logger.Fatalf("初始化 Redis 失败: %v", err)
|
||||
}
|
||||
defer database.CloseRedis()
|
||||
|
||||
// 初始化存储
|
||||
if err := storage.Init(&cfg.Storage); err != nil {
|
||||
logger.Fatalf("初始化存储失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化验证器
|
||||
validation.InitValidator()
|
||||
|
||||
// 设置 Gin 模式
|
||||
if cfg.IsProduction() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// 创建路由
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(middleware.Logger())
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
// 静态文件服务
|
||||
r.Static("/public", "./public")
|
||||
|
||||
// 注册路由
|
||||
setupRoutes(r)
|
||||
|
||||
// 启动服务器
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
go func() {
|
||||
logger.Infof("服务器启动,监听端口 %d", cfg.Server.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待中断信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
logger.Info("正在关闭服务器...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
logger.Fatalf("服务器强制关闭: %v", err)
|
||||
}
|
||||
|
||||
logger.Info("服务器已关闭")
|
||||
}
|
||||
|
||||
func setupRoutes(r *gin.Engine) {
|
||||
// 初始化限速器
|
||||
middleware.InitRateLimiters()
|
||||
|
||||
// Swagger 文档
|
||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// 健康检查
|
||||
r.GET("/health", handler.HealthCheck)
|
||||
|
||||
// API 路由
|
||||
func registerRoutes(r *gin.RouterGroup) {
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
// 首页
|
||||
api.GET("/", handler.Home)
|
||||
}
|
||||
api.GET("/", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "Welcome to {{.Name}}!"})
|
||||
})
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -158,13 +75,190 @@ func setupRoutes(r *gin.Engine) {
|
||||
env: "dev" # dev/test/prod
|
||||
debug: true
|
||||
base_url: "http://localhost:8080"
|
||||
token_expire: 86400 # Token过期时间(秒)
|
||||
|
||||
server:
|
||||
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机;内网IP=绑定指定网卡
|
||||
port: 8080
|
||||
mode: development # development 或 production
|
||||
read_timeout: 15s # 读超时
|
||||
write_timeout: 30s # 写超时
|
||||
idle_timeout: 60s # 空闲超时
|
||||
shutdown_timeout: 30s # 优雅关闭超时
|
||||
response_mode: business # business(默认,全200+业务码) 或 rest(按错误码映射HTTP status)
|
||||
|
||||
database:
|
||||
driver: mysql # mysql(默认)或 postgres
|
||||
host: localhost
|
||||
port: 3306
|
||||
user: root
|
||||
password: your_password
|
||||
name: {{.NameLower}}
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
# dsn: "自定义连接字符串,设置后优先于上面的字段"
|
||||
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password: ""
|
||||
db: 0
|
||||
|
||||
jwt:
|
||||
secret: your_jwt_secret_key_change_me_to_32_bytes_at_least
|
||||
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
|
||||
refresh_expire: "168h" # 刷新 token 过期时间
|
||||
issuer: xlgo
|
||||
algorithm: HS256 # HS256(默认)/HS384/HS512
|
||||
|
||||
storage:
|
||||
driver: local
|
||||
local:
|
||||
path: ./public
|
||||
base_url: http://localhost:8080/public
|
||||
|
||||
log:
|
||||
dir: ./logs
|
||||
max_size: 100
|
||||
max_backups: 30
|
||||
max_age: 30
|
||||
compress: true
|
||||
`,
|
||||
|
||||
// MainMinimal minimal 模板:轻量 HTTP 服务,不依赖 MySQL/Redis/Storage
|
||||
MainMinimal: `package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/EthanCodeCraft/xlgo-core/router"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径")
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfigPath(configPath),
|
||||
xlgo.WithLogger(),
|
||||
xlgo.WithHealthRoutes(),
|
||||
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
|
||||
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
|
||||
)
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Printf("启动失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func registerRoutes(r *gin.RouterGroup) {
|
||||
api := r.Group("/api/v1")
|
||||
api.GET("/", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "Hello {{.Name}}!"})
|
||||
})
|
||||
}
|
||||
`,
|
||||
|
||||
// ConfigMinimal minimal 模板配置:仅 app + server + log,无数据库/Redis
|
||||
ConfigMinimal: `app:
|
||||
name: "{{.Name}}"
|
||||
site_name: "{{.NameLower}}"
|
||||
version: "1.0.0"
|
||||
env: "dev"
|
||||
debug: true
|
||||
base_url: "http://localhost:8080"
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
mode: development
|
||||
|
||||
log:
|
||||
dir: ./logs
|
||||
max_size: 100
|
||||
max_backups: 30
|
||||
max_age: 30
|
||||
compress: true
|
||||
`,
|
||||
|
||||
// MainFull fullstack 模板:全组件(FullStack),含 Swagger + Storage
|
||||
MainFull: `package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/EthanCodeCraft/xlgo-core/router"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径")
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// NewFullStack 一键启用全部组件:Logger/MySQL/Redis/Storage/Wire/Health/Swagger/AutoMigrate
|
||||
// 如需排除个别组件,追加对应 Without* Option,例如 xlgo.WithoutSwaggerRoutes()
|
||||
app := xlgo.NewFullStack(
|
||||
xlgo.WithConfigPath(configPath),
|
||||
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
|
||||
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
|
||||
// xlgo.WithModels(&User{}, &Order{}), // 注册模型以启用自动迁移
|
||||
)
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Printf("启动失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func registerRoutes(r *gin.RouterGroup) {
|
||||
api := r.Group("/api/v1")
|
||||
api.GET("/", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "Welcome to {{.Name}} (fullstack)!"})
|
||||
})
|
||||
}
|
||||
`,
|
||||
|
||||
// ConfigFull fullstack 模板配置:全组件配置
|
||||
ConfigFull: `app:
|
||||
name: "{{.Name}}"
|
||||
site_name: "{{.NameLower}}"
|
||||
version: "1.0.0"
|
||||
env: "dev"
|
||||
debug: true
|
||||
base_url: "http://localhost:8080"
|
||||
|
||||
server:
|
||||
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
|
||||
port: 8080
|
||||
mode: development
|
||||
read_timeout: 15s
|
||||
write_timeout: 30s
|
||||
idle_timeout: 60s
|
||||
shutdown_timeout: 30s
|
||||
response_mode: business
|
||||
|
||||
database:
|
||||
driver: mysql # mysql(默认)或 postgres
|
||||
host: localhost
|
||||
port: 3306
|
||||
user: root
|
||||
@@ -180,8 +274,11 @@ redis:
|
||||
db: 0
|
||||
|
||||
jwt:
|
||||
secret: your_jwt_secret_key_here
|
||||
expire: 86400
|
||||
secret: your_jwt_secret_key_change_me_to_32_bytes_at_least
|
||||
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
|
||||
refresh_expire: "168h" # 刷新 token 过期时间
|
||||
issuer: xlgo
|
||||
algorithm: HS256 # HS256(默认)/HS384/HS512
|
||||
|
||||
storage:
|
||||
driver: local
|
||||
@@ -198,24 +295,14 @@ log:
|
||||
`,
|
||||
|
||||
// GoMod go.mod 文件模板
|
||||
// %s: module 名称;%s: xlgo 框架版本(来自 xlgo.Version,避免字面量散落)
|
||||
GoMod: `module %s
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/EthanCodeCraft/xlgo-core v%s
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/redis/go-redis/v9 v9.5.1
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.3
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/mysql v1.5.4
|
||||
gorm.io/gorm v1.25.7
|
||||
)
|
||||
`,
|
||||
|
||||
@@ -281,9 +368,8 @@ config.local.yaml
|
||||
Handler: `package handler
|
||||
|
||||
import (
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"{{.Module}}/response"
|
||||
)
|
||||
|
||||
// HealthCheck 健康检查
|
||||
@@ -305,8 +391,8 @@ func Home(c *gin.Context) {
|
||||
HandlerMake: `package handler
|
||||
|
||||
import (
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"xlgo/response"
|
||||
)
|
||||
|
||||
// %sHandler %s 处理器
|
||||
@@ -388,39 +474,41 @@ func (h *%sHandler) Delete(c *gin.Context) {
|
||||
import (
|
||||
"context"
|
||||
|
||||
"xlgo/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
xlrepo "github.com/EthanCodeCraft/xlgo-core/repository"
|
||||
"xlgo/model"
|
||||
)
|
||||
|
||||
// %sRepository %s 仓库
|
||||
type %sRepository struct {
|
||||
*BaseRepo[model.%s]
|
||||
*xlrepo.BaseRepo[model.%s]
|
||||
}
|
||||
|
||||
// New%sRepository 创建 %s 仓库
|
||||
func New%sRepository() *%sRepository {
|
||||
return &%sRepository{
|
||||
BaseRepo: NewBaseRepo[model.%s](database.GetDB()),
|
||||
BaseRepo: xlrepo.NewBaseRepo[model.%s](database.GetDB()),
|
||||
}
|
||||
}
|
||||
|
||||
// FindByName 根据名称查询
|
||||
// FindByName 根据名称查询。
|
||||
//
|
||||
// 走 BaseRepo.FindOne → readConn 读连接:默认路由到从库(读写分离,H6c),
|
||||
// 需读主库用 database.UseMaster(ctx),事务内自动 join 外层事务。
|
||||
// 不要用 r.GetDB() 自行查询——它返回注入的主库且不参与路由(M-35 footgun)。
|
||||
func (r *%sRepository) FindByName(ctx context.Context, name string) (*model.%s, error) {
|
||||
var m model.%s
|
||||
err := r.GetDB().WithContext(ctx).Where("name = ?", name).First(&m).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
return r.FindOne(ctx, "name = ?", name)
|
||||
}
|
||||
`,
|
||||
|
||||
// ModelMake make model 命令模板
|
||||
ModelMake: `package model
|
||||
|
||||
import xlmodel "github.com/EthanCodeCraft/xlgo-core/model"
|
||||
|
||||
// %s %s 模型
|
||||
type %s struct {
|
||||
BaseModel
|
||||
xlmodel.BaseModel
|
||||
Name string ` + "`" + `gorm:"size:100;not null" json:"name"` + "`" + `
|
||||
Description string ` + "`" + `gorm:"size:500" json:"description"` + "`" + `
|
||||
Status int ` + "`" + `gorm:"default:1" json:"status"` + "`" + ` // 1: 启用, 0: 禁用
|
||||
|
||||
+37
-4
@@ -4,21 +4,54 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func writeFile(path, content string) {
|
||||
func writeFile(path, content string) error {
|
||||
dir := filepath.Dir(path)
|
||||
// #nosec G301,G703 -- path is built by makeFile from fixed scaffold directories and a name that rejects separators/".."; 0755 is intentional for generated dirs.
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fmt.Printf("创建目录失败: %s\n", err)
|
||||
return
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// #nosec G306,G703 -- path is built by makeFile from fixed scaffold directories and a validated name; 0644 is intentional for generated source files.
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
fmt.Printf("写入文件失败: %s\n", err)
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
// #nosec G703 -- path is built by makeFile from fixed scaffold directories and a name that rejects separators/"..".
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
// 仅当"不存在"才返回 false;权限错误等其它错误视为"可能存在/不可覆写",
|
||||
// 避免把无权限访问的路径误判为可创建(M20:原 !os.IsNotExist 把权限错误当存在,
|
||||
// 反而安全;但语义模糊,显式区分更清晰)。
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func currentModule() string {
|
||||
data, err := os.ReadFile("go.mod")
|
||||
if err != nil {
|
||||
return "xlgo"
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "module ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, "module "))
|
||||
}
|
||||
}
|
||||
return "xlgo"
|
||||
}
|
||||
|
||||
func replaceModuleImports(content string) string {
|
||||
module := currentModule()
|
||||
content = strings.ReplaceAll(content, "\"xlgo/model\"", fmt.Sprintf("\"%s/model\"", module))
|
||||
content = strings.ReplaceAll(content, "\"xlgo/repository\"", fmt.Sprintf("\"%s/repository\"", module))
|
||||
content = strings.ReplaceAll(content, "\"xlgo/database\"", fmt.Sprintf("\"%s/database\"", module))
|
||||
content = strings.ReplaceAll(content, "\"xlgo/response\"", "\"github.com/EthanCodeCraft/xlgo-core/response\"")
|
||||
return content
|
||||
}
|
||||
|
||||
+290
-102
@@ -4,17 +4,68 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 解压安全相关错误。
|
||||
var (
|
||||
// ErrPathTraversal zip 条目名逃逸目标目录(C5a Zip-Slip)。
|
||||
ErrPathTraversal = errors.New("zip entry escapes destination directory")
|
||||
// ErrSymlinkEntry zip 条目为符号链接,已拒绝(防经软链二次穿越)。
|
||||
ErrSymlinkEntry = errors.New("zip entry is a symlink, rejected")
|
||||
// ErrDecompressLimit 解压大小超过上限(C5b 解压炸弹)。
|
||||
ErrDecompressLimit = errors.New("decompress size limit exceeded")
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultDecompressLimit 单流/单条目默认解压上限(100MB),防 OOM / 磁盘耗尽(C5b)。
|
||||
defaultDecompressLimit int64 = 100 * 1024 * 1024
|
||||
// defaultDecompressTotalLimit Unzip 默认累计解压上限(1GB)。
|
||||
defaultDecompressTotalLimit int64 = 1 * 1024 * 1024 * 1024
|
||||
)
|
||||
|
||||
// DecompressOptions 解压安全选项。Zip-Slip 防护(前缀锚定 + 拒绝符号链接)始终启用,无需配置;
|
||||
// 本选项仅控制解压大小上限以防解压炸弹(C5b)。
|
||||
type DecompressOptions struct {
|
||||
// MaxBytes 单流 / 单条目解压大小上限(字节)。0 = 默认 100MB,-1 = 不限制。
|
||||
MaxBytes int64
|
||||
// MaxTotalBytes Unzip 累计解压大小上限(字节)。0 = 默认 1GB,-1 = 不限制。
|
||||
// 仅 Unzip 生效。
|
||||
MaxTotalBytes int64
|
||||
}
|
||||
|
||||
// resolveLimit 解析大小上限:n<0 不限,n==0 用 def,n>0 用 n。
|
||||
func resolveLimit(n, def int64) int64 {
|
||||
if n < 0 {
|
||||
return -1
|
||||
}
|
||||
if n == 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// minLimit 返回两个上限中较小者;-1 视为无限。
|
||||
func minLimit(a, b int64) int64 {
|
||||
if a < 0 {
|
||||
return b
|
||||
}
|
||||
if b < 0 {
|
||||
return a
|
||||
}
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// GzipCompress 压缩数据
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: API响应压缩、数据传输常用
|
||||
func GzipCompress(data []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
@@ -28,37 +79,53 @@ func GzipCompress(data []byte) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GzipDecompress 解压缩数据
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: GzipCompress 的反向操作
|
||||
// GzipDecompress 解压缩数据。默认上限 100MB 防解压炸弹 OOM(C5b);
|
||||
// 需解压更大文件请用 GzipDecompressWithOptions。
|
||||
func GzipDecompress(data []byte) ([]byte, error) {
|
||||
return GzipDecompressWithOptions(data, DecompressOptions{})
|
||||
}
|
||||
|
||||
// GzipDecompressWithOptions 解压缩数据,可配置大小上限(C5b)。
|
||||
func GzipDecompressWithOptions(data []byte, opts DecompressOptions) ([]byte, error) {
|
||||
buf := bytes.NewReader(data)
|
||||
gz, err := gzip.NewReader(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gz.Close()
|
||||
return io.ReadAll(gz)
|
||||
|
||||
limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
|
||||
var reader io.Reader = gz
|
||||
if limit > 0 {
|
||||
// 多读 1 字节用于判断是否超限。
|
||||
reader = io.LimitReader(gz, limit+1)
|
||||
}
|
||||
out, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit > 0 && int64(len(out)) > limit {
|
||||
return nil, fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GzipCompressFile 压缩文件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 日志归档、文件传输常用
|
||||
func GzipCompressFile(src, dst string) error {
|
||||
// #nosec G304 -- src/dst 为调用方提供的本地文件路径,压缩 API 固有语义,非不可信输入
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// #nosec G304 -- 同上,dst 为调用方指定输出路径
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
gz := gzip.NewWriter(dstFile)
|
||||
defer gz.Close()
|
||||
|
||||
// 保留原文件名和时间戳
|
||||
if info, err := srcFile.Stat(); err == nil {
|
||||
@@ -66,14 +133,29 @@ func GzipCompressFile(src, dst string) error {
|
||||
gz.ModTime = info.ModTime()
|
||||
}
|
||||
|
||||
_, err = io.Copy(gz, srcFile)
|
||||
return err
|
||||
_, copyErr := io.Copy(gz, srcFile)
|
||||
// gz.Close 刷出 gzip 尾部(含 CRC/大小校验),失败说明归档损坏,必须向上传播(M16/B18)。
|
||||
closeErr := gz.Close()
|
||||
// dstFile.Close 失败(如延迟写盘)同样意味着归档可能不完整。
|
||||
dstErr := dstFile.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return dstErr
|
||||
}
|
||||
|
||||
// GzipDecompressFile 解压文件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: GzipCompressFile 的反向操作
|
||||
// GzipDecompressFile 解压文件。默认上限 100MB 防磁盘耗尽(C5b);
|
||||
// 需解压更大文件请用 GzipDecompressFileWithOptions。
|
||||
func GzipDecompressFile(src, dst string) error {
|
||||
return GzipDecompressFileWithOptions(src, dst, DecompressOptions{})
|
||||
}
|
||||
|
||||
// GzipDecompressFileWithOptions 解压文件,可配置大小上限(C5b)。
|
||||
func GzipDecompressFileWithOptions(src, dst string, opts DecompressOptions) (err error) {
|
||||
// #nosec G304 -- src 为调用方提供的本地文件路径,解压 API 固有语义,非不可信输入
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -86,130 +168,236 @@ func GzipDecompressFile(src, dst string) error {
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
// #nosec G304 -- dst 为调用方指定输出路径,解压 API 固有语义
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, gz)
|
||||
return err
|
||||
}
|
||||
|
||||
// Zip 压缩文件或目录
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 批量打包下载、备份常用
|
||||
// 参数: zipPath 目标zip文件路径,paths 要压缩的文件或目录列表
|
||||
func Zip(zipPath string, paths []string) error {
|
||||
// 创建目标目录
|
||||
if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建 zip 文件
|
||||
archive, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer archive.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(archive)
|
||||
defer zipWriter.Close()
|
||||
|
||||
for _, srcPath := range paths {
|
||||
srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator))
|
||||
|
||||
err = filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建文件头
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Method = zip.Deflate
|
||||
|
||||
// 设置相对路径
|
||||
header.Name, err = filepath.Rel(filepath.Dir(srcPath), path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
header.Name += string(os.PathSeparator)
|
||||
}
|
||||
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 dst,避免被拒炸弹遗留残片;
|
||||
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
|
||||
defer func() {
|
||||
if cerr := dstFile.Close(); cerr != nil {
|
||||
err = errors.Join(err, cerr)
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(dst)
|
||||
}
|
||||
}()
|
||||
|
||||
limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
|
||||
var written int64
|
||||
if limit > 0 {
|
||||
// CopyN 最多读 limit+1 字节,超限即判定为炸弹。
|
||||
written, err = io.CopyN(dstFile, gz, limit+1)
|
||||
} else {
|
||||
// #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹
|
||||
written, err = io.Copy(dstFile, gz)
|
||||
}
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
if limit > 0 && written > limit {
|
||||
return fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unzip 解压 zip 文件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: Zip 的反向操作
|
||||
// 参数: zipPath zip文件路径,dstDir 目标目录
|
||||
// Zip 压缩文件或目录
|
||||
// 参数: zipPath 目标zip文件路径,paths 要压缩的文件或目录列表
|
||||
func Zip(zipPath string, paths []string) error {
|
||||
// 创建目标目录
|
||||
if err := os.MkdirAll(filepath.Dir(zipPath), 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建 zip 文件
|
||||
// #nosec G304 -- zipPath 为调用方指定输出路径,压缩 API 固有语义
|
||||
archive, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zipWriter := zip.NewWriter(archive)
|
||||
|
||||
walkErr := func() error {
|
||||
for _, srcPath := range paths {
|
||||
srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator))
|
||||
|
||||
// #nosec G122 -- 压缩调用方提供的源路径,非解压不可信输入;symlink 跟随是压缩场景的可接受行为
|
||||
err := filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建文件头
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Method = zip.Deflate
|
||||
|
||||
// 设置相对路径
|
||||
header.Name, err = filepath.Rel(filepath.Dir(srcPath), path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
header.Name += string(os.PathSeparator)
|
||||
}
|
||||
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// #nosec G304,G122 -- path 为 Walk 遍历调用方源路径产生,非不可信输入;压缩场景接受 symlink 跟随语义。
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
// zipWriter.Close 刷出中央目录记录,失败说明归档损坏,必须向上传播(M16/B18)。
|
||||
zipCloseErr := zipWriter.Close()
|
||||
archiveCloseErr := archive.Close()
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if zipCloseErr != nil {
|
||||
return zipCloseErr
|
||||
}
|
||||
return archiveCloseErr
|
||||
}
|
||||
|
||||
// Unzip 解压 zip 文件。默认启用 Zip-Slip 防护(前缀锚定 + 拒绝符号链接),
|
||||
// 单条目上限 100MB、累计上限 1GB 防解压炸弹(C5b);需自定义上限请用 UnzipWithOptions。
|
||||
func Unzip(zipPath, dstDir string) error {
|
||||
return UnzipWithOptions(zipPath, dstDir, DecompressOptions{})
|
||||
}
|
||||
|
||||
// UnzipWithOptions 解压 zip 文件,可配置大小上限(C5b)。Zip-Slip 防护始终启用。
|
||||
func UnzipWithOptions(zipPath, dstDir string, opts DecompressOptions) error {
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// 用绝对路径作目标锚定根,避免相对路径 + `..` 组合绕过前缀校验(C5a)。
|
||||
absDst, err := filepath.Abs(dstDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve unzip destination failed: %w", err)
|
||||
}
|
||||
absDst = filepath.Clean(absDst)
|
||||
|
||||
entryLimit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
|
||||
totalLimit := resolveLimit(opts.MaxTotalBytes, defaultDecompressTotalLimit)
|
||||
var total int64
|
||||
|
||||
for _, file := range reader.File {
|
||||
if err := unzipFile(file, dstDir); err != nil {
|
||||
written, err := unzipFile(file, absDst, entryLimit, totalLimit, total)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += written
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzipFile(file *zip.File, dstDir string) error {
|
||||
filePath := path.Join(dstDir, file.Name)
|
||||
// unzipFile 解压单个 zip 条目到 absDst 下。
|
||||
// entryLimit: 单条目上限(-1 不限);totalLimit: 累计上限(-1 不限);accrued: 已解压累计字节。
|
||||
// 返回本条目写入字节数。
|
||||
func unzipFile(file *zip.File, absDst string, entryLimit, totalLimit, accrued int64) (written int64, err error) {
|
||||
// 拒绝符号链接条目,防经软链二次穿越(C5a)。
|
||||
if file.Mode()&os.ModeSymlink != 0 {
|
||||
return 0, fmt.Errorf("条目 %s 为符号链接: %w", file.Name, ErrSymlinkEntry)
|
||||
}
|
||||
|
||||
// zip 条目名规范用正斜杠;转成当前平台分隔符后再 Join,并以前缀锚定拒绝 `..` 逃逸(C5a)。
|
||||
name := filepath.FromSlash(file.Name)
|
||||
// 拒绝绝对路径与以分隔符开头的条目(非标准、可疑,避免平台语义差异)。
|
||||
// filepath.IsAbs 在 Windows 不认 "/x"(无盘符)为绝对路径,故补充分隔符前缀检查。
|
||||
if filepath.IsAbs(name) || strings.HasPrefix(name, string(os.PathSeparator)) || strings.HasPrefix(file.Name, "/") {
|
||||
return 0, fmt.Errorf("条目 %s 为绝对路径: %w", file.Name, ErrPathTraversal)
|
||||
}
|
||||
target := filepath.Join(absDst, name)
|
||||
if target == absDst || !strings.HasPrefix(target, absDst+string(os.PathSeparator)) {
|
||||
return 0, fmt.Errorf("条目 %s 逃逸目标目录: %w", file.Name, ErrPathTraversal)
|
||||
}
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
return os.MkdirAll(filePath, 0755)
|
||||
if err := os.MkdirAll(target, 0750); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 创建父目录
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||
return err
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 打开 zip 中的文件
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// 创建目标文件
|
||||
dstFile, err := os.Create(filePath)
|
||||
// #nosec G304 -- target 经前缀锚定校验(absDst+sep),已防 Zip-Slip 逃逸
|
||||
dstFile, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 target,避免被拒炸弹遗留残片;
|
||||
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
|
||||
defer func() {
|
||||
if cerr := dstFile.Close(); cerr != nil {
|
||||
err = errors.Join(err, cerr)
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(target)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(dstFile, rc)
|
||||
return err
|
||||
// 计算本次拷贝上限:单条目上限与累计剩余上限中较小者(-1 视为无限)。
|
||||
// remaining: 累计剩余(-1 表示累计不限);totalLimit>0 时若已无剩余,直接判超限。
|
||||
remaining := int64(-1)
|
||||
if totalLimit > 0 {
|
||||
remaining = totalLimit - accrued
|
||||
if remaining <= 0 {
|
||||
return 0, fmt.Errorf("累计解压超过上限 %d 字节: %w", totalLimit, ErrDecompressLimit)
|
||||
}
|
||||
}
|
||||
cap := minLimit(entryLimit, remaining)
|
||||
// cap 为 -1(两者皆不限)或 >0(有限上限,剩余已保证 >0),不会是 0。
|
||||
|
||||
if cap > 0 {
|
||||
written, err = io.CopyN(dstFile, rc, cap+1)
|
||||
} else {
|
||||
// #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹
|
||||
written, err = io.Copy(dstFile, rc)
|
||||
}
|
||||
if err != nil && err != io.EOF {
|
||||
return written, err
|
||||
}
|
||||
if cap > 0 && written > cap {
|
||||
return written, fmt.Errorf("条目 %s 超过解压上限 %d 字节: %w", file.Name, cap, ErrDecompressLimit)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
package compress_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/compress"
|
||||
)
|
||||
|
||||
// makeZipAt 构造一个 zip 文件,entries 为 name -> content;dir 条目用空 content 且 IsDir=true。
|
||||
func makeZipAt(t *testing.T, zipPath string, entries []struct {
|
||||
name string
|
||||
content string
|
||||
isDir bool
|
||||
}) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(zipPath), 0750); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create zip: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
zw := zip.NewWriter(f)
|
||||
for _, e := range entries {
|
||||
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
|
||||
if e.isDir {
|
||||
hdr.SetMode(os.ModeDir | 0750)
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateHeader %s: %v", e.name, err)
|
||||
}
|
||||
if !e.isDir {
|
||||
if _, err := w.Write([]byte(e.content)); err != nil {
|
||||
t.Fatalf("write %s: %v", e.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip writer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== C5a:Zip-Slip =====
|
||||
|
||||
// 回归 C5a:zip 条目名含 `..` 逃逸目标目录必须被拒绝,且不在 dst 外创建/覆盖文件。
|
||||
func TestUnzipZipSlipRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "evil.zip")
|
||||
// 在 dst 的父目录放一个蜜罐文件,确保穿越不会覆盖它。
|
||||
canary := filepath.Join(dir, "canary.txt")
|
||||
if err := os.WriteFile(canary, []byte("original"), 0644); err != nil {
|
||||
t.Fatalf("write canary: %v", err)
|
||||
}
|
||||
|
||||
dst := filepath.Join(dir, "out")
|
||||
makeZipAt(t, zipPath, []struct {
|
||||
name string
|
||||
content string
|
||||
isDir bool
|
||||
}{
|
||||
{name: "../canary.txt", content: "pwned"},
|
||||
})
|
||||
|
||||
err := compress.Unzip(zipPath, dst)
|
||||
if !errors.Is(err, compress.ErrPathTraversal) {
|
||||
t.Errorf("Unzip with ../ entry err = %v, want ErrPathTraversal", err)
|
||||
}
|
||||
// 蜜罐文件必须未被覆盖。
|
||||
data, err := os.ReadFile(canary)
|
||||
if err != nil {
|
||||
t.Fatalf("read canary: %v", err)
|
||||
}
|
||||
if string(data) != "original" {
|
||||
t.Errorf("canary overwritten by Zip-Slip: got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C5a:绝对路径条目必须被拒绝。
|
||||
func TestUnzipAbsolutePathRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "abs.zip")
|
||||
dst := filepath.Join(dir, "out")
|
||||
makeZipAt(t, zipPath, []struct {
|
||||
name string
|
||||
content string
|
||||
isDir bool
|
||||
}{
|
||||
{name: "/etc/evil.txt", content: "x"},
|
||||
})
|
||||
if err := compress.Unzip(zipPath, dst); !errors.Is(err, compress.ErrPathTraversal) {
|
||||
t.Errorf("Unzip absolute path err = %v, want ErrPathTraversal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C5a:合法相对路径条目不误伤(含子目录)。
|
||||
func TestUnzipNormalEntriesWork(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "ok.zip")
|
||||
dst := filepath.Join(dir, "out")
|
||||
makeZipAt(t, zipPath, []struct {
|
||||
name string
|
||||
content string
|
||||
isDir bool
|
||||
}{
|
||||
{name: "sub/", isDir: true},
|
||||
{name: "sub/a.txt", content: "hello"},
|
||||
{name: "top.txt", content: "world"},
|
||||
})
|
||||
if err := compress.Unzip(zipPath, dst); err != nil {
|
||||
t.Fatalf("Unzip normal: %v", err)
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(dst, "sub", "a.txt")); err != nil || string(b) != "hello" {
|
||||
t.Errorf("sub/a.txt = %q, err=%v, want 'hello'", string(b), err)
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(dst, "top.txt")); err != nil || string(b) != "world" {
|
||||
t.Errorf("top.txt = %q, err=%v, want 'world'", string(b), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C5a:符号链接条目必须被拒绝(防经软链二次穿越)。
|
||||
func TestUnzipSymlinkRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "symlink.zip")
|
||||
dst := filepath.Join(dir, "out")
|
||||
makeZipAt(t, zipPath, []struct {
|
||||
name string
|
||||
content string
|
||||
isDir bool
|
||||
}{
|
||||
{name: "lnk", content: "/etc/passwd"}, // content 为链接目标,mode 设为 symlink
|
||||
})
|
||||
// 把条目 mode 改成符号链接:重建 zip 时设置 ModeSymlink。
|
||||
// makeZipAt 不支持 symlink mode,这里直接用底层 API 重建。
|
||||
zipPath2 := filepath.Join(dir, "symlink2.zip")
|
||||
f, err := os.Create(zipPath2)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
zw := zip.NewWriter(f)
|
||||
hdr := &zip.FileHeader{Name: "lnk", Method: zip.Deflate}
|
||||
hdr.SetMode(os.ModeSymlink | 0777)
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateHeader: %v", err)
|
||||
}
|
||||
if _, err := w.Write([]byte("/etc/passwd")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
if err := compress.Unzip(zipPath2, dst); !errors.Is(err, compress.ErrSymlinkEntry) {
|
||||
t.Errorf("Unzip symlink err = %v, want ErrSymlinkEntry", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== C5b:解压炸弹 =====
|
||||
|
||||
// 回归 C5b:GzipDecompress 超限返回错误而非 OOM。
|
||||
// 用显式小上限验证限流代码路径(与默认 100MB 走同一 io.LimitReader + 超限判定逻辑)。
|
||||
func TestGzipDecompressBombLimit(t *testing.T) {
|
||||
// 2MB 解压后数据。
|
||||
big := bytes.Repeat([]byte("A"), 2*1024*1024)
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
if _, err := gz.Write(big); err != nil {
|
||||
t.Fatalf("gzip write: %v", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatalf("gzip close: %v", err)
|
||||
}
|
||||
compressed := buf.Bytes()
|
||||
|
||||
// 显式上限 1MB → 必须拒绝(2MB 超限)。
|
||||
if _, err := compress.GzipDecompressWithOptions(compressed, compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}); !errors.Is(err, compress.ErrDecompressLimit) {
|
||||
t.Errorf("GzipDecompress over-limit err = %v, want ErrDecompressLimit", err)
|
||||
}
|
||||
|
||||
// 显式 -1 不限 → 成功解压完整 2MB。
|
||||
out, err := compress.GzipDecompressWithOptions(compressed, compress.DecompressOptions{MaxBytes: -1})
|
||||
if err != nil {
|
||||
t.Errorf("GzipDecompress unlimited err = %v", err)
|
||||
}
|
||||
if len(out) != len(big) {
|
||||
t.Errorf("unlimited decompressed len = %d, want %d", len(out), len(big))
|
||||
}
|
||||
|
||||
// 默认上限(100MB)放行 2MB 正常数据。
|
||||
if _, err := compress.GzipDecompress(compressed); err != nil {
|
||||
t.Errorf("GzipDecompress default err = %v (2MB should pass default 100MB limit)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C5b:Unzip 单条目上限——超大条目被拒。
|
||||
func TestUnzipEntryBombLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "bomb.zip")
|
||||
dst := filepath.Join(dir, "out")
|
||||
|
||||
// 构造一个含 2MB(解压后)条目的 zip。
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
zw := zip.NewWriter(f)
|
||||
w, err := zw.Create("big.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := w.Write(bytes.Repeat([]byte("A"), 2 * 1024 * 1024)); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
// 显式单条目上限 1MB → 必须拒绝。
|
||||
opts := compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}
|
||||
if err := compress.UnzipWithOptions(zipPath, dst, opts); !errors.Is(err, compress.ErrDecompressLimit) {
|
||||
t.Errorf("Unzip entry bomb err = %v, want ErrDecompressLimit", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C5b:Unzip 累计上限——多个条目累计超限被拒。
|
||||
func TestUnzipTotalBombLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zipPath := filepath.Join(dir, "many.zip")
|
||||
dst := filepath.Join(dir, "out")
|
||||
|
||||
// 5 个 1MB 条目 = 累计 5MB;单条目上限 2MB(不超),累计上限 3MB → 第 4 个累计 4MB 超限。
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
zw := zip.NewWriter(f)
|
||||
chunk := bytes.Repeat([]byte("B"), 1*1024*1024)
|
||||
for i := 0; i < 5; i++ {
|
||||
w, err := zw.Create("file" + string(rune('0'+i)) + ".dat")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := w.Write(chunk); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
opts := compress.DecompressOptions{MaxBytes: 2 * 1024 * 1024, MaxTotalBytes: 3 * 1024 * 1024}
|
||||
if err := compress.UnzipWithOptions(zipPath, dst, opts); !errors.Is(err, compress.ErrDecompressLimit) {
|
||||
t.Errorf("Unzip total bomb err = %v, want ErrDecompressLimit", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C5b:GzipDecompressFile 单流封顶——超限返回错误而非磁盘耗尽。
|
||||
func TestGzipDecompressFileBombLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 2MB 解压后数据写入 gzip 文件。
|
||||
big := bytes.Repeat([]byte("A"), 2*1024*1024)
|
||||
srcGz := filepath.Join(dir, "src.gz")
|
||||
{
|
||||
f, err := os.Create(srcGz)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
gz := gzip.NewWriter(f)
|
||||
if _, err := gz.Write(big); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatalf("close gz: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// 显式上限 1MB → 必须拒绝(2MB 超限)。
|
||||
dst1 := filepath.Join(dir, "out1.txt")
|
||||
if err := compress.GzipDecompressFileWithOptions(srcGz, dst1, compress.DecompressOptions{MaxBytes: 1 * 1024 * 1024}); !errors.Is(err, compress.ErrDecompressLimit) {
|
||||
t.Errorf("GzipDecompressFile over-limit err = %v, want ErrDecompressLimit", err)
|
||||
}
|
||||
|
||||
// 显式 -1 不限 → 成功解压完整 2MB。
|
||||
dst2 := filepath.Join(dir, "out2.txt")
|
||||
if err := compress.GzipDecompressFileWithOptions(srcGz, dst2, compress.DecompressOptions{MaxBytes: -1}); err != nil {
|
||||
t.Errorf("GzipDecompressFile unlimited err = %v", err)
|
||||
}
|
||||
b, err := os.ReadFile(dst2)
|
||||
if err != nil {
|
||||
t.Fatalf("read out: %v", err)
|
||||
}
|
||||
if len(b) != len(big) {
|
||||
t.Errorf("unlimited out len = %d, want %d", len(b), len(big))
|
||||
}
|
||||
|
||||
// 默认上限(100MB)放行 2MB。
|
||||
dst3 := filepath.Join(dir, "out3.txt")
|
||||
if err := compress.GzipDecompressFile(srcGz, dst3); err != nil {
|
||||
t.Errorf("GzipDecompressFile default err = %v (2MB should pass default 100MB limit)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容性回归:原有 Zip/Unzip 闭环(合法归档)在默认防护下仍正常。
|
||||
func TestZipUnzipRoundTripStillWorks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
content := "round trip content"
|
||||
if err := os.WriteFile(src, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
zipPath := filepath.Join(dir, "rt.zip")
|
||||
if err := compress.Zip(zipPath, []string{src}); err != nil {
|
||||
t.Fatalf("Zip: %v", err)
|
||||
}
|
||||
dst := filepath.Join(dir, "out")
|
||||
if err := compress.Unzip(zipPath, dst); err != nil {
|
||||
t.Fatalf("Unzip: %v", err)
|
||||
}
|
||||
// Zip 用 filepath.Rel(src 的父目录, src) = "src.txt"。
|
||||
b, err := os.ReadFile(filepath.Join(dst, "src.txt"))
|
||||
if err != nil {
|
||||
// 目录结构可能因平台分隔符略有差异,尝试找 src.txt。
|
||||
_ = filepath.Walk(dst, func(p string, _ os.FileInfo, _ error) error {
|
||||
if strings.HasSuffix(p, "src.txt") {
|
||||
b, err = os.ReadFile(p)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if string(b) != content {
|
||||
t.Errorf("round trip = %q, want %q", string(b), content)
|
||||
}
|
||||
}
|
||||
+873
-200
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,499 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// writeConfig 写入临时配置文件并返回路径。
|
||||
func writeConfig(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(os.TempDir(), "xlgo_c10_test")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func validConfigYAML(port int) string {
|
||||
return "app:\n name: c10\n env: dev\nserver:\n port: " + itoa(port) + "\n"
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
b[i] = '-'
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// TestSetDefaultManagerConcurrent 并发置换默认 Manager 与并发读取,
|
||||
// 必须经 -race 无竞争(C10a)。
|
||||
func TestSetDefaultManagerConcurrent(t *testing.T) {
|
||||
// 准备若干可加载的 Manager
|
||||
paths := make([]string, 4)
|
||||
for i := range paths {
|
||||
paths[i] = writeConfig(t, "c10_concurrent_"+itoa(i)+".yaml", validConfigYAML(9000+i))
|
||||
}
|
||||
defer func() {
|
||||
for _, p := range paths {
|
||||
os.Remove(p)
|
||||
}
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
// 写者:并发 SetDefaultManager
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
m := config.NewManager(paths[idx])
|
||||
_, _ = m.Load()
|
||||
config.SetDefaultManager(m)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
// 读者:并发 Get / GetViper / GetString
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = config.Get()
|
||||
_ = config.GetViper()
|
||||
_ = config.GetString("server.port")
|
||||
}
|
||||
}()
|
||||
}
|
||||
// 跑足够长以让 -race 采到
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
// 还原全局状态,避免污染其他测试
|
||||
config.SetDefaultManager(nil)
|
||||
}
|
||||
|
||||
// TestLoadReturnsDefensiveCopy Load 返回的配置与 Get() 内部指针独立,
|
||||
// 修改返回值不污染全局(C10c)。
|
||||
func TestLoadReturnsDefensiveCopy(t *testing.T) {
|
||||
p := writeConfig(t, "c10_defensive.yaml", validConfigYAML(8081))
|
||||
defer os.Remove(p)
|
||||
|
||||
config.Set(nil)
|
||||
cfg, err := config.Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 8081 {
|
||||
t.Fatalf("port = %d, want 8081", cfg.Server.Port)
|
||||
}
|
||||
|
||||
// 调用方修改返回值
|
||||
cfg.Server.Port = 1
|
||||
cfg.App.Name = "mutated"
|
||||
|
||||
// 全局读取不受影响
|
||||
got := config.Get()
|
||||
if got == nil {
|
||||
t.Fatal("Get returned nil")
|
||||
}
|
||||
if got.Server.Port != 8081 {
|
||||
t.Errorf("global port polluted = %d, want 8081 (C10c)", got.Server.Port)
|
||||
}
|
||||
if got.App.Name == "mutated" {
|
||||
t.Errorf("global app name polluted (C10c)")
|
||||
}
|
||||
|
||||
config.SetDefaultManager(nil)
|
||||
}
|
||||
|
||||
// TestLoadDefensiveCopySliceContract 固化 M-G 的深拷贝语义契约:
|
||||
// 标量字段与切片字段均独立(修改不污染全局)。M-G 修复后 Load() 返回 Clone() 深拷贝,
|
||||
// 切片字段不再共享底层数组——调用方可安全修改切片元素。本测试锁定该行为,防止回退到浅拷贝。
|
||||
func TestLoadDefensiveCopySliceContract(t *testing.T) {
|
||||
content := "app:\n name: c10slice\nserver:\n port: 8090\ncors:\n allowed_origins:\n - https://a.example.com\n - https://b.example.com\n"
|
||||
p := writeConfig(t, "c10_slice.yaml", content)
|
||||
defer os.Remove(p)
|
||||
|
||||
config.Set(nil)
|
||||
cfg, err := config.Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
// 标量独立
|
||||
cfg.Server.Port = 1
|
||||
if got := config.Get().Server.Port; got != 8090 {
|
||||
t.Errorf("scalar polluted = %d, want 8090", got)
|
||||
}
|
||||
|
||||
// M-G:切片底层数组独立(深拷贝)——修改切片元素不污染全局。
|
||||
cfg.CORS.AllowedOrigins[0] = "https://mutated.example.com"
|
||||
if got := config.Get().CORS.AllowedOrigins[0]; got != "https://a.example.com" {
|
||||
t.Errorf("slice should be deep-copied (independent), global polluted to %q, want %q", got, "https://a.example.com")
|
||||
}
|
||||
|
||||
// append 也不应污染全局(深拷贝后调用方持独立切片)
|
||||
cfg.CORS.AllowedOrigins = append(cfg.CORS.AllowedOrigins, "https://c.example.com")
|
||||
if got := config.Get().CORS.AllowedOrigins; len(got) != 2 {
|
||||
t.Errorf("global slice length should be unaffected by caller append, got %d, want 2", len(got))
|
||||
}
|
||||
|
||||
config.SetDefaultManager(nil)
|
||||
}
|
||||
func TestReloadInvalidConfigKeepsOld(t *testing.T) {
|
||||
p := writeConfig(t, "c10_reload_bad.yaml", validConfigYAML(8082))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got := m.Get().Server.Port; got != 8082 {
|
||||
t.Fatalf("initial port = %d, want 8082", got)
|
||||
}
|
||||
|
||||
// 覆盖为非法配置(端口越界)
|
||||
if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
err := m.Reload()
|
||||
if err == nil {
|
||||
t.Fatal("Reload invalid config should return error (C10b)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "server.port") {
|
||||
t.Errorf("error should mention server.port, got %v", err)
|
||||
}
|
||||
// 旧配置保留
|
||||
if got := m.Get().Server.Port; got != 8082 {
|
||||
t.Errorf("old config not preserved = %d, want 8082 (C10b)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHotReloadInvalidConfigKeepsOld 文件监听路径遇非法配置保留旧配置,
|
||||
// 且不触发回调;监听仍存活,后续合法变更正常生效(C10b + C10d 监听健壮性)。
|
||||
func TestHotReloadInvalidConfigKeepsOld(t *testing.T) {
|
||||
p := writeConfig(t, "c10_watch_bad.yaml", validConfigYAML(8083))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
changes := make(chan int, 16)
|
||||
m.RegisterCallback(func(c *config.Config) {
|
||||
select {
|
||||
case changes <- c.Server.Port:
|
||||
default:
|
||||
}
|
||||
})
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher: %v", err)
|
||||
}
|
||||
defer m.StopWatcher()
|
||||
|
||||
// 1) 写入非法配置:应保留旧配置、不触发回调
|
||||
if err := os.WriteFile(p, []byte("app:\n name: bad\nserver:\n port: 99999\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile invalid: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if got := m.Get().Server.Port; got != 8083 {
|
||||
t.Errorf("invalid config leaked into global = %d, want 8083 (C10b)", got)
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
select {
|
||||
case port := <-changes:
|
||||
t.Errorf("callback fired for invalid config with port %d (C10b)", port)
|
||||
default:
|
||||
}
|
||||
|
||||
// 2) 写入合法配置:监听仍存活,应触发回调且全局更新
|
||||
if err := os.WriteFile(p, []byte(validConfigYAML(8084)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile valid: %v", err)
|
||||
}
|
||||
deadline = time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if got := m.Get().Server.Port; got == 8084 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if got := m.Get().Server.Port; got != 8084 {
|
||||
t.Fatalf("watcher did not reload valid config = %d, want 8084", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopWatcherReleasesGoroutine StopWatcher 后监听 goroutine 退出,无泄漏(C10d)。
|
||||
func TestStopWatcherReleasesGoroutine(t *testing.T) {
|
||||
p := writeConfig(t, "c10_stop.yaml", validConfigYAML(8085))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher: %v", err)
|
||||
}
|
||||
|
||||
// 等待监听 goroutine 就绪
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
before := runtime.NumGoroutine()
|
||||
|
||||
m.StopWatcher()
|
||||
|
||||
// 轮询确认 goroutine 退出
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runtime.NumGoroutine() < before {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if after := runtime.NumGoroutine(); after >= before {
|
||||
t.Errorf("watcher goroutine not released: before=%d after=%d (C10d)", before, after)
|
||||
}
|
||||
|
||||
// 幂等:再次 Stop 不 panic
|
||||
m.StopWatcher()
|
||||
}
|
||||
|
||||
// TestStartWatcherIdempotent 重复 StartWatcher 不创建多个监听 goroutine(幂等)。
|
||||
func TestStartWatcherIdempotent(t *testing.T) {
|
||||
p := writeConfig(t, "c10_idem.yaml", validConfigYAML(8086))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher 1: %v", err)
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
once := runtime.NumGoroutine()
|
||||
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher 2: %v", err)
|
||||
}
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher 3: %v", err)
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
twice := runtime.NumGoroutine()
|
||||
if twice > once {
|
||||
t.Errorf("idempotent StartWatcher leaked goroutine: once=%d twice=%d", once, twice)
|
||||
}
|
||||
m.StopWatcher()
|
||||
}
|
||||
|
||||
func TestStopWatcherWaitsInFlightReload(t *testing.T) {
|
||||
p := writeConfig(t, "c10_stop_wait.yaml", validConfigYAML(8091))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
m.RegisterCallback(func(*config.Config) {
|
||||
close(entered)
|
||||
<-release
|
||||
})
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(p, []byte(validConfigYAML(8092)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("热更新回调未触发")
|
||||
}
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
m.StopWatcher()
|
||||
close(stopped)
|
||||
}()
|
||||
select {
|
||||
case <-stopped:
|
||||
t.Fatal("StopWatcher 不应在 reload 回调结束前返回")
|
||||
case <-time.After(120 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("StopWatcher 未等待到 reload 回调结束")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithWatchFailureKeepsOldWatcher(t *testing.T) {
|
||||
p := writeConfig(t, "c10_keep_old.yaml", validConfigYAML(8093))
|
||||
defer os.Remove(p)
|
||||
|
||||
changes := make(chan int, 4)
|
||||
if _, err := config.LoadWithWatch(p, func(c *config.Config) {
|
||||
changes <- c.Server.Port
|
||||
}); err != nil {
|
||||
t.Fatalf("LoadWithWatch old: %v", err)
|
||||
}
|
||||
defer config.StopWatcher()
|
||||
|
||||
missing := filepath.Join(filepath.Dir(p), "missing.yaml")
|
||||
if _, err := config.LoadWithWatch(missing, nil); err == nil {
|
||||
t.Fatal("加载缺失配置应失败")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(p, []byte(validConfigYAML(8094)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
select {
|
||||
case port := <-changes:
|
||||
if port != 8094 {
|
||||
t.Fatalf("旧 watcher 回调端口错误: %d", port)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("加载失败后旧 watcher 不应被停止")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultManagerStopsOldWatcher(t *testing.T) {
|
||||
oldPath := writeConfig(t, "c10_old_default.yaml", validConfigYAML(8095))
|
||||
newPath := writeConfig(t, "c10_new_default.yaml", validConfigYAML(8096))
|
||||
defer os.Remove(oldPath)
|
||||
defer os.Remove(newPath)
|
||||
|
||||
oldManager := config.NewManager(oldPath)
|
||||
if _, err := oldManager.Load(); err != nil {
|
||||
t.Fatalf("old Load: %v", err)
|
||||
}
|
||||
changes := make(chan int, 4)
|
||||
oldManager.RegisterCallback(func(c *config.Config) {
|
||||
changes <- c.Server.Port
|
||||
})
|
||||
if err := oldManager.StartWatcher(); err != nil {
|
||||
t.Fatalf("old StartWatcher: %v", err)
|
||||
}
|
||||
config.SetDefaultManager(oldManager)
|
||||
|
||||
newManager := config.NewManager(newPath)
|
||||
if _, err := newManager.Load(); err != nil {
|
||||
t.Fatalf("new Load: %v", err)
|
||||
}
|
||||
config.SetDefaultManager(newManager)
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
if err := os.WriteFile(oldPath, []byte(validConfigYAML(8097)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile old: %v", err)
|
||||
}
|
||||
select {
|
||||
case port := <-changes:
|
||||
t.Fatalf("旧 watcher 已停止,不应收到端口 %d", port)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetAndViperReturnCopies(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{Name: "copy", Env: "dev"},
|
||||
CORS: config.CORSConfig{
|
||||
AllowedOrigins: []string{"https://a.example.com"},
|
||||
},
|
||||
}
|
||||
if err := config.Set(cfg); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
cfg.App.Name = "mutated-input"
|
||||
cfg.CORS.AllowedOrigins[0] = "https://mutated-input.example.com"
|
||||
got := config.Get()
|
||||
if got.App.Name != "copy" {
|
||||
t.Fatalf("Set 应保存副本,实际 App.Name=%q", got.App.Name)
|
||||
}
|
||||
if got.CORS.AllowedOrigins[0] != "https://a.example.com" {
|
||||
t.Fatalf("Set 应深拷贝切片,实际 origin=%q", got.CORS.AllowedOrigins[0])
|
||||
}
|
||||
|
||||
got.App.Name = "mutated-get"
|
||||
got.CORS.AllowedOrigins[0] = "https://mutated-get.example.com"
|
||||
gotAgain := config.Get()
|
||||
if gotAgain.App.Name != "copy" || gotAgain.CORS.AllowedOrigins[0] != "https://a.example.com" {
|
||||
t.Fatalf("Get 应返回副本,实际 %+v", gotAgain)
|
||||
}
|
||||
|
||||
if err := config.Set(&config.Config{Server: config.ServerConfig{Port: 99999}}); err == nil {
|
||||
t.Fatal("Set 非法配置应返回错误")
|
||||
}
|
||||
if got := config.Get().App.Name; got != "copy" {
|
||||
t.Fatalf("Set 非法配置不应覆盖旧配置,实际 App.Name=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetViperReturnsSnapshot(t *testing.T) {
|
||||
p := writeConfig(t, "c10_viper_snapshot.yaml", validConfigYAML(8098))
|
||||
defer os.Remove(p)
|
||||
|
||||
if _, err := config.Load(p); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
v := config.GetViper()
|
||||
if v == nil {
|
||||
t.Fatal("GetViper returned nil")
|
||||
}
|
||||
v.Set("app.name", "mutated")
|
||||
if got := config.GetString("app.name"); got != "c10" {
|
||||
t.Fatalf("GetViper 应返回快照,不应污染内部 viper,实际 app.name=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// TestSetKeepsViperInSync_Hconfig1 固化 H-config-1:Set(cfg) 后 Get() 与
|
||||
// GetString/GetInt/GetBool/GetViper 必须读到同一配置世界,消除原"Set 只更新类型化视图、
|
||||
// viper 视图停留旧值"的静默分裂(违反 C1 单一配置源)。
|
||||
func TestSetKeepsViperInSync_Hconfig1(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{Name: "sync-test", SiteName: "sync_site", Env: "prod", Debug: true},
|
||||
Server: config.ServerConfig{Port: 7777},
|
||||
}
|
||||
if err := config.Set(cfg); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
// 类型化视图
|
||||
got := config.Get()
|
||||
if got == nil || got.App.Name != "sync-test" || got.Server.Port != 7777 {
|
||||
t.Fatalf("Get() 不一致: %+v", got)
|
||||
}
|
||||
// viper 视图必须同源
|
||||
if v := config.GetString("app.name"); v != "sync-test" {
|
||||
t.Errorf("GetString(app.name) = %q, want sync-test(H-config-1 同源)", v)
|
||||
}
|
||||
if v := config.GetInt("server.port"); v != 7777 {
|
||||
t.Errorf("GetInt(server.port) = %d, want 7777(H-config-1)", v)
|
||||
}
|
||||
if v := config.GetBool("app.debug"); v != true {
|
||||
t.Errorf("GetBool(app.debug) = %v, want true(H-config-1)", v)
|
||||
}
|
||||
if v := config.GetString("app.env"); v != "prod" {
|
||||
t.Errorf("GetString(app.env) = %q, want prod(H-config-1)", v)
|
||||
}
|
||||
vp := config.GetViper()
|
||||
if vp == nil || vp.GetString("app.name") != "sync-test" {
|
||||
t.Errorf("GetViper().GetString(app.name) 不一致(H-config-1): %v", vp)
|
||||
}
|
||||
|
||||
// Set(nil) 清空,GetString 返回空(m.v=nil)
|
||||
if err := config.Set(nil); err != nil {
|
||||
t.Fatalf("Set(nil): %v", err)
|
||||
}
|
||||
if v := config.GetString("app.name"); v != "" {
|
||||
t.Errorf("Set(nil) 后 GetString 应为空, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetDurationGetDurationConsistent_Hconfig1 锁定 H-config-1 已知行为:Duration 字段经 Set
|
||||
// 重建 viper 后,GetString 字面格式与文件加载不同(mapstructure 存 time.Duration -> Duration.String()),
|
||||
// 但 typed view 与 GetDuration 在两条路径下一致。Duration 应经 typed view / GetDuration 读取。
|
||||
func TestSetDurationGetDurationConsistent_Hconfig1(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{Secret: strings.Repeat("k", 32), Expire: 24 * time.Hour},
|
||||
}
|
||||
if err := config.Set(cfg); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
if got := config.Get().JWT.Expire; got != 24*time.Hour {
|
||||
t.Errorf("Get().JWT.Expire = %v, want 24h", got)
|
||||
}
|
||||
if got := config.GetViper().GetDuration("jwt.expire"); got != 24*time.Hour {
|
||||
t.Errorf("GetDuration(jwt.expire) = %v, want 24h(Duration 同源应读 GetDuration/typed view)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadCallbackPanicIsolation_Mconfig1 固化 M-config-1:单个热重载回调 panic 不得
|
||||
// 向上传播、不得阻断后续回调、不得让 watcher/reload 链路静默失效。
|
||||
func TestReloadCallbackPanicIsolation_Mconfig1(t *testing.T) {
|
||||
p := writeConfig(t, "m1_panic.yaml", validConfigYAML(8301))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
called := make(chan int, 4)
|
||||
m.RegisterCallback(func(*config.Config) {
|
||||
panic("boom from user callback") // 模拟用户回调 panic
|
||||
})
|
||||
m.RegisterCallback(func(c *config.Config) {
|
||||
called <- c.Server.Port
|
||||
})
|
||||
|
||||
// Reload 不应把回调 panic 向上传播
|
||||
if err := m.Reload(); err != nil {
|
||||
t.Fatalf("Reload 不应传播回调 panic: %v", err)
|
||||
}
|
||||
// 第二个回调仍触发:panic 被隔离,未阻断后续回调
|
||||
select {
|
||||
case port := <-called:
|
||||
if port != 8301 {
|
||||
t.Errorf("第二个回调端口 = %d, want 8301", port)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("panic 后续回调未触发(M-config-1 隔离失败)")
|
||||
}
|
||||
|
||||
// 后续 reload 仍正常,watcher/reload 链路未静默失效
|
||||
if err := os.WriteFile(p, []byte(validConfigYAML(8302)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if err := m.Reload(); err != nil {
|
||||
t.Fatalf("panic 后 Reload 应仍可用: %v", err)
|
||||
}
|
||||
select {
|
||||
case port := <-called:
|
||||
if port != 8302 {
|
||||
t.Errorf("第二次 reload 回调端口 = %d, want 8302", port)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("panic 后 reload 链路静默失效(M-config-1)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLDSN_TLS_Mconfig2 固化 M-config-2 MySQL TLS:TLS=false 无 tls 参数;
|
||||
// TLS=true 无 CA 用内置 tls=true;TLS=true 有 CA 用 tls=MySQLTLSConfigName。
|
||||
func TestMySQLDSN_TLS_Mconfig2(t *testing.T) {
|
||||
db := config.DatabaseConfig{Host: "h", Port: 3306, User: "u", Password: "p", Name: "n"}
|
||||
if dsn := db.MySQLDSN(); strings.Contains(dsn, "tls=") {
|
||||
t.Errorf("TLS=false 不应含 tls 参数, dsn=%s", dsn)
|
||||
}
|
||||
|
||||
db.TLS = true
|
||||
if dsn := db.MySQLDSN(); !strings.Contains(dsn, "&tls=true") {
|
||||
t.Errorf("TLS=true 无 CA 应用内置 tls=true, dsn=%s", dsn)
|
||||
}
|
||||
|
||||
db.TLSRootCA = "/path/ca.pem"
|
||||
if dsn := db.MySQLDSN(); !strings.Contains(dsn, "&tls="+config.MySQLTLSConfigName) {
|
||||
t.Errorf("TLS=true 有 CA 应用 tls=%s, dsn=%s", config.MySQLTLSConfigName, dsn)
|
||||
}
|
||||
|
||||
// CustomDSN 优先,TLS 字段不影响 DSN()
|
||||
db.CustomDSN = "custom-dsn"
|
||||
if dsn := db.DSN(); dsn != "custom-dsn" {
|
||||
t.Errorf("CustomDSN 应优先, got %s", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostgresDSN_SSLMode_Mconfig2 固化 M-config-2 Postgres SSLMode:空默认 prefer;
|
||||
// 显式值透传(sslmode 不加引号,与原 disable 格式一致)。
|
||||
func TestPostgresDSN_SSLMode_Mconfig2(t *testing.T) {
|
||||
db := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n"}
|
||||
if dsn := db.PostgresDSN(); !strings.Contains(dsn, "sslmode=prefer ") {
|
||||
t.Errorf("空 SSLMode 默认 prefer, dsn=%s", dsn)
|
||||
}
|
||||
|
||||
for _, mode := range []string{"disable", "allow", "require", "verify-ca", "verify-full"} {
|
||||
db.SSLMode = mode
|
||||
if dsn := db.PostgresDSN(); !strings.Contains(dsn, "sslmode="+mode+" ") {
|
||||
t.Errorf("SSLMode=%s 应透传, dsn=%s", mode, dsn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMaxIdleExceedsOpen_Lconfig3 固化 L-config-3:MaxOpenConns>0 时
|
||||
// MaxIdleConns>MaxOpenConns 视为配置错误;MaxOpenConns=0(无限)不校验。
|
||||
func TestValidateMaxIdleExceedsOpen_Lconfig3(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
|
||||
MaxOpenConns: 10, MaxIdleConns: 20,
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "max_idle_conns") {
|
||||
t.Fatalf("MaxIdleConns>MaxOpenConns 应报错, got: %v", err)
|
||||
}
|
||||
|
||||
cfg.Database.MaxOpenConns = 0 // 未配置/无限,不校验 idle
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("MaxOpenConns=0 时不该校验 idle, got: %v", err)
|
||||
}
|
||||
|
||||
cfg.Database.MaxOpenConns = 20 // 合法
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("合法配置不应报错, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatePostgresSSLMode_Mconfig2 固化 M-config-2:非法 SSLMode 在 Validate 阶段报错。
|
||||
func TestValidatePostgresSSLMode_Mconfig2(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n",
|
||||
SSLMode: "invalid-mode",
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "ssl_mode") {
|
||||
t.Fatalf("非法 SSLMode 应报错, got: %v", err)
|
||||
}
|
||||
cfg.Database.SSLMode = "require"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("合法 SSLMode=require 不应报错, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTLSRootCAWithoutTLS_Mconfig2 固化 M-config-2:TLSRootCA 需配合 tls:true。
|
||||
func TestValidateTLSRootCAWithoutTLS_Mconfig2(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
|
||||
TLSRootCA: "/path/ca.pem", // TLS=false
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "tls_root_ca") {
|
||||
t.Fatalf("TLSRootCA 无 tls:true 应报错, got: %v", err)
|
||||
}
|
||||
cfg.Database.TLS = true
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("TLS=true + TLSRootCA 合法不应报错, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDSNAddrNilReceiver_Lconfig6 固化 L-config-6:DSN/MySQLDSN/PostgresDSN/Addr 对 nil receiver 安全。
|
||||
func TestDSNAddrNilReceiver_Lconfig6(t *testing.T) {
|
||||
var db *config.DatabaseConfig
|
||||
if v := db.DSN(); v != "" {
|
||||
t.Errorf("nil DatabaseConfig.DSN() = %q, want empty", v)
|
||||
}
|
||||
if v := db.MySQLDSN(); v != "" {
|
||||
t.Errorf("nil MySQLDSN() = %q, want empty", v)
|
||||
}
|
||||
if v := db.PostgresDSN(); v != "" {
|
||||
t.Errorf("nil PostgresDSN() = %q, want empty", v)
|
||||
}
|
||||
var r *config.RedisConfig
|
||||
if v := r.Addr(); v != "" {
|
||||
t.Errorf("nil RedisConfig.Addr() = %q, want empty", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloneDeepCopiesAllSliceFields_Mconfig4 固化 M-config-4:反射枚举 Config 所有切片/map 字段,
|
||||
// 断言 Clone 深拷贝(不同底层数组/map)。fixture 必须填充每个切片字段--新增切片字段若未在 Clone
|
||||
// 中处理、或未在 fixture 中填充,本测试都会失败,形成机械守卫。
|
||||
func TestCloneDeepCopiesAllSliceFields_Mconfig4(t *testing.T) {
|
||||
original := allSlicesPopulatedConfig()
|
||||
clone := original.Clone()
|
||||
|
||||
var check func(o, c reflect.Value, path string)
|
||||
check = func(o, c reflect.Value, path string) {
|
||||
for o.Kind() == reflect.Pointer {
|
||||
o = o.Elem()
|
||||
}
|
||||
for c.Kind() == reflect.Pointer {
|
||||
c = c.Elem()
|
||||
}
|
||||
if o.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
for i := 0; i < o.NumField(); i++ {
|
||||
of := o.Field(i)
|
||||
cf := c.Field(i)
|
||||
p := path + "." + o.Type().Field(i).Name
|
||||
switch of.Kind() {
|
||||
case reflect.Pointer, reflect.Struct:
|
||||
check(of, cf, p)
|
||||
case reflect.Slice:
|
||||
if of.IsNil() || of.Len() == 0 {
|
||||
t.Errorf("M-config-4: fixture 未填充切片 %s(新增切片字段须同步 fixture 与 Clone)", p)
|
||||
continue
|
||||
}
|
||||
if cf.IsNil() || cf.Len() != of.Len() {
|
||||
t.Errorf("M-config-4: Clone 后切片 %s 为 nil 或长度不一致", p)
|
||||
continue
|
||||
}
|
||||
if of.Pointer() == cf.Pointer() {
|
||||
t.Errorf("M-config-4: Clone 未深拷贝切片 %s(共享底层数组)", p)
|
||||
}
|
||||
case reflect.Map:
|
||||
if of.IsNil() || of.Len() == 0 {
|
||||
t.Errorf("M-config-4: fixture 未填充 map %s", p)
|
||||
continue
|
||||
}
|
||||
if of.Pointer() == cf.Pointer() {
|
||||
t.Errorf("M-config-4: Clone 未深拷贝 map %s(共享 map)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
check(reflect.ValueOf(original), reflect.ValueOf(clone), "Config")
|
||||
}
|
||||
|
||||
// allSlicesPopulatedConfig 返回一个填充了所有切片字段的 Config,供 Clone 覆盖断言使用。
|
||||
// 新增任何切片/map 字段到 Config 或其子结构体时,必须同步在此填充,否则
|
||||
// TestCloneDeepCopiesAllSliceFields_Mconfig4 会以"未填充"失败提醒。
|
||||
func allSlicesPopulatedConfig() *config.Config {
|
||||
return &config.Config{
|
||||
CORS: config.CORSConfig{
|
||||
AllowedOrigins: []string{"https://a.example.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedHeaders: []string{"X-Test"},
|
||||
ExposedHeaders: []string{"X-Exp"},
|
||||
},
|
||||
Upload: config.UploadConfig{
|
||||
AllowedImageTypes: []string{"image/jpeg"},
|
||||
AllowedVideoTypes: []string{"video/mp4"},
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Upload: config.UploadPolicy{
|
||||
AllowedExts: []string{".jpg"},
|
||||
AllowedMIMEs: []string{"image/jpeg"},
|
||||
},
|
||||
},
|
||||
OSS: config.OSSStorageConfig{
|
||||
Upload: config.UploadPolicy{
|
||||
AllowedExts: []string{".png"},
|
||||
AllowedMIMEs: []string{"image/png"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
+155
-4
@@ -3,6 +3,7 @@ package config_test
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
@@ -90,6 +91,100 @@ func TestDatabaseConfigDSN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseConfigPostgresDSN(t *testing.T) {
|
||||
db := config.DatabaseConfig{
|
||||
Driver: config.DriverPostgres,
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "password",
|
||||
Name: "testdb",
|
||||
}
|
||||
|
||||
dsn := db.DSN()
|
||||
expected := "host='localhost' port=5432 user='postgres' password='password' dbname='testdb' sslmode=prefer TimeZone='Asia/Shanghai'"
|
||||
if dsn != expected {
|
||||
t.Errorf("Postgres DSN = %s, want %s", dsn, expected)
|
||||
}
|
||||
|
||||
// 显式 MySQL DSN 不受 Driver 影响
|
||||
if db.MySQLDSN() == "" {
|
||||
t.Error("MySQLDSN should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseConfigUnknownDriverDoesNotFallback(t *testing.T) {
|
||||
db := config.DatabaseConfig{
|
||||
Driver: "no-such-driver",
|
||||
Host: "localhost",
|
||||
Port: 3306,
|
||||
User: "root",
|
||||
Password: "password",
|
||||
Name: "testdb",
|
||||
}
|
||||
|
||||
if dsn := db.DSN(); dsn != "" {
|
||||
t.Fatalf("未知 driver 不应静默回退 MySQL,实际 DSN=%q", dsn)
|
||||
}
|
||||
|
||||
cfg := &config.Config{Database: db}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "database.driver") {
|
||||
t.Fatalf("未知 driver 应在 Validate 阶段报错,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatabaseConfigDSNPasswordEscape_M9:含特殊字符的密码须被转义,不破坏 DSN。
|
||||
func TestDatabaseConfigDSNPasswordEscape_M9(t *testing.T) {
|
||||
// MySQL:密码含 @/:/空格 → url.QueryEscape
|
||||
mysql := config.DatabaseConfig{
|
||||
Driver: config.DriverMySQL,
|
||||
Host: "localhost",
|
||||
Port: 3306,
|
||||
User: "root",
|
||||
Password: "p@ss w:ord",
|
||||
Name: "testdb",
|
||||
}
|
||||
mdsn := mysql.MySQLDSN()
|
||||
// 密码段应被转义,@ 不应与 DSN 的 @ 分隔符混淆
|
||||
if !strings.Contains(mdsn, "root:p%40ss+w%3Aord@tcp") {
|
||||
t.Errorf("MySQL DSN password not escaped: %s", mdsn)
|
||||
}
|
||||
|
||||
// Postgres:密码含单引号 → 翻倍转义
|
||||
pg := config.DatabaseConfig{
|
||||
Driver: config.DriverPostgres,
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "p'ord",
|
||||
Name: "testdb",
|
||||
}
|
||||
pdsn := pg.PostgresDSN()
|
||||
if !strings.Contains(pdsn, `password='p\'ord'`) {
|
||||
t.Errorf("Postgres DSN password not escaped: %s", pdsn)
|
||||
}
|
||||
|
||||
// Timezone 可配置
|
||||
pg2 := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n", Timezone: "UTC"}
|
||||
if !strings.Contains(pg2.PostgresDSN(), "TimeZone='UTC'") {
|
||||
t.Errorf("Postgres DSN should honor Timezone=UTC: %s", pg2.PostgresDSN())
|
||||
}
|
||||
mysql2 := config.DatabaseConfig{Driver: config.DriverMySQL, Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", Timezone: "UTC"}
|
||||
if !strings.Contains(mysql2.MySQLDSN(), "loc=UTC") {
|
||||
t.Errorf("MySQL DSN should honor Timezone=UTC: %s", mysql2.MySQLDSN())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseConfigCustomDSN(t *testing.T) {
|
||||
db := config.DatabaseConfig{
|
||||
Driver: config.DriverPostgres,
|
||||
CustomDSN: "custom-connection-string",
|
||||
}
|
||||
if db.DSN() != "custom-connection-string" {
|
||||
t.Errorf("CustomDSN should take precedence, got %s", db.DSN())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisConfigAddr(t *testing.T) {
|
||||
redis := config.RedisConfig{
|
||||
Host: "localhost",
|
||||
@@ -130,8 +225,8 @@ redis:
|
||||
db: 0
|
||||
|
||||
jwt:
|
||||
secret: "test-secret"
|
||||
expire: 3600
|
||||
secret: "test-secret-12345678901234567890123456789012"
|
||||
expire: "1h"
|
||||
`
|
||||
tmpFile, err := setupTempFile("test_config.yaml", content)
|
||||
if err != nil {
|
||||
@@ -216,10 +311,66 @@ func TestConfigGetString(_ *testing.T) {
|
||||
// 此函数保留为占位符,避免删除后影响其他测试引用
|
||||
}
|
||||
|
||||
func TestConfigLoadReloadsDifferentFiles(t *testing.T) {
|
||||
first, err := setupTempFile("first_config.yaml", "app:\n name: first\nserver:\n port: 1001\n")
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile first error: %v", err)
|
||||
}
|
||||
second, err := setupTempFile("second_config.yaml", "app:\n name: second\nserver:\n port: 1002\n")
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile second error: %v", err)
|
||||
}
|
||||
defer os.Remove(first)
|
||||
defer os.Remove(second)
|
||||
|
||||
cfg, err := config.Load(first)
|
||||
if err != nil {
|
||||
t.Fatalf("Load first error: %v", err)
|
||||
}
|
||||
if cfg.App.Name != "first" || cfg.Server.Port != 1001 {
|
||||
t.Fatalf("unexpected first config: %+v", cfg)
|
||||
}
|
||||
|
||||
cfg, err = config.Load(second)
|
||||
if err != nil {
|
||||
t.Fatalf("Load second error: %v", err)
|
||||
}
|
||||
if cfg.App.Name != "second" || cfg.Server.Port != 1002 {
|
||||
t.Fatalf("unexpected second config: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManagerIsolation(t *testing.T) {
|
||||
first, err := setupTempFile("manager_first.yaml", "app:\n name: manager_first\n")
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile first error: %v", err)
|
||||
}
|
||||
second, err := setupTempFile("manager_second.yaml", "app:\n name: manager_second\n")
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile second error: %v", err)
|
||||
}
|
||||
defer os.Remove(first)
|
||||
defer os.Remove(second)
|
||||
|
||||
m1 := config.NewManager(first)
|
||||
m2 := config.NewManager(second)
|
||||
cfg1, err := m1.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("m1 Load error: %v", err)
|
||||
}
|
||||
cfg2, err := m2.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("m2 Load error: %v", err)
|
||||
}
|
||||
if cfg1.App.Name != "manager_first" || cfg2.App.Name != "manager_second" {
|
||||
t.Fatalf("managers should be isolated: %+v %+v", cfg1, cfg2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSet(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{
|
||||
Name: "Manual",
|
||||
Name: "Manual",
|
||||
SiteName: "manual_site",
|
||||
},
|
||||
}
|
||||
@@ -287,4 +438,4 @@ func TestUploadConfig(t *testing.T) {
|
||||
if upload.MaxFileSize != 10 {
|
||||
t.Error("UploadConfig failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Validate 校验配置完整性与取值合法性(#16)。
|
||||
// 在 Manager.Load 解析后自动调用,把"运行时第一次请求才暴露"的配置错误
|
||||
// 提前到进程启动期。返回的 error 描述具体字段,便于定位。
|
||||
func (c *Config) Validate() error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("配置为空")
|
||||
}
|
||||
var problems []string
|
||||
|
||||
// Server
|
||||
if c.Server.Port < 0 || c.Server.Port > 65535 {
|
||||
problems = append(problems, fmt.Sprintf("server.port 超出范围(0-65535): %d", c.Server.Port))
|
||||
}
|
||||
if c.Server.TLS.Enabled {
|
||||
if strings.TrimSpace(c.Server.TLS.CertFile) == "" || strings.TrimSpace(c.Server.TLS.KeyFile) == "" {
|
||||
problems = append(problems, "server.tls 启用后必须同时配置 cert_file 与 key_file")
|
||||
}
|
||||
}
|
||||
if !validDuration(c.Server.ReadTimeout) || !validDuration(c.Server.WriteTimeout) ||
|
||||
!validDuration(c.Server.IdleTimeout) || !validDuration(c.Server.ShutdownTimeout) {
|
||||
problems = append(problems, "server 的 timeout 配置不能为负值")
|
||||
}
|
||||
|
||||
// JWT:仅当配置了 secret 时校验(未启用 jwt 的项目可留空)
|
||||
if c.JWT.Secret != "" {
|
||||
if len(c.JWT.Secret) < 32 {
|
||||
problems = append(problems, fmt.Sprintf("jwt.secret 长度不足 32 字节(当前 %d),HMAC 密钥过短不安全", len(c.JWT.Secret)))
|
||||
}
|
||||
if c.JWT.Expire < 0 || c.JWT.RefreshExpire < 0 {
|
||||
problems = append(problems, "jwt.expire / jwt.refresh_expire 不能为负值")
|
||||
}
|
||||
}
|
||||
|
||||
// Database:出现任一数据库字段时视为启用;driver 为空按 MySQL 兼容处理。
|
||||
if c.Database.isConfigured() {
|
||||
driver := strings.TrimSpace(c.Database.Driver)
|
||||
if driver != "" {
|
||||
if _, ok := LookupDSNBuilder(driver); !ok {
|
||||
problems = append(problems, fmt.Sprintf("database.driver 未注册: %s", driver))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Host) == "" {
|
||||
problems = append(problems, "database.host 启用数据库后必填")
|
||||
}
|
||||
if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Name) == "" {
|
||||
problems = append(problems, "database.name 启用数据库后必填")
|
||||
}
|
||||
if strings.TrimSpace(c.Database.CustomDSN) == "" && (c.Database.Port <= 0 || c.Database.Port > 65535) {
|
||||
problems = append(problems, fmt.Sprintf("database.port 超出范围(1-65535): %d", c.Database.Port))
|
||||
}
|
||||
// L-config-3:连接池配置交叉校验。MaxOpenConns>0 时 MaxIdleConns 不应超过它,
|
||||
// 否则 database/sql 会按 MaxOpenConns 截断空闲连接,配置意图与实际不符。
|
||||
if c.Database.MaxOpenConns > 0 && c.Database.MaxIdleConns > c.Database.MaxOpenConns {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"database.max_idle_conns(%d) 不应大于 max_open_conns(%d)", c.Database.MaxIdleConns, c.Database.MaxOpenConns))
|
||||
}
|
||||
// M-config-2:Postgres SSLMode 合法性(非空时校验,空由 PostgresDSN 默认 prefer)。
|
||||
if sslmode := strings.TrimSpace(c.Database.SSLMode); sslmode != "" {
|
||||
if !validPostgresSSLMode(sslmode) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"database.ssl_mode 非法: %s(允许: disable/allow/prefer/require/verify-ca/verify-full)", sslmode))
|
||||
}
|
||||
}
|
||||
// M-config-2:TLSRootCA 仅在 TLS=true 时生效,单独配置而无 tls:true 视为配置不一致。
|
||||
if strings.TrimSpace(c.Database.TLSRootCA) != "" && !c.Database.TLS {
|
||||
problems = append(problems, "database.tls_root_ca 需配合 tls: true 才生效")
|
||||
}
|
||||
}
|
||||
|
||||
// Redis:仅当配置了 host 时校验
|
||||
if strings.TrimSpace(c.Redis.Host) != "" {
|
||||
if c.Redis.Port <= 0 || c.Redis.Port > 65535 {
|
||||
problems = append(problems, fmt.Sprintf("redis.port 超出范围(1-65535): %d", c.Redis.Port))
|
||||
}
|
||||
}
|
||||
|
||||
// Trace:仅当启用时校验采样比例。ExporterType/Propagator 的枚举校验委托 trace.Init
|
||||
// (随 OTel 版本可能扩展,避免 config 与 trace 双源枚举漂移)。
|
||||
if c.Trace.Enabled {
|
||||
if c.Trace.SampleRatio < 0 || c.Trace.SampleRatio > 1 {
|
||||
problems = append(problems, fmt.Sprintf("trace.sample_ratio 超出范围(0.0-1.0): %v", c.Trace.SampleRatio))
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("配置校验失败: %s", strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validDuration 校验 Duration 非负(0 表示未配置/用默认,合法)。
|
||||
func validDuration(d time.Duration) bool { return d >= 0 }
|
||||
|
||||
// validPostgresSSLMode 校验 PostgreSQL sslmode 取值(M-config-2)。
|
||||
func validPostgresSSLMode(s string) bool {
|
||||
switch s {
|
||||
case "disable", "allow", "prefer", "require", "verify-ca", "verify-full":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
func validBase() *config.Config {
|
||||
return &config.Config{
|
||||
Server: config.ServerConfig{Port: 8080},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOK(t *testing.T) {
|
||||
if err := validBase().Validate(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateServerPort(t *testing.T) {
|
||||
c := validBase()
|
||||
c.Server.Port = 99999
|
||||
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "server.port") {
|
||||
t.Fatalf("expected server.port error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJWTSecretTooShort(t *testing.T) {
|
||||
c := validBase()
|
||||
c.JWT.Secret = "short"
|
||||
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "jwt.secret") {
|
||||
t.Fatalf("expected jwt.secret error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJWTSecretAbsentSkipped(t *testing.T) {
|
||||
c := validBase()
|
||||
// secret 为空时不校验(未启用 jwt)
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTraceSampleRatioWhenEnabled(t *testing.T) {
|
||||
c := validBase()
|
||||
c.Trace.Enabled = true
|
||||
c.Trace.SampleRatio = 1.5
|
||||
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "trace.sample_ratio") {
|
||||
t.Fatalf("expected trace.sample_ratio error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTraceDisabledSkipsRatio(t *testing.T) {
|
||||
c := validBase()
|
||||
// Enabled=false 时即使 SampleRatio 非法也不校验(trace 未启用)
|
||||
c.Trace.SampleRatio = 1.5
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatalf("unexpected error when trace disabled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatabaseMissingHost(t *testing.T) {
|
||||
c := validBase()
|
||||
c.Database.Driver = "mysql"
|
||||
c.Database.Port = 3306
|
||||
c.Database.Name = "db"
|
||||
// Host 留空
|
||||
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "database.host") {
|
||||
t.Fatalf("expected database.host error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTLSMissingCert(t *testing.T) {
|
||||
c := validBase()
|
||||
c.Server.TLS.Enabled = true
|
||||
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "cert_file") {
|
||||
t.Fatalf("expected tls cert_file error, got %v", err)
|
||||
}
|
||||
}
|
||||
+105
-15
@@ -6,20 +6,47 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeMu 串行化所有 Console 的写出序列(P1 #7)。
|
||||
// 用包级锁而非 per-Console 锁:多个 Console 实例可能共享同一底层 writer(如 stdout),
|
||||
// per-Console 锁无法阻止跨实例交错;且 Windows printColor 的"设色→写→复位"三步必须整体
|
||||
// 原子,否则并发彩色输出会串色。控制台输出非热路径,单一全局写锁的串行化开销可忽略。
|
||||
var writeMu sync.Mutex
|
||||
|
||||
// Level 日志级别
|
||||
type Level int
|
||||
type Level int32
|
||||
|
||||
const (
|
||||
// LevelDebug 调试级别(最低)。
|
||||
LevelDebug Level = iota
|
||||
// LevelInfo 普通信息。
|
||||
LevelInfo
|
||||
// LevelSuccess 成功信息。
|
||||
LevelSuccess
|
||||
// LevelWarn 警告。
|
||||
LevelWarn
|
||||
// LevelError 错误(最高常规级别)。
|
||||
LevelError
|
||||
|
||||
// LevelSilent 完全静默:所有调用都不输出
|
||||
LevelSilent Level = 127
|
||||
)
|
||||
|
||||
// String 返回级别名称
|
||||
func (l Level) String() string {
|
||||
if c, ok := colors[l]; ok {
|
||||
return c.Name
|
||||
}
|
||||
if l == LevelSilent {
|
||||
return "Silent"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// Color 颜色定义
|
||||
type Color struct {
|
||||
Code string
|
||||
@@ -34,14 +61,25 @@ var colors = map[Level]Color{
|
||||
LevelError: {Code: "1;31", Name: "Error"}, // 亮红色
|
||||
}
|
||||
|
||||
// Console 控制台打印器
|
||||
// Console 控制台打印器。
|
||||
//
|
||||
// console 包定位:开发期彩色 stdout 工具,跟 fmt.Println 同级。
|
||||
// 不写文件、不感知运行环境、不做任何隐式 level 切换——
|
||||
// 所有 level 行为都由调用方显式控制(SetLevel / WithLevel)。
|
||||
//
|
||||
// 业务可观测信息(用户登录、订单状态变更等"上线后必须保留的事件")
|
||||
// 请使用 logger 包;console 仅用于开发期肉眼调试。
|
||||
type Console struct {
|
||||
output io.Writer
|
||||
isColor bool
|
||||
showTime bool
|
||||
showCall bool
|
||||
timeFmt string
|
||||
skipCall int
|
||||
output io.Writer
|
||||
isColor bool
|
||||
showTime bool
|
||||
showCall bool
|
||||
timeFmt string
|
||||
skipCall int
|
||||
|
||||
// level 通过 atomic 访问,支持运行期热切换且并发安全。
|
||||
// 用 int32 存储 Level,0 = LevelDebug,与零值默认对齐。
|
||||
level atomic.Int32
|
||||
}
|
||||
|
||||
// Option 配置选项
|
||||
@@ -68,11 +106,14 @@ func WithTime(show bool) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithCaller 设置是否显示调用位置
|
||||
func WithCaller(show bool, skip int) Option {
|
||||
// WithCaller 设置是否显示调用位置。
|
||||
// skip 可选,默认 2(直接调用方);自封装一层时传 3。
|
||||
func WithCaller(show bool, skip ...int) Option {
|
||||
return func(c *Console) {
|
||||
c.showCall = show
|
||||
c.skipCall = skip
|
||||
if len(skip) > 0 && skip[0] > 0 {
|
||||
c.skipCall = skip[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +124,17 @@ func WithTimeFormat(fmt string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithLevel 设置最低输出级别。低于该级别的调用会被静默丢弃。
|
||||
//
|
||||
// 例:WithLevel(LevelWarn) 只输出 Warn 与 Error;
|
||||
//
|
||||
// WithLevel(LevelSilent) 完全静默,常用于压测或上线观察期临时关闭调试输出。
|
||||
func WithLevel(l Level) Option {
|
||||
return func(c *Console) {
|
||||
c.level.Store(int32(l))
|
||||
}
|
||||
}
|
||||
|
||||
// New 创建控制台打印器
|
||||
func New(opts ...Option) *Console {
|
||||
c := &Console{
|
||||
@@ -93,17 +145,53 @@ func New(opts ...Option) *Console {
|
||||
timeFmt: "15:04:05.000",
|
||||
skipCall: 2,
|
||||
}
|
||||
// 默认 LevelDebug:开发期所有级别都打印。生产期请显式 SetLevel/WithLevel。
|
||||
c.level.Store(int32(LevelDebug))
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SetLevel 运行期切换最低输出级别。并发安全。
|
||||
func (c *Console) SetLevel(l Level) {
|
||||
c.level.Store(int32(l))
|
||||
}
|
||||
|
||||
// Level 返回当前最低输出级别
|
||||
func (c *Console) Level() Level {
|
||||
return Level(c.level.Load())
|
||||
}
|
||||
|
||||
// Default 默认控制台
|
||||
var Default = New()
|
||||
|
||||
// SetLevel 设置默认控制台的最低输出级别。并发安全。
|
||||
//
|
||||
// 典型用法(在 main 中根据 cfg 显式切换):
|
||||
//
|
||||
// if cfg.IsProduction() {
|
||||
// console.SetLevel(console.LevelWarn) // 生产期只看 Warn / Error
|
||||
// }
|
||||
//
|
||||
// 框架不会自动根据环境模式切换,选择权完全在调用方。
|
||||
func SetLevel(l Level) {
|
||||
Default.SetLevel(l)
|
||||
}
|
||||
|
||||
// GetLevel 返回默认控制台当前最低输出级别。
|
||||
// (命名加 Get 前缀是因为 Level 已被类型占用,Go 不允许同名函数。)
|
||||
func GetLevel() Level {
|
||||
return Default.Level()
|
||||
}
|
||||
|
||||
// print 内部打印函数
|
||||
func (c *Console) print(level Level, s ...any) {
|
||||
// 级别过滤:低于阈值或调用方显式 LevelSilent 时直接返回,零开销
|
||||
if level < c.Level() {
|
||||
return
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// 时间
|
||||
@@ -127,12 +215,15 @@ func (c *Console) print(level Level, s ...any) {
|
||||
// 内容
|
||||
sb.WriteString(fmt.Sprint(s...))
|
||||
|
||||
// 输出
|
||||
// 输出。P1 #7:全局写锁串行化整个写序列,避免并发交错输出;
|
||||
// Windows 下 printColor 的"设色→写→复位"三步也被此锁整体保护,防止串色。
|
||||
writeMu.Lock()
|
||||
if c.isColor {
|
||||
c.printColor(color.Code, sb.String())
|
||||
} else {
|
||||
fmt.Fprintln(c.output, sb.String())
|
||||
}
|
||||
writeMu.Unlock()
|
||||
}
|
||||
|
||||
// getCaller 获取调用位置
|
||||
@@ -141,9 +232,8 @@ func (c *Console) getCaller() string {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
// 只取文件名
|
||||
idx := strings.LastIndex(file, "/")
|
||||
if idx >= 0 {
|
||||
// 只取文件名(兼容 / 与 \ 分隔)
|
||||
if idx := strings.LastIndexAny(file, "/\\"); idx >= 0 {
|
||||
file = file[idx+1:]
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", file, line)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package console_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/console"
|
||||
@@ -26,3 +28,125 @@ func TestConsole(t *testing.T) {
|
||||
c.Warn("自定义控制台 - Warn")
|
||||
c.Error("自定义控制台 - Error")
|
||||
}
|
||||
|
||||
// TestConsoleLevelFilter 验证显式 level 屏蔽:低于阈值的调用不输出。
|
||||
// 这是方案 A 的核心契约——用户显式控制何时屏蔽,框架不做隐式行为。
|
||||
func TestConsoleLevelFilter(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
c := console.New(
|
||||
console.WithOutput(&buf),
|
||||
console.WithColor(false),
|
||||
console.WithTime(false),
|
||||
console.WithCaller(false),
|
||||
console.WithLevel(console.LevelWarn),
|
||||
)
|
||||
|
||||
c.Debug("DEBUG_MARK")
|
||||
c.Info("INFO_MARK")
|
||||
c.Success("SUCCESS_MARK")
|
||||
c.Warn("WARN_MARK")
|
||||
c.Error("ERROR_MARK")
|
||||
|
||||
out := buf.String()
|
||||
|
||||
// Warn / Error 必须输出
|
||||
if !strings.Contains(out, "WARN_MARK") {
|
||||
t.Errorf("Warn should be printed at LevelWarn, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "ERROR_MARK") {
|
||||
t.Errorf("Error should be printed at LevelWarn, got: %q", out)
|
||||
}
|
||||
|
||||
// Debug / Info / Success 必须被静默
|
||||
for _, mark := range []string{"DEBUG_MARK", "INFO_MARK", "SUCCESS_MARK"} {
|
||||
if strings.Contains(out, mark) {
|
||||
t.Errorf("%s should be filtered at LevelWarn, but found in: %q", mark, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleLevelSilent 验证 LevelSilent 完全静默所有调用。
|
||||
func TestConsoleLevelSilent(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
c := console.New(
|
||||
console.WithOutput(&buf),
|
||||
console.WithColor(false),
|
||||
console.WithLevel(console.LevelSilent),
|
||||
)
|
||||
|
||||
c.Debug("D")
|
||||
c.Info("I")
|
||||
c.Success("S")
|
||||
c.Warn("W")
|
||||
c.Error("E")
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("LevelSilent should suppress all output, got %d bytes: %q", buf.Len(), buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleSetLevel 验证运行期热切换 level。
|
||||
func TestConsoleSetLevel(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
c := console.New(
|
||||
console.WithOutput(&buf),
|
||||
console.WithColor(false),
|
||||
console.WithTime(false),
|
||||
console.WithCaller(false),
|
||||
)
|
||||
|
||||
// 默认 LevelDebug,Debug 应输出
|
||||
c.Debug("FIRST")
|
||||
if !strings.Contains(buf.String(), "FIRST") {
|
||||
t.Errorf("Debug should print at default LevelDebug, got: %q", buf.String())
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
|
||||
// 切到 LevelError 后,Debug 应静默
|
||||
c.SetLevel(console.LevelError)
|
||||
if got := c.Level(); got != console.LevelError {
|
||||
t.Errorf("Level() = %v, want LevelError", got)
|
||||
}
|
||||
c.Debug("SECOND")
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("Debug should be filtered after SetLevel(LevelError), got: %q", buf.String())
|
||||
}
|
||||
|
||||
// Error 仍然输出
|
||||
c.Error("THIRD")
|
||||
if !strings.Contains(buf.String(), "THIRD") {
|
||||
t.Errorf("Error should print at LevelError, got: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsolePackageLevelAPI 验证包级 SetLevel / GetLevel 操作的是 Default 实例。
|
||||
func TestConsolePackageLevelAPI(t *testing.T) {
|
||||
original := console.GetLevel()
|
||||
t.Cleanup(func() { console.SetLevel(original) })
|
||||
|
||||
console.SetLevel(console.LevelWarn)
|
||||
if got := console.GetLevel(); got != console.LevelWarn {
|
||||
t.Errorf("GetLevel() = %v, want LevelWarn", got)
|
||||
}
|
||||
if got := console.Default.Level(); got != console.LevelWarn {
|
||||
t.Errorf("Default.Level() = %v, want LevelWarn (package SetLevel must affect Default)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleLevelString 验证 Level.String 输出可读名称(错误信息 / 日志会用到)。
|
||||
func TestConsoleLevelString(t *testing.T) {
|
||||
cases := map[console.Level]string{
|
||||
console.LevelDebug: "Debug",
|
||||
console.LevelInfo: "Info",
|
||||
console.LevelSuccess: "Success",
|
||||
console.LevelWarn: "Warn",
|
||||
console.LevelError: "Error",
|
||||
console.LevelSilent: "Silent",
|
||||
}
|
||||
for l, want := range cases {
|
||||
if got := l.String(); got != want {
|
||||
t.Errorf("Level(%d).String() = %q, want %q", l, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
-19
@@ -4,6 +4,7 @@ package console
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
@@ -21,31 +22,48 @@ var colorMap = map[string]uintptr{
|
||||
"1;31": 12, // 亮红色
|
||||
}
|
||||
|
||||
// printColor 彩色打印
|
||||
// printColor 彩色打印。
|
||||
//
|
||||
// 修复 M17:原实现对 syscall.Stdout 设置颜色、却把文本写到 c.output——
|
||||
// 当 c.output 非 stdout(如 WithOutput 指向文件)时,颜色落到错误句柄、文本落文件,
|
||||
// 二者分裂。现按 c.output 实际类型取句柄:*os.File 用其 Fd(),否则放弃着色只写文本。
|
||||
func (c *Console) printColor(code, msg string) {
|
||||
color := colorMap[code]
|
||||
if color == 0 {
|
||||
color = 7 // 默认淡灰色
|
||||
}
|
||||
|
||||
proc := kernel32.NewProc("SetConsoleTextAttribute")
|
||||
_, _, _ = proc.Call(uintptr(syscall.Stdout), color)
|
||||
fmt.Fprintln(c.output, msg)
|
||||
_, _, _ = proc.Call(uintptr(syscall.Stdout), 7) // 恢复默认颜色
|
||||
}
|
||||
|
||||
// EnableVirtualTerminal 启用虚拟终端支持(Windows 10+)
|
||||
func EnableVirtualTerminal() error {
|
||||
// 尝试启用 ANSI 转义序列支持
|
||||
proc := kernel32.NewProc("GetConsoleMode")
|
||||
var mode uint32
|
||||
_, _, err := proc.Call(uintptr(syscall.Stdout), uintptr(unsafe.Pointer(&mode)))
|
||||
if err != syscall.Errno(0) {
|
||||
return err
|
||||
handle, ok := consoleHandle(c.output)
|
||||
if !ok {
|
||||
// 非 *os.File(如 bytes.Buffer/文件),无法设置控制台属性,退化为纯文本。
|
||||
fmt.Fprintln(c.output, msg)
|
||||
return
|
||||
}
|
||||
|
||||
mode |= 0x0004 // ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
proc = kernel32.NewProc("SetConsoleMode")
|
||||
_, _, err = proc.Call(uintptr(syscall.Stdout), uintptr(mode))
|
||||
return err
|
||||
proc := kernel32.NewProc("SetConsoleTextAttribute")
|
||||
_, _, _ = proc.Call(handle, color)
|
||||
fmt.Fprintln(c.output, msg)
|
||||
_, _, _ = proc.Call(handle, 7) // 恢复默认颜色
|
||||
}
|
||||
|
||||
// consoleHandle 从 io.Writer 取 Windows 控制台句柄;非 *os.File 返回 (0, false)。
|
||||
func consoleHandle(w interface{ Write([]byte) (int, error) }) (uintptr, bool) {
|
||||
f, ok := w.(*os.File)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
fd := f.Fd()
|
||||
switch fd {
|
||||
case uintptr(syscall.Stdout), uintptr(syscall.Stderr), uintptr(syscall.Stdin):
|
||||
return fd, true
|
||||
default:
|
||||
// 重定向到文件/管道的 *os.File,SetConsoleTextAttribute 无意义,退化为纯文本。
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// (M17:原 EnableVirtualTerminal 为死代码且从未被调用,已移除。
|
||||
// Windows 10+ 默认支持 ANSI/VT 着色;需要 VT 的调用方应自行调 kernel32。)
|
||||
|
||||
// 保留 unsafe 引用以备将来 VT 扩展;当前 printColor 不再需要,故显式抑制未用告警。
|
||||
var _ = unsafe.Sizeof(uintptr(0))
|
||||
|
||||
+494
-120
@@ -3,20 +3,33 @@ package cron
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Task 定时任务
|
||||
type Task struct {
|
||||
Name string // 任务名称
|
||||
Schedule Schedule // 调度规则
|
||||
Handler TaskHandler // 任务处理函数
|
||||
Enabled bool // 是否启用
|
||||
LastRun time.Time // 上次运行时间
|
||||
NextRun time.Time // 下次运行时间
|
||||
RunCount int // 运行次数
|
||||
Name string // 任务名称
|
||||
Schedule Schedule // 调度规则
|
||||
Handler TaskHandler // 任务处理函数
|
||||
Enabled bool // 是否启用
|
||||
LastRun time.Time // 上次运行时间
|
||||
NextRun time.Time // 下次运行时间
|
||||
RunCount int // 运行次数
|
||||
LastError error // CR3 修复:最近一次执行错误
|
||||
|
||||
// running 防止长任务跨 tick 重叠执行(C12b)。
|
||||
// 用指针以便 GetTask/ListTasks 返回 Task 拷贝时不触发 atomic.Bool 值类型的
|
||||
// copylocks 警告;拷贝共享同一守卫状态,但该字段未导出,外部无法操作。
|
||||
// nil 表示未初始化(仅非 AddTask 构造的 Task),checkAndRun/RunTask 以 nil 守卫跳过。
|
||||
running *atomic.Bool
|
||||
}
|
||||
|
||||
// TaskHandler 任务处理函数
|
||||
@@ -27,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,
|
||||
@@ -49,12 +100,21 @@ 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,
|
||||
Handler: handler,
|
||||
Enabled: true,
|
||||
NextRun: schedule.Next(time.Now()),
|
||||
running: &atomic.Bool{},
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -100,7 +160,7 @@ func (s *Scheduler) DisableTask(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTask 获取任务
|
||||
// GetTask 获取任务(返回拷贝快照,避免外部并发读 live 指针,C12a)。
|
||||
func (s *Scheduler) GetTask(name string) (*Task, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@@ -109,43 +169,77 @@ func (s *Scheduler) GetTask(name string) (*Task, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("任务不存在: %s", name)
|
||||
}
|
||||
return task, nil
|
||||
cp := *task
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// ListTasks 获取所有任务
|
||||
// ListTasks 获取所有任务(返回拷贝快照,C12a)。
|
||||
func (s *Scheduler) ListTasks() []*Task {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
tasks := make([]*Task, 0, len(s.tasks))
|
||||
for _, task := range s.tasks {
|
||||
tasks = append(tasks, task)
|
||||
cp := *task
|
||||
tasks = append(tasks, &cp)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
// RunTask 立即运行任务
|
||||
func (s *Scheduler) RunTask(name string) error {
|
||||
// executeTask 在 exec 边界运行 task handler;recover 捕获 panic 转为 error(含调用栈),
|
||||
// 防止调度 goroutine 内未 recover 的 panic 终止整个进程(M13)。
|
||||
// 命名返回值让 defer 在 panic 时改写 err。两侧调用点(RunTask / checkAndRun goroutine)
|
||||
// 共用此边界,panic 一律转为 error 记入 LastError 并向上返回,不破坏 running 守卫与 wg.Done
|
||||
// (recover 在本函数内部完成,外侧 defer 仍正常执行)。
|
||||
func (s *Scheduler) executeTask(t *Task) (err error) {
|
||||
s.mu.RLock()
|
||||
task, ok := s.tasks[name]
|
||||
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(ctx)
|
||||
}
|
||||
|
||||
// RunTask 立即运行任务(手动触发,同步返回 handler 错误)。
|
||||
//
|
||||
// 占用 per-task running 守卫,与调度循环互斥,防止同一任务重叠执行(C12b)。
|
||||
// 不推进 NextRun(手动触发不影响调度节奏,C12c)。LastRun/RunCount 在锁内更新(C12a)。
|
||||
//
|
||||
// L-J:handler 收到的 ctx 为调度器 s.ctx。Stop 后 s.ctx 已取消,handler 会收到 canceled
|
||||
// ctx——这是预期行为(手动触发在调度器停止后不应继续执行长任务)。调用方若需在 Stop 后
|
||||
// 仍运行一次性任务,应在自己的 ctx 下执行而非依赖调度器。
|
||||
func (s *Scheduler) RunTask(name string) error {
|
||||
s.mu.Lock()
|
||||
task, ok := s.tasks[name]
|
||||
s.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("任务不存在: %s", name)
|
||||
}
|
||||
|
||||
return s.runTask(task)
|
||||
}
|
||||
// 占用 running 守卫(nil 守卫直接放行,仅防御非 AddTask 构造的 Task)。
|
||||
if task.running != nil && !task.running.CompareAndSwap(false, true) {
|
||||
return fmt.Errorf("任务正在执行中: %s", name)
|
||||
}
|
||||
defer func() {
|
||||
if task.running != nil {
|
||||
task.running.Store(false)
|
||||
}
|
||||
}()
|
||||
|
||||
// runTask 执行任务
|
||||
func (s *Scheduler) runTask(task *Task) error {
|
||||
task.LastRun = time.Now()
|
||||
task.RunCount++
|
||||
|
||||
err := task.Handler(s.ctx)
|
||||
err := s.executeTask(task)
|
||||
|
||||
s.mu.Lock()
|
||||
task.NextRun = task.Schedule.Next(time.Now())
|
||||
task.LastRun = time.Now()
|
||||
task.RunCount++
|
||||
task.LastError = err // M13: 手动路径也记 LastError(与调度路径对齐)
|
||||
s.mu.Unlock()
|
||||
|
||||
return err
|
||||
@@ -153,34 +247,66 @@ func (s *Scheduler) runTask(task *Task) 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 停止调度器
|
||||
// Stop 停止调度器并无限等待在跑任务退出(要求 handler 尊重 ctx.Done)。
|
||||
// 若担心某 handler 不响应 ctx 而永久阻塞,请用 StopWithTimeout。
|
||||
func (s *Scheduler) Stop() {
|
||||
s.StopWithTimeout(0)
|
||||
}
|
||||
|
||||
// StopWithTimeout 停止调度器,最多等待 timeout 让在跑任务退出(P1 #10)。
|
||||
// 返回 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
|
||||
return true
|
||||
}
|
||||
s.running = false
|
||||
cancel := s.cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
cancel()
|
||||
if timeout <= 0 {
|
||||
s.wg.Wait()
|
||||
return true
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { s.wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-time.After(timeout):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// run 运行调度循环
|
||||
func (s *Scheduler) run() {
|
||||
func (s *Scheduler) run(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
@@ -188,25 +314,86 @@ func (s *Scheduler) run() {
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.checkAndRun()
|
||||
s.checkAndRun(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndRun 检查并运行到期任务
|
||||
func (s *Scheduler) checkAndRun() {
|
||||
// checkAndRun 检查并运行到期任务。
|
||||
//
|
||||
// C12b:per-task running 守卫(CAS)防止长任务跨 tick 重叠 spawn。
|
||||
// C12c:占用守卫后**先推进 NextRun**(以上次 NextRun 锚定,非 time.Now()),
|
||||
// 避免每周期累积 handler 时长致调度漂移;推进在 spawn 前,下次 tick 不会重复 spawn。
|
||||
// C12a:NextRun 推进在写锁内;LastRun/RunCount 在 goroutine 内写锁更新。
|
||||
//
|
||||
// M-H 修复:锁内只收集到期任务(CAS 占用 + 推进 NextRun + wg.Add),锁外再 spawn。
|
||||
// 原实现整个遍历+spawn 持写锁,task 多时阻塞 GetTask/AddTask/RunTask 等管理 API。
|
||||
//
|
||||
// wg.Add 必须在锁内完成:Stop 先持锁置 running=false 再 wg.Wait,若 wg.Add 在锁外,
|
||||
// Stop 可能在两个 due 任务之间读到计数器 0 并提前返回,留下后启动的 goroutine 在调度器
|
||||
// 停止后运行(其 wg.Done 还会触发计数器下溢 panic)。锁内 Add 保证 Stop 的 wg.Wait
|
||||
// 一定能等到本批全部 goroutine。收集阶段已 CAS 占用守卫并推进 NextRun,故 spawn 必须
|
||||
// 执行(否则守卫不释放、NextRun 已推进却未跑)。spawn 用 s.ctx——Stop 后 ctx 已取消,
|
||||
// handler 收到 canceled ctx 应及时退出。
|
||||
func (s *Scheduler) checkAndRun(ctxs ...context.Context) {
|
||||
now := time.Now()
|
||||
|
||||
s.mu.RLock()
|
||||
for _, task := range s.tasks {
|
||||
if task.Enabled && !task.NextRun.IsZero() && now.After(task.NextRun) {
|
||||
go s.runTask(task)
|
||||
}
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
// 占用 running 守卫;正在执行则跳过本轮(防重叠 C12b)。
|
||||
if task.running != nil && !task.running.CompareAndSwap(false, true) {
|
||||
continue
|
||||
}
|
||||
// 先推进 NextRun(以上次 NextRun 锚定防漂移 C12c),再收集待 spawn。
|
||||
task.NextRun = task.Schedule.Next(task.NextRun)
|
||||
s.wg.Add(1) // 锁内 Add,保证 Stop 的 wg.Wait 等到本批(见上方注释)
|
||||
due = append(due, task)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, t := range due {
|
||||
go func(t *Task) {
|
||||
defer s.wg.Done()
|
||||
defer func() {
|
||||
if t.running != nil {
|
||||
t.running.Store(false)
|
||||
}
|
||||
}()
|
||||
|
||||
err := s.executeTaskWithContext(ctx, t)
|
||||
|
||||
s.mu.Lock()
|
||||
t.LastRun = time.Now()
|
||||
t.RunCount++
|
||||
t.LastError = err // CR3 修复:记录错误不再静默丢弃
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
logger.Error("cron task failed",
|
||||
zap.String("task", t.Name),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(t)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
// IntervalSchedule 固定间隔调度
|
||||
@@ -221,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}
|
||||
}
|
||||
|
||||
@@ -241,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}
|
||||
}
|
||||
|
||||
@@ -251,27 +440,35 @@ type WeeklySchedule struct {
|
||||
Minute int
|
||||
}
|
||||
|
||||
// Next 计算下次运行时间
|
||||
// Next 计算下次运行时间。
|
||||
//
|
||||
// C12d:原实现 `daysUntil <= 0 → +7` 仅按 weekday 差值,不比较当天时刻,
|
||||
// 当天目标时刻未到(如周一 9:00 目标、当前周一 12:00 之前的 8:00)被错误跳一周。
|
||||
// 改为:先算今天的目标时刻,按 `((day-now)+7)%7` 加天数,再与 now 比较——
|
||||
// 当天未到点则本周,当天已过则下周。
|
||||
func (s *WeeklySchedule) Next(now time.Time) time.Time {
|
||||
daysUntil := int(s.Day) - int(now.Weekday())
|
||||
if daysUntil <= 0 {
|
||||
daysUntil += 7
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), s.Hour, s.Minute, 0, 0, now.Location())
|
||||
daysUntil := (int(s.Day) - int(now.Weekday()) + 7) % 7
|
||||
next = next.AddDate(0, 0, daysUntil)
|
||||
if !next.After(now) {
|
||||
next = next.AddDate(0, 0, 7)
|
||||
}
|
||||
|
||||
next := time.Date(now.Year(), now.Month(), now.Day()+daysUntil, s.Hour, s.Minute, 0, 0, now.Location())
|
||||
return next
|
||||
}
|
||||
|
||||
// Weekly 每周指定时间运行
|
||||
func Weekly(day time.Weekday, hour, minute int) *WeeklySchedule {
|
||||
validateWeekday(day)
|
||||
validateClock(hour, minute, "Weekly")
|
||||
return &WeeklySchedule{Day: day, Hour: hour, Minute: minute}
|
||||
}
|
||||
|
||||
// FullCronSchedule 完整 Cron 表达式调度
|
||||
// 格式: "分钟 小时 日 月 星期" (5字段)
|
||||
// 示例: "0 12 * * *" 每天12点
|
||||
// "0 0 1 * *" 每月1号凌晨
|
||||
// "0 9-17 * * 1-5" 周一到周五 9点到17点每小时
|
||||
//
|
||||
// "0 0 1 * *" 每月1号凌晨
|
||||
// "0 9-17 * * 1-5" 周一到周五 9点到17点每小时
|
||||
type FullCronSchedule struct {
|
||||
Minute string // 分钟: 0-59, "*", "*/n", "a-b", "a,b,c"
|
||||
Hour string // 小时: 0-23, "*", "*/n", "a-b", "a,b,c"
|
||||
@@ -306,42 +503,115 @@ func (s *FullCronSchedule) match(t time.Time) bool {
|
||||
s.matchField(s.Weekday, int(t.Weekday()), 0, 6)
|
||||
}
|
||||
|
||||
// matchField 匹配单个字段
|
||||
// matchField 匹配单个字段(C12e 重写)。
|
||||
//
|
||||
// 旧实现先判 `-` 再判 `*/` 最后列表,且 parseInt 忽略非数字逐位累积:
|
||||
// - `1-5,8` 因整字段含 `-` 被当范围,parseInt("5,8")=58 → 范围被破坏为 1..58,列表项丢失。
|
||||
// - `garbage` → parseInt=0,分/时/周字段 value=0 时误触发。
|
||||
// - `*/garbage` → step=0 → return true 匹配全部。
|
||||
// - 周日 `7` 不匹配(Go Sunday=0)。
|
||||
//
|
||||
// 新实现:先按逗号拆列表,每项独立判 `*/n` / `a-b/n` / `a-b` / 单值(列表分支独立于范围分支);
|
||||
// 全部用 strconv.Atoi 返错;weekday 字段(min=0,max=6)7→0,范围 lo>hi 环绕。
|
||||
func (s *FullCronSchedule) matchField(field string, value int, min, max int) bool {
|
||||
if field == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理范围 "a-b"
|
||||
if strings.Contains(field, "-") {
|
||||
parts := strings.Split(field, "-")
|
||||
if len(parts) == 2 {
|
||||
start := parseInt(parts[0])
|
||||
end := parseInt(parts[1])
|
||||
return value >= start && value <= end
|
||||
for _, raw := range strings.Split(field, ",") {
|
||||
item := strings.TrimSpace(raw)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 处理步长 "*/n"
|
||||
if strings.HasPrefix(field, "*/") {
|
||||
step := parseInt(strings.TrimPrefix(field, "*/"))
|
||||
if step > 0 {
|
||||
return (value - min) % step == 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理列表 "a,b,c"
|
||||
for _, p := range strings.Split(field, ",") {
|
||||
if parseInt(strings.TrimSpace(p)) == value {
|
||||
if matchCronItem(item, value, min, max) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseCron 解析完整 Cron 表达式
|
||||
// matchCronItem 处理单个 cron 字段项(已按逗号拆分)。
|
||||
func matchCronItem(item string, value, min, max int) bool {
|
||||
isWeekday := min == 0 && max == 6
|
||||
|
||||
// 步长 "*/n" 或 "a-b/n"
|
||||
if idx := strings.Index(item, "/"); idx >= 0 {
|
||||
base := item[:idx]
|
||||
step, err := strconv.Atoi(item[idx+1:])
|
||||
if err != nil || step <= 0 {
|
||||
return false
|
||||
}
|
||||
lo, hi := min, max
|
||||
if base != "*" {
|
||||
rlo, rhi, err := parseCronRange(base, min, max)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
lo, hi = rlo, rhi
|
||||
}
|
||||
return value >= lo && value <= hi && (value-lo)%step == 0
|
||||
}
|
||||
|
||||
// 范围 "a-b"
|
||||
if strings.Contains(item, "-") {
|
||||
lo, hi, err := parseCronRange(item, min, max)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if isWeekday && lo > hi {
|
||||
// 环绕:lo..6 ∪ 0..hi(如 "6-1" = 周六、周日、周一)
|
||||
return (value >= lo && value <= max) || (value >= min && value <= hi)
|
||||
}
|
||||
return value >= lo && value <= hi
|
||||
}
|
||||
|
||||
// 单值
|
||||
v, err := strconv.Atoi(item)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if isWeekday && v == 7 {
|
||||
v = 0
|
||||
}
|
||||
if v < min || v > max {
|
||||
return false
|
||||
}
|
||||
return v == value
|
||||
}
|
||||
|
||||
// parseCronRange 解析 "a-b" 范围,含边界校验与 weekday 7→0 归一化。
|
||||
func parseCronRange(s string, min, max int) (int, int, error) {
|
||||
parts := strings.SplitN(s, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("invalid range %q", s)
|
||||
}
|
||||
lo, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
hi, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, fmt.Errorf("invalid range %q", s)
|
||||
}
|
||||
isWeekday := min == 0 && max == 6
|
||||
if isWeekday {
|
||||
// 7 → 0(周日)。但若两端归一化后都为 0 而原始值不同(如 "0-7"/"7-0"),
|
||||
// 语义为"整周"却坍缩成"仅周日",属歧义范围,拒绝以免静默错误匹配。
|
||||
nlo, nhi := lo, hi
|
||||
if nlo == 7 {
|
||||
nlo = 0
|
||||
}
|
||||
if nhi == 7 {
|
||||
nhi = 0
|
||||
}
|
||||
if nlo == nhi && lo != hi {
|
||||
return 0, 0, fmt.Errorf("ambiguous weekday range %q (0 与 7 均为周日)", s)
|
||||
}
|
||||
lo, hi = nlo, nhi
|
||||
}
|
||||
if lo < min || lo > max || hi < min || hi > max {
|
||||
return 0, 0, fmt.Errorf("range out of bounds %q", s)
|
||||
}
|
||||
return lo, hi, nil
|
||||
}
|
||||
|
||||
// ParseCron 解析完整 Cron 表达式。
|
||||
// 格式: "分钟 小时 日 月 星期"
|
||||
// 示例:
|
||||
//
|
||||
@@ -350,11 +620,50 @@ func (s *FullCronSchedule) matchField(field string, value int, min, max int) boo
|
||||
// "0 9-17 * * 1-5" - 工作日9-17点每小时
|
||||
// "0 0 1 * *" - 每月1号凌晨
|
||||
// "0 0 * * 0" - 每周日凌晨
|
||||
//
|
||||
// 非法表达式会 panic(fail-fast),动态输入请用 ParseCronStrict 处理 error。
|
||||
// 如需旧版“非法表达式回退为每分钟”的兼容语义,请显式使用 ParseCronOrDefault。
|
||||
func ParseCron(expr string) *FullCronSchedule {
|
||||
if sched, err := ParseCronStrict(expr); err == nil {
|
||||
return sched
|
||||
} else {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseCronOrDefault parses a Cron expression and falls back to all "*" (every
|
||||
// minute) when expr is invalid. Prefer ParseCronStrict or ParseCron for new
|
||||
// code; this helper exists for callers that intentionally want legacy fallback
|
||||
// semantics.
|
||||
func ParseCronOrDefault(expr string) *FullCronSchedule {
|
||||
if sched, err := ParseCronStrict(expr); err == nil {
|
||||
return sched
|
||||
}
|
||||
return &FullCronSchedule{"*", "*", "*", "*", "*"}
|
||||
}
|
||||
|
||||
// ParseCronStrict 严格解析 Cron 表达式,校验字段数与各字段范围,非法返 error。
|
||||
// 字段范围:分钟 0-59,小时 0-23,日 1-31,月 1-12,星期 0-6(周日=0,7 归一为 0)。
|
||||
func ParseCronStrict(expr string) (*FullCronSchedule, error) {
|
||||
fields := strings.Fields(expr)
|
||||
if len(fields) != 5 {
|
||||
// 默认每分钟执行
|
||||
return &FullCronSchedule{"*", "*", "*", "*", "*"}
|
||||
return nil, fmt.Errorf("cron: 需要 5 个字段,实际 %d", len(fields))
|
||||
}
|
||||
specs := []struct {
|
||||
val string
|
||||
min, max int
|
||||
name string
|
||||
}{
|
||||
{fields[0], 0, 59, "minute"},
|
||||
{fields[1], 0, 23, "hour"},
|
||||
{fields[2], 1, 31, "day"},
|
||||
{fields[3], 1, 12, "month"},
|
||||
{fields[4], 0, 6, "weekday"},
|
||||
}
|
||||
for _, sp := range specs {
|
||||
if err := validateCronField(sp.val, sp.min, sp.max, sp.name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &FullCronSchedule{
|
||||
Minute: fields[0],
|
||||
@@ -362,7 +671,65 @@ func ParseCron(expr string) *FullCronSchedule {
|
||||
Day: fields[2],
|
||||
Month: fields[3],
|
||||
Weekday: fields[4],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateCronField 校验单个 cron 字段语法与范围。
|
||||
func validateCronField(field string, min, max int, name string) error {
|
||||
if field == "*" {
|
||||
return nil
|
||||
}
|
||||
for _, raw := range strings.Split(field, ",") {
|
||||
item := strings.TrimSpace(raw)
|
||||
if item == "" {
|
||||
return fmt.Errorf("cron %s: 空列表项", name)
|
||||
}
|
||||
if err := validateCronItem(item, min, max, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCronItem(item string, min, max int, name string) error {
|
||||
isWeekday := min == 0 && max == 6
|
||||
norm := func(v int) int {
|
||||
if isWeekday && v == 7 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
if idx := strings.Index(item, "/"); idx >= 0 {
|
||||
base := item[:idx]
|
||||
step, err := strconv.Atoi(item[idx+1:])
|
||||
if err != nil || step <= 0 {
|
||||
return fmt.Errorf("cron %s: 非法步长 %q", name, item)
|
||||
}
|
||||
if base == "*" {
|
||||
return nil
|
||||
}
|
||||
lo, hi, err := parseCronRange(base, min, max)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cron %s: %v", name, err)
|
||||
}
|
||||
_ = lo
|
||||
_ = hi
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(item, "-") {
|
||||
if _, _, err := parseCronRange(item, min, max); err != nil {
|
||||
return fmt.Errorf("cron %s: %v", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
v, err := strconv.Atoi(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cron %s: 非数字 %q", name, item)
|
||||
}
|
||||
if v = norm(v); v < min || v > max {
|
||||
return fmt.Errorf("cron %s: %d 超出范围 [%d,%d]", name, v, min, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CronSchedule 简化 Cron 调度(仅分钟和小时)
|
||||
@@ -399,55 +766,53 @@ func (s *CronSchedule) matchValue(pattern string, value int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// splitPattern 将逗号分隔的模式解析为值列表(C12e:用 strconv.Atoi,非法项跳过)。
|
||||
func splitPattern(pattern string) []int {
|
||||
var values []int
|
||||
for _, p := range split(pattern, ',') {
|
||||
v := parseInt(p)
|
||||
if v >= 0 {
|
||||
values = append(values, v)
|
||||
for _, p := range strings.Split(pattern, ",") {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(p))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func split(s string, sep byte) []string {
|
||||
var parts []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == sep {
|
||||
parts = append(parts, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
parts = append(parts, s[start:])
|
||||
return parts
|
||||
}
|
||||
|
||||
func parseInt(s string) int {
|
||||
var v int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
v = v*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Cron 创建类 Cron 调度
|
||||
func Cron(minute, hour string) *CronSchedule {
|
||||
return &CronSchedule{Minute: minute, Hour: hour}
|
||||
}
|
||||
|
||||
// 全局调度器
|
||||
var globalScheduler *Scheduler
|
||||
var schedulerOnce sync.Once
|
||||
// 全局调度器。init 时预创建默认实例(与 storage.DefaultStorage / cache.defaultCachePtr
|
||||
// 对齐),使 SwapDefaultScheduler 返回非 nil、App 回滚总能恢复一个有效默认。用 atomic.Pointer
|
||||
// 保护并发读写,消除原 once+裸指针读写竞态。GetScheduler 仍保留懒初始化分支作防御。
|
||||
var globalScheduler atomic.Pointer[Scheduler]
|
||||
|
||||
// GetScheduler 获取全局调度器
|
||||
func init() {
|
||||
globalScheduler.Store(NewScheduler())
|
||||
}
|
||||
|
||||
// GetScheduler 获取全局调度器(并发安全;init 后通常非 nil,保留懒初始化作防御)。
|
||||
func GetScheduler() *Scheduler {
|
||||
schedulerOnce.Do(func() {
|
||||
globalScheduler = NewScheduler()
|
||||
})
|
||||
return globalScheduler
|
||||
if s := globalScheduler.Load(); s != nil {
|
||||
return s
|
||||
}
|
||||
ns := NewScheduler()
|
||||
if globalScheduler.CompareAndSwap(nil, ns) {
|
||||
return ns
|
||||
}
|
||||
return globalScheduler.Load()
|
||||
}
|
||||
|
||||
// SwapDefaultScheduler 将指定 Scheduler 置为全局默认,并返回被替换的旧调度器。
|
||||
// 旧调度器不会被停止,供 App 初始化这类需要失败回滚的生命周期流程暂存(照
|
||||
// database.SwapDefaultManager / SwapDefaultRedisManager 模式)。nil 被忽略,返回当前默认。
|
||||
func SwapDefaultScheduler(s *Scheduler) *Scheduler {
|
||||
if s == nil {
|
||||
return globalScheduler.Load()
|
||||
}
|
||||
return globalScheduler.Swap(s)
|
||||
}
|
||||
|
||||
// AddTask 添加任务到全局调度器
|
||||
@@ -460,7 +825,16 @@ func Start() {
|
||||
GetScheduler().Start()
|
||||
}
|
||||
|
||||
// Stop 停止全局调度器
|
||||
// Stop 停止全局调度器(无限等待,见 Scheduler.Stop)。
|
||||
func Stop() {
|
||||
GetScheduler().Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// StopGlobalWithTimeout 停止全局调度器并最多等待 timeout(P1 #10)。
|
||||
// 全局调度器尚未创建时无操作返回 true,不会因此惰性创建它——供 App.Shutdown 安全调用。
|
||||
func StopGlobalWithTimeout(timeout time.Duration) bool {
|
||||
if s := globalScheduler.Load(); s != nil {
|
||||
return s.StopWithTimeout(timeout)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stepSchedule 是一个固定步长调度,Next(now)=now+step,用于确定性测试 C12c 锚定。
|
||||
type stepSchedule struct{ step time.Duration }
|
||||
|
||||
func (s stepSchedule) Next(now time.Time) time.Time { return now.Add(s.step) }
|
||||
|
||||
// TestC12bNoOverlapManualDrive 验证 per-task running 守卫防止重叠执行。
|
||||
//
|
||||
// 修复前:checkAndRun 无 running 守卫,handler 未完成时下一次 checkAndRun(NextRun 仍为过去)
|
||||
// 会再次 spawn 同一任务,并发执行。
|
||||
// 手动驱动两轮 checkAndRun,handler 阻塞至释放——修复后第二轮被 running 守卫拦截,仅 1 次 spawn。
|
||||
func TestC12bNoOverlapManualDrive(t *testing.T) {
|
||||
s := NewScheduler()
|
||||
var started int32
|
||||
release := make(chan struct{})
|
||||
task := s.AddTask("block", stepSchedule{step: time.Microsecond}, func(ctx context.Context) error {
|
||||
atomic.AddInt32(&started, 1)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
task.NextRun = time.Now().Add(-time.Hour) // 过去 → 到期
|
||||
|
||||
s.checkAndRun() // 第一轮:spawn,handler 阻塞
|
||||
for atomic.LoadInt32(&started) == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
s.checkAndRun() // 第二轮:修复后 running 守卫拦截,不再 spawn
|
||||
time.Sleep(50 * time.Millisecond) // 给潜在二次 spawn 启动时间
|
||||
|
||||
if n := atomic.LoadInt32(&started); n != 1 {
|
||||
t.Errorf("task overlapped: started = %d, want 1 (C12b)", n)
|
||||
}
|
||||
|
||||
close(release)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for task.running != nil && task.running.Load() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("running guard never released")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestC12cNextRunAnchoredOnPrevious 验证 NextRun 以上次 NextRun 锚定(不漂移)。
|
||||
//
|
||||
// 修复前:NextRun = schedule.Next(time.Now())(handler 完成后的 now),每周期累积 handler 时长。
|
||||
// 修复后:NextRun = schedule.Next(task.NextRun)(上次计划时间锚定)。
|
||||
//
|
||||
// 将 NextRun 设到过去的固定锚点 T0,手动驱动 3 轮 checkAndRun(每轮等 handler 完成),
|
||||
// 断言 NextRun == T0 + 3*step(锚定)。漂移实现下 NextRun ≈ now+step(远大于 T0+3*step)。
|
||||
func TestC12cNextRunAnchoredOnPrevious(t *testing.T) {
|
||||
s := NewScheduler()
|
||||
step := 100 * time.Millisecond
|
||||
|
||||
done := make(chan struct{}, 16)
|
||||
task := s.AddTask("anchored", stepSchedule{step: step}, func(ctx context.Context) error {
|
||||
// 模拟 handler 耗时——修复后不应影响 NextRun 锚定。
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
done <- struct{}{}
|
||||
return nil
|
||||
})
|
||||
|
||||
// 将 NextRun 设到过去的固定锚点 T0(远早于 now,确保每轮都到期触发)。
|
||||
T0 := time.Now().Add(-10 * time.Second)
|
||||
task.NextRun = T0
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
s.checkAndRun()
|
||||
// 等 handler 执行;若 NextRun 未锚定(漂移实现下 NextRun 跳到未来,
|
||||
// 后续 checkAndRun 不再到期),done 不会收到 → 超时明确失败而非挂起。
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("iter %d: handler did not fire (NextRun drifted to future, not anchored on previous) (C12c)", i)
|
||||
}
|
||||
// 等 running 守卫释放(defer 在 handler 返回后置 false)。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for task.running != nil && task.running.Load() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("running guard never released (iter %d)", i)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
got := task.NextRun
|
||||
want := T0.Add(3 * step)
|
||||
if diff := got.Sub(want); diff < -5*time.Millisecond || diff > 5*time.Millisecond {
|
||||
t.Errorf("NextRun not anchored on previous: got %v, want %v (diff %v, C12c drift)", got, want, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// C12e 直接测试 matchField(未导出,故 internal test)。
|
||||
//
|
||||
// 修复前:matchField 先判 `-` 把整字段当范围,parseInt 忽略非数字逐位累积:
|
||||
// - "1-5,8" 含 `-` → parseInt("5,8")=58 → 范围 1..58 全匹配,列表项 8 丢失。
|
||||
// - "garbage" → parseInt=0,value=0 误触发。
|
||||
// - "*/garbage" → step=0 → return true 匹配全部。
|
||||
|
||||
var schedForMatch = &FullCronSchedule{}
|
||||
|
||||
func TestC12eMatchFieldListAndRangeIndependent(t *testing.T) {
|
||||
// "1-5,8" 仅匹配 1,2,3,4,5,8。
|
||||
for _, v := range []int{1, 2, 3, 4, 5, 8} {
|
||||
if !schedForMatch.matchField("1-5,8", v, 0, 59) {
|
||||
t.Errorf("1-5,8 should match %d (C12e)", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []int{0, 6, 7, 9, 30, 58} {
|
||||
if schedForMatch.matchField("1-5,8", v, 0, 59) {
|
||||
t.Errorf("1-5,8 should NOT match %d (C12e range broken to 1..58)", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestC12eMatchFieldGarbageNotMatch(t *testing.T) {
|
||||
for v := 0; v <= 59; v++ {
|
||||
if schedForMatch.matchField("garbage", v, 0, 59) {
|
||||
t.Errorf("garbage should not match any value, matched %d (C12e)", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestC12eMatchFieldStarSlashGarbageNotMatchAll(t *testing.T) {
|
||||
// 修复前 step=0 → return true 匹配全部。
|
||||
matched := 0
|
||||
for v := 0; v <= 59; v++ {
|
||||
if schedForMatch.matchField("*/garbage", v, 0, 59) {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
if matched != 0 {
|
||||
t.Errorf("*/garbage should match nothing, matched %d (C12e step=0 bug)", matched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestC12eMatchFieldWeekday7IsSunday(t *testing.T) {
|
||||
// weekday 字段 7 → 0(周日)。
|
||||
if !schedForMatch.matchField("7", 0, 0, 6) {
|
||||
t.Error("weekday 7 should match Sunday(0) (C12e)")
|
||||
}
|
||||
if schedForMatch.matchField("7", 7, 0, 6) {
|
||||
t.Error("weekday 7 should not match value 7 (out of range after normalize)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestC12eMatchFieldRangeAndStepAndList(t *testing.T) {
|
||||
// 范围 9-17。
|
||||
for _, v := range []int{9, 12, 17} {
|
||||
if !schedForMatch.matchField("9-17", v, 0, 23) {
|
||||
t.Errorf("9-17 should match %d", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []int{8, 18} {
|
||||
if schedForMatch.matchField("9-17", v, 0, 23) {
|
||||
t.Errorf("9-17 should NOT match %d", v)
|
||||
}
|
||||
}
|
||||
// 步长 */15。
|
||||
for _, v := range []int{0, 15, 30, 45} {
|
||||
if !schedForMatch.matchField("*/15", v, 0, 59) {
|
||||
t.Errorf("*/15 should match %d", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []int{1, 7, 16} {
|
||||
if schedForMatch.matchField("*/15", v, 0, 59) {
|
||||
t.Errorf("*/15 should NOT match %d", v)
|
||||
}
|
||||
}
|
||||
// 范围步长 9-17/2 → 9,11,13,15,17。
|
||||
for _, v := range []int{9, 11, 13, 15, 17} {
|
||||
if !schedForMatch.matchField("9-17/2", v, 0, 23) {
|
||||
t.Errorf("9-17/2 should match %d", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []int{10, 12, 14, 16} {
|
||||
if schedForMatch.matchField("9-17/2", v, 0, 23) {
|
||||
t.Errorf("9-17/2 should NOT match %d", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package cron_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/cron"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// C12a:runTask 计数写入无锁 + GetTask/ListTasks 返回 live 指针 → data race
|
||||
// ============================================================
|
||||
|
||||
// TestC12aConcurrentReadWriteNoRace 验证并发 RunTask/GetTask/ListTasks + 调度运行
|
||||
// 无数据竞争(-race)。
|
||||
//
|
||||
// 修复前:runTask 无锁写 LastRun/RunCount,GetTask/ListTasks 返回 live 指针并发读 →
|
||||
// -race 必采到 DATA RACE。修复后:写入纳入锁、Getter 返回拷贝。
|
||||
func TestC12aConcurrentReadWriteNoRace(t *testing.T) {
|
||||
scheduler := cron.NewScheduler()
|
||||
|
||||
scheduler.AddTask("t1", cron.Every(50*time.Millisecond), func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
scheduler.AddTask("t2", cron.Every(80*time.Millisecond), func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
scheduler.Start()
|
||||
t.Cleanup(scheduler.Stop)
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
// 读取返回拷贝的字段——若写侧无锁,与此处读竞争(C12a)。
|
||||
if g, err := scheduler.GetTask("t1"); err == nil {
|
||||
_ = g.RunCount
|
||||
_ = g.LastRun
|
||||
}
|
||||
for _, tk := range scheduler.ListTasks() {
|
||||
_ = tk.RunCount
|
||||
_ = tk.LastRun
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = scheduler.RunTask("t2")
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestC12aGetTaskReturnsCopy 验证 GetTask 返回拷贝,修改返回值不影响内部状态。
|
||||
func TestC12aGetTaskReturnsCopy(t *testing.T) {
|
||||
scheduler := cron.NewScheduler()
|
||||
scheduler.AddTask("t", cron.Every(time.Minute), func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
got, err := scheduler.GetTask("t")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
got.RunCount = 9999
|
||||
got.Enabled = false
|
||||
|
||||
again, _ := scheduler.GetTask("t")
|
||||
if again.RunCount == 9999 {
|
||||
t.Error("GetTask returned live pointer (RunCount mutated internally)")
|
||||
}
|
||||
if !again.Enabled {
|
||||
t.Error("GetTask returned live pointer (Enabled mutated internally)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestC12aListTasksReturnsCopies 验证 ListTasks 元素为拷贝。
|
||||
func TestC12aListTasksReturnsCopies(t *testing.T) {
|
||||
scheduler := cron.NewScheduler()
|
||||
scheduler.AddTask("t", cron.Every(time.Minute), func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
list := scheduler.ListTasks()
|
||||
list[0].RunCount = 777
|
||||
|
||||
again := scheduler.ListTasks()
|
||||
if again[0].RunCount == 777 {
|
||||
t.Error("ListTasks returned live pointer (RunCount mutated internally)")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// C12b:长任务跨 tick 重叠执行
|
||||
// ============================================================
|
||||
|
||||
// TestC12bRunTaskConcurrentManualTriggerReturnsError 验证手动 RunTask 占用守卫期间,
|
||||
// 再次 RunTask 返"任务正在执行中"错误。
|
||||
func TestC12bRunTaskConcurrentManualTriggerReturnsError(t *testing.T) {
|
||||
scheduler := cron.NewScheduler()
|
||||
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
scheduler.AddTask("block", cron.Every(time.Hour), func(ctx context.Context) error {
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
|
||||
var firstErr error
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
firstErr = scheduler.RunTask("block")
|
||||
}()
|
||||
|
||||
<-started
|
||||
|
||||
secondErr := scheduler.RunTask("block")
|
||||
if secondErr == nil {
|
||||
t.Error("second RunTask should fail while first is running (C12b running guard)")
|
||||
}
|
||||
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
t.Errorf("first RunTask error: %v", firstErr)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// C12d:Weekly 当天未到点目标被跳一周
|
||||
// ============================================================
|
||||
|
||||
func TestC12dWeeklySameDayBeforeTarget(t *testing.T) {
|
||||
schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0}
|
||||
|
||||
// 周一 8:00(目标 9:00 未到)→ 应返回本周一 9:00,不跳周。
|
||||
now := time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC) // 2026-06-29 是周一
|
||||
if now.Weekday() != time.Monday {
|
||||
t.Fatalf("test fixture: expected Monday, got %v", now.Weekday())
|
||||
}
|
||||
next := schedule.Next(now)
|
||||
want := time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC)
|
||||
if !next.Equal(want) {
|
||||
t.Errorf("Weekly same-day-before-target: got %v, want %v (C12d skip-week bug)", next, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestC12dWeeklySameDayAfterTarget(t *testing.T) {
|
||||
schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0}
|
||||
|
||||
// 周一 10:00(目标 9:00 已过)→ 下周一 9:00。
|
||||
now := time.Date(2026, 6, 29, 10, 0, 0, 0, time.UTC)
|
||||
next := schedule.Next(now)
|
||||
want := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC)
|
||||
if !next.Equal(want) {
|
||||
t.Errorf("Weekly same-day-after-target: got %v, want %v", next, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestC12dWeeklyCrossWeek(t *testing.T) {
|
||||
schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0}
|
||||
|
||||
// 周三 → 下周一。
|
||||
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) // 周三
|
||||
if now.Weekday() != time.Wednesday {
|
||||
t.Fatalf("test fixture: expected Wednesday, got %v", now.Weekday())
|
||||
}
|
||||
next := schedule.Next(now)
|
||||
want := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC)
|
||||
if !next.Equal(want) {
|
||||
t.Errorf("Weekly cross-week: got %v, want %v", next, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// C12e:cron 解析缺陷(周日 7 / 范围环绕 / 严格解析)
|
||||
// (1-5,8 / garbage / */garbage 的 matchField 直接测试见 cron_c12_internal_test.go)
|
||||
// ============================================================
|
||||
|
||||
func TestC12eWeekdaySundayAs7(t *testing.T) {
|
||||
schedule := cron.FullCronSchedule{
|
||||
Minute: "0",
|
||||
Hour: "0",
|
||||
Day: "*",
|
||||
Month: "*",
|
||||
Weekday: "7",
|
||||
}
|
||||
// 0 0 * * 7 → 每周日凌晨。从周六 23:00 找下一个匹配应落周日 00:00。
|
||||
now := time.Date(2026, 6, 27, 23, 0, 0, 0, time.UTC) // 周六
|
||||
if now.Weekday() != time.Saturday {
|
||||
t.Fatalf("test fixture: expected Saturday, got %v", now.Weekday())
|
||||
}
|
||||
next := schedule.Next(now)
|
||||
if next.Weekday() != time.Sunday {
|
||||
t.Errorf("0 0 * * 7 should land on Sunday, got %v (C12e 7≠Sunday)", next.Weekday())
|
||||
}
|
||||
if next.Hour() != 0 || next.Minute() != 0 {
|
||||
t.Errorf("0 0 * * 7 should land on 00:00, got %v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestC12eWeekdayRangeWraparound(t *testing.T) {
|
||||
// "6-1" 在 weekday 环绕:周六(6)、周日(0)、周一(1)。
|
||||
schedule := cron.FullCronSchedule{
|
||||
Minute: "0",
|
||||
Hour: "0",
|
||||
Day: "*",
|
||||
Month: "*",
|
||||
Weekday: "6-1",
|
||||
}
|
||||
for _, w := range []time.Weekday{time.Saturday, time.Sunday, time.Monday} {
|
||||
tt := time.Date(2026, 6, 29, 0, 0, 0, 0, time.UTC) // 周一
|
||||
for tt.Weekday() != w {
|
||||
tt = tt.AddDate(0, 0, 1)
|
||||
}
|
||||
next := schedule.Next(tt.Add(-1 * time.Minute))
|
||||
if next.Weekday() != w {
|
||||
t.Errorf("6-1 should match %v, got %v", w, next.Weekday())
|
||||
}
|
||||
}
|
||||
// 周二不应被 6-1 匹配,下一个应是周六。
|
||||
tt := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) // 周二
|
||||
if tt.Weekday() != time.Tuesday {
|
||||
t.Fatalf("test fixture: expected Tuesday, got %v", tt.Weekday())
|
||||
}
|
||||
next := schedule.Next(tt.Add(-1 * time.Minute))
|
||||
if next.Weekday() != time.Saturday {
|
||||
t.Errorf("6-1 should skip Tuesday, next match %v, want Saturday", next.Weekday())
|
||||
}
|
||||
}
|
||||
|
||||
// TestC12eParseCronStrict 验证严格解析。
|
||||
func TestC12eParseCronStrict(t *testing.T) {
|
||||
cases := []struct {
|
||||
expr string
|
||||
ok bool
|
||||
}{
|
||||
{"0 12 * * *", true},
|
||||
{"*/15 * * * *", true},
|
||||
{"0 9-17 * * 1-5", true},
|
||||
{"0 0 1 * 7", true}, // 周日 7 合法
|
||||
{"0 0 * * 0-7", false},
|
||||
{"invalid", false},
|
||||
{"1-5,8 0 * * *", true},
|
||||
{"60 0 * * *", false}, // 分钟越界
|
||||
{"0 25 * * *", false}, // 小时越界
|
||||
{"0 0 0 * *", false}, // 日越界
|
||||
{"0 0 * 13 *", false}, // 月越界
|
||||
{"0 0 * * 9", false}, // 周越界
|
||||
{"garbage 0 * * *", false},
|
||||
{"*/0 * * * *", false}, // step=0 非法
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, err := cron.ParseCronStrict(c.expr)
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("ParseCronStrict(%q) unexpected error: %v", c.expr, err)
|
||||
}
|
||||
if !c.ok && err == nil {
|
||||
t.Errorf("ParseCronStrict(%q) expected error, got nil", c.expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestC12eParseCronFallback verifies ParseCron now fails fast on invalid input.
|
||||
func TestC12eParseCronFallback(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("ParseCron(invalid) should panic")
|
||||
}
|
||||
}()
|
||||
_ = cron.ParseCron("invalid")
|
||||
}
|
||||
|
||||
func TestC12eParseCronOrDefaultFallback(t *testing.T) {
|
||||
s := cron.ParseCronOrDefault("invalid")
|
||||
if s.Minute != "*" || s.Hour != "*" || s.Day != "*" || s.Month != "*" || s.Weekday != "*" {
|
||||
t.Errorf("ParseCronOrDefault(invalid) should fall back to all-*, got %+v", s)
|
||||
}
|
||||
// 合法表达式不回退。
|
||||
s = cron.ParseCron("1-5,8 0 * * *")
|
||||
if s.Minute != "1-5,8" {
|
||||
t.Errorf("ParseCron valid: Minute = %q, want 1-5,8", s.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
// TestC12eCronScheduleGarbageNoMatch 验证简化 Cron 不再 parseInt 容错为 0。
|
||||
func TestC12eCronScheduleGarbageNoMatch(t *testing.T) {
|
||||
bad := cron.CronSchedule{Minute: "garbage", Hour: "9"}
|
||||
// garbage → splitPattern 返空 → matchMinute 返 false → 24h 内无匹配。
|
||||
now := time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC)
|
||||
next := bad.Next(now)
|
||||
if !next.IsZero() {
|
||||
t.Errorf("CronSchedule garbage minute should not match, got next=%v (C12e)", next)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"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 直接上抛 → 测试进程崩溃。
|
||||
func TestCronRunTaskPanicRecovered_M13(t *testing.T) {
|
||||
s := NewScheduler()
|
||||
s.AddTask("panic", Every(time.Minute), func(context.Context) error { panic("boom") })
|
||||
|
||||
err := s.RunTask("panic")
|
||||
if err == nil || !strings.Contains(err.Error(), "panic recovered") {
|
||||
t.Fatalf("RunTask err = %v, want a panic-recovered error", err)
|
||||
}
|
||||
|
||||
got, _ := s.GetTask("panic")
|
||||
if got.LastError == nil || !strings.Contains(got.LastError.Error(), "panic recovered") {
|
||||
t.Fatalf("LastError = %v, want panic-recovered error", got.LastError)
|
||||
}
|
||||
|
||||
// running 守卫必须已释放:再次 RunTask 不应返“任务正在执行中”(会再次 panic→recover)。
|
||||
err2 := s.RunTask("panic")
|
||||
if err2 != nil && strings.Contains(err2.Error(), "任务正在执行中") {
|
||||
t.Fatal("running guard not released after panic (second RunTask blocked)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCronRunTaskRecordsLastError_M13 回归:RunTask 手动路径此前只更 LastRun/RunCount,
|
||||
// 不记 LastError。修复后与调度路径一致,正常 error 也记入 LastError。
|
||||
func TestCronRunTaskRecordsLastError_M13(t *testing.T) {
|
||||
s := NewScheduler()
|
||||
sentinel := errors.New("sentinel fail")
|
||||
s.AddTask("err", Every(time.Minute), func(context.Context) error { return sentinel })
|
||||
|
||||
err := s.RunTask("err")
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("RunTask err = %v, want sentinel", err)
|
||||
}
|
||||
got, _ := s.GetTask("err")
|
||||
if !errors.Is(got.LastError, sentinel) {
|
||||
t.Fatalf("LastError = %v, want sentinel", got.LastError)
|
||||
}
|
||||
if got.RunCount != 1 {
|
||||
t.Fatalf("RunCount = %d, want 1", got.RunCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCronCheckAndRunPanicRecovered_M13 回归:调度 goroutine 内 handler panic 时,
|
||||
// recover 防止进程崩溃;同 tick 派生的兄弟 goroutine 不受影响;无 goroutine 泄漏。
|
||||
// 同包测试直接驱动 checkAndRun + wg.Wait,零 sleep、确定性。
|
||||
// 修复前:派生 goroutine 内未 recover 的 panic 终止测试进程。
|
||||
func TestCronCheckAndRunPanicRecovered_M13(t *testing.T) {
|
||||
s := NewScheduler()
|
||||
var ran atomic.Int32
|
||||
s.AddTask("panic", Every(time.Millisecond), func(context.Context) error { panic("boom") })
|
||||
s.AddTask("canary", Every(time.Millisecond), func(context.Context) error {
|
||||
ran.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
// AddTask 设 NextRun=now+1ms;手动改到过去确保两者 due,跨过 1s ticker 的不确定性。
|
||||
s.mu.Lock()
|
||||
s.tasks["panic"].NextRun = time.Now().Add(-time.Second)
|
||||
s.tasks["canary"].NextRun = time.Now().Add(-time.Second)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.checkAndRun()
|
||||
s.wg.Wait() // 等派生的两个 goroutine 退出,证无泄漏
|
||||
|
||||
pt, _ := s.GetTask("panic")
|
||||
if pt.LastError == nil || !strings.Contains(pt.LastError.Error(), "panic recovered") {
|
||||
t.Fatalf("panic task LastError = %v, want panic-recovered error", pt.LastError)
|
||||
}
|
||||
if ran.Load() == 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -232,10 +232,14 @@ func TestParseCron(t *testing.T) {
|
||||
}
|
||||
|
||||
// 无效表达式返回默认
|
||||
schedule = cron.ParseCron("invalid")
|
||||
if schedule.Minute != "*" || schedule.Hour != "*" {
|
||||
t.Error("ParseCron invalid should return default")
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("ParseCron invalid should panic")
|
||||
}
|
||||
}()
|
||||
_ = cron.ParseCron("invalid")
|
||||
}()
|
||||
}
|
||||
|
||||
func TestFullCronScheduleMatch(t *testing.T) {
|
||||
@@ -261,4 +265,4 @@ func TestFullCronScheduleMatch(t *testing.T) {
|
||||
if next.Minute() != 10 || next.Hour() != 10 {
|
||||
t.Errorf("Next after 10:07 should be 10:10, got %v", next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// 内置驱动常量(更多驱动可通过 RegisterDialect 扩展)
|
||||
const (
|
||||
DriverMySQL = config.DriverMySQL
|
||||
DriverPostgres = config.DriverPostgres
|
||||
)
|
||||
|
||||
// DialectorFactory 根据 DSN 返回 GORM Dialector
|
||||
type DialectorFactory func(dsn string) gorm.Dialector
|
||||
|
||||
// DialectSpec 描述一种数据库方言:如何建立连接 + 如何拼接 DSN
|
||||
type DialectSpec struct {
|
||||
// Name 驱动主名称(如 "mysql"、"postgres"、"sqlite"),大小写不敏感
|
||||
Name string
|
||||
// Aliases 驱动别名(如 postgres 的 "postgresql"、"pg")
|
||||
Aliases []string
|
||||
// Dialector 由 DSN 构造 GORM Dialector
|
||||
Dialector DialectorFactory
|
||||
// DSN 由 DatabaseConfig 拼接连接字符串。可选。
|
||||
// 不提供时仅 CustomDSN 能直接生效;需要由配置字段拼接连接串的自定义驱动应显式提供该函数。
|
||||
DSN config.DSNBuilder
|
||||
}
|
||||
|
||||
var (
|
||||
dialectsMu sync.RWMutex
|
||||
dialects = map[string]DialectorFactory{}
|
||||
)
|
||||
|
||||
// RegisterDialect 注册一种数据库方言。
|
||||
// 同时把 DSN 构建器登记到 config 包,使 cfg.Database.DSN() 也能识别新驱动。
|
||||
// 已注册的同名驱动会被覆盖。
|
||||
//
|
||||
// 用法示例(接入 SQLite):
|
||||
//
|
||||
// import "gorm.io/driver/sqlite"
|
||||
//
|
||||
// database.RegisterDialect(database.DialectSpec{
|
||||
// Name: "sqlite",
|
||||
// Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
|
||||
// DSN: func(c *config.DatabaseConfig) string { return c.Name }, // 文件路径
|
||||
// })
|
||||
func RegisterDialect(spec DialectSpec) {
|
||||
if spec.Dialector == nil || strings.TrimSpace(spec.Name) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
dialectsMu.Lock()
|
||||
for _, n := range append([]string{spec.Name}, spec.Aliases...) {
|
||||
key := normalizeDriver(n)
|
||||
if key != "" {
|
||||
dialects[key] = spec.Dialector
|
||||
}
|
||||
}
|
||||
dialectsMu.Unlock()
|
||||
|
||||
if spec.DSN != nil {
|
||||
config.RegisterDSNBuilder(spec.Name, spec.DSN, spec.Aliases...)
|
||||
}
|
||||
}
|
||||
|
||||
// LookupDialect 查找已注册的 Dialector 工厂
|
||||
func LookupDialect(driver string) (DialectorFactory, bool) {
|
||||
key := normalizeDriver(driver)
|
||||
dialectsMu.RLock()
|
||||
defer dialectsMu.RUnlock()
|
||||
f, ok := dialects[key]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// RegisteredDialects 返回所有已注册的驱动名(用于诊断)
|
||||
func RegisteredDialects() []string {
|
||||
dialectsMu.RLock()
|
||||
defer dialectsMu.RUnlock()
|
||||
names := make([]string, 0, len(dialects))
|
||||
for k := range dialects {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Dialector 根据配置返回 GORM Dialector。
|
||||
// 驱动由 cfg.Database.Driver 决定;未指定时默认 MySQL,非空但未注册时返回会初始化失败的
|
||||
// Dialector,避免拼写错误静默回退到 MySQL。
|
||||
func Dialector(cfg *config.Config) gorm.Dialector {
|
||||
if cfg == nil {
|
||||
logger.Warn("database: 配置为空,回退到 MySQL 空 DSN")
|
||||
return mysql.Open("")
|
||||
}
|
||||
return dialectorForDSN(cfg.Database.Driver, cfg.Database.DSN())
|
||||
}
|
||||
|
||||
// dialectorForDSN 根据驱动名和 DSN 返回 Dialector
|
||||
func dialectorForDSN(driver, dsn string) gorm.Dialector {
|
||||
normalized := normalizeDriver(driver)
|
||||
if normalized == "" {
|
||||
normalized = DriverMySQL
|
||||
}
|
||||
if f, ok := LookupDialect(normalized); ok {
|
||||
return f(dsn)
|
||||
}
|
||||
logger.Warnf("database: 驱动 %q 未注册(已注册: %s),拒绝静默回退到 MySQL;请修正配置或先注册方言",
|
||||
normalized, strings.Join(RegisteredDialects(), ", "))
|
||||
return errorDialector{
|
||||
name: "invalid",
|
||||
err: fmt.Errorf("数据库驱动未注册: %s", normalized),
|
||||
}
|
||||
}
|
||||
|
||||
type errorDialector struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
func (d errorDialector) Name() string { return d.name }
|
||||
|
||||
func (d errorDialector) Initialize(*gorm.DB) error {
|
||||
if d.err == nil {
|
||||
return errors.New("数据库驱动未注册")
|
||||
}
|
||||
return d.err
|
||||
}
|
||||
|
||||
func (d errorDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
|
||||
|
||||
func (d errorDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
|
||||
func (d errorDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
|
||||
func (d errorDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
|
||||
|
||||
func (d errorDialector) QuoteTo(writer clause.Writer, str string) {
|
||||
_, _ = writer.WriteString(str)
|
||||
}
|
||||
|
||||
func (d errorDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
|
||||
// normalizeDriver 规范化驱动名(小写、去空白)
|
||||
func normalizeDriver(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
// driverDescription 返回带别名提示的驱动描述(用于错误信息和日志)
|
||||
func driverDescription(driver string) string {
|
||||
key := normalizeDriver(driver)
|
||||
if key == "" {
|
||||
return DriverMySQL + " (default)"
|
||||
}
|
||||
if _, ok := LookupDialect(key); ok {
|
||||
return key
|
||||
}
|
||||
return fmt.Sprintf("%s (unregistered)", key)
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 内置 MySQL
|
||||
RegisterDialect(DialectSpec{
|
||||
Name: DriverMySQL,
|
||||
Dialector: func(dsn string) gorm.Dialector { return mysql.Open(dsn) },
|
||||
DSN: func(c *config.DatabaseConfig) string { return c.MySQLDSN() },
|
||||
})
|
||||
// 内置 PostgreSQL
|
||||
RegisterDialect(DialectSpec{
|
||||
Name: DriverPostgres,
|
||||
Aliases: []string{"postgresql", "pg"},
|
||||
Dialector: func(dsn string) gorm.Dialector { return postgres.Open(dsn) },
|
||||
DSN: func(c *config.DatabaseConfig) string { return c.PostgresDSN() },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type dbModeContextKey struct{}
|
||||
|
||||
// txContextKey 携带外层事务的 *gorm.DB,使 repository 等上层在调用时能 join 到外层事务
|
||||
// (H6c:外层 ctx 事务无法 join)。由 WithTx 注入、TxFromContext 读取。
|
||||
type txContextKey struct{}
|
||||
|
||||
const (
|
||||
dbModeMaster = "master"
|
||||
dbModeReplica = "replica"
|
||||
)
|
||||
|
||||
func normalizeContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ReplicaPicker 从库选择策略
|
||||
type ReplicaPicker interface {
|
||||
Pick(replicas []*gorm.DB) *gorm.DB
|
||||
}
|
||||
|
||||
// RoundRobinPicker 轮询选择从库
|
||||
type RoundRobinPicker struct {
|
||||
mu sync.Mutex
|
||||
counter int
|
||||
}
|
||||
|
||||
// Pick 轮询选择一个从库
|
||||
func (p *RoundRobinPicker) Pick(replicas []*gorm.DB) *gorm.DB {
|
||||
if len(replicas) == 0 {
|
||||
return nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
idx := p.counter % len(replicas)
|
||||
p.counter = (idx + 1) % len(replicas)
|
||||
p.mu.Unlock()
|
||||
return replicas[idx]
|
||||
}
|
||||
|
||||
// RandomPicker 随机选择从库。
|
||||
//
|
||||
// 使用 crypto/rand 生成随机索引,并发安全且不可预测。len(replicas)<=0 返回 nil;
|
||||
// crypto/rand 失败(极罕见,如熵池耗尽)时回退到 replicas[0],保证可用性。
|
||||
type RandomPicker struct{}
|
||||
|
||||
// Pick 随机选择一个从库
|
||||
func (p *RandomPicker) Pick(replicas []*gorm.DB) *gorm.DB {
|
||||
if len(replicas) == 0 {
|
||||
return nil
|
||||
}
|
||||
n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(len(replicas))))
|
||||
if err != nil {
|
||||
return replicas[0]
|
||||
}
|
||||
return replicas[int(n.Int64())]
|
||||
}
|
||||
|
||||
// Manager 数据库管理器,持有主库与从库连接实例
|
||||
type Manager struct {
|
||||
cfg *config.Config
|
||||
master *gorm.DB
|
||||
replicas []*gorm.DB
|
||||
picker ReplicaPicker
|
||||
mu sync.Mutex
|
||||
// opMu 串行化 InitDB / InitDBWithReplicas / Close 这类资源生命周期操作。
|
||||
// 避免关闭已开始后初始化又发布新连接,或初始化发布后被并发 Close 置空。
|
||||
opMu sync.Mutex
|
||||
|
||||
// #21 健康自愈
|
||||
healthy atomic.Bool // 主库是否健康
|
||||
replicaHealthy []atomic.Bool // 每个从库的健康标记,索引与 replicas 对齐
|
||||
probeFailures int // 主库连续探活失败次数
|
||||
probeMu sync.Mutex // 保护 probeFailures
|
||||
replicaHealthSet bool // replicaHealthy 是否已按 replicas 长度初始化
|
||||
}
|
||||
|
||||
// NewManager 创建数据库管理器
|
||||
func NewManager(cfg *config.Config) *Manager {
|
||||
return &Manager{cfg: cfg, picker: &RandomPicker{}}
|
||||
}
|
||||
|
||||
// getCfg 在锁内读取 m.cfg(P1 #11:消除与 InitDB 写 m.cfg 的数据竞争)。
|
||||
func (m *Manager) getCfg() *config.Config {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg
|
||||
}
|
||||
|
||||
// setCfg 在锁内写入 m.cfg(P1 #11)。
|
||||
func (m *Manager) setCfg(cfg *config.Config) {
|
||||
m.mu.Lock()
|
||||
m.cfg = cfg
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetPicker 设置从库选择策略
|
||||
func (m *Manager) SetPicker(p ReplicaPicker) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.picker = p
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Picker 返回当前从库选择策略
|
||||
func (m *Manager) Picker() ReplicaPicker {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.picker
|
||||
}
|
||||
|
||||
// Master 返回主库实例。
|
||||
// 经 m.mu 锁保护读取,避免与 InitDB/Close 的写竞争返回已关闭/nil 池(C11d)。
|
||||
func (m *Manager) Master() *gorm.DB {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.master
|
||||
}
|
||||
|
||||
// Replicas 返回所有从库实例的拷贝。
|
||||
// 经 m.mu 锁保护读取并返回拷贝,避免调用方持活切片与 InitDBWithReplicas/Close 重置竞争(C11d)。
|
||||
func (m *Manager) Replicas() []*gorm.DB {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.replicas == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]*gorm.DB, len(m.replicas))
|
||||
copy(out, m.replicas)
|
||||
return out
|
||||
}
|
||||
|
||||
// Replica 按策略选择一个从库;无从库时返回主库。
|
||||
// #21:启用探活后,自动过滤不健康的从库;全不健康时回退到全部从库(仍可服务)。
|
||||
// 全程持 m.mu 锁,避免 replicas/master 与重建路径写竞争(C11d)。
|
||||
func (m *Manager) Replica() *gorm.DB {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if len(m.replicas) == 0 {
|
||||
return m.master
|
||||
}
|
||||
pool := m.replicas
|
||||
// 启用探活且至少有一个健康标记时,仅从健康从库中选取
|
||||
if m.replicaHealthSet {
|
||||
var healthy []*gorm.DB
|
||||
for i, r := range m.replicas {
|
||||
if i < len(m.replicaHealthy) && m.replicaHealthy[i].Load() {
|
||||
healthy = append(healthy, r)
|
||||
}
|
||||
}
|
||||
if len(healthy) > 0 {
|
||||
pool = healthy
|
||||
}
|
||||
// healthy 为空时回退到全部 replicas,避免读流量完全中断
|
||||
}
|
||||
|
||||
if m.picker != nil {
|
||||
if db := m.picker.Pick(pool); db != nil {
|
||||
return db
|
||||
}
|
||||
}
|
||||
return pool[0]
|
||||
}
|
||||
|
||||
// IsHealthy 返回主库当前健康状态(#21)。供 readiness/health 探针联动。
|
||||
func (m *Manager) IsHealthy() bool {
|
||||
return m.healthy.Load()
|
||||
}
|
||||
|
||||
// initReplicaHealth 按 replicas 数量初始化健康标记(全部为健康)。
|
||||
// 已初始化(replicaHealthSet=true)时早返回;重建从库前须先 resetReplicaHealth 重置,
|
||||
// 否则健康切片长度与新 replicas 错位(C11a)。
|
||||
func (m *Manager) initReplicaHealth() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.replicaHealthSet {
|
||||
return
|
||||
}
|
||||
m.replicaHealthy = make([]atomic.Bool, len(m.replicas))
|
||||
for i := range m.replicaHealthy {
|
||||
m.replicaHealthy[i].Store(true)
|
||||
}
|
||||
m.replicaHealthSet = true
|
||||
}
|
||||
|
||||
// ensureReplicaHealthLocked 按当前 replicas 重建健康标记。调用方须持有 m.mu。
|
||||
func (m *Manager) ensureReplicaHealthLocked() {
|
||||
m.replicaHealthy = make([]atomic.Bool, len(m.replicas))
|
||||
for i := range m.replicaHealthy {
|
||||
m.replicaHealthy[i].Store(true)
|
||||
}
|
||||
m.replicaHealthSet = true
|
||||
}
|
||||
|
||||
// resetReplicaHealth 清空从库健康标记,使下次 initReplicaHealth 按新 replicas 长度重建。
|
||||
// 重建从库(InitDBWithReplicas)/Close 前必须调用,避免健康切片与新 replicas 长度错位(C11a)。
|
||||
// 调用方须持有 m.mu。
|
||||
func (m *Manager) resetReplicaHealth() {
|
||||
m.replicaHealthy = nil
|
||||
m.replicaHealthSet = false
|
||||
}
|
||||
|
||||
// StartProbing 启动主库与从库的健康探活后台循环(#21)。
|
||||
// 阻塞调用方,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
|
||||
// 周期 ping 主库,连续失败达阈值后标记不健康(IsHealthy=false);
|
||||
// 同时 ping 各从库,失败则从读流量剔除,恢复后自动重新纳入。
|
||||
func (m *Manager) StartProbing(ctx context.Context) {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.initReplicaHealth()
|
||||
|
||||
cfg := m.getCfg() // P1 #11:锁内快照,避免与 InitDB 写 m.cfg 竞态
|
||||
interval := 30 * time.Second
|
||||
if cfg != nil && cfg.Database.HealthCheckInterval > 0 {
|
||||
interval = cfg.Database.HealthCheckInterval
|
||||
}
|
||||
threshold := 3
|
||||
if cfg != nil && cfg.Database.HealthCheckFailureThreshold > 0 {
|
||||
threshold = cfg.Database.HealthCheckFailureThreshold
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.probeOnce(ctx, threshold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// probeOnce 执行一轮主库+从库探活并更新健康标记。
|
||||
func (m *Manager) probeOnce(ctx context.Context, threshold int) {
|
||||
// 主库
|
||||
if err := m.HealthCheck(ctx); err != nil {
|
||||
m.probeMu.Lock()
|
||||
m.probeFailures++
|
||||
if m.probeFailures >= threshold {
|
||||
if m.healthy.Load() {
|
||||
logger.Warnf("数据库主库连续探活失败 %d 次,标记为不健康: %v", m.probeFailures, err)
|
||||
}
|
||||
m.healthy.Store(false)
|
||||
}
|
||||
m.probeMu.Unlock()
|
||||
} else {
|
||||
m.probeMu.Lock()
|
||||
if m.probeFailures >= threshold && !m.healthy.Load() {
|
||||
logger.Info("数据库主库探活恢复,重新标记为健康")
|
||||
}
|
||||
m.probeFailures = 0
|
||||
m.probeMu.Unlock()
|
||||
m.healthy.Store(true)
|
||||
}
|
||||
|
||||
// 从库
|
||||
m.mu.Lock()
|
||||
replicas := make([]*gorm.DB, len(m.replicas))
|
||||
copy(replicas, m.replicas)
|
||||
if len(replicas) > 0 && !m.replicaHealthSet {
|
||||
m.ensureReplicaHealthLocked()
|
||||
}
|
||||
healthSet := m.replicaHealthSet
|
||||
replicaHealthy := m.replicaHealthy // 快照切片头,避免与 resetReplicaHealth 写竞争
|
||||
m.mu.Unlock()
|
||||
if !healthSet {
|
||||
return
|
||||
}
|
||||
for i, r := range replicas {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
sqlDB, err := r.DB()
|
||||
if err != nil {
|
||||
if i < len(replicaHealthy) {
|
||||
replicaHealthy[i].Store(false)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// M11(H-db-1):从库探活 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞探活 goroutine。
|
||||
if err := pingWithTimeout(sqlDB, ctx); err != nil {
|
||||
if i < len(replicaHealthy) && replicaHealthy[i].Load() {
|
||||
logger.Warnf("数据库从库 #%d 探活失败,暂时剔除读流量: %v", i, err)
|
||||
}
|
||||
if i < len(replicaHealthy) {
|
||||
replicaHealthy[i].Store(false)
|
||||
}
|
||||
} else {
|
||||
if i < len(replicaHealthy) {
|
||||
replicaHealthy[i].Store(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FromContext 根据上下文选择数据库
|
||||
func (m *Manager) FromContext(ctx context.Context) *gorm.DB {
|
||||
ctx = normalizeContext(ctx)
|
||||
mode, ok := ctx.Value(dbModeContextKey{}).(string)
|
||||
if !ok {
|
||||
return m.Replica()
|
||||
}
|
||||
switch mode {
|
||||
case dbModeMaster:
|
||||
return m.Master()
|
||||
case dbModeReplica:
|
||||
return m.Replica()
|
||||
default:
|
||||
return m.Replica()
|
||||
}
|
||||
}
|
||||
|
||||
// Open 打开主库连接
|
||||
func (m *Manager) Open(ctx context.Context) error {
|
||||
cfg := m.getCfg() // P1 #11:锁内读取,避免与 InitDB 写竞态
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
return m.InitDB(ctx, cfg)
|
||||
}
|
||||
|
||||
// OpenWithReplicas 打开主库与从库连接
|
||||
func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) error {
|
||||
cfg := m.getCfg() // P1 #11:锁内读取
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
return m.InitDBWithReplicas(ctx, cfg, replicaDSNs)
|
||||
}
|
||||
|
||||
// closeDB 关闭 gorm.DB 底层连接池。nil 或未初始化(无 ConnPool)时返回 nil,不 panic。
|
||||
// 用于重建/关闭路径释放旧池,避免直接覆盖致泄漏(C11b/C11c)。
|
||||
func closeDB(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
func warnCloseDB(db *gorm.DB, context string) {
|
||||
if err := closeDB(db); err != nil {
|
||||
logger.Warnf("%s: %v", context, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭主库与全部从库连接,并重置从库健康状态。
|
||||
// 字段置空在锁内完成(保证新读取得到 nil),实际关闭在锁外执行避免持锁阻塞。
|
||||
func (m *Manager) Close() error {
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
m.mu.Lock()
|
||||
master := m.master
|
||||
replicas := m.replicas
|
||||
m.master = nil
|
||||
m.replicas = nil
|
||||
m.resetReplicaHealth()
|
||||
m.healthy.Store(false)
|
||||
m.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
if err := closeDB(master); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
for _, replica := range replicas {
|
||||
if err := closeDB(replica); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查,主库不可达时返回错误。
|
||||
//
|
||||
// M11 完整覆盖(H-db-1 修复):ping 经 pingWithTimeout 受 healthCheckTimeout(3s) 约束,
|
||||
// 使后台探活(probeOnce)与 /health 端点(app.go 经本方法)都不会被挂起 DB(连接活但不
|
||||
// 响应)无限阻塞。ctx 自带更短 deadline 时优先尊重 ctx。
|
||||
func (m *Manager) HealthCheck(ctx context.Context) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.mu.Lock()
|
||||
db := m.master
|
||||
m.mu.Unlock()
|
||||
if db == nil {
|
||||
return errors.New("数据库主库未初始化")
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pingWithTimeout(sqlDB, ctx)
|
||||
}
|
||||
|
||||
// DefaultManager 默认数据库管理器,包级 facade 代理到它。
|
||||
//
|
||||
// 主线A 修复:改用 atomic.Pointer 保护读写,消除原裸指针(外部直接
|
||||
// `database.DefaultManager = ...` 赋值与请求 goroutine 经 facade 读取)之间的数据竞争。
|
||||
// 与 config.defaultManager / database.DefaultRedis / storage.DefaultStorage /
|
||||
// cache.defaultCachePtr / jwt.defaultManager 对齐——框架内包级可变全局一律 atomic.Pointer。
|
||||
//
|
||||
// 类型由 *Manager 变更为 atomic.Pointer[Manager](breaking):下游若直接调用
|
||||
// DefaultManager.Init/Master 等方法需改用 InitDB/GetDB 等 facade,或
|
||||
// DefaultManager.Load().Init(...),或经 GetDefaultManager() 取实例后再调方法。
|
||||
var DefaultManager atomic.Pointer[Manager]
|
||||
|
||||
func init() {
|
||||
DefaultManager.Store(NewManager(nil))
|
||||
}
|
||||
|
||||
// SwapDefaultManager 将指定 Manager 置为全局默认,并返回被替换的旧 Manager。
|
||||
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存。
|
||||
// nil 被忽略,以防 facade Load 到 nil panic。
|
||||
func SwapDefaultManager(m *Manager) *Manager {
|
||||
if m == nil {
|
||||
return DefaultManager.Load()
|
||||
}
|
||||
return DefaultManager.Swap(m)
|
||||
}
|
||||
|
||||
// SetDefaultManager 提升指定 Manager 为全局默认,并关闭被替换的旧 Manager。
|
||||
// 用于多实例场景或测试注入。nil 被忽略以防 facade Load 到 nil panic。
|
||||
// 若调用方需要保留旧 Manager 用于回滚,请使用 SwapDefaultManager。
|
||||
func SetDefaultManager(m *Manager) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
old := SwapDefaultManager(m)
|
||||
if old != nil && old != m {
|
||||
if err := old.Close(); err != nil {
|
||||
logger.Warnf("关闭被替换的旧数据库 manager 失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultManager 返回全局默认 Manager(atomic 读取,并发安全)。
|
||||
// 替代直接读 DefaultManager 包级变量(类型已改为 atomic.Pointer,直接读得到的是 atomic
|
||||
// 值而非 *Manager)。需直接持有 Manager 调用其方法时用本函数或 DefaultManager.Load()。
|
||||
func GetDefaultManager() *Manager {
|
||||
return DefaultManager.Load()
|
||||
}
|
||||
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。
|
||||
// ctx 控制 Ping 与重试等待;调用方取消 ctx 时初始化会尽快返回。
|
||||
func (m *Manager) InitDB(ctx context.Context, cfg *config.Config) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
return m.initDB(ctx, cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("数据库初始化已取消: %w", err)
|
||||
}
|
||||
m.setCfg(cfg) // P1 #11:锁内写入,避免与 StartProbing/Open 读竞态
|
||||
|
||||
// GORM 日志配置
|
||||
var gormLogLevel gormlogger.LogLevel
|
||||
if cfg.IsDevelopment() {
|
||||
gormLogLevel = gormlogger.Info
|
||||
} else {
|
||||
gormLogLevel = gormlogger.Warn
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormLogLevel),
|
||||
// H-db-1:禁用 gorm.Open 的自动 Ping(gorm.go:204,ConnPool 为 *sql.DB 时会调
|
||||
// pinger.Ping() 无超时)。挂起 DB(连接活但不响应)下该 Ping 无 ctx deadline 无限阻塞,
|
||||
// 发生在 initDB 的 pingWithTimeout 之前,使启动卡死。框架改用 pingWithTimeout(3s)自管
|
||||
// 启动 ping,故禁用 gorm 无超时自动 ping。InitDBWithReplicas 的 replica 路径同理。
|
||||
DisableAutomaticPing: true,
|
||||
}
|
||||
|
||||
// M-config-2:MySQL 启用 TLS 且配置自定义 CA 时,注册命名 TLS 配置,使 DSN 中 tls=<name> 生效。
|
||||
// 失败 fail-fast 返回错误,绝不静默回退明文连接。非 MySQL / 未配 CA 为 no-op。
|
||||
if err := ensureMySQLTLSRegistered(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 重试配置
|
||||
maxRetries := 5
|
||||
retryDelay := time.Second
|
||||
|
||||
var lastErr error
|
||||
for i := range maxRetries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("数据库初始化已取消: %w", err)
|
||||
}
|
||||
// 连接主库:先打开到局部变量,仅 Ping 成功后才安装为 m.master,
|
||||
// 避免 Ping 失败时下轮覆盖 m.master 泄漏旧池(C11b)。
|
||||
db, err := gorm.Open(Dialector(cfg), gormConfig)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// 不可恢复的错误(认证失败、未知数据库、DSN 非法等)直接返回,不必重试
|
||||
if !isTransientDBError(err) {
|
||||
return fmt.Errorf("数据库连接失败(不可恢复): %w", err)
|
||||
}
|
||||
} else {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
warnCloseDB(db, "关闭刚打开的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮泄漏
|
||||
} else {
|
||||
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
if cfg.Database.ConnMaxIdleTime > 0 {
|
||||
sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime)
|
||||
}
|
||||
|
||||
// M11(H-db-1):启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 致 InitDB 重试失败而非无限阻塞。
|
||||
if err := pingWithTimeout(sqlDB, ctx); err == nil {
|
||||
// 成功:安装为新主库,关闭旧主库池(重建路径覆盖前先释放旧资源,C11b)
|
||||
m.mu.Lock()
|
||||
old := m.master
|
||||
m.master = db
|
||||
m.mu.Unlock()
|
||||
m.healthy.Store(true) // Ping 通过才标记健康(#21)
|
||||
warnCloseDB(old, "关闭旧数据库主库连接池失败")
|
||||
logger.Info("数据库主库连接成功",
|
||||
zap.String("driver", driverDescription(cfg.Database.Driver)),
|
||||
zap.String("host", cfg.Database.Host),
|
||||
zap.Int("port", cfg.Database.Port))
|
||||
return nil
|
||||
} else {
|
||||
// Ping 失败(如服务端暂时不可达)视作可重试
|
||||
lastErr = err
|
||||
warnCloseDB(db, "关闭 Ping 失败的数据库连接池失败") // C11b: 关闭刚打开的池,避免下轮覆盖泄漏
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Warnf("数据库连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, lastErr)
|
||||
if i == maxRetries-1 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("数据库初始化已取消: %w", ctx.Err())
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
retryDelay *= 2
|
||||
if retryDelay > 30*time.Second {
|
||||
retryDelay = 30 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("数据库连接失败(重试 %d 次): %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// isTransientDBError 判断数据库连接错误是否值得重试。
|
||||
// 认证失败、未知数据库、非法 DSN/驱动等属于配置类错误,重试无意义,直接返回更友好。
|
||||
// D5 修复:覆盖 MySQL 和 PostgreSQL 常见非瞬态错误。
|
||||
func isTransientDBError(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
nonTransient := []string{
|
||||
// MySQL
|
||||
"Access denied", // 认证失败(用户名/密码错误)
|
||||
"authentication plugin", // 认证插件不支持
|
||||
"Unknown database", // 目标库不存在
|
||||
"invalid DSN", // DSN 语法错误
|
||||
"unknown driver", // 驱动未注册
|
||||
"unsupported driver", // 驱动不支持
|
||||
// PostgreSQL(D5 修复;P1 #12:移除过宽的独立 "database" 子串——它会把
|
||||
// "the database system is starting up" 等瞬态错误误判为非瞬态而放弃重试。
|
||||
// PG 目标库不存在的消息形如 database "x" does not exist,用 "does not exist" 精确匹配)。
|
||||
"password authentication failed", // pg 密码错误
|
||||
"does not exist", // pg 目标库/角色不存在
|
||||
"no pg_hba.conf entry", // pg_hba.conf 拒绝
|
||||
}
|
||||
for _, sub := range nonTransient {
|
||||
if strings.Contains(msg, sub) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// replicaMaxOpenConns 计算从库连接池 MaxOpenConns(H-A 修复)。
|
||||
// masterMax>0 时取 max(1, masterMax/2)(从库适当减少,但绝不截断为 0);
|
||||
// masterMax<=0(未配置/无限)时返回 0,与主库"无限"语义一致。
|
||||
func replicaMaxOpenConns(masterMax int) int {
|
||||
if masterMax <= 0 {
|
||||
return 0
|
||||
}
|
||||
half := masterMax / 2
|
||||
if half < 1 {
|
||||
half = 1
|
||||
}
|
||||
return half
|
||||
}
|
||||
|
||||
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
|
||||
// replicaDSNs: 从库连接字符串列表(需与主库驱动匹配)
|
||||
func (m *Manager) InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
// 先初始化主库
|
||||
if err := m.initDB(ctx, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// C11c: 重建从库前关闭旧从库池;C11a: 重置健康状态,使下次 initReplicaHealth 按新 replicas 长度重建
|
||||
m.mu.Lock()
|
||||
oldReplicas := m.replicas
|
||||
m.replicas = nil
|
||||
m.resetReplicaHealth()
|
||||
m.mu.Unlock()
|
||||
for _, r := range oldReplicas {
|
||||
warnCloseDB(r, "关闭旧数据库从库连接池失败")
|
||||
}
|
||||
|
||||
// 初始化从库
|
||||
if len(replicaDSNs) > 0 {
|
||||
var gormLogLevel gormlogger.LogLevel
|
||||
if cfg.IsDevelopment() {
|
||||
gormLogLevel = gormlogger.Info
|
||||
} else {
|
||||
gormLogLevel = gormlogger.Warn
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormLogLevel),
|
||||
// H-db-1:禁用 gorm.Open 自动 Ping(同 initDB),框架用 pingWithTimeout 自管 replica 启动 ping。
|
||||
DisableAutomaticPing: true,
|
||||
}
|
||||
|
||||
// 先构建到局部切片,全部成功后再安装,避免部分构建期间外部读到中间态
|
||||
var newReplicas []*gorm.DB
|
||||
for i, dsn := range replicaDSNs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
for _, r := range newReplicas {
|
||||
warnCloseDB(r, "关闭已打开的数据库从库连接池失败")
|
||||
}
|
||||
return fmt.Errorf("数据库从库初始化已取消: %w", err)
|
||||
}
|
||||
replicaDB, err := gorm.Open(dialectorForDSN(cfg.Database.Driver, dsn), gormConfig)
|
||||
if err != nil {
|
||||
logger.Warnf("数据库从库 %d 连接失败: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sqlDB, err := replicaDB.DB()
|
||||
if err != nil {
|
||||
logger.Warnf("数据库从库 %d 获取连接池失败: %v", i+1, err)
|
||||
warnCloseDB(replicaDB, "关闭刚打开的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
|
||||
continue
|
||||
}
|
||||
|
||||
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
// H-A 修复:从库 MaxOpenConns 适当减少,但须避免截断为 0。
|
||||
// database/sql 中 SetMaxOpenConns(0) 表示"无限制"——原实现 MaxOpenConns/2 在
|
||||
// 配置为 1 时得 0,反而让从库连接池无上限(与"减少"意图相反、高并发下打爆 DB);
|
||||
// 配置为 0(未配置/无限)时 0/2=0 恰好"无限",与主库一致,保持语义。
|
||||
// 现规则:MaxOpenConns>0 时取 max(1, /2);<=0 时从库亦无限(0),与主库对齐。
|
||||
sqlDB.SetMaxOpenConns(replicaMaxOpenConns(cfg.Database.MaxOpenConns))
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
// M11(H-db-1):从库启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞 InitDBWithReplicas。
|
||||
if err := pingWithTimeout(sqlDB, ctx); err != nil {
|
||||
logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err)
|
||||
warnCloseDB(replicaDB, "关闭 Ping 失败的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
|
||||
continue
|
||||
}
|
||||
|
||||
newReplicas = append(newReplicas, replicaDB)
|
||||
logger.Info("数据库从库连接成功", zap.Int("index", i+1))
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.replicas = newReplicas
|
||||
m.ensureReplicaHealthLocked()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。
|
||||
func InitDB(ctx context.Context, cfg *config.Config) error {
|
||||
return DefaultManager.Load().InitDB(ctx, cfg)
|
||||
}
|
||||
|
||||
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
|
||||
func InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error {
|
||||
return DefaultManager.Load().InitDBWithReplicas(ctx, cfg, replicaDSNs)
|
||||
}
|
||||
|
||||
// GetReadDB 获取读库实例(按策略选择从库)
|
||||
func GetReadDB() *gorm.DB {
|
||||
return DefaultManager.Load().Replica()
|
||||
}
|
||||
|
||||
// GetWriteDB 获取写库实例(主库)
|
||||
func GetWriteDB() *gorm.DB {
|
||||
return DefaultManager.Load().Master()
|
||||
}
|
||||
|
||||
// GetDB 获取数据库实例(默认主库,兼容旧代码)
|
||||
func GetDB() *gorm.DB {
|
||||
return DefaultManager.Load().Master()
|
||||
}
|
||||
|
||||
// GetReplicas 获取所有从库实例
|
||||
func GetReplicas() []*gorm.DB {
|
||||
return DefaultManager.Load().Replicas()
|
||||
}
|
||||
|
||||
// SetReplicaPicker 设置默认管理器的从库选择策略
|
||||
func SetReplicaPicker(p ReplicaPicker) {
|
||||
DefaultManager.Load().SetPicker(p)
|
||||
}
|
||||
|
||||
// UseMaster 强制使用主库(用于事务或需要实时数据的场景)
|
||||
func UseMaster(ctx context.Context) context.Context {
|
||||
ctx = normalizeContext(ctx)
|
||||
return context.WithValue(ctx, dbModeContextKey{}, dbModeMaster)
|
||||
}
|
||||
|
||||
// UseReplica 强制使用从库(用于报表查询等场景)
|
||||
func UseReplica(ctx context.Context) context.Context {
|
||||
ctx = normalizeContext(ctx)
|
||||
return context.WithValue(ctx, dbModeContextKey{}, dbModeReplica)
|
||||
}
|
||||
|
||||
// GetDBFromContext 根据上下文选择数据库
|
||||
func GetDBFromContext(ctx context.Context) *gorm.DB {
|
||||
return DefaultManager.Load().FromContext(ctx)
|
||||
}
|
||||
|
||||
// WithTx 将外层事务注入 ctx,使上层(如 repository.BaseRepo)在调用时能 join 到该事务
|
||||
// 而非另开连接/路由到主从库(H6c)。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// err := database.TransactionWithContext(ctx, func(tx *gorm.DB) error {
|
||||
// ctx2 := database.WithTx(ctx, tx)
|
||||
// // 传 ctx2 给 repo 方法,repo 内部会优先使用该 tx
|
||||
// return repo.FindByID(ctx2, id) // 此查询参与外层事务
|
||||
// })
|
||||
//
|
||||
// 注意:tx 仅在该 ctx 的生命周期内有效;事务提交/回滚后不得再用该 ctx 携带的 tx。
|
||||
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
|
||||
ctx = normalizeContext(ctx)
|
||||
if tx == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, txContextKey{}, tx)
|
||||
}
|
||||
|
||||
// TxFromContext 取出 ctx 携带的外层事务;无则返回 nil。
|
||||
func TxFromContext(ctx context.Context) *gorm.DB {
|
||||
ctx = normalizeContext(ctx)
|
||||
if tx, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
|
||||
return tx
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoMigrate 自动迁移数据库表结构(由应用通过 WithMigrator/WithModels 注册)
|
||||
func AutoMigrate() error {
|
||||
logger.Info("数据库表结构迁移完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭所有数据库连接(主库与从库),等价于 CloseAll。
|
||||
// 历史上仅关闭主库、遗留从库池泄漏(C11f),已修正为委托 CloseAll。
|
||||
func Close() error {
|
||||
return CloseAll()
|
||||
}
|
||||
|
||||
// CloseAll 关闭所有数据库连接(包括从库)
|
||||
func CloseAll() error {
|
||||
return DefaultManager.Load().Close()
|
||||
}
|
||||
|
||||
// Transaction 事务操作(自动使用主库)
|
||||
func Transaction(fn func(tx *gorm.DB) error) error {
|
||||
db := DefaultManager.Load().Master()
|
||||
if db == nil {
|
||||
return errors.New("数据库未初始化")
|
||||
}
|
||||
return db.Transaction(fn)
|
||||
}
|
||||
|
||||
// TransactionWithContext 带上下文的事务操作
|
||||
func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
db := DefaultManager.Load().Master()
|
||||
if db == nil {
|
||||
return errors.New("数据库未初始化")
|
||||
}
|
||||
return db.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
// ReadQuery 读查询。遵循 ctx 中的数据库路由标记;未指定时默认走从库。
|
||||
func ReadQuery(ctx context.Context, model any, query string, args ...any) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
db := GetDBFromContext(ctx)
|
||||
if db == nil {
|
||||
return errors.New("数据库未初始化")
|
||||
}
|
||||
return db.WithContext(ctx).Where(query, args...).Find(model).Error
|
||||
}
|
||||
|
||||
// WriteQuery 在主库上执行查询并扫描到 model(强制主库,绕过从库延迟)。
|
||||
// 注意:命名沿用历史,实际用 .Find() 扫描结果集(读取语义),并非写操作——
|
||||
// 强制主库是为了读到刚写入的最新数据(read-your-writes)。命名误导见 M11。
|
||||
func WriteQuery(ctx context.Context, model any, query string, args ...any) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
db := DefaultManager.Load().Master()
|
||||
if db == nil {
|
||||
return errors.New("数据库未初始化")
|
||||
}
|
||||
return db.WithContext(ctx).Where(query, args...).Find(model).Error
|
||||
}
|
||||
|
||||
// healthCheckTimeout 健康检查单次 ping 超时,避免探针被慢/挂起的 DB 长期阻塞(M11)。
|
||||
const healthCheckTimeout = 3 * time.Second
|
||||
|
||||
// pingWithTimeout 带超时的 ping,ctx 由调用方传入时优先尊重其 deadline。
|
||||
func pingWithTimeout(sqlDB *sql.DB, parent context.Context) error {
|
||||
parent = normalizeContext(parent)
|
||||
ctx, cancel := context.WithTimeout(parent, healthCheckTimeout)
|
||||
defer cancel()
|
||||
return sqlDB.PingContext(ctx)
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查(主库 + 从库),单次 ping 限 3s 超时(M11)。
|
||||
func HealthCheck() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
ctx := context.Background()
|
||||
m := DefaultManager.Load()
|
||||
|
||||
// 检查主库
|
||||
if master := m.Master(); master != nil {
|
||||
sqlDB, err := master.DB()
|
||||
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
|
||||
result["master"] = true
|
||||
} else {
|
||||
result["master"] = false
|
||||
}
|
||||
} else {
|
||||
result["master"] = false
|
||||
}
|
||||
|
||||
// 检查从库
|
||||
for i, replica := range m.Replicas() {
|
||||
if replica != nil {
|
||||
sqlDB, err := replica.DB()
|
||||
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
|
||||
result[fmt.Sprintf("replica_%d", i+1)] = true
|
||||
} else {
|
||||
result[fmt.Sprintf("replica_%d", i+1)] = false
|
||||
}
|
||||
} else {
|
||||
result[fmt.Sprintf("replica_%d", i+1)] = false
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// IsDBHealthy 返回主库探活健康状态(#21)。
|
||||
// 与 HealthCheck()(实时 ping)不同,这是后台探活维护的缓存标记,
|
||||
// 供 readiness 探针快速判断是否接流量,避免每次探针都同步 ping。
|
||||
func IsDBHealthy() bool {
|
||||
return DefaultManager.Load().IsHealthy()
|
||||
}
|
||||
|
||||
// StartDBProbing 启动主库/从库探活后台循环(#21)。
|
||||
// 阻塞,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
|
||||
func StartDBProbing(ctx context.Context) {
|
||||
DefaultManager.Load().StartProbing(ctx)
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 部分用例触发 logger.Warnf 路径,确保 logger 已初始化避免 nil deref。
|
||||
logger.Close()
|
||||
}
|
||||
|
||||
// sentinelDB 返回一个可安全调用 .DB() 的 gorm.DB 占位实例。
|
||||
// 直接用 &gorm.DB{} 会令内嵌 *Config 为 nil,.DB() 访问提升字段 ConnPool 时 nil deref;
|
||||
// 这里给定非 nil Config,.DB() 走到 nil ConnPool 分支返回 ErrInvalidDB 而不 panic。
|
||||
func sentinelDB() *gorm.DB {
|
||||
return &gorm.DB{Config: &gorm.Config{}}
|
||||
}
|
||||
|
||||
// TestC11MasterReplicasConcurrentReadWrite 验证 Master/Replicas/Replica 全程加锁,
|
||||
// 与并发重建路径(写 master/replicas)无数据竞争(C11d)。
|
||||
// 修复前 Master/Replicas 为裸读,-race 必采到竞争。
|
||||
func TestC11MasterReplicasConcurrentReadWrite(t *testing.T) {
|
||||
m := &Manager{picker: &RandomPicker{}}
|
||||
sentinels := []*gorm.DB{{}, {}, {}}
|
||||
replicaSets := [][]*gorm.DB{
|
||||
{sentinels[0], sentinels[1]},
|
||||
{sentinels[2]},
|
||||
{},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
// 写者:在锁内置换 master/replicas(模拟 InitDB/InitDBWithReplicas/Close 的写路径)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.master = sentinels[i%len(sentinels)]
|
||||
m.replicas = replicaSets[i%len(replicaSets)]
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// 读者:并发调用读取方法
|
||||
for range 4 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = m.Master()
|
||||
_ = m.Replicas()
|
||||
_ = m.Replica()
|
||||
_ = m.FromContext(context.Background())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 让竞争窗口运行一段时间,确保 -race 能采到(若有未加锁读)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestC11ReplicasReturnsCopy 验证 Replicas 返回拷贝,调用方修改不影响内部状态(C11d)。
|
||||
func TestC11ReplicasReturnsCopy(t *testing.T) {
|
||||
m := &Manager{picker: &RandomPicker{}}
|
||||
m.mu.Lock()
|
||||
m.replicas = []*gorm.DB{{}, {}}
|
||||
m.mu.Unlock()
|
||||
|
||||
got := m.Replicas()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 replicas, got %d", len(got))
|
||||
}
|
||||
got[0] = nil
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.replicas[0] == nil {
|
||||
t.Fatal("Replicas should return a copy, not the live slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestC11ReplicaHealthResetOnRebuild 验证重建从库前重置健康状态,
|
||||
// 使 initReplicaHealth 按新 replicas 长度重建(C11a)。
|
||||
// 修复前 initReplicaHealth 的 replicaHealthSet 早返回导致健康切片与新 replicas 长度错位。
|
||||
func TestC11ReplicaHealthResetOnRebuild(t *testing.T) {
|
||||
m := &Manager{picker: &RandomPicker{}}
|
||||
|
||||
// 首轮:2 个从库 + 健康初始化
|
||||
m.mu.Lock()
|
||||
m.replicas = []*gorm.DB{{}, {}}
|
||||
m.mu.Unlock()
|
||||
m.initReplicaHealth()
|
||||
if !m.replicaHealthSet || len(m.replicaHealthy) != 2 {
|
||||
t.Fatalf("expected health init for 2 replicas, got set=%v len=%d", m.replicaHealthSet, len(m.replicaHealthy))
|
||||
}
|
||||
|
||||
// 重建:3 个从库 + 重置健康状态(C11a/C11c 路径)
|
||||
m.mu.Lock()
|
||||
m.replicas = []*gorm.DB{{}, {}, {}}
|
||||
m.resetReplicaHealth()
|
||||
m.mu.Unlock()
|
||||
m.initReplicaHealth()
|
||||
if !m.replicaHealthSet || len(m.replicaHealthy) != 3 {
|
||||
t.Fatalf("C11a: expected re-init aligned with 3 new replicas, got set=%v len=%d", m.replicaHealthSet, len(m.replicaHealthy))
|
||||
}
|
||||
for i := range m.replicaHealthy {
|
||||
if !m.replicaHealthy[i].Load() {
|
||||
t.Fatal("re-init should mark all replicas healthy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4ProbeOnceReinitializesReplicaHealthAfterRebuild(t *testing.T) {
|
||||
m := &Manager{picker: &RandomPicker{}}
|
||||
m.mu.Lock()
|
||||
m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
|
||||
m.resetReplicaHealth()
|
||||
m.mu.Unlock()
|
||||
|
||||
m.probeOnce(context.Background(), 1)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.replicaHealthSet {
|
||||
t.Fatal("probeOnce 应在从库重建后自动重建健康标记")
|
||||
}
|
||||
if len(m.replicaHealthy) != 2 {
|
||||
t.Fatalf("健康标记长度应与从库数量一致,实际 %d", len(m.replicaHealthy))
|
||||
}
|
||||
}
|
||||
|
||||
// TestC11ReplicaHealthStaleWithoutReset 反向验证:不调 resetReplicaHealth 时
|
||||
// initReplicaHealth 早返回,健康切片长度不随新 replicas 变化(复现 C11a 缺陷根因)。
|
||||
func TestC11ReplicaHealthStaleWithoutReset(t *testing.T) {
|
||||
m := &Manager{picker: &RandomPicker{}}
|
||||
m.mu.Lock()
|
||||
m.replicas = []*gorm.DB{{}, {}}
|
||||
m.mu.Unlock()
|
||||
m.initReplicaHealth()
|
||||
|
||||
// 仅换 replicas 不重置健康状态
|
||||
m.mu.Lock()
|
||||
m.replicas = []*gorm.DB{{}, {}, {}}
|
||||
m.mu.Unlock()
|
||||
m.initReplicaHealth() // replicaHealthSet 仍为 true → 早返回
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if len(m.replicaHealthy) == 3 {
|
||||
t.Fatal("without reset, initReplicaHealth should NOT re-align (early-returns); this proves reset is required")
|
||||
}
|
||||
if len(m.replicaHealthy) != 2 {
|
||||
t.Fatalf("expected stale len=2, got %d", len(m.replicaHealthy))
|
||||
}
|
||||
}
|
||||
|
||||
// TestC11ManagerCloseResetsState 验证 Close 清空 master/replicas 并重置健康状态(C11a/C11c/C11d)。
|
||||
func TestC11ManagerCloseResetsState(t *testing.T) {
|
||||
m := &Manager{picker: &RandomPicker{}}
|
||||
m.mu.Lock()
|
||||
m.master = sentinelDB()
|
||||
m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
|
||||
m.replicaHealthy = make([]atomic.Bool, 2)
|
||||
m.replicaHealthy[0].Store(true)
|
||||
m.replicaHealthy[1].Store(true)
|
||||
m.replicaHealthSet = true
|
||||
m.mu.Unlock()
|
||||
m.healthy.Store(true)
|
||||
|
||||
// closeDB 对空 gorm.DB{}(无 ConnPool)返回 ErrInvalidDB,Close 收集后 join 返回;
|
||||
// 这里不关心关闭错误,只断言状态被重置。
|
||||
_ = m.Close()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.master != nil {
|
||||
t.Fatal("expected master nil after Close")
|
||||
}
|
||||
if m.replicas != nil {
|
||||
t.Fatal("expected replicas nil after Close")
|
||||
}
|
||||
if m.replicaHealthSet {
|
||||
t.Fatal("expected replicaHealthSet reset after Close")
|
||||
}
|
||||
if m.replicaHealthy != nil {
|
||||
t.Fatal("expected replicaHealthy nil after Close")
|
||||
}
|
||||
if m.healthy.Load() {
|
||||
t.Fatal("expected healthy=false after Close")
|
||||
}
|
||||
}
|
||||
|
||||
// TestC11PackageCloseClosesReplicas 验证包级 Close 委托 CloseAll,
|
||||
// 关闭从库而非仅主库(C11f)。修复前包级 Close 仅关 master,replicas 残留。
|
||||
func TestC11PackageCloseClosesReplicas(t *testing.T) {
|
||||
// 保存并恢复 DefaultManager 状态,避免污染其他用例
|
||||
defer func() {
|
||||
DefaultManager.Load().mu.Lock()
|
||||
DefaultManager.Load().master = nil
|
||||
DefaultManager.Load().replicas = nil
|
||||
DefaultManager.Load().resetReplicaHealth()
|
||||
DefaultManager.Load().healthy.Store(false)
|
||||
DefaultManager.Load().mu.Unlock()
|
||||
}()
|
||||
|
||||
DefaultManager.Load().mu.Lock()
|
||||
DefaultManager.Load().master = sentinelDB()
|
||||
DefaultManager.Load().replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
|
||||
DefaultManager.Load().mu.Unlock()
|
||||
|
||||
// 包级 Close → CloseAll → DefaultManager.Load().Close(),应同时清空 master 与 replicas
|
||||
_ = Close()
|
||||
|
||||
DefaultManager.Load().mu.Lock()
|
||||
master := DefaultManager.Load().master
|
||||
repl := DefaultManager.Load().replicas
|
||||
DefaultManager.Load().mu.Unlock()
|
||||
|
||||
if master != nil {
|
||||
t.Fatal("C11f: expected master nil after package Close")
|
||||
}
|
||||
if repl != nil {
|
||||
t.Fatal("C11f: package Close should close replicas too (delegates to CloseAll), got residual replicas")
|
||||
}
|
||||
}
|
||||
|
||||
// TestC11HealthCheckLockedRead 验证 HealthCheck 在锁内快照 master,未初始化时返错(C11d)。
|
||||
func TestC11HealthCheckLockedRead(t *testing.T) {
|
||||
m := &Manager{picker: &RandomPicker{}}
|
||||
if err := m.HealthCheck(context.Background()); err == nil {
|
||||
t.Fatal("expected error when health checking uninitialized master")
|
||||
}
|
||||
|
||||
// 空 gorm.DB(无 ConnPool):DB() 返 ErrInvalidDB,HealthCheck 返该错而非 panic
|
||||
m.mu.Lock()
|
||||
m.master = sentinelDB()
|
||||
m.mu.Unlock()
|
||||
if err := m.HealthCheck(context.Background()); err == nil {
|
||||
t.Fatal("expected error for gorm.DB without ConnPool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultManagerClosesReplacedManager(t *testing.T) {
|
||||
old := NewManager(nil)
|
||||
old.mu.Lock()
|
||||
old.master = sentinelDB()
|
||||
old.replicas = []*gorm.DB{sentinelDB()}
|
||||
old.replicaHealthy = make([]atomic.Bool, 1)
|
||||
old.replicaHealthy[0].Store(true)
|
||||
old.replicaHealthSet = true
|
||||
old.mu.Unlock()
|
||||
old.healthy.Store(true)
|
||||
|
||||
orig := SwapDefaultManager(old)
|
||||
t.Cleanup(func() {
|
||||
current := SwapDefaultManager(orig)
|
||||
if current != nil && current != orig {
|
||||
_ = current.Close()
|
||||
}
|
||||
})
|
||||
|
||||
next := NewManager(nil)
|
||||
SetDefaultManager(next)
|
||||
if GetDefaultManager() != next {
|
||||
t.Fatal("SetDefaultManager 应安装新的默认 manager")
|
||||
}
|
||||
|
||||
old.mu.Lock()
|
||||
defer old.mu.Unlock()
|
||||
if old.master != nil || old.replicas != nil || old.replicaHealthy != nil || old.replicaHealthSet {
|
||||
t.Fatal("SetDefaultManager 应关闭并清空被替换的旧 manager,避免连接池泄漏")
|
||||
}
|
||||
if old.healthy.Load() {
|
||||
t.Fatal("SetDefaultManager 关闭旧 manager 后健康状态应为 false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapDefaultManagerPreservesReplacedManager(t *testing.T) {
|
||||
old := NewManager(nil)
|
||||
old.mu.Lock()
|
||||
old.master = sentinelDB()
|
||||
old.replicas = []*gorm.DB{sentinelDB()}
|
||||
old.mu.Unlock()
|
||||
|
||||
orig := SwapDefaultManager(old)
|
||||
t.Cleanup(func() {
|
||||
current := SwapDefaultManager(orig)
|
||||
if current != nil && current != orig {
|
||||
_ = current.Close()
|
||||
}
|
||||
_ = old.Close()
|
||||
})
|
||||
|
||||
next := NewManager(nil)
|
||||
previous := SwapDefaultManager(next)
|
||||
if previous != old {
|
||||
t.Fatal("SwapDefaultManager 应返回被替换的旧 manager")
|
||||
}
|
||||
if GetDefaultManager() != next {
|
||||
t.Fatal("SwapDefaultManager 应安装新的默认 manager")
|
||||
}
|
||||
|
||||
old.mu.Lock()
|
||||
defer old.mu.Unlock()
|
||||
if old.master == nil || len(old.replicas) != 1 {
|
||||
t.Fatal("SwapDefaultManager 不应关闭旧 manager,旧资源需可用于失败回滚")
|
||||
}
|
||||
}
|
||||
|
||||
type transientDialector struct{}
|
||||
|
||||
func (d transientDialector) Name() string { return "m4_transient" }
|
||||
|
||||
func (d transientDialector) Initialize(*gorm.DB) error {
|
||||
return errors.New("temporary connection failure")
|
||||
}
|
||||
|
||||
func (d transientDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
|
||||
|
||||
func (d transientDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
|
||||
func (d transientDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
|
||||
func (d transientDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
|
||||
|
||||
func (d transientDialector) QuoteTo(writer clause.Writer, str string) {
|
||||
_, _ = writer.WriteString(str)
|
||||
}
|
||||
|
||||
func (d transientDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
|
||||
func TestM4InitDBHonorsContextDuringRetrySleep(t *testing.T) {
|
||||
const driver = "m4_transient_retry"
|
||||
RegisterDialect(DialectSpec{
|
||||
Name: driver,
|
||||
Dialector: func(string) gorm.Dialector { return transientDialector{} },
|
||||
DSN: func(*config.DatabaseConfig) string { return "m4://retry" },
|
||||
})
|
||||
|
||||
m := NewManager(nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
err := m.InitDB(ctx, &config.Config{Database: config.DatabaseConfig{Driver: driver}})
|
||||
if err == nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("InitDB 应返回 context.Canceled,实际: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("InitDB 不应等待完整 retry sleep,耗时 %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4CloseWaitsForLifecycleOperation(t *testing.T) {
|
||||
m := NewManager(nil)
|
||||
m.opMu.Lock()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = m.Close()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Close 不应越过正在执行的生命周期操作")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
m.opMu.Unlock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("释放生命周期锁后 Close 未返回")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// hungDriver 是测试用 database/sql 驱动,其 Conn.PingContext 阻塞直到 ctx 取消,
|
||||
// 模拟"挂起 DB"(TCP 连接活但不响应查询,区别于宕机的 connection-refused 快速失败)。
|
||||
// 用于回归 H-db-1:ping 路径须经 pingWithTimeout 受 healthCheckTimeout(3s) 约束,
|
||||
// 挂起 DB 不得无限阻塞探活 goroutine / 启动 / /health 端点。
|
||||
type hungDriver struct{}
|
||||
|
||||
func (hungDriver) Open(name string) (driver.Conn, error) { return hungConn{}, nil }
|
||||
|
||||
type hungConn struct{}
|
||||
|
||||
func (hungConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("hung: not implemented") }
|
||||
func (hungConn) Close() error { return nil }
|
||||
func (hungConn) Begin() (driver.Tx, error) { return nil, errors.New("hung: not implemented") }
|
||||
|
||||
// Ping 阻塞直到 ctx 取消(模拟挂起 DB 永不响应 ping)。实现 driver.Pinger 接口
|
||||
// (方法名为 Ping 而非 PingContext,否则 *sql.DB.PingContext 视为 no-op 返回 nil)。
|
||||
func (hungConn) Ping(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
var registerHungOnce sync.Once
|
||||
|
||||
func registerHungDriver() {
|
||||
registerHungOnce.Do(func() { sql.Register("xlgo_hung_hdb1", hungDriver{}) })
|
||||
}
|
||||
|
||||
func newHungSqlDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
registerHungDriver()
|
||||
db, err := sql.Open("xlgo_hung_hdb1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open hung driver: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// newHungGormDB 构造底层 *sql.DB 为挂起驱动的 gorm.DB,其 DB() 返回挂起 *sql.DB。
|
||||
func newHungGormDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
return &gorm.DB{Config: &gorm.Config{ConnPool: newHungSqlDB(t)}}
|
||||
}
|
||||
|
||||
// assertBoundedPing 在 max+2s 内等待 fn 返回;超时则 fail(H-db-1:ping 应被
|
||||
// pingWithTimeout 3s 约束,不应无限 hang)。返回 fn 的 error 供调用方断言。
|
||||
func assertBoundedPing(t *testing.T, fn func() error, max time.Duration) error {
|
||||
t.Helper()
|
||||
done := make(chan error, 1)
|
||||
start := time.Now()
|
||||
go func() { done <- fn() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if elapsed := time.Since(start); elapsed > max {
|
||||
t.Fatalf("ping 路径耗时 %v 超过上限 %v(H-db-1:应被 pingWithTimeout 3s 约束)", elapsed, max)
|
||||
}
|
||||
return err
|
||||
case <-time.After(max + 2*time.Second):
|
||||
t.Fatalf("ping 路径在挂起 DB 上无限阻塞(H-db-1:pingWithTimeout 未覆盖该路径)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestPingWithTimeoutBoundsHungDB_Hdb1 回归 H-db-1 机制:pingWithTimeout 对挂起 DB 的
|
||||
// *sql.DB.PingContext 限 healthCheckTimeout 返回,不因 ctx 无 deadline 无限阻塞。
|
||||
// 修复前若直接 PingContext(Background()) 会无限阻塞。
|
||||
func TestPingWithTimeoutBoundsHungDB_Hdb1(t *testing.T) {
|
||||
sqlDB := newHungSqlDB(t)
|
||||
err := assertBoundedPing(t, func() error {
|
||||
return pingWithTimeout(sqlDB, context.Background())
|
||||
}, healthCheckTimeout+1*time.Second)
|
||||
if err == nil {
|
||||
t.Fatalf("挂起 DB 的 ping 应返回超时错误,got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerHealthCheckBoundsHungDB_Hdb1 回归 H-db-1 主路径:m.HealthCheck(Background)
|
||||
// 对挂起主库经 pingWithTimeout ~3s 返回超时错误,不无限阻塞。该路径被后台探活
|
||||
// probeOnce(master)与 /health 端点(app.go 经 dbm.HealthCheck)共用。
|
||||
// 修复前 m.HealthCheck 用裸 sqlDB.PingContext(ctx),Background ctx 无 deadline -> 无限阻塞。
|
||||
func TestManagerHealthCheckBoundsHungDB_Hdb1(t *testing.T) {
|
||||
m := NewManager(nil)
|
||||
m.master = newHungGormDB(t)
|
||||
err := assertBoundedPing(t, func() error {
|
||||
return m.HealthCheck(context.Background())
|
||||
}, healthCheckTimeout+1*time.Second)
|
||||
if err == nil {
|
||||
t.Fatalf("挂起主库的 HealthCheck 应返回超时错误,got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeOnceReplicaBoundsHungDB_Hdb1 回归 H-db-1 从库探活路径:probeOnce 对挂起从库
|
||||
// 经 pingWithTimeout ~3s 返回,不无限阻塞探活 goroutine(#21 自愈不冻结)。master 置 nil
|
||||
// 使 HealthCheck 快速返回"未初始化",让 probeOnce 只在从库 ping 路径耗时。
|
||||
// 修复前 probeOnce 从库用裸 sqlDB.PingContext(ctx),Background ctx 无 deadline -> 无限阻塞。
|
||||
func TestProbeOnceReplicaBoundsHungDB_Hdb1(t *testing.T) {
|
||||
m := NewManager(nil)
|
||||
m.master = nil // master 路径快速失败,集中测从库 ping 超时
|
||||
m.replicas = []*gorm.DB{newHungGormDB(t)}
|
||||
m.replicaHealthSet = true
|
||||
m.replicaHealthy = make([]atomic.Bool, 1)
|
||||
m.replicaHealthy[0].Store(true)
|
||||
|
||||
_ = assertBoundedPing(t, func() error {
|
||||
m.probeOnce(context.Background(), 3)
|
||||
return nil
|
||||
}, healthCheckTimeout+1*time.Second)
|
||||
|
||||
// 挂起从库应被标记不健康(剔除读流量,#21 自愈生效)
|
||||
if m.replicaHealthy[0].Load() {
|
||||
t.Errorf("挂起从库应被标记不健康(replicaHealthy=false),got true")
|
||||
}
|
||||
}
|
||||
|
||||
// hungDialector 让 gorm.Open 成功并注入挂起 *sql.DB 作为 ConnPool,使 initDB 的
|
||||
// db.DB() 返回挂起 *sql.DB、pingWithTimeout 触发。用于回归启动 ping 路径。
|
||||
type hungDialector struct{ sqlDB *sql.DB }
|
||||
|
||||
func (d hungDialector) Name() string { return "hdb1_hung" }
|
||||
func (d hungDialector) Initialize(db *gorm.DB) error { db.Config.ConnPool = d.sqlDB; return nil }
|
||||
func (d hungDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
|
||||
func (d hungDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
func (d hungDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
func (d hungDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
|
||||
func (d hungDialector) QuoteTo(w clause.Writer, s string) { _, _ = w.WriteString(s) }
|
||||
func (d hungDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
|
||||
// registerHungDialect 注册挂起 dialect(幂等)。
|
||||
func registerHungDialect(t *testing.T) {
|
||||
t.Helper()
|
||||
RegisterDialect(DialectSpec{
|
||||
Name: "hdb1_hung",
|
||||
Dialector: func(string) gorm.Dialector { return hungDialector{sqlDB: newHungSqlDB(t)} },
|
||||
DSN: func(*config.DatabaseConfig) string { return "hdb1_hung://" },
|
||||
})
|
||||
}
|
||||
|
||||
// hungCfg 构造用挂起 dialect 的 config。
|
||||
func hungCfg() *config.Config {
|
||||
return &config.Config{Database: config.DatabaseConfig{Driver: "hdb1_hung"}}
|
||||
}
|
||||
|
||||
// TestInitDBBoundsHungDB_Hdb1 回归 H-db-1 启动 master ping 路径:InitDB(Background) 对
|
||||
// 挂起主库的 ping 经 pingWithTimeout 3s 失败,5 次重试后总耗时 ~15s 返回错误,不无限阻塞。
|
||||
// 修复前 initDB 用裸 sqlDB.PingContext(ctx),Background 无 deadline -> 首次 ping 无限阻塞。
|
||||
// 用 ctx 在首次 ping 超时后取消加速(避免 15s 全跑),同时证明 ctx 仍可中断重试循环。
|
||||
func TestInitDBBoundsHungDB_Hdb1(t *testing.T) {
|
||||
registerHungDialect(t)
|
||||
m := NewManager(nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// 4s 后 cancel:首次 ping 经 pingWithTimeout ~3s 超时返回错误,进入重试前 ctx 已取消,
|
||||
// initDB 在重试循环的 ctx.Err() 检查处快速返回。若修复前裸 PingContext(Background)
|
||||
// 首次即无限阻塞,cancel 无法中断 -> assertBoundedPing 超时 fail。
|
||||
go func() { time.Sleep(4 * time.Second); cancel() }()
|
||||
|
||||
_ = assertBoundedPing(t, func() error {
|
||||
return m.InitDB(ctx, hungCfg())
|
||||
}, 6*time.Second)
|
||||
}
|
||||
|
||||
// TestInitDBWithReplicasBoundsHungDB_Hdb1 回归 H-db-1 启动 replica ping 路径:
|
||||
// InitDBWithReplicas 对挂起 replica DSN 的 ping 经 pingWithTimeout 失败(replica 被 continue
|
||||
// 跳过),主库经挂起 dialect 同样 ping 失败。整流程有界返回,不无限阻塞。
|
||||
// 修复前 replica 启动 ping 用裸 PingContext -> 挂起 replica 无限阻塞 InitDBWithReplicas。
|
||||
func TestInitDBWithReplicasBoundsHungDB_Hdb1(t *testing.T) {
|
||||
registerHungDialect(t)
|
||||
m := NewManager(nil)
|
||||
|
||||
// 用可取消 ctx 限制总时长;主库挂起 ping ~3s 失败后 InitDBWithReplicas 直接返回错误
|
||||
// (主库失败不进入 replica 初始化)。此处验证主库 ping 路径有界即可覆盖启动 ping 约束。
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(4 * time.Second); cancel() }()
|
||||
|
||||
err := assertBoundedPing(t, func() error {
|
||||
return m.InitDBWithReplicas(ctx, hungCfg(), []string{"hdb1_hung://replica"})
|
||||
}, 6*time.Second)
|
||||
if err == nil {
|
||||
t.Fatalf("挂起主库的 InitDBWithReplicas 应返回错误,got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestCloseAllWithoutInit(t *testing.T) {
|
||||
if err := database.CloseAll(); err != nil {
|
||||
t.Fatalf("CloseAll without init should not error: %v", err)
|
||||
}
|
||||
if database.GetDB() != nil {
|
||||
t.Fatal("expected DB nil")
|
||||
}
|
||||
if database.GetReadDB() != nil {
|
||||
t.Fatal("expected read DB nil")
|
||||
}
|
||||
if len(database.GetReplicas()) != 0 {
|
||||
t.Fatal("expected replicas empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBContextHelpersWithoutInit(t *testing.T) {
|
||||
ctx := database.UseMaster(context.Background())
|
||||
if db := database.GetDBFromContext(ctx); db != nil {
|
||||
t.Fatal("expected nil DB without init")
|
||||
}
|
||||
|
||||
ctx = database.UseReplica(context.Background())
|
||||
if db := database.GetDBFromContext(ctx); db != nil {
|
||||
t.Fatal("expected nil read DB without init")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundRobinPicker(t *testing.T) {
|
||||
replicas := []*gorm.DB{{}, {}, {}}
|
||||
p := &database.RoundRobinPicker{}
|
||||
|
||||
first := p.Pick(replicas)
|
||||
second := p.Pick(replicas)
|
||||
third := p.Pick(replicas)
|
||||
fourth := p.Pick(replicas)
|
||||
|
||||
if first == nil || second == nil || third == nil {
|
||||
t.Fatal("Picker returned nil for non-empty replicas")
|
||||
}
|
||||
if first != replicas[0] || second != replicas[1] || third != replicas[2] {
|
||||
t.Fatal("RoundRobinPicker should cycle through replicas in order")
|
||||
}
|
||||
if fourth != replicas[0] {
|
||||
t.Fatal("RoundRobinPicker should wrap around to the first replica")
|
||||
}
|
||||
if p.Pick(nil) != nil || p.Pick([]*gorm.DB{}) != nil {
|
||||
t.Fatal("Picker should return nil for empty replicas")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomPicker(t *testing.T) {
|
||||
replicas := []*gorm.DB{{}, {}}
|
||||
p := &database.RandomPicker{}
|
||||
|
||||
picked := p.Pick(replicas)
|
||||
if picked == nil {
|
||||
t.Fatal("RandomPicker returned nil for non-empty replicas")
|
||||
}
|
||||
if picked != replicas[0] && picked != replicas[1] {
|
||||
t.Fatal("RandomPicker returned a replica not in the slice")
|
||||
}
|
||||
if p.Pick(nil) != nil || p.Pick([]*gorm.DB{}) != nil {
|
||||
t.Fatal("RandomPicker should return nil for empty replicas")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerReplicaFallbackToMaster(t *testing.T) {
|
||||
mgr := database.NewManager(&config.Config{})
|
||||
if mgr.Master() != nil {
|
||||
t.Fatal("expected nil master before init")
|
||||
}
|
||||
if mgr.Replicas() != nil {
|
||||
t.Fatal("expected nil replicas before init")
|
||||
}
|
||||
// 无从库时 Replica 应返回 master(此处均为 nil)
|
||||
if mgr.Replica() != nil {
|
||||
t.Fatal("expected Replica to fall back to master when no replicas")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerSetPicker(t *testing.T) {
|
||||
mgr := database.NewManager(&config.Config{})
|
||||
rr := &database.RoundRobinPicker{}
|
||||
mgr.SetPicker(rr)
|
||||
if mgr.Picker() != rr {
|
||||
t.Fatal("SetPicker did not install the picker")
|
||||
}
|
||||
// nil 不应覆盖已有 picker
|
||||
mgr.SetPicker(nil)
|
||||
if mgr.Picker() != rr {
|
||||
t.Fatal("SetPicker(nil) should not clear the existing picker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultManagerHealthCheckWithoutInit(t *testing.T) {
|
||||
if err := database.GetDefaultManager().HealthCheck(context.Background()); err == nil {
|
||||
t.Fatal("expected error when health checking uninitialized master")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilContextHelpersDoNotPanic(t *testing.T) {
|
||||
_ = database.CloseAll()
|
||||
|
||||
if ctx := database.UseMaster(nil); ctx == nil {
|
||||
t.Fatal("UseMaster(nil) 应返回可用 context")
|
||||
}
|
||||
if ctx := database.UseReplica(nil); ctx == nil {
|
||||
t.Fatal("UseReplica(nil) 应返回可用 context")
|
||||
}
|
||||
if db := database.GetDBFromContext(nil); db != nil {
|
||||
t.Fatal("未初始化数据库时 GetDBFromContext(nil) 应返回 nil")
|
||||
}
|
||||
if ctx := database.WithTx(nil, nil); ctx == nil {
|
||||
t.Fatal("WithTx(nil, nil) 应返回可用 context")
|
||||
}
|
||||
if tx := database.TxFromContext(nil); tx != nil {
|
||||
t.Fatal("TxFromContext(nil) 应返回 nil")
|
||||
}
|
||||
if err := database.TransactionWithContext(nil, func(tx *gorm.DB) error { return nil }); err == nil {
|
||||
t.Fatal("未初始化数据库时 TransactionWithContext(nil) 应返回错误")
|
||||
}
|
||||
if err := database.ReadQuery(nil, &struct{}{}, "1=1"); err == nil {
|
||||
t.Fatal("未初始化数据库时 ReadQuery(nil) 应返回错误")
|
||||
}
|
||||
if err := database.WriteQuery(nil, &struct{}{}, "1=1"); err == nil {
|
||||
t.Fatal("未初始化数据库时 WriteQuery(nil) 应返回错误")
|
||||
}
|
||||
if got := database.HealthCheck(); !got["master"] {
|
||||
// HealthCheck 内部使用 background context;未初始化时 master=false 即可。
|
||||
return
|
||||
}
|
||||
t.Fatal("未初始化数据库时 HealthCheck master 不应为 true")
|
||||
}
|
||||
|
||||
func TestNilConfigInitializationReturnsError(t *testing.T) {
|
||||
mgr := database.NewManager(nil)
|
||||
if err := mgr.InitDB(context.Background(), nil); err == nil {
|
||||
t.Fatal("InitDB(nil) 应返回错误")
|
||||
}
|
||||
if err := mgr.InitDBWithReplicas(context.Background(), nil, nil); err == nil {
|
||||
t.Fatal("InitDBWithReplicas(nil) 应返回错误")
|
||||
}
|
||||
if err := database.InitDB(context.Background(), nil); err == nil {
|
||||
t.Fatal("包级 InitDB(nil) 应返回错误")
|
||||
}
|
||||
if err := database.InitDBWithReplicas(context.Background(), nil, nil); err == nil {
|
||||
t.Fatal("包级 InitDBWithReplicas(nil) 应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialectorNilConfigDoesNotPanic(t *testing.T) {
|
||||
if d := database.Dialector(nil); d == nil {
|
||||
t.Fatal("Dialector(nil) 应返回兜底 dialector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialectorSelectsByDriver(t *testing.T) {
|
||||
mysqlCfg := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: config.DriverMySQL, Host: "localhost", Port: 3306,
|
||||
User: "root", Password: "pass", Name: "db",
|
||||
}}
|
||||
if name := database.Dialector(mysqlCfg).Name(); name != "mysql" {
|
||||
t.Fatalf("expected mysql dialector, got %q", name)
|
||||
}
|
||||
|
||||
pgCfg := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: config.DriverPostgres, Host: "localhost", Port: 5432,
|
||||
User: "postgres", Password: "pass", Name: "db",
|
||||
}}
|
||||
if name := database.Dialector(pgCfg).Name(); name != "postgres" {
|
||||
t.Fatalf("expected postgres dialector, got %q", name)
|
||||
}
|
||||
|
||||
// 别名也应解析为 postgres
|
||||
pgAliasCfg := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: "PG", Host: "localhost", Port: 5432,
|
||||
User: "postgres", Password: "pass", Name: "db",
|
||||
}}
|
||||
if name := database.Dialector(pgAliasCfg).Name(); name != "postgres" {
|
||||
t.Fatalf("expected postgres dialector via alias, got %q", name)
|
||||
}
|
||||
|
||||
// 未指定 Driver 时默认 mysql
|
||||
defaultCfg := &config.Config{Database: config.DatabaseConfig{
|
||||
Host: "localhost", Port: 3306, User: "root", Password: "pass", Name: "db",
|
||||
}}
|
||||
if name := database.Dialector(defaultCfg).Name(); name != "mysql" {
|
||||
t.Fatalf("expected default mysql dialector, got %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// stubDialector 是一个用于测试 RegisterDialect 的占位 Dialector。
|
||||
type stubDialector struct{ name string }
|
||||
|
||||
func (s stubDialector) Name() string { return s.name }
|
||||
func (s stubDialector) Initialize(_ *gorm.DB) error { return nil }
|
||||
func (s stubDialector) Migrator(db *gorm.DB) gorm.Migrator { return nil }
|
||||
func (s stubDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
func (s stubDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
func (s stubDialector) BindVarTo(writer clause.Writer, _ *gorm.Statement, _ any) {}
|
||||
func (s stubDialector) QuoteTo(writer clause.Writer, str string) { _, _ = writer.WriteString(str) }
|
||||
func (s stubDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
|
||||
func TestRegisterDialectAndCustomDriver(t *testing.T) {
|
||||
const driver = "stubdb"
|
||||
|
||||
database.RegisterDialect(database.DialectSpec{
|
||||
Name: driver,
|
||||
Aliases: []string{"stub"},
|
||||
Dialector: func(dsn string) gorm.Dialector { return stubDialector{name: "stubdb"} },
|
||||
DSN: func(c *config.DatabaseConfig) string {
|
||||
return "stub://" + c.Host
|
||||
},
|
||||
})
|
||||
|
||||
// Dialector 工厂可以解析主名和别名
|
||||
if _, ok := database.LookupDialect(driver); !ok {
|
||||
t.Fatal("expected stubdb dialector to be registered")
|
||||
}
|
||||
if _, ok := database.LookupDialect("STUB"); !ok {
|
||||
t.Fatal("expected stub alias to be registered (case-insensitive)")
|
||||
}
|
||||
|
||||
cfg := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: driver, Host: "localhost",
|
||||
}}
|
||||
if name := database.Dialector(cfg).Name(); name != "stubdb" {
|
||||
t.Fatalf("expected stubdb dialector, got %q", name)
|
||||
}
|
||||
|
||||
// config.DSN() 应使用注册的 DSN 构建器
|
||||
if dsn := cfg.Database.DSN(); dsn != "stub://localhost" {
|
||||
t.Fatalf("expected DSN built by registered builder, got %q", dsn)
|
||||
}
|
||||
|
||||
// 未知驱动应 fail-closed,避免拼写错误静默连向 MySQL。
|
||||
unknownCfg := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: "no-such-driver", Host: "localhost", Port: 3306,
|
||||
User: "root", Password: "pass", Name: "db",
|
||||
}}
|
||||
d := database.Dialector(unknownCfg)
|
||||
if name := d.Name(); name != "invalid" {
|
||||
t.Fatalf("expected invalid dialector for unknown driver, got %q", name)
|
||||
}
|
||||
if _, err := gorm.Open(d, &gorm.Config{}); err == nil || !strings.Contains(err.Error(), "数据库驱动未注册") {
|
||||
t.Fatalf("未知 driver 应在 GORM 初始化时报错,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredDialectsContainsBuiltins(t *testing.T) {
|
||||
registered := database.RegisteredDialects()
|
||||
want := map[string]bool{"mysql": false, "postgres": false, "pg": false}
|
||||
for _, n := range registered {
|
||||
if _, ok := want[n]; ok {
|
||||
want[n] = true
|
||||
}
|
||||
}
|
||||
for k, found := range want {
|
||||
if !found {
|
||||
t.Errorf("expected %q to be registered by default", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
// DB 主库实例(写操作)
|
||||
DB *gorm.DB
|
||||
// DBRead 读库实例(读操作)
|
||||
DBRead *gorm.DB
|
||||
// replicas 从库列表
|
||||
replicas []*gorm.DB
|
||||
// replicaMutex 从库选择锁
|
||||
replicaMutex sync.Mutex
|
||||
)
|
||||
|
||||
// InitMySQL 初始化 MySQL 连接(带重试机制)
|
||||
func InitMySQL(cfg *config.Config) error {
|
||||
var err error
|
||||
|
||||
// GORM 日志配置
|
||||
var gormLogLevel gormlogger.LogLevel
|
||||
if cfg.IsDevelopment() {
|
||||
gormLogLevel = gormlogger.Info
|
||||
} else {
|
||||
gormLogLevel = gormlogger.Warn
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormLogLevel),
|
||||
}
|
||||
|
||||
// 重试配置
|
||||
maxRetries := 5
|
||||
retryDelay := time.Second
|
||||
|
||||
for i := range maxRetries {
|
||||
// 连接主库
|
||||
DB, err = gorm.Open(mysql.Open(cfg.Database.DSN()), gormConfig)
|
||||
if err == nil {
|
||||
sqlDB, err := DB.DB()
|
||||
if err == nil {
|
||||
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := sqlDB.Ping(); err == nil {
|
||||
logger.Info("MySQL 主库连接成功", zap.String("host", cfg.Database.Host), zap.Int("port", cfg.Database.Port))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Warnf("MySQL 连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, err)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay *= 2
|
||||
if retryDelay > 30*time.Second {
|
||||
retryDelay = 30 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("MySQL 连接失败(重试 %d 次): %w", maxRetries, err)
|
||||
}
|
||||
|
||||
// InitMySQLWithReplicas 初始化 MySQL 主从连接
|
||||
// masterDSN: 主库连接字符串
|
||||
// replicaDSNs: 从库连接字符串列表
|
||||
func InitMySQLWithReplicas(cfg *config.Config, replicaDSNs []string) error {
|
||||
// 先初始化主库
|
||||
if err := InitMySQL(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化从库
|
||||
if len(replicaDSNs) > 0 {
|
||||
var gormLogLevel gormlogger.LogLevel
|
||||
if cfg.IsDevelopment() {
|
||||
gormLogLevel = gormlogger.Info
|
||||
} else {
|
||||
gormLogLevel = gormlogger.Warn
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormLogLevel),
|
||||
}
|
||||
|
||||
for i, dsn := range replicaDSNs {
|
||||
replicaDB, err := gorm.Open(mysql.Open(dsn), gormConfig)
|
||||
if err != nil {
|
||||
logger.Warnf("MySQL 从库 %d 连接失败: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sqlDB, err := replicaDB.DB()
|
||||
if err != nil {
|
||||
logger.Warnf("MySQL 从库 %d 获取连接池失败: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns / 2) // 从库连接数可适当减少
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
logger.Warnf("MySQL 从库 %d Ping 失败: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
replicas = append(replicas, replicaDB)
|
||||
logger.Info("MySQL 从库连接成功", zap.Int("index", i+1))
|
||||
}
|
||||
|
||||
// 设置默认读库
|
||||
if len(replicas) > 0 {
|
||||
DBRead = replicas[0]
|
||||
} else {
|
||||
DBRead = DB // 无从库时使用主库
|
||||
}
|
||||
} else {
|
||||
DBRead = DB // 无从库配置时使用主库
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetReadDB 获取读库实例(自动选择从库)
|
||||
func GetReadDB() *gorm.DB {
|
||||
if len(replicas) == 0 {
|
||||
return DB
|
||||
}
|
||||
|
||||
replicaMutex.Lock()
|
||||
defer replicaMutex.Unlock()
|
||||
|
||||
// 随机选择一个从库
|
||||
idx := rand.Intn(len(replicas))
|
||||
return replicas[idx]
|
||||
}
|
||||
|
||||
// GetWriteDB 获取写库实例(主库)
|
||||
func GetWriteDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
// GetDB 获取数据库实例(默认主库,兼容旧代码)
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
// GetReplicas 获取所有从库实例
|
||||
func GetReplicas() []*gorm.DB {
|
||||
return replicas
|
||||
}
|
||||
|
||||
// UseMaster 强制使用主库(用于事务或需要实时数据的场景)
|
||||
func UseMaster(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, "db_mode", "master")
|
||||
}
|
||||
|
||||
// UseReplica 强制使用从库(用于报表查询等场景)
|
||||
func UseReplica(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, "db_mode", "replica")
|
||||
}
|
||||
|
||||
// GetDBFromContext 根据上下文选择数据库
|
||||
func GetDBFromContext(ctx context.Context) *gorm.DB {
|
||||
mode, ok := ctx.Value("db_mode").(string)
|
||||
if !ok {
|
||||
return GetReadDB()
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "master":
|
||||
return DB
|
||||
case "replica":
|
||||
return GetReadDB()
|
||||
default:
|
||||
return GetReadDB()
|
||||
}
|
||||
}
|
||||
|
||||
// DBResolver 数据库解析器(用于 GORM 钩子)
|
||||
type DBResolver struct{}
|
||||
|
||||
// BeforeQuery 查询前钩子,自动路由到从库
|
||||
func (r *DBResolver) BeforeQuery(db *gorm.DB) {
|
||||
// 如果在事务中,使用主库
|
||||
if db.Statement.ConnPool != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查上下文
|
||||
ctx := db.Statement.Context
|
||||
if ctx != nil {
|
||||
mode, ok := ctx.Value("db_mode").(string)
|
||||
if ok && mode == "master" {
|
||||
return // 强制主库
|
||||
}
|
||||
}
|
||||
|
||||
// 读操作路由到从库
|
||||
if len(replicas) > 0 && DBRead != nil {
|
||||
db.Statement.ConnPool = DBRead.Statement.ConnPool
|
||||
}
|
||||
}
|
||||
|
||||
// AutoMigrate 自动迁移数据库表结构(由应用重写)
|
||||
func AutoMigrate() error {
|
||||
logger.Info("数据库表结构迁移完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func Close() error {
|
||||
if DB == nil {
|
||||
return nil
|
||||
}
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
// CloseAll 关闭所有数据库连接(包括从库)
|
||||
func CloseAll() error {
|
||||
// 关闭主库
|
||||
if err := Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 关闭从库
|
||||
for _, replica := range replicas {
|
||||
if replica == nil {
|
||||
continue
|
||||
}
|
||||
sqlDB, err := replica.DB()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
replicas = nil
|
||||
DBRead = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Transaction 事务操作(自动使用主库)
|
||||
func Transaction(fn func(tx *gorm.DB) error) error {
|
||||
return DB.Transaction(fn)
|
||||
}
|
||||
|
||||
// TransactionWithContext 带上下文的事务操作
|
||||
func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
||||
return DB.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
// ReadQuery 读查询(自动路由到从库)
|
||||
func ReadQuery(ctx context.Context, model any, query string, args ...any) error {
|
||||
db := GetDBFromContext(ctx)
|
||||
return db.WithContext(ctx).Where(query, args...).Find(model).Error
|
||||
}
|
||||
|
||||
// WriteQuery 写查询(强制使用主库)
|
||||
func WriteQuery(ctx context.Context, model any, query string, args ...any) error {
|
||||
return DB.WithContext(ctx).Where(query, args...).Find(model).Error
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查
|
||||
func HealthCheck() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
|
||||
// 检查主库
|
||||
if DB != nil {
|
||||
sqlDB, err := DB.DB()
|
||||
if err == nil && sqlDB.Ping() == nil {
|
||||
result["master"] = true
|
||||
} else {
|
||||
result["master"] = false
|
||||
}
|
||||
} else {
|
||||
result["master"] = false
|
||||
}
|
||||
|
||||
// 检查从库
|
||||
for i, replica := range replicas {
|
||||
if replica != nil {
|
||||
sqlDB, err := replica.DB()
|
||||
if err == nil && sqlDB.Ping() == nil {
|
||||
result[fmt.Sprintf("replica_%d", i+1)] = true
|
||||
} else {
|
||||
result[fmt.Sprintf("replica_%d", i+1)] = false
|
||||
}
|
||||
} else {
|
||||
result[fmt.Sprintf("replica_%d", i+1)] = false
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
+167
-21
@@ -2,7 +2,11 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
@@ -11,38 +15,180 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
// RedisClient 全局 Redis 客户端
|
||||
RedisClient *redis.Client
|
||||
)
|
||||
// RedisManager Redis 连接管理器(#10)。照 database.Manager 模式:
|
||||
// 实例化 + DefaultRedis 全局默认 + 包级 facade 代理,支持多实例与测试注入。
|
||||
type RedisManager struct {
|
||||
mu sync.Mutex
|
||||
cfg *config.Config
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// InitRedis 初始化 Redis 连接
|
||||
func InitRedis(cfg *config.Config) error {
|
||||
RedisClient = redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr(),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
// DefaultRedis 默认 Redis 管理器,包级 facade 代理到它。
|
||||
//
|
||||
// C-1/H-4 修复:改用 atomic.Pointer 保护读写,消除原裸指针置换(SetDefaultRedisManager)
|
||||
// 与请求 goroutine 无锁读(GetRedis 等 facade)之间的数据竞争。与 config.defaultManager
|
||||
// 对齐。类型由 *RedisManager 变更为 atomic.Pointer[RedisManager](breaking:下游若直接
|
||||
// 调用 DefaultRedis.Init 等方法需改用 InitRedis 等 facade,或 DefaultRedis.Load().Init)。
|
||||
var DefaultRedis atomic.Pointer[RedisManager]
|
||||
|
||||
func init() {
|
||||
DefaultRedis.Store(NewRedisManager())
|
||||
}
|
||||
|
||||
// NewRedisManager 创建 Redis 管理器实例。
|
||||
func NewRedisManager() *RedisManager { return &RedisManager{} }
|
||||
|
||||
// SwapDefaultRedisManager 将指定 RedisManager 置为全局默认,并返回被替换的旧 Manager。
|
||||
// 旧 Manager 不会被关闭,供 App 初始化这类需要失败回滚的生命周期流程暂存。
|
||||
// nil 被忽略,以防 facade Load 到 nil panic。
|
||||
func SwapDefaultRedisManager(m *RedisManager) *RedisManager {
|
||||
if m == nil {
|
||||
return DefaultRedis.Load()
|
||||
}
|
||||
return DefaultRedis.Swap(m)
|
||||
}
|
||||
|
||||
// SetDefaultRedisManager 提升指定 RedisManager 为全局默认,并关闭被替换的旧 Manager。
|
||||
// 用于多实例场景或测试注入 mock。并发安全。若调用方需要保留旧 Manager
|
||||
// 用于回滚,请使用 SwapDefaultRedisManager。
|
||||
func SetDefaultRedisManager(m *RedisManager) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
old := SwapDefaultRedisManager(m)
|
||||
if old != nil && old != m {
|
||||
if err := old.Close(); err != nil {
|
||||
logger.Warnf("关闭被替换的旧 Redis manager 失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultRedisManager 返回当前默认 Redis 管理器。
|
||||
func GetDefaultRedisManager() *RedisManager {
|
||||
return DefaultRedis.Load()
|
||||
}
|
||||
|
||||
// newRedisClient 构造带 D7 超时(Dial 5s / Read 3s / Write 3s)的 redis.Client。
|
||||
// 由 Init 与测试共用,确保所有 client 实例一致具备超时约束(挂起 Redis 不无限阻塞)。
|
||||
func newRedisClient(addr, password string, db int) *redis.Client {
|
||||
return redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: db,
|
||||
DialTimeout: 5 * time.Second, // D7 修复:连接超时
|
||||
ReadTimeout: 3 * time.Second, // D7 修复:读超时(约束 HealthCheck Ping 不被挂起 Redis 阻塞)
|
||||
WriteTimeout: 3 * time.Second, // D7 修复:写超时
|
||||
})
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
ctx := context.Background()
|
||||
if err := RedisClient.Ping(ctx).Err(); err != nil {
|
||||
// Init 初始化 Redis 连接并 ping 验证。
|
||||
func (m *RedisManager) Init(cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("Redis 配置未设置")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
client := newRedisClient(cfg.Redis.Addr(), cfg.Redis.Password, cfg.Redis.DB)
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(pingCtx).Err(); err != nil {
|
||||
if cerr := client.Close(); cerr != nil {
|
||||
return errors.Join(fmt.Errorf("Redis 连接失败: %w", err), fmt.Errorf("Redis 关闭失败: %w", cerr))
|
||||
}
|
||||
return fmt.Errorf("Redis 连接失败: %w", err)
|
||||
}
|
||||
|
||||
old := m.client
|
||||
m.cfg = cfg
|
||||
m.client = client
|
||||
if old != nil {
|
||||
if err := old.Close(); err != nil {
|
||||
logger.Warnf("关闭旧 Redis 连接失败: %v", err)
|
||||
}
|
||||
}
|
||||
logger.Info("Redis 连接成功", zap.String("addr", cfg.Redis.Addr()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseRedis 关闭 Redis 连接
|
||||
func CloseRedis() error {
|
||||
if RedisClient != nil {
|
||||
return RedisClient.Close()
|
||||
// Close 关闭 Redis 连接。
|
||||
func (m *RedisManager) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.client == nil {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
err := m.client.Close()
|
||||
m.client = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRedis 获取 Redis 客户端
|
||||
func GetRedis() *redis.Client {
|
||||
return RedisClient
|
||||
// Client 返回当前 Redis 客户端(未初始化返回 nil)。
|
||||
func (m *RedisManager) Client() *redis.Client {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.client
|
||||
}
|
||||
|
||||
// setClientForTest 仅测试用:在持锁下替换 manager 的 client,返回旧 client。
|
||||
// 供 SetTestRedisClient 注入 miniredis 等 mock,消除原包级 redisClient 双源真相。
|
||||
func (m *RedisManager) setClientForTest(c *redis.Client) *redis.Client {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
old := m.client
|
||||
m.client = c
|
||||
return old
|
||||
}
|
||||
|
||||
// HealthCheck Redis 健康检查。
|
||||
func (m *RedisManager) HealthCheck(ctx context.Context) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.mu.Lock()
|
||||
client := m.client
|
||||
m.mu.Unlock()
|
||||
if client == nil {
|
||||
return fmt.Errorf("Redis 未初始化")
|
||||
}
|
||||
return client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// --- 包级 facade(代理到 DefaultRedis,兼容存量) ---
|
||||
|
||||
// InitRedis 初始化 Redis 连接
|
||||
func InitRedis(cfg *config.Config) error {
|
||||
return DefaultRedis.Load().Init(cfg)
|
||||
}
|
||||
|
||||
// CloseRedis 关闭 Redis 连接
|
||||
func CloseRedis() error {
|
||||
return DefaultRedis.Load().Close()
|
||||
}
|
||||
|
||||
// HealthCheckRedis Redis 健康检查
|
||||
func HealthCheckRedis(ctx context.Context) error {
|
||||
return DefaultRedis.Load().HealthCheck(ctx)
|
||||
}
|
||||
|
||||
// GetRedis 获取 Redis 客户端(未初始化返回 nil)。
|
||||
//
|
||||
// H-4 修复:单源——仅从 DefaultRedis.Load().Client() 取,废弃原包级 redisClient
|
||||
// 回退路径(其在 manager 替换后会返回 stale client,且无锁读存在竞态)。
|
||||
// 测试注入的 mock client 经 SetTestRedisClient 直接设在当前 manager 上,闭环一致。
|
||||
func GetRedis() *redis.Client {
|
||||
return DefaultRedis.Load().Client()
|
||||
}
|
||||
|
||||
// SetTestRedisClient 供测试注入 miniredis 等 mock 客户端。
|
||||
// 返回旧客户端引用以便测试清理时恢复。生产代码严禁调用。
|
||||
//
|
||||
// H-4 修复:改为在当前默认 manager 上持锁替换 client(setClientForTest),
|
||||
// 不再写独立的包级 redisClient 变量,消除双源真相与无锁写竞态。
|
||||
//
|
||||
// 约束:操作 DefaultRedis.Load() 返回的当前默认 manager。测试应避免在调用本函数
|
||||
// 的同时并发 SetDefaultRedisManager 替换默认 manager,否则注入/恢复会作用到不同
|
||||
// manager 上。典型用法为 init 阶段注入、t.Cleanup 恢复,串行执行。
|
||||
func SetTestRedisClient(c *redis.Client) *redis.Client {
|
||||
return DefaultRedis.Load().setClientForTest(c)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// startHungRedisListener 启动一个接受 TCP 连接但不读写任何数据的 listener,
|
||||
// 模拟"挂起 Redis"(连接建立但不响应 RESP,区别于宕机的 connection-refused)。
|
||||
// 返回 listener 地址;t.Cleanup 关闭 listener。
|
||||
func startHungRedisListener(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("hung redis listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return // listener 关闭
|
||||
}
|
||||
// 故意不读写、不关闭,持住连接模拟挂起。连接由 t.Cleanup 的 ln.Close 间接清理。
|
||||
_ = conn
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
// TestRedisHealthCheckBoundsHungRedis_Hdb1 回归 H-db-1 边界(Redis 侧):
|
||||
// RedisManager.HealthCheck(Background) 对挂起 Redis(连接活但不响应)应受
|
||||
// redis client 的 ReadTimeout(3s,redis.go D7) 约束有界返回错误,不无限阻塞。
|
||||
// ctx 无 deadline 时由 client ReadTimeout 兜底;ctx 自带更短 deadline 时优先尊重 ctx。
|
||||
// 修复前/缺 ReadTimeout 时 Ping(Background) 会无限阻塞。
|
||||
func TestRedisHealthCheckBoundsHungRedis_Hdb1(t *testing.T) {
|
||||
addr := startHungRedisListener(t)
|
||||
cfg := redisTestConfig(t, addr)
|
||||
|
||||
m := NewRedisManager()
|
||||
if err := m.Init(cfg); err == nil {
|
||||
// Init 的 Ping 有 5s ctx,挂起 Redis 下应在 ~3s(ReadTimeout)失败返回错误。
|
||||
t.Fatalf("挂起 Redis 的 Init 应返回错误,got nil")
|
||||
}
|
||||
// Init 失败后 m.client 未被安装(nil),HealthCheck 返 "Redis 未初始化"。
|
||||
// 为测 HealthCheck 自身的 ping 超时约束,需 client 已安装但指向挂起 Redis。
|
||||
// 用 setClientForTest 注入一个指向挂起 Redis 的 client。
|
||||
client := newRedisClientForTest(addr)
|
||||
old := m.setClientForTest(client)
|
||||
t.Cleanup(func() {
|
||||
if old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
// HealthCheck(Background):挂起 Redis 下 client.Ping 受 ReadTimeout 3s 约束有界返回。
|
||||
done := make(chan error, 1)
|
||||
start := time.Now()
|
||||
go func() { done <- m.HealthCheck(context.Background()) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatalf("挂起 Redis 的 HealthCheck 应返回错误,got nil")
|
||||
}
|
||||
// 应在 ReadTimeout(3s) 附近返回,给 6s 上限(含连接/重试余量)。
|
||||
if elapsed > 6*time.Second {
|
||||
t.Fatalf("HealthCheck 耗时 %v 超过 6s 上限(应受 ReadTimeout 3s 约束)", elapsed)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("HealthCheck 在挂起 Redis 上无限阻塞(ReadTimeout 未约束 Ping)")
|
||||
}
|
||||
}
|
||||
|
||||
// newRedisClientForTest 构造指向 addr 的 redis.Client(复用 Init 的 client 配置,
|
||||
// 含 DialTimeout 5s / ReadTimeout 3s / WriteTimeout 3s)。
|
||||
func newRedisClientForTest(addr string) *redis.Client {
|
||||
return newRedisClient(addr, "", 0)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestSetDefaultRedisManagerClosesReplacedManager(t *testing.T) {
|
||||
old := NewRedisManager()
|
||||
old.setClientForTest(redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}))
|
||||
|
||||
orig := SwapDefaultRedisManager(old)
|
||||
t.Cleanup(func() {
|
||||
current := SwapDefaultRedisManager(orig)
|
||||
if current != nil && current != orig {
|
||||
_ = current.Close()
|
||||
}
|
||||
})
|
||||
|
||||
next := NewRedisManager()
|
||||
SetDefaultRedisManager(next)
|
||||
if GetDefaultRedisManager() != next {
|
||||
t.Fatal("SetDefaultRedisManager 应安装新的默认 manager")
|
||||
}
|
||||
if old.Client() != nil {
|
||||
t.Fatal("SetDefaultRedisManager 应关闭并清空被替换的旧 Redis manager")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapDefaultRedisManagerPreservesReplacedManager(t *testing.T) {
|
||||
old := NewRedisManager()
|
||||
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"})
|
||||
old.setClientForTest(client)
|
||||
|
||||
orig := SwapDefaultRedisManager(old)
|
||||
t.Cleanup(func() {
|
||||
current := SwapDefaultRedisManager(orig)
|
||||
if current != nil && current != orig {
|
||||
_ = current.Close()
|
||||
}
|
||||
_ = old.Close()
|
||||
})
|
||||
|
||||
next := NewRedisManager()
|
||||
previous := SwapDefaultRedisManager(next)
|
||||
if previous != old {
|
||||
t.Fatal("SwapDefaultRedisManager 应返回被替换的旧 manager")
|
||||
}
|
||||
if GetDefaultRedisManager() != next {
|
||||
t.Fatal("SwapDefaultRedisManager 应安装新的默认 manager")
|
||||
}
|
||||
if old.Client() != client {
|
||||
t.Fatal("SwapDefaultRedisManager 不应关闭旧 Redis manager,旧资源需可用于失败回滚")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
)
|
||||
|
||||
func redisTestConfig(t *testing.T, addr string) *config.Config {
|
||||
t.Helper()
|
||||
host, portText, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split redis addr: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse redis port: %v", err)
|
||||
}
|
||||
return &config.Config{Redis: config.RedisConfig{Host: host, Port: port}}
|
||||
}
|
||||
|
||||
func TestRedisManagerInitClosesPreviousClient(t *testing.T) {
|
||||
mr1 := miniredis.RunT(t)
|
||||
mr2 := miniredis.RunT(t)
|
||||
|
||||
m := NewRedisManager()
|
||||
if err := m.Init(redisTestConfig(t, mr1.Addr())); err != nil {
|
||||
t.Fatalf("first Init: %v", err)
|
||||
}
|
||||
first := m.Client()
|
||||
if first == nil {
|
||||
t.Fatal("first client is nil")
|
||||
}
|
||||
|
||||
if err := m.Init(redisTestConfig(t, mr2.Addr())); err != nil {
|
||||
t.Fatalf("second Init: %v", err)
|
||||
}
|
||||
if err := first.Ping(context.Background()).Err(); err == nil {
|
||||
t.Fatal("first client still usable after second Init; old Redis pool was not closed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
)
|
||||
|
||||
func TestCloseRedisWithoutInit(t *testing.T) {
|
||||
if err := database.CloseRedis(); err != nil {
|
||||
t.Fatalf("CloseRedis without init should not error: %v", err)
|
||||
}
|
||||
if database.GetRedis() != nil {
|
||||
t.Fatal("expected Redis client nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckRedisWithoutInit(t *testing.T) {
|
||||
if err := database.HealthCheckRedis(context.Background()); err == nil {
|
||||
t.Fatal("expected health check error without Redis init")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisNilConfigReturnsError(t *testing.T) {
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(prev) })
|
||||
|
||||
if err := database.NewRedisManager().Init(nil); err == nil {
|
||||
t.Fatal("RedisManager.Init(nil) 应返回错误")
|
||||
}
|
||||
if err := database.InitRedis(nil); err == nil {
|
||||
t.Fatal("包级 InitRedis(nil) 应返回错误")
|
||||
}
|
||||
if err := database.HealthCheckRedis(nil); err == nil {
|
||||
t.Fatal("未初始化 Redis 时 HealthCheckRedis(nil) 应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultRedisConcurrentSetAndGet C-1/H-4 回归:并发 SetDefaultRedisManager
|
||||
// 与 GetRedis/CloseRedis/HealthCheckRedis facade 读取不应触发数据竞争。
|
||||
// 修复前 DefaultRedis 是裸 *Redis.Pointer,SetDefaultRedisManager 无锁写、
|
||||
// facade 无锁读,-race 必采。修复后 atomic.Pointer 保护。
|
||||
func TestDefaultRedisConcurrentSetAndGet(t *testing.T) {
|
||||
// 保存并恢复默认 manager,避免污染其他测试。
|
||||
orig := database.NewRedisManager()
|
||||
database.SetDefaultRedisManager(orig)
|
||||
// 不设 client,GetRedis 返回 nil;本用例只验证读写无竞态。
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
database.SetDefaultRedisManager(database.NewRedisManager())
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = database.GetRedis()
|
||||
_ = database.CloseRedis()
|
||||
_ = database.HealthCheckRedis(context.Background())
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsTransientDBError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, true},
|
||||
{"access denied", errors.New("Error 1045: Access denied for user 'root'@'localhost'"), false},
|
||||
{"auth plugin", errors.New("authentication plugin 'caching_sha2_password' cannot be loaded"), false},
|
||||
{"unknown database", errors.New("Error 1049: Unknown database 'foo'"), false},
|
||||
{"invalid DSN", errors.New("invalid DSN: missing the slash separating the database name"), false},
|
||||
{"unknown driver", errors.New("sql: unknown driver \"foobar\" (forgotten import?)"), false},
|
||||
{"connection refused (transient)", errors.New("dial tcp 127.0.0.1:3306: connect: connection refused"), true},
|
||||
{"i/o timeout (transient)", errors.New("dial tcp 10.0.0.1:3306: i/o timeout"), true},
|
||||
{"empty msg", errors.New(""), true},
|
||||
// P1 #12:PG 目标库不存在应视为非瞬态(不重试)。
|
||||
{"pg database not exist", errors.New(`FATAL: database "app" does not exist (SQLSTATE 3D000)`), false},
|
||||
{"pg password auth failed", errors.New("FATAL: password authentication failed for user \"app\""), false},
|
||||
// P1 #12 回归核心:含 "database" 但是瞬态的错误必须仍被判为瞬态(可重试),
|
||||
// 修复前独立 "database" 子串会把它误判为非瞬态而放弃重试。
|
||||
{"pg starting up (transient)", errors.New("FATAL: the database system is starting up (SQLSTATE 57P03)"), true},
|
||||
{"pg in recovery (transient)", errors.New("FATAL: the database system is in recovery mode"), true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isTransientDBError(tt.err); got != tt.want {
|
||||
t.Errorf("isTransientDBError(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// ensureMySQLTLSRegistered 按 cfg.Database 的 TLS 配置注册命名 TLS 配置到 go-sql-driver/mysql(M-config-2)。
|
||||
//
|
||||
// go-sql-driver/mysql v1.7.0 的 tls DSN 参数语义:
|
||||
// - tls=true:内置安全(&tls.Config{},系统根 CA + ServerName 自动取自 host + 证书校验),无需注册。
|
||||
// - tls=<name>:引用经 RegisterTLSConfig 注册的命名配置,用于私有 CA/自签证书。
|
||||
//
|
||||
// 本函数仅在「TLS=true 且 TLSRootCA 非空」时注册 config.MySQLTLSConfigName 命名配置:
|
||||
// 加载 TLSRootCA 的 PEM 到 RootCAs,ServerName 取自 Host,MinVersion=TLS1.2。其余情况(TLS 未启用、
|
||||
// 或启用但用内置 tls=true)直接返回 nil。
|
||||
//
|
||||
// 非 MySQL 驱动(如 postgres,用 SSLMode)跳过。失败返回错误,由 initDB fail-fast,
|
||||
// 绝不静默回退明文连接(生产 DB 流量明文是安全风险)。
|
||||
//
|
||||
// 注意:RegisterTLSConfig 是驱动级全局状态——一个名字对应唯一 *tls.Config(含唯一 ServerName),
|
||||
// 同名重复注册会覆盖前者且不报错,故无法用「同名」同时匹配多个不同 host。
|
||||
//
|
||||
// 覆盖范围(注册配置的 ServerName 固定取自主库 Host):
|
||||
// - 单 host 集群:replica DSN 的 host 与主库 Host 相同(同机不同端口,或经同一 LB/主机名暴露)——
|
||||
// master 与 replica DSN 均用 tls=MySQLTLSConfigName,ServerName 一致,握手通过。
|
||||
// - 多 host replicas + 私有 CA:replica DSN 的 host 与主库 Host 不同时,本注册的 ServerName(主库
|
||||
// host)与 replica 证书 SAN 不匹配,握手失败。此场景须用户为每个 replica host 注册**不同名**的
|
||||
// TLS 配置(各自 ServerName=该 replica host)并自行构造对应 replica DSN(tls=<该名>);框架
|
||||
// MySQLDSN() 硬编码 tls=MySQLTLSConfigName,不能用于这些 replica DSN。切勿对 MySQLTLSConfigName
|
||||
// 同名重复注册——会覆盖主库注册、导致主库握手失败。
|
||||
//
|
||||
// replica 路径(InitDBWithReplicas 的 replicaDSNs)由调用方传原始 DSN,不经本函数注册。
|
||||
// 多集群不同 CA 亦属已知多 App 限制,需用户按上述方式自行注册。
|
||||
func ensureMySQLTLSRegistered(cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
db := &cfg.Database
|
||||
if !db.TLS || strings.TrimSpace(db.TLSRootCA) == "" {
|
||||
return nil
|
||||
}
|
||||
// 仅 MySQL 走注册路径;postgres 用 SSLMode,其他驱动自管 TLS。空 driver 默认 MySQL。
|
||||
drv := normalizeDriver(db.Driver)
|
||||
if drv != "" && drv != DriverMySQL {
|
||||
return nil
|
||||
}
|
||||
|
||||
pem, err := os.ReadFile(db.TLSRootCA)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 MySQL TLS CA 文件失败 %q: %w", db.TLSRootCA, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return fmt.Errorf("解析 MySQL TLS CA 文件失败 %q: 非 PEM 格式或无有效证书", db.TLSRootCA)
|
||||
}
|
||||
tlsCfg := &tls.Config{
|
||||
RootCAs: pool,
|
||||
ServerName: db.Host,
|
||||
MinVersion: tls.VersionTLS12, // 禁用 TLS1.0/1.1,符合安全基线
|
||||
}
|
||||
if err := mysqldriver.RegisterTLSConfig(config.MySQLTLSConfigName, tlsCfg); err != nil {
|
||||
return fmt.Errorf("注册 MySQL TLS 配置 %q 失败: %w", config.MySQLTLSConfigName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// writeSelfSignedCAPEM 生成一个自签名 CA 证书并写入临时 PEM 文件,返回路径。
|
||||
func writeSelfSignedCAPEM(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "xlgo-test-ca"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("x509.CreateCertificate: %v", err)
|
||||
}
|
||||
p := filepath.Join(os.TempDir(), "xlgo_tls_ca_test.pem")
|
||||
if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestEnsureMySQLTLSRegistered_NoOpCases 固化 M-config-2:非 MySQL / 未启用 TLS / 无 CA 时
|
||||
// ensureMySQLTLSRegistered 为 no-op,不注册、不报错。
|
||||
func TestEnsureMySQLTLSRegistered_NoOpCases(t *testing.T) {
|
||||
if err := ensureMySQLTLSRegistered(nil); err != nil {
|
||||
t.Errorf("nil cfg 应 no-op, got %v", err)
|
||||
}
|
||||
mysqlNoTLS := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
|
||||
}}
|
||||
if err := ensureMySQLTLSRegistered(mysqlNoTLS); err != nil {
|
||||
t.Errorf("TLS=false 应 no-op, got %v", err)
|
||||
}
|
||||
mysqlBuiltIn := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
|
||||
TLS: true, // 无 TLSRootCA,用内置 tls=true
|
||||
}}
|
||||
if err := ensureMySQLTLSRegistered(mysqlBuiltIn); err != nil {
|
||||
t.Errorf("TLS=true 无 CA 应 no-op(内置 tls=true), got %v", err)
|
||||
}
|
||||
// postgres 用 SSLMode,不走 MySQL TLS 注册
|
||||
pg := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n",
|
||||
TLS: true, TLSRootCA: "/path/ca.pem",
|
||||
}}
|
||||
if err := ensureMySQLTLSRegistered(pg); err != nil {
|
||||
t.Errorf("postgres 应跳过 MySQL TLS 注册, got %v", err)
|
||||
}
|
||||
// 空 driver(默认 MySQL)+ 无 TLS:no-op
|
||||
emptyDrv := &config.Config{Database: config.DatabaseConfig{
|
||||
Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
|
||||
}}
|
||||
if err := ensureMySQLTLSRegistered(emptyDrv); err != nil {
|
||||
t.Errorf("空 driver 无 TLS 应 no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureMySQLTLSRegistered_BadCA 固化 M-config-2:CA 文件不可读或非 PEM 时 fail-fast,
|
||||
// 绝不静默回退明文连接。
|
||||
func TestEnsureMySQLTLSRegistered_BadCA(t *testing.T) {
|
||||
missing := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
|
||||
TLS: true, TLSRootCA: "/no/such/ca.pem",
|
||||
}}
|
||||
if err := ensureMySQLTLSRegistered(missing); err == nil || !strings.Contains(err.Error(), "读取") {
|
||||
t.Fatalf("CA 文件不存在应报读取错误, got: %v", err)
|
||||
}
|
||||
|
||||
nonPEM := filepath.Join(os.TempDir(), "xlgo_tls_nonpem.pem")
|
||||
if err := os.WriteFile(nonPEM, []byte("this is not a pem file"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
bad := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n",
|
||||
TLS: true, TLSRootCA: nonPEM,
|
||||
}}
|
||||
if err := ensureMySQLTLSRegistered(bad); err == nil || !strings.Contains(err.Error(), "解析") {
|
||||
t.Fatalf("非 PEM 文件应报解析错误, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureMySQLTLSRegistered_ResolvesViaParseDSN 固化 M-config-2 端到端:
|
||||
// 注册前 ParseDSN(tls=xlgo-mysql) 报 unknown config name;注册后成功解析且 TLS 非 nil。
|
||||
// 利用 go-sql-driver/mysql 的 TLS 解析发生在 ParseDSN 的 normalize() 阶段,无需真实 DB 连接。
|
||||
func TestEnsureMySQLTLSRegistered_ResolvesViaParseDSN(t *testing.T) {
|
||||
caPath := writeSelfSignedCAPEM(t)
|
||||
defer os.Remove(caPath)
|
||||
|
||||
// 清理同名残留注册,保证「注册前」断言不被前序测试污染
|
||||
mysqldriver.DeregisterTLSConfig(config.MySQLTLSConfigName)
|
||||
defer mysqldriver.DeregisterTLSConfig(config.MySQLTLSConfigName)
|
||||
|
||||
cfg := &config.Config{Database: config.DatabaseConfig{
|
||||
Driver: "mysql", Host: "127.0.0.1", Port: 3306, User: "u", Password: "p", Name: "n",
|
||||
TLS: true, TLSRootCA: caPath,
|
||||
}}
|
||||
dsn := cfg.Database.DSN()
|
||||
if !strings.Contains(dsn, "tls="+config.MySQLTLSConfigName) {
|
||||
t.Fatalf("DSN 应含 tls=%s: %s", config.MySQLTLSConfigName, dsn)
|
||||
}
|
||||
|
||||
// 注册前:ParseDSN 应报 unknown config name
|
||||
if _, err := mysqldriver.ParseDSN(dsn); err == nil {
|
||||
t.Fatal("注册前 ParseDSN 不应成功(未知 TLS 配置名)")
|
||||
} else if !strings.Contains(err.Error(), "unknown config name") {
|
||||
t.Logf("注册前 ParseDSN 错误(预期未知配置名): %v", err)
|
||||
}
|
||||
|
||||
// 注册后:ParseDSN 成功,TLS 已解析为非 nil
|
||||
if err := ensureMySQLTLSRegistered(cfg); err != nil {
|
||||
t.Fatalf("ensureMySQLTLSRegistered: %v", err)
|
||||
}
|
||||
mc, err := mysqldriver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("注册后 ParseDSN 应成功, got: %v", err)
|
||||
}
|
||||
if mc.TLS == nil {
|
||||
t.Error("注册后 ParseDSN 的 TLS 配置应为非 nil(CA 已注册)")
|
||||
}
|
||||
if mc.TLS.ServerName != "127.0.0.1" {
|
||||
t.Errorf("TLS ServerName = %q, want 127.0.0.1", mc.TLS.ServerName)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
# xlgo v1.1.0 实施计划 — HA & Manager 化
|
||||
|
||||
> 本文件为 v1.1.0 版本的逐项实施计划,对应体检报告(`docs/plans/Version_v1.0.2_report.md`)第三~四章 #10-#24 架构/HA 改进。
|
||||
> 范围:13 项(#20 RedisRateLimiter、#23 Recover 带 request_id 已实现,本次核对配合)。
|
||||
|
||||
## Context
|
||||
|
||||
v1.0.3(bug fix)与 v1.0.4(DX & Docs)已发布推送,GitHub Release 已创建,工作区干净。v1.0.2 体检报告规划的 v1.1.0 阶段共 13 项架构/HA 改进,目标是把 xlgo 从"一半组件可注入、一半是单例"的撕裂状态,贯彻到"通用 / 高可用 / 易上手"。
|
||||
|
||||
本次一次性推完 13 项,发 v1.1.0。已确认的边界决策:
|
||||
|
||||
- **版本策略**:v1.1.0 接受少量 breaking(#11 删 wire 包、#14 删 `AppConfig.TokenExpire`、删 `StartServerWithPort`/`GracefulShutdown`、`JWTConfig.Expire` 类型变更),在 CHANGELOG「升级说明」章节列出;其余(#15 response)用全局开关默认兼容存量。
|
||||
- **#10 Manager 化**:storage/cache/redis/jwt/logger **5 个全做**(含 logger),照 `database.Manager` + 包级 facade 蓝本。
|
||||
- **#15 response**:全局 `SetMode(ModeBusiness|ModeREST)`,默认 `ModeBusiness`(现状全 200 + 业务码)兼容;`ModeREST` 下 401/404/500 返回对应 HTTP status,body 仍带业务码。可在 `ServerConfig.ResponseMode` 配置。
|
||||
- **#11 wire**:删掉整个 `wire` 包(其事 App Option 已覆盖)。
|
||||
- **#18**:接受 `prometheus/client_golang` 新依赖。
|
||||
- **#12**:只提供 `WithHook(Hook{...})` 机制,不引入 etcd 依赖、不提供内置示例。
|
||||
|
||||
## 实现方案(按依赖顺序分 6 组)
|
||||
|
||||
### 组 1:配置层(其余项的基础)
|
||||
|
||||
**#13 Server 参数配置化** — `config/config.go:84-88`
|
||||
`ServerConfig` 增字段:`ReadTimeout/WriteTimeout/IdleTimeout/ShutdownTimeout/MaxHeaderBytes`、`TLS{Enabled,CertFile,KeyFile}`、`UnixSocket string`、`ResponseMode string`。`app.go:408-414` 的 `http.Server` 改为读这些字段(缺省回退到当前硬编码值)。TLS 开启时用 `ListenAndServeTLS`;`UnixSocket` 非空时优先 unix socket。Shutdown 超时读 `ShutdownTimeout`。
|
||||
|
||||
**#14 JWTConfig.Expire time.Duration + 删 TokenExpire** — `config/config.go:44, 209-213`
|
||||
`JWTConfig.Expire` 改 `time.Duration`(mapstructure + viper string 解析 `"24h"`),新增 `RefreshExpire time.Duration`、`Issuer`、`Algorithm`。**删 `AppConfig.TokenExpire`**(breaking)。`jwt/jwt.go` 内过期取值改读 `JWTConfig.Expire`。Duration 解析依赖 mapstructure decode hook。
|
||||
|
||||
**#16 Config Validate** — 新增 `config/config.go` `(*Config).Validate() error`
|
||||
校验:`Server.Port` 范围、`JWT.Secret` 非空且 ≥32 字符(启用 jwt 时)、启用 mysql 时关键字段、Duration 非负等。`config.Manager.Load`(`config/manager.go:340`)解析后自动调用,错误包裹返回,把"运行时第一次请求才暴露"提前到启动。
|
||||
|
||||
**#15 response Mode 开关** — `response/response.go`
|
||||
新增 `type Mode int`、`ModeBusiness/ModeREST`、包级 `currentMode` + `SetMode(m)`、`Mode()`。`Fail/Unauthorized/NotFound/ServerError/RateLimit/FailWithCode` 内:`ModeREST` 时按错误码/错误类型映射 HTTP status(`ErrUnauthorized→401`、`ErrNotFound→404`、`ErrServer→500`、`ErrRateLimit→429`、参数类→400),`c.JSON(status, Response{...})`;`ModeBusiness` 维持 200。`App.Init` 末尾按 `ServerConfig.ResponseMode` 调 `response.SetMode`。新增 `response.Custom(c, httpStatus, code, data)` 显式 API。
|
||||
|
||||
### 组 2:组件 Manager 化(#10,5 组件,照 database.Manager 蓝本)
|
||||
|
||||
蓝本参考 `database/manager.go:180-192`(`DefaultManager` 包级实例 + `SetDefaultManager` + 包级 facade 代理 + 实例方法)。每个组件统一模式:
|
||||
- 新增 `type Manager struct{ ...; mu sync.Mutex }` + `var DefaultXxx = &Manager{}`
|
||||
- `SetDefaultManager(m)` 提升用户实例到全局
|
||||
- 包级 `Init/Get/操作` 函数代理到 `DefaultXxx`(**保留,兼容存量**)
|
||||
- `App` 持有各 Manager 实例(`App` 加字段),`WithXxx` 时初始化 App 自己的实例并 `SetDefaultXxx`
|
||||
|
||||
顺序(按依赖):**redis → cache → jwt → storage → logger**
|
||||
|
||||
1. **redis** — `database/redis.go`:新增 `type RedisManager struct{ cfg; client *redis.Client; mu }` + `DefaultRedis`。`InitRedis/CloseRedis/GetRedis/HealthCheckRedis` 代理。下游 5 处直接读 `database.RedisClient`(`jwt/jwt.go`、`middleware/ratelimit.go`、`cache/cache.go`、`cache/lock.go` 20+ 处、`app.go`)全部改为 `database.GetRedis()`。`cache/lock.go` 改为接受 `*redis.Client` 参数或内部 `GetRedis()`。
|
||||
|
||||
2. **cache** — `cache/cache.go`:`type CacheManager struct{ client *redis.Client; svc CacheService; mu }` + `DefaultCache`。`redisCache.client` 从硬编码 `database.RedisClient` 改为构造时注入。`Init/GetCache` 代理。`cache/lock.go` 的分布式锁函数改走 `DefaultCache` 或显式传 client。
|
||||
|
||||
3. **jwt** — `jwt/jwt.go`:`type Manager struct{ blacklist *TokenBlacklist; cfg *config.JWTConfig; mu }` + `DefaultJWT`。`TokenBlacklist.Add/IsBlacklisted` 内部 `database.RedisClient` 改 `database.GetRedis()`。`GenerateToken/ParseToken/InvalidateToken/RefreshToken/...` 代理到 `DefaultJWT`。
|
||||
|
||||
4. **storage** — `storage/storage.go`:`type Manager struct{ cfg; current Storage; mu }` + `DefaultStorage`。`Init/GetStorage/SetStorage/Upload/...` 代理到 `DefaultStorage.current`。最干净,无外部下游。
|
||||
|
||||
5. **logger** — `logger/logger.go`:`type Manager struct{ cfg; logger/apiLog/dbLog *zap.Logger; fileWriters; mu }` + `DefaultLogger`。包级 `Init/Sync/Close/Info/.../APILog/DBLog` 代理。**特殊**:logger 是 `App.Init` 最先初始化的组件,`DefaultLogger` 初始化前包级函数须安全降级到 Nop(现状 `Close()` 已重置为 Nop,沿用)。下游 8 包的 `logger.Info(...)` 调用点无需改(仍走包级 facade)。
|
||||
|
||||
`App` struct(`app.go:41-62`)加字段:`redisMgr *database.RedisManager`、`cacheMgr *cache.CacheManager`、`jwtMgr *jwt.Manager`、`storageMgr *storage.Manager`、`loggerMgr *logger.Manager`。
|
||||
|
||||
### 组 3:App 生命周期
|
||||
|
||||
**#12 Lifecycle Hooks** — `app.go`
|
||||
新增 `type Hook struct{ Name string; OnInit func(*App) error; OnStart func(*App) error; OnReady func(*App); OnStop func(*App) error }` + `WithHook(Hook) Option`。`App` 加 `hooks []Hook`。`Init()` 内组件初始化完成后按序调 `OnInit`;`StartServer` 监听前调 `OnStart`,端口就绪后调 `OnReady`;`Shutdown` 开头调 `OnStop`。各 hook 错误中断流程并返回。
|
||||
|
||||
**#22 App.Go + in-flight goroutine** — `app.go`
|
||||
`App` 加 `wg sync.WaitGroup` + `ctx context.Context`(根 ctx)+ `cancel`。新增 `App.Go(fn func(ctx context.Context))`:`wg.Add(1); go func(){ defer wg.Done(); fn(ctx) }()`。`Shutdown` 在 `OnStop` 后、关 HTTP 前调 `cancel()` 并 `wg.Wait()`(带 `ShutdownTimeout` 超时)。
|
||||
|
||||
**#11 删 wire 包** — 删 `wire/wire.go`(整包)。清理 `app.go` 的 `WithWire/WithoutWire/enableWire` 及 `Init()` 中 `wire.InitServices()` 调用。`cache.Init()` 原由 wire 触发,改由 `WithRedis`/`WithCache` 触发(或 `App.Init` 显式调)。
|
||||
|
||||
**清理双轨** — `app.go:494-537`:删 `StartServerWithPort`、`GracefulShutdown`(与 `App.StartServer`/`App.Shutdown` 重复)。breaking,升级说明列出。
|
||||
|
||||
### 组 4:中间件与路由
|
||||
|
||||
**#24 RequestID 默认装入** — `app.go:~348`
|
||||
`App.Init` 中间件链改为无条件 `a.router.Use(middleware.RequestID())`(在 Recovery 之前),让每个响应/panic 日志都带 request_id。核对 #23:`middleware/recover.go:20` 已 `GetRequestID(c)`,配合 #24 后 panic 日志 request_id 非空。移除 `gin.Recovery()` 双重保险(保留 `middleware.Recover()` 一个即可)。
|
||||
|
||||
**#19 请求级 Timeout 中间件** — 新增 `middleware/timeout.go`
|
||||
`func Timeout(d time.Duration) gin.HandlerFunc`:`ctx, cancel := context.WithTimeout(c.Request.Context(), d); defer cancel(); c.Request = c.Request.WithContext(ctx); c.Next()`。可通过 `WithRequestTimeout(d)` Option 装入全局,下游 GORM/Redis 走 `c.Request.Context()` 级联取消。
|
||||
|
||||
**#18 Prometheus metrics** — 新依赖 `prometheus/client_golang`
|
||||
新增 `middleware/metrics.go`:`middleware.Metrics()` 记录 HTTP latency / status code / in-flight(histogram + counter + gauge)。新增 `router/metrics.go`:`RegisterMetricsRoute(r, path...)` 默认 `/metrics` 挂 `promhttp.Handler()`。新增 `WithMetricsRoute(path...)` Option。`App.Init` 装入 `Metrics()` 中间件 + 路由。
|
||||
|
||||
**#17 livez/readyz** — `router/router.go`
|
||||
新增 `RegisterLivenessRoute(r)` → `GET /livez` 永不依赖外部(进程存活即 200)。新增 `RegisterReadinessRoute(r, checks...)` → `GET /readyz` 复用 `HealthCheck`,失败 503。新增 `WithLivenessRoute()`/`WithReadinessRoute()` Option。`/health` 保留兼容。`livez` 不查依赖、`readyz` 查依赖,对 K8s probe 友好。
|
||||
|
||||
### 组 5:依赖健康自愈(#21)
|
||||
|
||||
**#21 主库探活 + replica 健康剔除** — `database/manager.go`
|
||||
- `database.Manager` 加 `healthy bool` + `consecutiveFailures int` + `healthMu`。`Pool.SetConnMaxIdleTime` 配置化(`DatabaseConfig` 加 `ConnMaxIdleTime`)。
|
||||
- 探活:`App.Init` 末尾用 `App.Go` 起后台 goroutine,每 30s `HealthCheck(ctx)`,连续 N 次失败标记 `healthy=false`;`readyz`/`/health` 读此标记返回 503。
|
||||
- `ReplicaPicker` 接口加健康度:replica ping 失败剔除轮询,恢复后重新纳入。`RoundRobinPicker`/`RandomPicker` 实现 health-aware 选取。
|
||||
|
||||
### 组 6:收尾与发版
|
||||
|
||||
- `app.go:27` `const Version = "1.1.0"`。
|
||||
- `CHANGELOG.md` 加 `[1.1.0]` 章节(Added/Changed/Fixed + **升级说明** breaking 列表)。README 更新日志 + 底部链接。
|
||||
- `examples/`(full/minimal)同步:full 例子的配置文件加 `server.read_timeout` 等新字段、`jwt.expire: 24h`,验证 5 组件 Manager 化后仍可跑。
|
||||
- 测试:`go test ./...` 全绿;为 Manager 化、Validate、response Mode、livez/readyz、metrics、timeout、App.Go 补单测。
|
||||
- `go mod tidy`。
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
| 文件 | 涉及 issue |
|
||||
|---|---|
|
||||
| `app.go` | #10 App 字段、#12 hooks、#13 server、#19/#18/#17/#24 路由中间件、#21 探活、#22 App.Go、#11 删 wire、删双轨、Version |
|
||||
| `config/config.go` | #13 ServerConfig、#14 JWTConfig+删TokenExpire、#16 Validate |
|
||||
| `config/manager.go` | #16 Load 调 Validate、Duration decode hook |
|
||||
| `response/response.go` | #15 Mode 开关 + status 映射 |
|
||||
| `database/redis.go` | #10 redis Manager |
|
||||
| `database/manager.go` | #21 探活 + replica 健康剔除 |
|
||||
| `cache/cache.go` `cache/lock.go` | #10 cache Manager + 解耦 RedisClient |
|
||||
| `jwt/jwt.go` | #10 jwt Manager + #14 Expire |
|
||||
| `storage/storage.go` | #10 storage Manager |
|
||||
| `logger/logger.go` | #10 logger Manager |
|
||||
| `middleware/{requestid,recover}.go` | #24 装入 + #23 核对 |
|
||||
| `middleware/timeout.go`(新) | #19 |
|
||||
| `middleware/metrics.go`(新) | #18 |
|
||||
| `router/router.go` `router/metrics.go`(新) | #17 livez/readyz + #18 /metrics |
|
||||
| `wire/wire.go` | #11 删除 |
|
||||
| `go.mod` | #18 prometheus 依赖 |
|
||||
| `CHANGELOG.md` `README.md` | 发版 |
|
||||
|
||||
## 验证
|
||||
|
||||
1. `go mod tidy && go build ./...` — 编译通过。
|
||||
2. `go test ./...` — 全绿,含新增单测。
|
||||
3. `go run ./example`(full 栈)— 启动无错,`/livez`→200、`/readyz`→200(依赖在时)、`/metrics`→prometheus 文本、`/health` 兼容;配置错误时 `Validate` 在启动期拦截。
|
||||
4. 手动验证 breaking:删 wire 后 `example` 不再 import wire;`AppConfig.TokenExpire` 删除后 grep 无残留;`response.SetMode(ModeREST)` 下 `Unauthorized` 返回 401。
|
||||
5. `App.Go` 起一个长任务 goroutine,发 SIGTERM,确认 `Shutdown` 等其退出(日志可见)。
|
||||
6. 主库探活:手动关 mysql,30s 后 `/readyz`→503、`/health`→503;恢复后自动转 200。
|
||||
7. 发版:commit + annotated tag `v1.1.0` → `git push xlgo-core main && git push xlgo-core v1.1.0`;release 内容写本地 `gitHub_release_v1.1.0.md`(.gitignore 忽略),用户网页创建。
|
||||
@@ -0,0 +1,519 @@
|
||||
# xlgo v1.0.2 体检报告
|
||||
|
||||
> 资深 Go 视角的代码与架构 Review。目标是把 xlgo 从"业务沉淀工具集"打磨成"通用 / 高可用 / 易上手"的开源 Web 框架。
|
||||
>
|
||||
> 整体判断:v1.0.2 已经把"业务耦合 + 不可组合 + 框架内 Fatal"这三个最大的设计债还掉了,框架骨架是健康的。但仍有若干**真实 Bug**和**架构层面的债**,按下方优先级逐项核查即可。
|
||||
|
||||
---
|
||||
|
||||
## 一、必须立刻修的真实 Bug(带行号)
|
||||
|
||||
这些不是设计取舍,是确凿的缺陷,先于一切改进。
|
||||
|
||||
### 1. `response.CodeSuccess` 与 `CodeInvalidParams` 撞码 ⚠️
|
||||
|
||||
```go
|
||||
// response/error.go:13-16
|
||||
CodeSuccess = 1 // 成功
|
||||
CodeFail = 0 // 通用失败
|
||||
CodeInvalidParams = 1 // 参数错误 ← 跟 Success 同值!
|
||||
```
|
||||
|
||||
只要任何业务调用 `response.FailWithError(c, response.ErrInvalidParams)`,前端拿到的 `code` 跟成功响应一模一样。这是**生产级 bug**。
|
||||
|
||||
**建议**:制定明确的码段策略——
|
||||
|
||||
- `0` = success(业内更通用),或者 `200`/`0` 二选一
|
||||
- `1` 留给"通用失败"
|
||||
- 参数错误使用 `40001` 等业务码段
|
||||
- 同时为了避免后续重复,写一个 `init()` 自检:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
seen := map[int]string{}
|
||||
for code, name := range allErrorCodes {
|
||||
if old, ok := seen[code]; ok {
|
||||
panic(fmt.Sprintf("duplicate error code %d: %s vs %s", code, old, name))
|
||||
}
|
||||
seen[code] = name
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. CORS 中 `Allow-Credentials` 永远是 `true` ⚠️
|
||||
|
||||
```go
|
||||
// middleware/cors.go:86-91
|
||||
if corsConfig != nil && corsConfig.AllowCredentials {
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
} else {
|
||||
c.Header("Access-Control-Allow-Credentials", "true") // 默认允许 ← 错
|
||||
}
|
||||
```
|
||||
|
||||
并且当 `Origin: *` 时还会被浏览器拒绝。这是 CORS 经典坑。**修复**:
|
||||
|
||||
```go
|
||||
if corsConfig.AllowCredentials && allowedOrigin != "*" {
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 日志在开发模式下写两份到同一文件链 ⚠️
|
||||
|
||||
```go
|
||||
// logger/logger.go:95-99
|
||||
core := zapcore.NewTee(apiCore, dbCore, consoleCore)
|
||||
Logger = zap.New(core, ...)
|
||||
```
|
||||
|
||||
`Logger` 是全局通用 logger,但 Tee 把 dbCore 也接进去——结果**所有 `logger.Info(...)` 都会同时写到 `api.log` 和 `database.log`**,再加一份控制台。`APILog()`/`DBLog()` 的"分流"形同虚设。
|
||||
|
||||
**修复**:通用 logger 只写 console + 一个 app.log;`APILog()`/`DBLog()` 各自独立 core,互不 Tee。
|
||||
|
||||
### 4. `DBResolver.BeforeQuery` 是死代码 ⚠️
|
||||
|
||||
```go
|
||||
// database/mysql.go:386-408
|
||||
func (r *DBResolver) BeforeQuery(db *gorm.DB) { ... }
|
||||
```
|
||||
|
||||
这个 hook 从未通过 `db.Callback().Query().Before(...)` 注册过。所以**读写分离实际上需要业务侧自己调用 `GetDBFromContext(ctx)`**,但 README/GUIDE 暗示它会自动路由。要么把 hook 真正注册上,要么把这段代码删掉、文档明确"显式 `UseReplica/UseMaster`"。
|
||||
|
||||
我倾向**删掉**——GORM 官方有 `dbresolver` plugin,实现得更完整(权重、policy)。引入它比自造轮子更稳。
|
||||
|
||||
### 5. 重试策略对所有错误一视同仁
|
||||
|
||||
```go
|
||||
// database/mysql.go:213-240
|
||||
maxRetries := 5
|
||||
// 不论是 driver 错、密码错、端口错都重试 5 次,指数退避
|
||||
```
|
||||
|
||||
**密码错误**也会让进程在启动阶段死等 1 分钟才报错,体验很差。建议区分:
|
||||
|
||||
- `*mysql.MySQLError` Code 1045(access denied)/ 1049(unknown db)等 → 直接返回,不重试
|
||||
- `net.OpError`、`io.EOF`、上下文未到等 → 重试
|
||||
|
||||
### 6. `generateJTI` 忽略 `rand.Read` 错误
|
||||
|
||||
```go
|
||||
// jwt/jwt.go:40-44
|
||||
func generateJTI() string {
|
||||
bytes := make([]byte, 16)
|
||||
rand.Read(bytes) // ← 错误丢弃
|
||||
return base64.URLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
```
|
||||
|
||||
`crypto/rand.Read` 在 Linux 早期启动或某些容器中**确实会失败**。失败时 JTI 会是全零,黑名单可能误判。改为返回 `(string, error)` 或在失败时 `panic` 都比静默吞错好。
|
||||
|
||||
### 7. `repository.QueryBuilder.Page` 的 Count 受残留 limit 影响
|
||||
|
||||
```go
|
||||
// repository/repository.go:404-417
|
||||
countDB := qb.db.Session(&gorm.Session{}) // ← 复用了已 Limit/Offset 的 db
|
||||
if err := countDB.WithContext(ctx).Count(&total).Error; err != nil { ... }
|
||||
```
|
||||
|
||||
如果用户先 `.Limit(10)` 再 `.Page(...)`,`countDB` 会带 LIMIT,Count 是错的。需要 `Limit(-1).Offset(-1).Order("")`:
|
||||
|
||||
```go
|
||||
countDB := qb.db.Session(&gorm.Session{}).Limit(-1).Offset(-1).Order("")
|
||||
```
|
||||
|
||||
### 8. `OSSStorage.Upload` 文件名冲突风险
|
||||
|
||||
```go
|
||||
// storage/storage.go:205
|
||||
objectKey := fmt.Sprintf("%s/%d%s", filepath.Join(...), now.UnixNano(), ext)
|
||||
```
|
||||
|
||||
并发上传 / 容器集群同纳秒会产生**完全相同的 key**,OSS 会覆盖。补一个随机后缀或 uuid:
|
||||
|
||||
```go
|
||||
objectKey := fmt.Sprintf("%s/%d-%s%s", dir, now.UnixNano(), randHex(8), ext)
|
||||
```
|
||||
|
||||
### 9. `go.mod` indirect 里的可疑版本
|
||||
|
||||
```
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9
|
||||
```
|
||||
|
||||
这是 2026-04-01 的 pseudo-version,搭配 `golang.org/x/crypto v0.49.0`、`golang.org/x/net v0.52.0` 看起来正常,但需要确认这是 OTel 1.43 强制带来的传递依赖,还是历史 `go.sum` 没整理。建议跑一次 `go mod tidy && go mod verify` 之后人工 review。
|
||||
|
||||
---
|
||||
|
||||
## 二、架构层面的"债"(影响通用性与多实例化)
|
||||
|
||||
v1.0.2 把 config 和 database 改成了 Manager + 全局 facade 的双轨,但还有几个核心组件**没跟上这套抽象**:
|
||||
|
||||
### 10. Storage / Cache / Redis / JWT / Logger 仍是单例
|
||||
|
||||
| 组件 | 全局变量 | 多实例可能性 |
|
||||
|---|---|---|
|
||||
| `storage.storage` | 包级 var | 不能同时连 OSS + 本地 |
|
||||
| `cache.globalCache` | 包级 var | 不能为不同业务设不同 prefix/TTL 默认值 |
|
||||
| `database.RedisClient` | 包级 var | 不能多 Redis(缓存 + 队列 + 限流分库) |
|
||||
| `jwt.tokenBlacklist` | 包级 var | 不能区分 user-token 和 refresh-token blacklist |
|
||||
| `logger.Logger` | 包级 var | 不能区分多 app/多模块独立日志 |
|
||||
|
||||
**建议**:照 `database.Manager` + `database.DefaultManager` 的模式,每个组件提供 `XxxManager` 类型 + 全局便捷 facade,App 持有自己的 Manager 实例。这样:
|
||||
|
||||
- 单元测试可以注入 mock
|
||||
- 多 App 共存(比如同进程跑 admin + api 两个 Engine)
|
||||
- 微服务里组件解耦
|
||||
|
||||
优先级:**Redis Manager 最重要**(因为 JWT、Cache、RateLimiter、分布式锁都依赖它,目前全是访问 `database.RedisClient`,没法替换)。
|
||||
|
||||
### 11. `wire` 包名误导
|
||||
|
||||
```go
|
||||
// wire/wire.go - 整个文件 32 行
|
||||
func InitServices() { ... }
|
||||
```
|
||||
|
||||
它叫 `wire`,但跟 Google Wire 没关系,也不是 DI 容器。新用户会困惑。建议二选一:
|
||||
|
||||
- **删掉**——其实现的事 App 通过 Option 已经做了
|
||||
- **真正引入 Wire 或 fx/uber**——给一个最小 DI 范式
|
||||
|
||||
### 12. `App.Init()` / `App.Run()` 缺少 Lifecycle Hooks
|
||||
|
||||
现在的 App 内部是硬编码顺序:config → logger → mysql → redis → storage → wire → migrate → routes。如果用户想插入"Migrate 之前先初始化分布式锁,避免多副本同时迁移"或者"启动后注册到服务发现",没有钩子。
|
||||
|
||||
**建议**(v1.1.0 路线):
|
||||
|
||||
```go
|
||||
type Hook struct {
|
||||
Name string
|
||||
OnInit func(*App) error // Init 流程内
|
||||
OnStart func(*App) error // 监听端口前
|
||||
OnReady func(*App) // 端口就绪后
|
||||
OnStop func(*App) error // Shutdown 前
|
||||
}
|
||||
|
||||
func WithHook(h Hook) Option
|
||||
```
|
||||
|
||||
并提供两个内置示例:`hooks.RegisterEtcd(...)`、`hooks.RegisterDistributedMigrate(...)`。
|
||||
|
||||
### 13. Server 参数全部硬编码
|
||||
|
||||
```go
|
||||
// app.go:400-406
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
```
|
||||
|
||||
加上 `Shutdown` 30s 超时。这些都该进 `ServerConfig`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8080
|
||||
read_timeout: 15s
|
||||
write_timeout: 30s
|
||||
idle_timeout: 60s
|
||||
shutdown_timeout: 30s
|
||||
max_header_bytes: 1048576
|
||||
tls:
|
||||
enabled: false
|
||||
cert_file: ""
|
||||
key_file: ""
|
||||
unix_socket: "" # 优先级高于 port
|
||||
```
|
||||
|
||||
### 14. `JWTConfig.Expire` 与 `AppConfig.TokenExpire` 重复且单位不明
|
||||
|
||||
两个字段都是过期秒数,都没有 `time.Duration` 类型。Go 项目应优先用 `time.Duration` + viper 的 `string` 解析(`"24h"`、`"30m"`),**单位看就懂**:
|
||||
|
||||
```go
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
Expire time.Duration `mapstructure:"expire"` // "24h"
|
||||
RefreshExpire time.Duration `mapstructure:"refresh_expire"` // "168h"
|
||||
Issuer string `mapstructure:"issuer"`
|
||||
Algorithm string `mapstructure:"algorithm"` // HS256/RS256
|
||||
}
|
||||
```
|
||||
|
||||
`AppConfig.TokenExpire` 直接删掉。
|
||||
|
||||
### 15. `response` 把 4xx/5xx 全压成 HTTP 200
|
||||
|
||||
```go
|
||||
// response/response.go:32-39
|
||||
func Success(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, ...) // ← 永远 200
|
||||
}
|
||||
// Unauthorized / Fail / NotFound / ServerError 全部 200
|
||||
```
|
||||
|
||||
这是国内典型"业务码 in body"的玩法,**对接 APM、Prometheus、APISIX/网关、Sentry 都很难受**——它们靠 HTTP status 区分异常。建议:
|
||||
|
||||
1. 默认仍保留业务码模式(兼容存量),但允许通过全局开关切到"REST 模式":
|
||||
|
||||
```go
|
||||
response.SetMode(response.ModeREST) // 或在 ServerConfig 中
|
||||
// 401 错误 → 返回 HTTP 401, body 带业务 code
|
||||
```
|
||||
|
||||
2. 或者更优雅:`Fail` 带一个明示的 HTTP status:
|
||||
|
||||
```go
|
||||
response.Fail(c, response.ErrUnauthorized) // 自动 401
|
||||
response.Custom(c, http.StatusBadRequest, ErrInvalidParams, nil)
|
||||
```
|
||||
|
||||
### 16. 配置缺少 Validate
|
||||
|
||||
`config.Manager.Load` 解析完直接返回,对必填字段 / 取值范围都没校验。建议加 `Validate() error`:
|
||||
|
||||
```go
|
||||
func (c *Config) Validate() error {
|
||||
if c.Server.Port <= 0 || c.Server.Port > 65535 { ... }
|
||||
if c.JWT.Secret != "" && len(c.JWT.Secret) < 32 { ... }
|
||||
// 启用 mysql 时强制要求关键字段
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
并在 `Manager.Load` 内自动调用——配置错把启动时间从"运行时第一次请求"提前到"进程启动",是高可用的小细节。
|
||||
|
||||
---
|
||||
|
||||
## 三、高可用 / 生产就绪的缺口
|
||||
|
||||
### 17. 没有 Liveness / Readiness 区分
|
||||
|
||||
v1.0.2 的 `/health` 只有一个,对 K8s 不友好。K8s probe 期望:
|
||||
|
||||
- `/livez`:进程是否活着(**永远不依赖外部**,只检查 goroutine、内存)
|
||||
- `/readyz`:是否可以接流量(依赖 mysql/redis 通透)
|
||||
|
||||
建议在保持 `/health` 兼容的同时加:
|
||||
|
||||
```go
|
||||
xlgo.WithLivenessRoute() // GET /livez
|
||||
xlgo.WithReadinessRoute() // GET /readyz, 复用 healthChecks
|
||||
```
|
||||
|
||||
### 18. 没有 Prometheus / Metrics 中间件
|
||||
|
||||
通用 Web 框架不带 metrics endpoint 是硬伤。建议:
|
||||
|
||||
```go
|
||||
import "github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
func WithMetricsRoute(path ...string) Option // 默认 /metrics
|
||||
```
|
||||
|
||||
并提供一个 `middleware.Metrics()`——HTTP latency、status code、in-flight 这些标配指标。
|
||||
|
||||
### 19. 没有请求级超时中间件
|
||||
|
||||
`http.Server.ReadTimeout` 是连接级。**业务级**超时需要 `middleware.Timeout(5*time.Second)`:
|
||||
|
||||
```go
|
||||
func Timeout(d time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
|
||||
defer cancel()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
下游 GORM/Redis 调用走 `c.Request.Context()` 才能真正级联取消。
|
||||
|
||||
### 20. RateLimiter 内存版无法集群共享
|
||||
|
||||
`middleware/ratelimit.go` 的 `RateLimiter` 是单进程内存版,多副本部署时限流器各管各的。Redis 版本(`RedisRateLimiter`)应该一并提供,并且:
|
||||
|
||||
- 用 lua 脚本实现 token bucket 或滑动窗口(避免多次 round-trip)
|
||||
- 默认每个限流器有 `Name`,方便 Prometheus 上报"被限流次数"
|
||||
|
||||
### 21. 没有依赖健康自愈
|
||||
|
||||
主库宕机后 `database.Manager.master` 会一直握着断连。建议:
|
||||
|
||||
- `Pool.SetConnMaxIdleTime` 配置化
|
||||
- 探活定时任务:每 30s ping 一次,连续 N 次失败标记"unhealthy",readiness 立即返回 503
|
||||
- Replica 健康剔除:`ReplicaPicker` 支持权重 + 健康度(v1.1.0 路线)
|
||||
|
||||
### 22. Graceful shutdown 没等业务 in-flight goroutine
|
||||
|
||||
现在 `Shutdown` 只关 HTTP server。如果业务在 handler 里 spawn 了后台 goroutine(异步发短信、写日志),它们会被进程退出强制砍掉。建议:
|
||||
|
||||
- App 暴露 `App.Go(fn func(ctx context.Context))`,内部维护 `sync.WaitGroup`
|
||||
- Shutdown 时 `wg.Wait()` 带超时
|
||||
|
||||
### 23. `gin.Recovery` + `middleware.Recover` 双重保险但没 trace_id
|
||||
|
||||
panic 时只记录 stack,没有 request_id / trace_id 关联。建议 Recover 中间件改为:
|
||||
|
||||
```go
|
||||
func Recover() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
rid := c.GetString("request_id")
|
||||
logger.Error("panic recovered",
|
||||
zap.String("request_id", rid),
|
||||
zap.Any("error", r),
|
||||
zap.ByteString("stack", debug.Stack()))
|
||||
response.ServerError(c, "服务器内部错误")
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 24. RequestID 中间件没默认装入
|
||||
|
||||
`response.go` 依赖 `c.GetString("request_id")` 但默认中间件链里没有这一环。建议 `app.Init` 时无条件 `Use(middleware.RequestID())`,让每个响应都带 `request_id`,trace 才有意义。
|
||||
|
||||
---
|
||||
|
||||
## 四、易上手 / 开发体验
|
||||
|
||||
### 25. 模块路径与 import alias 不一致
|
||||
|
||||
`go.mod` 是 `github.com/EthanCodeCraft/xlgo-core`,CLAUDE.md 又提"本地导入用 xlgo"——新用户第一次 `go mod tidy` 多半会撞墙。建议:
|
||||
|
||||
- 模块路径直接定为 `github.com/EthanCodeCraft/xlgo`(去掉 `-core`),**包名仍然 `xlgo`**
|
||||
- README 第一段就给完整 import 语句,不要让用户猜
|
||||
|
||||
### 26. 代码里大量 "评分: ⭐⭐⭐⭐⭐" 注释
|
||||
|
||||
`response.go`、`storage.go`、`repository.go`、`config.go`、`cache.go`、`middleware/cors.go` ……到处都是:
|
||||
|
||||
```go
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 文件下载封装,自动设置响应头
|
||||
```
|
||||
|
||||
这些是 AI 生成留下的"自夸",**对外发布的库代码里出现这个会显得不专业**。建议批量删掉。如果想留设计理由,改成 `// Why: ...` 风格的简洁注释。
|
||||
|
||||
### 27. 大量 `Without*` Option 实际上不需要
|
||||
|
||||
```go
|
||||
WithLogger / WithoutLogger
|
||||
WithMySQL / WithoutMySQL
|
||||
WithRedis / WithoutRedis
|
||||
...
|
||||
```
|
||||
|
||||
每对都是 v1.0.2 在"全开 vs 全关"摇摆产生的副产品。既然已经定调"`xlgo.New()` 是轻量",那 `WithoutLogger` 的用途就只剩"用了 `NewFullStack` 又想关掉一个"。**建议**:
|
||||
|
||||
- `Without*` 全部标 `Deprecated`
|
||||
- 文档统一推荐组合:
|
||||
- `xlgo.New(...)` + 显式 `With*`
|
||||
- `xlgo.NewFullStack(...)` 全开
|
||||
- 真要关单项,让 FullStack 接受函数式排除:`xlgo.NewFullStack(xlgo.Disable("redis"))`
|
||||
|
||||
### 28. CLI 模板太单一
|
||||
|
||||
`xlgo new` 只生成一种模板。`Version_Update_Plan_v1.0.2.md` 第 884 行已经规划了:
|
||||
|
||||
```
|
||||
xlgo new myproject --template minimal
|
||||
xlgo new myproject --template api
|
||||
xlgo new myproject --template fullstack
|
||||
xlgo new myproject --template grpc # 建议补
|
||||
xlgo new myproject --template microservice
|
||||
```
|
||||
|
||||
是时候做了。最小模板对降低"上手第一公里"的心理负担非常关键。
|
||||
|
||||
### 29. 缺一个 examples/ 目录
|
||||
|
||||
我看到 README 里有 `make run` → `go run ./example`,但仓库里**没有 example 目录**。新用户 clone 之后跑不起来。建议至少补两个:
|
||||
|
||||
- `examples/minimal/` —— 50 行能跑
|
||||
- `examples/full/` —— mysql + redis + jwt + 一个 user CRUD
|
||||
|
||||
### 30. CHANGELOG 与 Version_Update_Plan 应分离
|
||||
|
||||
`Version_Update_Plan_v1.0.2.md` 是规划文档,不应放仓库根。建议:
|
||||
|
||||
```
|
||||
docs/
|
||||
├── CHANGELOG.md # 追加格式,每个版本 Added/Changed/Fixed
|
||||
├── plans/
|
||||
│ └── v1.0.2.md # 历史规划归档
|
||||
├── architecture.md
|
||||
└── migration/v1.0.1-to-v1.0.2.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、值得期待的 v1.1+ 路线
|
||||
|
||||
按优先级排成一个推荐的迭代节奏:
|
||||
|
||||
### v1.0.3(Bug Fix Release,1~2 周)
|
||||
|
||||
**修真实 bug,不破坏 API**
|
||||
|
||||
- ✅ #1 错误码冲突 + 自检
|
||||
- ✅ #2 CORS Allow-Credentials
|
||||
- ✅ #3 Logger Tee bug
|
||||
- ✅ #4 删掉死代码 DBResolver / 引入 gorm dbresolver
|
||||
- ✅ #6 `generateJTI` 错误处理
|
||||
- ✅ #7 QueryBuilder.Page Count
|
||||
- ✅ #8 OSS 文件名冲突
|
||||
- ✅ #9 go.mod tidy 复查
|
||||
- ✅ #26 删除"评分"注释
|
||||
|
||||
### v1.0.4(DX & Docs)
|
||||
|
||||
- ✅ #25 模块路径修正
|
||||
- ✅ #28 CLI 多模板
|
||||
- ✅ #29 examples/
|
||||
- ✅ #30 文档结构调整
|
||||
|
||||
### v1.1.0(HA & Manager 化)
|
||||
|
||||
- #10 Storage/Cache/Redis/JWT 全部 Manager 化
|
||||
- #12 Lifecycle Hooks
|
||||
- #13 Server 参数全配置化
|
||||
- #14 `time.Duration` 配置
|
||||
- #16 Config Validate
|
||||
- #17 livez / readyz
|
||||
- #18 metrics 中间件
|
||||
- #19 请求级 Timeout
|
||||
- #20 Redis 限流器
|
||||
- #21 主库探活 + replica 健康剔除
|
||||
- #22 等业务 goroutine
|
||||
- #23 Recover 带 request_id
|
||||
- #24 RequestID 默认装入
|
||||
|
||||
### v1.2.0(生态)
|
||||
|
||||
- 内置 OpenAPI 3.x(替代 swaggo,后者已半弃维)
|
||||
- gRPC Gateway 模板
|
||||
- 多租户模板(参考 #1 错误码 namespace)
|
||||
- RBAC 扩展包
|
||||
- DDD / Clean Architecture 项目模板
|
||||
|
||||
---
|
||||
|
||||
## 六、整体判断
|
||||
|
||||
xlgo 的 v1.0.2 已经迈过了"内部工具集"到"框架"的这道坎,特别是**dialect 注册表**、**config.Manager + SetDefaultManager**、**database.Manager** 这三个设计是真正的框架式抽象,证明设计上是在往正确方向走。
|
||||
|
||||
但要打到"通用 / 高可用 / 易上手",**最值得马上做的两件事**是:
|
||||
|
||||
1. **先把第一节那 9 个 Bug 修掉**——它们里任意一个被开源用户踩到,都会发 issue 质疑框架质量。
|
||||
2. **把 Storage/Cache/Redis/JWT 也 Manager 化**——这是把 v1.0.2 的好抽象"贯彻到底"。否则现在是**一半组件可注入,一半是单例**的撕裂状态,对中大型项目和单元测试都不友好。
|
||||
|
||||
建议从 v1.0.3 的 Bug Fix 开始动手,优先级:
|
||||
|
||||
> #1 (错误码冲突) → #2 (CORS) → #3 (Logger Tee) → #4 (DBResolver 死代码)
|
||||
|
||||
这几个改完都不破坏 API,可以一个 PR 一个 commit 走 review。
|
||||
@@ -0,0 +1,485 @@
|
||||
# xlgo Web 框架评估报告(v2.0 优化后版本)
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
xlgo 是一个基于 Go + Gin 构建的轻量级 Web 开发框架,旨在提供后端开发的基础设施。本报告基于 v2.0 版本(经过全面优化和 zl 工具库移植后)进行评估。
|
||||
|
||||
---
|
||||
|
||||
## 二、本轮优化内容汇总
|
||||
|
||||
### 2.1 新增核心包
|
||||
|
||||
| 包名 | 来源 | 函数数 | 核心功能 |
|
||||
|------|------|--------|----------|
|
||||
| `utils/` | zl/utils | 111 | 随机数、字符串、时间、转换、文件、URL、验证、加密、HTTP客户端、UUID |
|
||||
| `console/` | zl/utils/print_*.go | 22 | 彩色控制台输出(Debug/Info/Success/Warn/Error) |
|
||||
| `compress/` | zl/service/compress | 7 | Gzip/Zip 压缩解压 |
|
||||
| `cache/keybuilder.go` | zl/service/cache | 新增 | 键名前缀管理,多站点共用 Redis |
|
||||
| `cache/lock.go` | zl/service/cache | 新增 | 分布式锁、计数器、TTL管理 |
|
||||
| `middleware/requestid.go` | zl/middleware | 2 | 请求ID追踪 |
|
||||
| `middleware/recover.go` | zl/middleware | 2 | Panic恢复中间件 |
|
||||
| `handler/handler.go` | zl/response | 增强 | 类型安全参数获取(QueryInt/PathInt/FormInt等) |
|
||||
| `response/response.go` | zl/response | 增强 | RequestID字段、文件下载、HTML响应、跳转 |
|
||||
| `config/config.go` | 新增 | AppConfig | 应用配置(SiteName/Env/Version等) |
|
||||
|
||||
### 2.2 配置增强
|
||||
|
||||
新增 `AppConfig` 配置块,支持站点别名:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
name: "用户管理系统"
|
||||
site_name: "user_api" # 站点别名,用于缓存键前缀、日志标识
|
||||
version: "1.0.0"
|
||||
env: "prod"
|
||||
debug: false
|
||||
base_url: "https://user.example.com"
|
||||
token_expire: 86400
|
||||
```
|
||||
|
||||
### 2.3 CLI 工具重构
|
||||
|
||||
将原来单个 `main.go`(约700行)拆分为模块化结构:
|
||||
|
||||
```
|
||||
cmd/xlgo/
|
||||
├── main.go # 入口和命令路由(~50行)
|
||||
├── types.go # 类型定义
|
||||
├── utils.go # 工具函数
|
||||
├── commands.go # 命令实现
|
||||
└── templates.go # 所有模板定义
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、项目规模对比
|
||||
|
||||
| 指标 | v1.1.0 | v2.0 | 提升 |
|
||||
|------|--------|------|------|
|
||||
| Go 源文件数 | 28 | 54 | +93% |
|
||||
| 代码行数 | ~2,800 | ~8,400 | +200% |
|
||||
| 包数量 | 16 | 23 | +44% |
|
||||
| 函数数量 | ~150 | ~450 | +200% |
|
||||
| 测试覆盖 | 0% | 8% (2/24包) | +8% | utils/console已有测试 |
|
||||
|
||||
---
|
||||
|
||||
## 四、模块完整性评估(v2.0)
|
||||
|
||||
### 4.1 核心模块评分对比
|
||||
|
||||
| 模块 | v1.1.0 | v2.0 | 提升 | 说明 |
|
||||
|------|--------|------|------|------|
|
||||
| 配置管理 | 8/10 | 9/10 | +1 | 新增 AppConfig、SiteName |
|
||||
| 数据库 | 8/10 | 8/10 | - | 保持稳定 |
|
||||
| Redis/缓存 | 9/10 | 10/10 | +1 | 分布式锁、键名前缀、计数器 |
|
||||
| JWT 认证 | 8/10 | 8/10 | - | 保持稳定 |
|
||||
| 日志系统 | 9/10 | 9/10 | - | 保持稳定 |
|
||||
| 中间件 | 8/10 | 9/10 | +1 | RequestID、Recover、彩色输出 |
|
||||
| 文件存储 | 9/10 | 9/10 | - | 保持稳定 |
|
||||
| 工具函数 | 3/10 | 9/10 | +6 | 111个实用函数 |
|
||||
| 验证器 | 9/10 | 9/10 | - | 保持稳定 |
|
||||
| 错误处理 | 8/10 | 8/10 | - | 保持稳定 |
|
||||
| 密码加密 | 9/10 | 9/10 | - | 保持稳定 |
|
||||
| SSE 支持 | 8/10 | 8/10 | - | 保持稳定 |
|
||||
| WebSocket | 8/10 | 8/10 | - | 保持稳定 |
|
||||
| 定时任务 | 7/10 | 7/10 | - | 保持稳定 |
|
||||
| 测试支持 | 8/10 | 8/10 | - | 保持稳定 |
|
||||
| CLI 工具 | 7/10 | 8/10 | +1 | 模块化重构、模板分离 |
|
||||
| 压缩解压 | 0/10 | 8/10 | +8 | Gzip/Zip完整实现 |
|
||||
| 控制台输出 | 0/10 | 9/10 | +9 | 跨平台彩色输出 |
|
||||
|
||||
### 4.2 新增 utils 包功能清单
|
||||
|
||||
#### 4.2.1 随机数生成 (random.go)
|
||||
```go
|
||||
RandString(16) // 随机字符串(字母+数字)
|
||||
RandDigit(6) // 随机数字字符串
|
||||
RandInt(1, 100) // 随机整数
|
||||
RandInt64(0, 1000) // 随机int64
|
||||
```
|
||||
|
||||
#### 4.2.2 字符串处理 (strings.go)
|
||||
```go
|
||||
IsBlank(s) // 检查空白
|
||||
IsAnyBlank(strs...) // 批量检查
|
||||
DefaultIfBlank(s, def) // 默认值
|
||||
Substr(s, 0, 10) // 子字符串(支持中文)
|
||||
StrLen(s) // Unicode长度
|
||||
EqualsIgnoreCase(a, b) // 不区分大小写
|
||||
Trim(s) // 去除空白
|
||||
```
|
||||
|
||||
#### 4.2.3 时间日期 (datetime.go)
|
||||
```go
|
||||
NowUnix() // 秒时间戳
|
||||
NowTimestamp() // 毫秒时间戳
|
||||
FromUnix(unix) // 时间戳转时间
|
||||
FormatDateTime(t) // 格式化 "2006-01-02 15:04:05"
|
||||
StartOfDay(t) // 当天开始
|
||||
EndOfDay(t) // 当天结束
|
||||
StartOfMonth(t) // 当月开始
|
||||
EndOfMonth(t) // 当月结束
|
||||
```
|
||||
|
||||
#### 4.2.4 类型转换 (convert.go)
|
||||
```go
|
||||
ToInt(s) // 字符串转int
|
||||
ToIntDefault(s, 0) // 带默认值
|
||||
ToInt64(s) // 转int64
|
||||
ToFloat64(s) // float64
|
||||
CalcPageCount(100, 10) // 计算总页数
|
||||
CalcOffset(2, 20) // 分页偏移
|
||||
```
|
||||
|
||||
#### 4.2.5 文件操作 (file.go)
|
||||
```go
|
||||
FileExists(path) // 检查文件存在
|
||||
DirExists(path) // 检查目录存在
|
||||
EnsureDir(path) // 确保目录存在
|
||||
ReadFile(path) // 读取文件
|
||||
WriteFile(path, data) // 写入文件
|
||||
CopyFile(dst, src) // 复制文件
|
||||
```
|
||||
|
||||
#### 4.2.6 URL处理 (url.go)
|
||||
```go
|
||||
ParseURL(rawURL) // 解析URL
|
||||
builder.AddQuery("key", "value") // 链式添加参数
|
||||
URLEncode(s) // URL编码
|
||||
URLDecode(s) // URL解码
|
||||
```
|
||||
|
||||
#### 4.2.7 格式验证 (validator.go)
|
||||
```go
|
||||
IsPhone(phone) // 手机号验证
|
||||
IsEmail(email) // 邮箱验证
|
||||
IsIPv4(ip) // IPv4验证
|
||||
IsIDCard(id) // 身份证验证
|
||||
IsChinese(s) // 中文验证
|
||||
IsNumeric(s) // 数字验证
|
||||
IsAlphanumeric(s) // 字母数字验证
|
||||
```
|
||||
|
||||
#### 4.2.8 加密编码 (crypto.go)
|
||||
```go
|
||||
MD5(s) // MD5哈希
|
||||
SHA256(s) // SHA256哈希
|
||||
Base64Encode(data) // Base64编码
|
||||
Base64URLEncode(data) // URL安全编码
|
||||
```
|
||||
|
||||
#### 4.2.9 HTTP客户端 (http.go)
|
||||
```go
|
||||
client := NewHTTPClient()
|
||||
client.SetTimeout(30*time.Second)
|
||||
client.SetHeader("Authorization", "Bearer xxx")
|
||||
client.Get(url, params) // GET请求
|
||||
client.PostJSON(url, data) // POST JSON
|
||||
client.Upload(url, files, params) // 文件上传
|
||||
```
|
||||
|
||||
#### 4.2.10 UUID生成 (uuid.go)
|
||||
```go
|
||||
UUID() // UUID v4字符串
|
||||
UUIDShort() // 短UUID(无横线)
|
||||
UUIDValid(s) // 验证UUID
|
||||
```
|
||||
|
||||
### 4.3 新增 console 包功能
|
||||
|
||||
```go
|
||||
console.Debug("调试信息") // 青色
|
||||
console.Info("普通信息") // 白色
|
||||
console.Success("成功信息") // 绿色
|
||||
console.Warn("警告信息") // 黄色
|
||||
console.Error("错误信息") // 红色
|
||||
|
||||
// 自定义配置
|
||||
c := console.New(
|
||||
console.WithColor(true),
|
||||
console.WithTime(true),
|
||||
console.WithCaller(true, 2),
|
||||
)
|
||||
c.Debug("自定义控制台输出")
|
||||
```
|
||||
|
||||
### 4.4 新增 compress 包功能
|
||||
|
||||
```go
|
||||
// Gzip 数据压缩
|
||||
GzipCompress(data)
|
||||
GzipDecompress(data)
|
||||
|
||||
// Gzip 文件压缩
|
||||
GzipCompressFile(src, dst)
|
||||
GzipDecompressFile(src, dst)
|
||||
|
||||
// Zip 打包
|
||||
Zip(zipPath, paths) // 打包文件/目录
|
||||
Unzip(zipPath, dstDir) // 解压到目录
|
||||
```
|
||||
|
||||
### 4.5 缓存键名前缀管理
|
||||
|
||||
```go
|
||||
// 自动从配置读取 site_name
|
||||
cache.K("user:1") // -> "cache:user_api:user:1"
|
||||
cache.KTemp("token") // -> "temp:user_api:token"
|
||||
cache.KPerm("config") // -> "perm:user_api:config"
|
||||
cache.KLock("order:123") // -> "lock:user_api:order:123"
|
||||
cache.KCounter("visit") // -> "counter:user_api:visit"
|
||||
cache.KSession("sid") // -> "session:user_api:sid"
|
||||
|
||||
// 分布式锁
|
||||
cache.Lock(ctx, key, ttl)
|
||||
cache.Unlock(ctx, key)
|
||||
cache.TryLock(ctx, key, ttl, retry, maxRetry)
|
||||
cache.WithLock(ctx, key, ttl, func(context.Context) error) // 自动管理锁
|
||||
|
||||
// 计数器
|
||||
cache.Incr(ctx, key)
|
||||
cache.Decr(ctx, key)
|
||||
```
|
||||
|
||||
### 4.6 中间件增强
|
||||
|
||||
```go
|
||||
// RequestID - 请求追踪
|
||||
r.Use(middleware.RequestID())
|
||||
// 响应头自动添加 X-Request-ID
|
||||
|
||||
// Recover - Panic恢复
|
||||
r.Use(middleware.Recover()) // 生产环境
|
||||
r.Use(middleware.RecoverWithDetail()) // 开发环境(返回详细信息)
|
||||
```
|
||||
|
||||
### 4.7 Handler 参数获取增强
|
||||
|
||||
```go
|
||||
// 类型安全的参数获取
|
||||
page := handler.QueryInt(c, "page", 1)
|
||||
id := handler.PathInt64(c, "id", 0)
|
||||
price := handler.QueryFloat64(c, "price", 0.0)
|
||||
enabled := handler.QueryBool(c, "enabled", false)
|
||||
count := handler.FormInt(c, "count", 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、多站点共用 Redis 方案
|
||||
|
||||
### 5.1 问题背景
|
||||
|
||||
多个小项目共用一台 Redis 服务器时,缓存键名可能冲突:
|
||||
- 项目A: `user:1`
|
||||
- 项目B: `user:1`
|
||||
- 项目C: `user:1`
|
||||
|
||||
### 5.2 解决方案
|
||||
|
||||
通过 `site_name` 配置自动添加前缀:
|
||||
|
||||
| 项目 | config.yaml | 实际键名 |
|
||||
|------|-------------|----------|
|
||||
| 用户API | `site_name: user_api` | `cache:user_api:user:1` |
|
||||
| 订单API | `site_name: order_api` | `cache:order_api:user:1` |
|
||||
| 支付API | `site_name: pay_api` | `cache:pay_api:user:1` |
|
||||
|
||||
### 5.3 使用方式
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
app:
|
||||
site_name: "my_project"
|
||||
```
|
||||
|
||||
```go
|
||||
// 无需额外代码,自动生效
|
||||
cache.K("user:1") // -> "cache:my_project:user:1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、性能评估(v2.0)
|
||||
|
||||
### 6.1 性能优化点
|
||||
|
||||
| 优化项 | 说明 |
|
||||
|--------|------|
|
||||
| RandString/RandDigit | 使用 sync.Pool 复用 rand.Source |
|
||||
| StringToBytes/BytesToString | 零拷贝转换 |
|
||||
| HTTPClient | 链式配置,连接复用 |
|
||||
| 缓存键名 | 预构建,避免重复拼接 |
|
||||
|
||||
### 6.2 性能基准估算
|
||||
|
||||
| 场景 | 预估 QPS | 说明 |
|
||||
|------|----------|------|
|
||||
| 简单查询 API | 12,000-18,000 | 无 DB 查询 |
|
||||
| 带 DB 查询 | 3,000-5,000 | 单表主键查询 |
|
||||
| 缓存查询 | 10,000-15,000 | Redis 缓存命中 |
|
||||
| SSE 流式响应 | 5,000-8,000 | 单连接 |
|
||||
| WebSocket 连接 | 10,000+ | 连接数 |
|
||||
| 文件压缩 | 50-100 MB/s | Gzip |
|
||||
| HTTP请求 | 1,000-5,000 | 外部API调用 |
|
||||
|
||||
---
|
||||
|
||||
## 七、扩展性评估(v2.0)
|
||||
|
||||
| 方面 | v1.1.0 | v2.0 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 存储扩展 | 本地 + OSS | 本地 + OSS | 保持 |
|
||||
| 缓存扩展 | 基础操作 | 分布式锁、计数器、前缀 | +3功能 |
|
||||
| 验证扩展 | 自定义规则 | 手机/邮箱/IP等预定义 | +7规则 |
|
||||
| 通信扩展 | HTTP + SSE + WS | 保持 | - |
|
||||
| 工具扩展 | 无 | 111个函数 | +111 |
|
||||
| 压缩扩展 | 无 | Gzip + Zip | +2 |
|
||||
| 控制台扩展 | 无 | 彩色输出 | +1 |
|
||||
|
||||
---
|
||||
|
||||
## 八、易用性评估(v2.0)
|
||||
|
||||
### 8.1 开发效率提升
|
||||
|
||||
| 任务 | v1.1.0 | v2.0 | 节省 |
|
||||
|------|--------|------|------|
|
||||
| 创建新项目 | 5 分钟 | 5 分钟 | - |
|
||||
| 添加 CRUD 模块 | 10 分钟 | 10 分钟 | - |
|
||||
| 请求验证 | 5 分钟 | 5 分钟 | - |
|
||||
| 参数类型转换 | 手动编写 | 调用 handler.QueryInt | 90% |
|
||||
| 文件压缩 | 手动实现 | 调用 compress.Zip | 100% |
|
||||
| 随机字符串 | 手动实现 | 调用 utils.RandString | 100% |
|
||||
| 时间格式化 | 记忆布局 | 调用 utils.FormatDateTime | 80% |
|
||||
| 分布式锁 | 手动实现 | 调用 cache.Lock | 100% |
|
||||
| HTTP请求 | 手动封装 | 调用 NewHTTPClient().Get | 100% |
|
||||
| 控制台调试 | fmt.Println | console.Debug(彩色) | 50% |
|
||||
|
||||
### 8.2 代码简化示例
|
||||
|
||||
```go
|
||||
// v1.1.0 - 手动实现
|
||||
func GetUser(c *gin.Context) {
|
||||
pageStr := c.Query("page")
|
||||
page := 1
|
||||
if pageStr != "" {
|
||||
p, err := strconv.Atoi(pageStr)
|
||||
if err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
// v2.0 - 一行搞定
|
||||
func GetUser(c *gin.Context) {
|
||||
page := handler.QueryInt(c, "page", 1)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、安全性评估(v2.0)
|
||||
|
||||
| 检查项 | v1.1.0 | v2.0 | 说明 |
|
||||
|--------|--------|------|------|
|
||||
| SQL 注入 | ✅ GORM | ✅ GORM | 保持 |
|
||||
| XSS | ⚠️ 应用层 | ⚠️ 应用层 | 保持 |
|
||||
| CSRF | ❌ 无 | ✅ 完善 | +1 | 多种模式:Cookie/API/DoubleSubmit |
|
||||
| 密码加密 | ✅ bcrypt | ✅ bcrypt | 保持 |
|
||||
| 请求验证 | ✅ validator | ✅ validator | 保持 |
|
||||
| 限流防护 | ✅ 完善 | ✅ 完善 | 保持 |
|
||||
| JWT 安全 | ✅ 黑名单 | ✅ 黑名单 | 保持 |
|
||||
| Panic恢复 | ❌ 无 | ✅ Recover中间件 | +1 |
|
||||
| 请求追踪 | ❌ 无 | ✅ RequestID | +1 |
|
||||
| 类型安全 | ⚠️ 手动 | ✅ 强类型函数 | +1 |
|
||||
|
||||
---
|
||||
|
||||
## 十、综合评分(v2.0)
|
||||
|
||||
| 维度 | v1.1.0 | v2.0 | 提升 |
|
||||
|------|--------|------|------|
|
||||
| 完整性 | 8.5/10 | 9.5/10 | +1.0 |
|
||||
| 性能 | 8.5/10 | 9.0/10 | +0.5 |
|
||||
| 扩展性 | 7.5/10 | 9.0/10 | +1.5 |
|
||||
| 易用性 | 9.0/10 | 9.5/10 | +0.5 |
|
||||
| 安全性 | 8.0/10 | 9.0/10 | +1.0 |
|
||||
| **总分** | **8.35/10** | **9.2/10** | **+0.85** |
|
||||
|
||||
---
|
||||
|
||||
## 十一、剩余改进建议
|
||||
|
||||
### 11.1 P3 - 下一步优化
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| Kafka 生产者 | 低 | 需重新设计连接池 |
|
||||
| WebSocket 客户端 | 低 | 框架已有服务端实现 |
|
||||
| 链路追踪 | 中 | OpenTelemetry集成 |
|
||||
| 单元测试 | 高 | 进行中,覆盖所有包 |
|
||||
| 性能基准 | 中 | 建立benchmark |
|
||||
|
||||
### 11.2 测试建议
|
||||
|
||||
```bash
|
||||
# 运行现有测试
|
||||
go test ./utils/... ./console/... -v
|
||||
|
||||
# 建议添加
|
||||
go test ./cache/... ./handler/... ./middleware/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、结论
|
||||
|
||||
经过本轮全面优化,xlgo 框架已从 **生产就绪** 提升至 **功能丰富** 水平。
|
||||
|
||||
### 12.1 核心优势
|
||||
|
||||
1. **工具库完善** - 111个实用函数,覆盖日常开发80%场景
|
||||
2. **多站点支持** - SiteName配置解决共用Redis冲突
|
||||
3. **类型安全** - QueryInt/PathInt等避免手动转换
|
||||
4. **开发调试** - 彩色控制台输出,一眼识别日志级别
|
||||
5. **分布式支持** - Redis分布式锁开箱即用
|
||||
6. **文件处理** - Gzip/Zip压缩解压
|
||||
|
||||
### 12.2 适用场景
|
||||
|
||||
- ✅ 中大型 API 服务
|
||||
- ✅ 用户管理系统
|
||||
- ✅ AI 应用后端
|
||||
- ✅ 实时通信应用
|
||||
- ✅ 多项目共用资源
|
||||
- ✅ 创业项目 MVP
|
||||
- ✅ 学习 Go Web 开发
|
||||
|
||||
### 12.3 开发效率总结
|
||||
|
||||
| 场景 | 使用 xlgo v2.0 | 不使用框架 | 节省 |
|
||||
|------|----------------|------------|------|
|
||||
| 用户系统 | 4-5 人天 | 10-16 人天 | 60% |
|
||||
| AI应用 | 4-5 人天 | 9-14 人天 | 55% |
|
||||
| 通用API | 2-3 人天 | 5-8 人天 | 60% |
|
||||
|
||||
---
|
||||
|
||||
## 十三、版本历史
|
||||
|
||||
| 版本 | 日期 | 主要变化 |
|
||||
|------|------|----------|
|
||||
| v1.0.0 | 2026-04-29 | 基础框架 |
|
||||
| v1.1.0 | 2026-04-29 | P0/P1/P2修复(12项) |
|
||||
| v2.0.0 | 2026-04-29 | zl工具库移植、模块重构 |
|
||||
|
||||
---
|
||||
|
||||
*评估日期:2026-04-29*
|
||||
*评估版本:v2.0.0*
|
||||
*优化内容:新增5个包 + 增强5个包 + 配置扩展 + CLI重构 = 约120个函数*
|
||||
@@ -0,0 +1,50 @@
|
||||
# xlgo 示例
|
||||
|
||||
两个可运行示例,帮助快速上手 xlgo。
|
||||
|
||||
## minimal — 最小 HTTP 服务
|
||||
|
||||
不依赖 MySQL / Redis / Storage,纯 HTTP + 健康检查。第一次接触 xlgo 从这里开始。
|
||||
|
||||
```bash
|
||||
go run ./examples/minimal
|
||||
```
|
||||
|
||||
访问 http://localhost:8081/health(健康检查)与 http://localhost:8081/api/v1/(示例路由)。
|
||||
|
||||
## full — 完整业务 API
|
||||
|
||||
包含 MySQL + Redis + JWT + 一个 user CRUD(登录发 token、认证路由、创建/查询用户)。
|
||||
|
||||
**运行前需准备**:
|
||||
- MySQL(修改 `examples/full/config.yaml` 的 `database` 配置)
|
||||
- Redis(修改 `examples/full/config.yaml` 的 `redis` 配置)
|
||||
|
||||
```bash
|
||||
go run ./examples/full
|
||||
```
|
||||
|
||||
启动后自动迁移 user 表,并初始化示例用户 `alice/secret`(密码以 bcrypt 哈希保存)。接口:
|
||||
|
||||
| 方法 | 路径 | 说明 | 认证 |
|
||||
|---|---|---|---|
|
||||
| POST | /api/v1/login | 登录,返回 token | 否 |
|
||||
| GET | /api/v1/users/:id | 查询用户 | 是(Bearer token) |
|
||||
| POST | /api/v1/users | 创建用户 | 是(Bearer token) |
|
||||
|
||||
登录示例:
|
||||
```bash
|
||||
curl -X POST http://localhost:8082/api/v1/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"secret"}'
|
||||
```
|
||||
|
||||
创建用户示例:
|
||||
```bash
|
||||
curl -X POST http://localhost:8082/api/v1/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"username":"bob","password":"s3cret"}'
|
||||
```
|
||||
|
||||
> 示例代码为演示用途,已演示 bcrypt 密码哈希与登录校验;生产环境仍需配合 `validation` 包完善入参校验、密码强度策略与用户注册流程。
|
||||
@@ -0,0 +1,44 @@
|
||||
app:
|
||||
name: "xlgo-full-example"
|
||||
env: "dev"
|
||||
debug: true
|
||||
|
||||
server:
|
||||
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
|
||||
port: 8082
|
||||
mode: development
|
||||
read_timeout: 15s
|
||||
write_timeout: 30s
|
||||
idle_timeout: 60s
|
||||
shutdown_timeout: 30s
|
||||
response_mode: business # business(默认) 或 rest
|
||||
|
||||
database:
|
||||
driver: mysql
|
||||
host: localhost
|
||||
port: 3306
|
||||
user: root
|
||||
password: your_password
|
||||
name: xlgo_example
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password: ""
|
||||
db: 0
|
||||
|
||||
jwt:
|
||||
secret: change-me-to-a-long-random-secret-at-least-32-chars
|
||||
expire: "24h"
|
||||
refresh_expire: "168h"
|
||||
issuer: xlgo
|
||||
algorithm: HS256
|
||||
|
||||
log:
|
||||
dir: ./logs
|
||||
max_size: 100
|
||||
max_backups: 30
|
||||
max_age: 30
|
||||
compress: true
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package main 是 xlgo 的完整示例:MySQL + Redis + JWT + 一个 user CRUD。
|
||||
//
|
||||
// 运行前需准备:
|
||||
// - MySQL(config.yaml 中 database 配置)
|
||||
// - Redis(config.yaml 中 redis 配置)
|
||||
//
|
||||
// 启动后会自动迁移 user 表。访问:
|
||||
//
|
||||
// POST /api/v1/login {"username":"alice","password":"secret"} → 返回 token
|
||||
// GET /api/v1/users/:id (需 Authorization: Bearer <token>)
|
||||
// POST /api/v1/users (需 Authorization: Bearer <token>,创建用户)
|
||||
//
|
||||
// 示例会在启动时初始化 alice/secret,密码以 bcrypt 哈希保存。
|
||||
//
|
||||
// 运行:
|
||||
//
|
||||
// go run ./examples/full
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/EthanCodeCraft/xlgo-core/repository"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/EthanCodeCraft/xlgo-core/router"
|
||||
"github.com/EthanCodeCraft/xlgo-core/validation"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User 示例模型
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;size:64" json:"username"`
|
||||
Password string `gorm:"size:128" json:"-"` // bcrypt 哈希,永不返回给客户端
|
||||
UserType string `gorm:"size:32" json:"user_type"`
|
||||
}
|
||||
|
||||
var userRepo *repository.BaseRepo[User]
|
||||
|
||||
func main() {
|
||||
app := xlgo.NewFullStack(
|
||||
xlgo.WithConfigPath("./examples/full/config.yaml"),
|
||||
xlgo.WithModels(&User{}),
|
||||
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
|
||||
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
|
||||
xlgo.WithHook(xlgo.Hook{
|
||||
Name: "seed-example-user",
|
||||
OnInit: func(*xlgo.App) error {
|
||||
return ensureExampleUser(context.Background())
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Printf("启动失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func registerRoutes(r *gin.RouterGroup) {
|
||||
// 延迟初始化 repo:此时 App.Init 已完成,database.GetDB() 可用
|
||||
userRepo = repository.NewBaseRepo[User](database.GetDB())
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
// 公开路由:登录
|
||||
api.POST("/login", login)
|
||||
|
||||
// 认证路由
|
||||
auth := api.Group("/", middleware.AuthRequired())
|
||||
auth.GET("/users/:id", getUser)
|
||||
auth.POST("/users", createUser)
|
||||
}
|
||||
|
||||
func login(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := userRepo.FindOne(c.Request.Context(), "username = ?", req.Username)
|
||||
if err != nil {
|
||||
response.Fail(c, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
if !validation.CheckPassword(u.Password, req.Password) {
|
||||
response.Fail(c, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.GenerateToken(u.ID, u.Username, "user", u.UserType)
|
||||
if err != nil {
|
||||
response.ServerError(c, "生成 token 失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"token": token})
|
||||
}
|
||||
|
||||
func getUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// 示例简化:直接用 repo 查询,实际应转 uint
|
||||
var uid uint
|
||||
if _, err := fmt.Sscanf(id, "%d", &uid); err != nil {
|
||||
response.Fail(c, "用户ID无效")
|
||||
return
|
||||
}
|
||||
u, err := userRepo.FindByID(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
response.NotFound(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
response.Success(c, u)
|
||||
}
|
||||
|
||||
func createUser(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
UserType string `json:"user_type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := validation.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
response.ServerError(c, "密码加密失败")
|
||||
return
|
||||
}
|
||||
u := User{
|
||||
Username: req.Username,
|
||||
Password: hash,
|
||||
UserType: req.UserType,
|
||||
}
|
||||
if u.UserType == "" {
|
||||
u.UserType = "user"
|
||||
}
|
||||
if err := userRepo.Create(c.Request.Context(), &u); err != nil {
|
||||
response.Fail(c, "创建失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, u)
|
||||
}
|
||||
|
||||
func ensureExampleUser(ctx context.Context) error {
|
||||
_, err := userRepo.FindOne(ctx, "username = ?", "alice")
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
hash, err := validation.HashPassword("secret")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return userRepo.Create(ctx, &User{
|
||||
Username: "alice",
|
||||
Password: hash,
|
||||
UserType: "user",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
"github.com/EthanCodeCraft/xlgo-core/validation"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupExampleRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
database.RegisterDialect(database.DialectSpec{
|
||||
Name: "sqlite_full_example_test",
|
||||
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
|
||||
DSN: func(c *config.DatabaseConfig) string {
|
||||
return c.CustomDSN
|
||||
},
|
||||
})
|
||||
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Driver: "sqlite_full_example_test",
|
||||
CustomDSN: filepath.Join(t.TempDir(), "full-example.db"),
|
||||
},
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-for-full-example-at-least-32-bytes",
|
||||
Expire: time.Hour,
|
||||
RefreshExpire: 24 * time.Hour,
|
||||
Issuer: "xlgo",
|
||||
Algorithm: "HS256",
|
||||
},
|
||||
}
|
||||
if err := config.Set(cfg); err != nil {
|
||||
t.Fatalf("设置测试配置失败: %v", err)
|
||||
}
|
||||
|
||||
mgr := database.NewManager(cfg)
|
||||
if err := mgr.InitDB(context.Background(), cfg); err != nil {
|
||||
t.Fatalf("初始化测试数据库失败: %v", err)
|
||||
}
|
||||
db := mgr.Master()
|
||||
if err := db.AutoMigrate(&User{}); err != nil {
|
||||
t.Fatalf("迁移示例用户表失败: %v", err)
|
||||
}
|
||||
prev := database.SwapDefaultManager(mgr)
|
||||
t.Cleanup(func() {
|
||||
current := database.SwapDefaultManager(prev)
|
||||
if current != nil && current != prev {
|
||||
_ = current.Close()
|
||||
}
|
||||
})
|
||||
|
||||
// JWT 黑名单走 Redis(ParseToken 默认 fail-closed,无 Redis 会拒绝所有 token)。
|
||||
// 测试用 miniredis 注入,避免依赖外部 Redis。
|
||||
mr := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = redisClient.Close() })
|
||||
prevJWTMgr := jwt.GetDefaultJWT()
|
||||
jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(redisClient))
|
||||
t.Cleanup(func() { jwt.SetDefaultJWTManager(prevJWTMgr) })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
registerRoutes(r.Group(""))
|
||||
if err := ensureExampleUser(context.Background()); err != nil {
|
||||
t.Fatalf("初始化示例用户失败: %v", err)
|
||||
}
|
||||
return r, db
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, r http.Handler, path string, body string, token string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("解析响应 JSON 失败: %v, body=%s", err, rec.Body.String())
|
||||
}
|
||||
return rec.Code, payload
|
||||
}
|
||||
|
||||
func tokenFromResponse(t *testing.T, payload map[string]any) string {
|
||||
t.Helper()
|
||||
data, ok := payload["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("响应缺少 data 对象: %#v", payload)
|
||||
}
|
||||
token, ok := data["token"].(string)
|
||||
if !ok || strings.TrimSpace(token) == "" {
|
||||
t.Fatalf("响应缺少 token: %#v", payload)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func TestFullExampleLoginAndPasswordFlow(t *testing.T) {
|
||||
r, db := setupExampleRouter(t)
|
||||
|
||||
var seeded User
|
||||
if err := db.Where("username = ?", "alice").First(&seeded).Error; err != nil {
|
||||
t.Fatalf("示例用户 alice 应在启动时初始化: %v", err)
|
||||
}
|
||||
if seeded.Password == "" || seeded.Password == "secret" {
|
||||
t.Fatal("示例用户密码必须保存为 bcrypt 哈希,不能保存明文")
|
||||
}
|
||||
if !validation.CheckPassword(seeded.Password, "secret") {
|
||||
t.Fatal("示例用户 alice/secret 应能通过密码校验")
|
||||
}
|
||||
|
||||
status, wrong := postJSON(t, r, "/api/v1/login", `{"username":"alice","password":"wrong"}`, "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("业务失败模式下 HTTP 状态应为 200,got %d", status)
|
||||
}
|
||||
if code, _ := wrong["code"].(float64); code == 0 {
|
||||
t.Fatalf("错误密码应返回业务失败: %#v", wrong)
|
||||
}
|
||||
|
||||
_, loginPayload := postJSON(t, r, "/api/v1/login", `{"username":"alice","password":"secret"}`, "")
|
||||
token := tokenFromResponse(t, loginPayload)
|
||||
|
||||
_, createPayload := postJSON(t, r, "/api/v1/users", `{"username":"bob","password":"s3cret"}`, token)
|
||||
if code, _ := createPayload["code"].(float64); code != 0 {
|
||||
t.Fatalf("带 token 创建用户应成功: %#v", createPayload)
|
||||
}
|
||||
|
||||
var bob User
|
||||
if err := db.Where("username = ?", "bob").First(&bob).Error; err != nil {
|
||||
t.Fatalf("创建后的用户应写入数据库: %v", err)
|
||||
}
|
||||
if bob.Password == "" || bob.Password == "s3cret" {
|
||||
t.Fatal("创建用户时必须保存 bcrypt 哈希,不能保存明文")
|
||||
}
|
||||
if !validation.CheckPassword(bob.Password, "s3cret") {
|
||||
t.Fatal("创建用户的密码哈希应能通过校验")
|
||||
}
|
||||
|
||||
_, bobLogin := postJSON(t, r, "/api/v1/login", `{"username":"bob","password":"s3cret"}`, "")
|
||||
_ = tokenFromResponse(t, bobLogin)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
app:
|
||||
name: "xlgo-minimal-example"
|
||||
env: "dev"
|
||||
debug: true
|
||||
|
||||
server:
|
||||
port: 8081
|
||||
mode: development
|
||||
@@ -0,0 +1,42 @@
|
||||
// Package main 是 xlgo 的最小可运行示例。
|
||||
//
|
||||
// 仅依赖一个 config.yaml,不初始化 MySQL / Redis / Storage,
|
||||
// 适合第一次接触 xlgo、或纯 HTTP 场景。
|
||||
//
|
||||
// 运行:
|
||||
//
|
||||
// go run ./examples/minimal
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/EthanCodeCraft/xlgo-core/router"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := xlgo.New(
|
||||
xlgo.WithConfigPath("./examples/minimal/config.yaml"),
|
||||
xlgo.WithLogger(),
|
||||
xlgo.WithHealthRoutes(),
|
||||
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
|
||||
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
|
||||
)
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Printf("启动失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func registerRoutes(r *gin.RouterGroup) {
|
||||
api := r.Group("/api/v1")
|
||||
api.GET("/", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "Hello xlgo!"})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
# xlgo 框架文档/注释/代码一致性审查报告
|
||||
|
||||
> 审查日期:2026-07-12
|
||||
> 审查范围:60 个非测试 `.go` 源文件,25 个包目录
|
||||
> 方法:每条结论均亲自读源码确认;对初筛标"无问题"的文件做抽查复审;专门核查跨函数契约。不转述初筛结论。
|
||||
|
||||
## 关于本次审查
|
||||
|
||||
本次审查对每一条结论都亲自读取了源码确认,并对标"无问题"的文件做了抽查复审,还专门查了跨函数契约。下方为经得起复核的结果。
|
||||
|
||||
**真实数字:23 项**,分三档,每档标了置信度。
|
||||
|
||||
---
|
||||
|
||||
## 一、整体审查概览
|
||||
|
||||
- **审查文件总数**:60(25 个包目录,全部非测试源文件)
|
||||
- **亲自读源码确认的问题**:23 项
|
||||
- **A 档·实质错误(误导下游)**:10 项 - 注释与代码逻辑矛盾,会让人按注释写错代码
|
||||
- **B 档·导出符号注释缺失/不完整**:6 项 - 不致命但违反 godoc 规范或同块内风格不一
|
||||
- **C 档·描述不完整**:7 项 - 边界/默认行为没写全,不算错但该补
|
||||
- **代码健康度评分**:**7.5 / 10**
|
||||
|
||||
**整体评价**:xlgo 注释质量**两极分化**。核心安全/并发路径(`tls.go`、`validate.go`、`recover.go`、`repository.go` 的 H6 修复块、`logger` 的 H7 块)注释极其详尽且与代码逐条对应,是全仓库最好的;问题集中在两类边缘:① 历史修复后注释没同步(RandomPicker、closeResources 三连、model/base);② 工具函数/utils 的边界行为和导出常量注释缺失。**本轮主要发现是注释/文档问题,未确认 CRITICAL/HIGH 代码缺陷**(`storage.go` 超限错误类型是唯一存疑点,见第四节)。
|
||||
|
||||
---
|
||||
|
||||
## 二、A 档·实质错误(10 项,高置信,该修)
|
||||
|
||||
这些全部亲自读了源码,证据确凿。
|
||||
|
||||
### 1. app.go:462 / 495 / 1053 - closeResources 三连契约错误(跨函数)
|
||||
|
||||
这是最严重的一组,**同一个契约错误散布在三处注释,互相印证却全错**:
|
||||
|
||||
- **【位置 1】第 462 行 `failAfterInit` 注释**:
|
||||
```go
|
||||
// 顺序:... -> closeResources。先停 goroutine 再关资源...
|
||||
```
|
||||
实际第 483-488 行调的是 `stopCron` + `rollbackReplacedResources`,**不调 `closeResources`**。
|
||||
|
||||
- **【位置 2】第 495 行 `closeResources` 注释**:
|
||||
```go
|
||||
// closeResources 关闭 db->redis->logger(M1)。...供 failAfterInit 与 doShutdown 复用。
|
||||
```
|
||||
grep 确认 `closeResources` 只被 `doShutdown`(第 1057 行)调用,`failAfterInit` 不调它。**"供 failAfterInit 复用"是假的**。
|
||||
|
||||
- **【位置 3】第 1053 行 `doShutdown` 注释**:
|
||||
```go
|
||||
// db/redis/logger 经 closeResources 统一关闭(与 failAfterInit 复用,M1)。
|
||||
```
|
||||
同样错--`failAfterInit` 走 `rollbackReplacedResources`,不复用 `closeResources`。
|
||||
|
||||
**建议**:把三处注释统一修正--`failAfterInit` 回滚走 `rollbackReplacedResources`(恢复 Init 前默认 manager 再关新建 manager),`closeResources` 仅供 `doShutdown` 复用。`failAfterInit` 选 `rollback` 而非 `close` 是有意的(Init 失败要恢复默认 manager 不直接关),注释应点明这个区别。
|
||||
|
||||
### 2. database/manager.go:64-67 - RandomPicker 注释引用了已废弃的 rand.Intn
|
||||
|
||||
```go
|
||||
// RandomPicker 随机选择从库。
|
||||
//
|
||||
// D2 注释:rand.Intn 自 Go 1.20 起(go.dev/issue/54899)内部使用 per-goroutine
|
||||
// 随机源,并发安全无需额外同步。本模块 go.mod 声明 go 1.25.0,满足此要求。
|
||||
```
|
||||
实际第 75 行:`cryptorand.Int(cryptorand.Reader, big.NewInt(...))` - 已改用 `crypto/rand`,与 `math/rand.Intn`、issue 54899、per-goroutine 随机源**全部无关**。并发安全的原因现在是 `crypto/rand` 本身线程安全。注释整段过时。
|
||||
|
||||
**建议修改为**:
|
||||
```go
|
||||
// RandomPicker 随机选择从库。
|
||||
//
|
||||
// 使用 crypto/rand 生成随机索引,并发安全且不可预测。len(replicas)<=0 返回 nil;
|
||||
// crypto/rand 失败(极罕见,如熵池耗尽)时回退到 replicas[0],保证可用性。
|
||||
```
|
||||
|
||||
### 3. model/base.go:9-10 - 注释内部自相矛盾
|
||||
|
||||
```go
|
||||
// 时间列类型(MySQL 通常为 datetime(0) 或 timestamp,保留亚秒精度)。
|
||||
```
|
||||
`datetime(0)` = 0 位小数秒(秒级精度),与"保留亚秒精度"直接冲突。GORM MySQL 驱动默认是 `datetime(6)`。应为 `datetime(6)`。
|
||||
|
||||
**建议修改为**:
|
||||
```go
|
||||
// BaseModel 基础模型。CreatedAt/UpdatedAt 不显式指定 type,由 GORM 按驱动选默认
|
||||
// 时间列类型(MySQL 通常为 datetime(6),保留亚秒精度)。
|
||||
```
|
||||
|
||||
### 4. cache/keybuilder.go:47 / 75 / 156 - 三处示例分隔符全错
|
||||
|
||||
默认分隔符是 `:`(第 17、62 行),但三处示例都用下划线:
|
||||
- 第 47 行:`WithCacheType("session") -> "session_site_a_user:1"` -> 应为 `session:site_a:user:1`
|
||||
- 第 75 行:`kb.Build("user:1") -> "cache_site_a_user:1"` -> 应为 `cache:site_a:user:1`(且与同注释内格式说明 `{separator}` 矛盾)
|
||||
- 第 156 行:`kb.BuildPattern("user:*") -> "cache_site_a_user:*"` -> 应为 `cache:site_a:user:*`
|
||||
|
||||
同文件第 44 行 `WithSeparator` 示例却正确用 `:`,说明这是历史遗留笔误。
|
||||
|
||||
### 5. repository/repository.go:37 - Delete 接口注释绝对化
|
||||
|
||||
```go
|
||||
// Delete 删除记录(软删除)
|
||||
Delete(ctx context.Context, id uint) error
|
||||
```
|
||||
实现 `BaseRepo.Delete`(第 156-160 行)契约:"若 T 内嵌 gorm.DeletedAt(或 gorm.Model)为软删除;否则为硬删除"。接口注释说死"软删除",下游按接口理解会误判。
|
||||
|
||||
**建议修改为**:
|
||||
```go
|
||||
// Delete 删除记录(T 含软删除字段时为软删除,否则为硬删除;详见 BaseRepo.Delete)
|
||||
Delete(ctx context.Context, id uint) error
|
||||
```
|
||||
|
||||
### 6. repository/repository.go:144 - UpdateFields 示例语义错误
|
||||
|
||||
```go
|
||||
// repo.UpdateFields(ctx, &User{Name: "new"}, "name") // 仅更新 name
|
||||
```
|
||||
函数第 150-152 行 `db.Where(conds[0], conds[1:]...)` 把 `"name"` 当 WHERE 条件(`WHERE name`),不是"仅更新 name 字段"的选择器。struct 模式 `Updates(&User{Name:"new"})` 本就只更新非零字段,无需传 `"name"`。示例会误导人把字段名当条件传。第二行(map + `"id = ?"`)正确。
|
||||
|
||||
**建议修改为**:
|
||||
```go
|
||||
// 示例:
|
||||
//
|
||||
// repo.UpdateFields(ctx, &User{Name: "new"}) // struct:仅更新非零字段 name
|
||||
// repo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map:显式置零,带条件
|
||||
```
|
||||
|
||||
### 7. middleware/csrf.go:260 - CSRFToken 注释"用于 API 模式"误导
|
||||
|
||||
```go
|
||||
// CSRFToken 返回 CSRF Token 的处理器(用于 API 模式)
|
||||
```
|
||||
实现第 262-274 行:取 gin context 的 "csrf_token"(Cookie 模式中间件写入),取不到就地生成一次性 token 直接返回,**不写入 `apiTokens`**。而 API 模式可校验 token 由 `GenerateAPIToken`(第 345 行)颁发。所以 `CSRFToken` 产出的 token **不能被 `CSRFForAPI` 消费**,注释会让人误以为它产出 API 模式可校验 token。
|
||||
|
||||
**建议修改为**:
|
||||
```go
|
||||
// CSRFToken 返回当前请求上下文中的 CSRF Token;若上下文无 Token 则现场生成一个返回。
|
||||
// 适用于 Cookie/双重提交模式(与 CSRF/DoubleSubmitCookie 中间件配合),
|
||||
// 不与 CSRFForAPI 配套--API 模式的可校验 Token 请用 GenerateAPIToken 颁发。
|
||||
```
|
||||
|
||||
### 8. examples/full/main.go:11 - POST /users 认证标注缺失
|
||||
|
||||
```go
|
||||
// GET /api/v1/users/:id (需 Authorization: Bearer <token>)
|
||||
// POST /api/v1/users (创建用户)
|
||||
```
|
||||
实际第 82-83 行:`auth := api.Group("/", middleware.AuthRequired())`,`auth.POST("/users", createUser)` 在 auth 组内,**同样需 token**。注释对 GET 标了认证,POST 没标,会让人以为 POST 是公开接口。
|
||||
|
||||
**建议修改为**:
|
||||
```go
|
||||
// POST /api/v1/login {"username":"alice","password":"secret"} -> 返回 token(公开)
|
||||
// GET /api/v1/users/:id (需 Authorization: Bearer <token>)
|
||||
// POST /api/v1/users (需 Authorization: Bearer <token>,创建用户)
|
||||
```
|
||||
|
||||
### 9. utils/random.go:39 - RandInt 与 RandInt64 的 min==max 边界与区间注释矛盾
|
||||
|
||||
```go
|
||||
// RandInt 返回 [min, max) 范围内的随机整数。
|
||||
func RandInt(min, max int) int {
|
||||
if min == max {
|
||||
return min // 等于 max,与半开区间 [min,max) 矛盾
|
||||
}
|
||||
```
|
||||
`[min, max)` 在 `min==max` 时应为空集,但代码返回 `min`(即 max)。`RandIntSecure`(第 112 行)与 `RandInt64Secure`(第 128 行)注释均已明确说明此例外("min==max 返回 min;max<min 自动交换"),但 `RandInt`(第 39 行)与 `RandInt64`(第 55 行)没说。同问题影响 2 个函数。
|
||||
|
||||
**建议修改为**:
|
||||
```go
|
||||
// RandInt 返回 [min, max) 范围内的随机整数。min==max 时返回 min;max<min 自动交换。
|
||||
```
|
||||
|
||||
### 10. examples/full/main.go:63 - `_ = app` 死代码 + 误导注释
|
||||
|
||||
```go
|
||||
// 初始化 user repository(App.Init 之后 master DB 才可用,这里在 registerRoutes 里延迟拿)
|
||||
_ = app
|
||||
```
|
||||
`_ = app` 是无意义空赋值(`app` 在 49 行声明、65 行 `app.Run()` 已用),注释紧贴它让人误以为二者相关。实际延迟初始化在 73 行 `registerRoutes` 内。建议删掉 `_ = app`,把注释移至 `registerRoutes` 内 repo 初始化处。
|
||||
|
||||
---
|
||||
|
||||
## 三、B 档·导出符号注释缺失/不完整(6 项,中置信,该补)
|
||||
|
||||
这些是导出符号(const/var 块常量及部分函数)注释缺失或不完整,不误导但违反 godoc 规范或同块内风格不一。
|
||||
|
||||
| 文件 | 位置 | 符号 | 说明 |
|
||||
|---|---|---|---|
|
||||
| ws/ws.go | 94-100 | `TypeText`/`TypeBinary`/`TypePing`/`TypePong`/`TypeClose` | 5 个导出常量无注释,类型 `MessageType` 有 |
|
||||
| ws/ws.go | 352-357 | `ErrHubNotRunning`/`ErrHubStopped`/`ErrHubQueueFull`/`ErrNilConnection` | 4 个导出错误变量无注释 |
|
||||
| console/console.go | 23-32 | `LevelDebug`/`LevelInfo`/`LevelSuccess`/`LevelWarn`/`LevelError` | 5 个级别常量无注释(仅 `LevelSilent` 有) |
|
||||
| jwt/jwt.go | 126-129 | `BlacklistFailOpen`/`BlacklistFailClosed` | 2 个导出常量无注释,类型 `BlacklistPolicy` 有 |
|
||||
| router/router.go | 680 | 包级 `GroupWithMiddlewareGroup` | 方法版(672 行)有详细注释,包级版无注释 |
|
||||
| middleware/ratelimit.go | 37 | `NewRateLimiter` | 已有简短注释("创建速率限制器(内存版)"),但未说明 `rate<=0`/`window<=0` panic(已确认 `mustValidRateLimit` 第 57-62 行真 panic)及必须 `Stop()` 释放 goroutine |
|
||||
|
||||
---
|
||||
|
||||
## 四、C 档·描述不完整(7 项,低置信,可选)
|
||||
|
||||
边界/默认行为没写全,注释本身不算错,建议补但不急。
|
||||
|
||||
| 文件 | 位置 | 符号 | 缺什么 |
|
||||
|---|---|---|---|
|
||||
| utils/strings.go | 105 | `Substr` | 未说 `length<=0` 返回空、`start` 越界返回空 |
|
||||
| utils/convert.go | 89/97 | `CalcPageCount`/`CalcOffset` | 未说非法入参返回 0 / 默认值(1、20) |
|
||||
| utils/datetime.go | 76/81 | `StartOfMonth`/`EndOfMonth` | 未说具体时刻(1日00:00 / 末日23:59:59.999999999) |
|
||||
| utils/file.go | 79 | `CopyFile` | 未说自动建目录、覆盖、参数顺序 dst,src |
|
||||
| utils/validator.go | 49 | `IsChinese` | 未说空串返回 false |
|
||||
| utils/crypto.go | 52 | `HashFile` | 未说返回 hex 编码、`newHash` 须返回新实例 |
|
||||
| response/error.go | 90 | `WithDetail` | 未文档化 nil 接收者行为(返回 `&Error{Detail:detail}`,`Code=0`/`Message=""`,不 panic) |
|
||||
|
||||
### 唯一存疑的代码问题(非纯文档)
|
||||
|
||||
**storage/storage.go:461 与 :664** - `LocalStorage.Get`/`OSSStorage.Get` 读取超 `maxReadBytes` 时返回包装 `ErrInvalidPath` 的错误。"超过最大读取限制"用 `ErrInvalidPath`(路径无效)语义不贴切,建议改 `ErrUploadTooLarge` 或新增专用错误。**这是唯一可能涉及代码(非注释)的问题**,但属错误类型选用,非功能 bug,标存疑待定夺。
|
||||
|
||||
---
|
||||
|
||||
## 五、关于"漏看/错看"的说明
|
||||
|
||||
本次审查纠正了初筛阶段的若干问题:
|
||||
|
||||
1. **虚高数字已修正**:初筛报出 55 项,其中大量为"可以写得更详细"类软问题。剔除噪声后真实硬伤 10 项。
|
||||
|
||||
2. **漏报已补**:初筛只报了 `failAfterInit` 一处,本次亲自 grep 确认了 `closeResources` 三连错误(第 462/495/1053 行),补上了漏掉的 2 处。这是典型的"局部对全局错"--三处注释单独看都像对,拼起来契约对不上。
|
||||
|
||||
3. **"无问题"文件复审**:抽查了 `cache.go`、`validation`(hash+password)、`compress`、`logger`(+field)、`test`、`uuid`、`examples/minimal`、`database/tls`、`config/validate`、`middleware`(cors/recover/timeout)。这些文件初筛标"无问题"**整体可信**--尤其 `tls.go`、`validate.go`、`recover.go` 注释质量很高。抽查中只在 `cache.go:264` `Init()` 注释发现一处边界(标"初始化全局缓存实例"但实际不启 Redis,依赖 `database.InitRedis`),属"注释过度承诺"边界,未列入硬伤,供参考。
|
||||
|
||||
4. **方法局限坦白**:未对全部 60 个文件逐行重读,而是对初筛报的所有问题源码逐条复核 + 抽查"无问题"文件覆盖各类情况 + 专门查跨函数契约。**仍可能存在的盲区**:① 某些没被重点提及、也没抽查的文件里可能藏着问题;② 跨文件断言只深挖了 closeResources 和 repository 路由两处,其他跨文件引用没全查。
|
||||
|
||||
---
|
||||
|
||||
## 附:审查覆盖
|
||||
|
||||
- **A 档 10 项**:全部亲自 Read 源码确认,含 7 项二次交叉复核(keybuilder、model/base、RandomPicker、repository Delete/UpdateFields、csrf CSRFToken、examples/full)。
|
||||
- **B 档 6 项**:全部亲自 Read 确认注释缺失或不完整(含 `NewRateLimiter` 的 panic/Stop 语义缺口经 `mustValidRateLimit` 源码确认)。
|
||||
- **C 档 7 项**:全部亲自 Read 确认边界行为。
|
||||
- **跨函数契约**:`closeResources`/`rollbackReplacedResources` 经 grep 全仓库调用点确认;`repository` 经 Read 确认接入 `database.GetDBFromContext`(H6 修复属实)。
|
||||
- **无问题文件**:抽查 10+ 个覆盖接口密集/注释最详尽/中间件/示例/工具各类型,初筛"无问题"裁定整体可信。
|
||||
@@ -3,42 +3,52 @@ module github.com/EthanCodeCraft/xlgo-core
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-playground/validator/v10 v10.19.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.5.1
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/text v0.38.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/mysql v1.5.4
|
||||
gorm.io/gorm v1.25.7
|
||||
gorm.io/driver/postgres v1.5.9
|
||||
gorm.io/gorm v1.25.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
@@ -51,6 +61,10 @@ require (
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
@@ -60,10 +74,14 @@ require (
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
@@ -74,16 +92,19 @@ require (
|
||||
github.com/swaggo/swag v1.16.3 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
@@ -91,4 +112,8 @@ require (
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
||||
@@ -4,8 +4,12 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible h1:Sg/2xHwDrioHpxTN6WMiwbXTpUEinBpHsN7mG21Rc2k=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -27,6 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
@@ -39,6 +45,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -66,8 +76,8 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -75,12 +85,20 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -89,6 +107,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
@@ -99,6 +119,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
@@ -116,14 +138,27 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
|
||||
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
@@ -165,8 +200,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
@@ -175,6 +214,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+J
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
@@ -191,29 +232,31 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -223,8 +266,8 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -233,15 +276,15 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
@@ -271,7 +314,17 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.4 h1:igQmHfKcbaTVyAIHNhhB888vvxh8EdQ2uSUT0LPcBso=
|
||||
gorm.io/driver/mysql v1.5.4/go.mod h1:9rYxJph/u9SWkWc9yY4XJ1F/+xO0S/ChOmbk3+Z5Tvs=
|
||||
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
|
||||
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
+43
-20
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -10,22 +11,39 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DefaultMaxJSONBodyBytes 是 BindJSON 的默认请求体上限。
|
||||
// 入口层无上限读 JSON 会让任意 handler 暴露 OOM 面;需要更大请求体的业务可改用
|
||||
// BindJSONWithMaxBytes 显式声明上限。
|
||||
const DefaultMaxJSONBodyBytes int64 = 1 << 20 // 1 MiB
|
||||
|
||||
// HealthCheck 健康检查
|
||||
// @Summary 健康检查
|
||||
// @Description 检查服务是否正常运行
|
||||
// @Tags 系统
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.Response
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /health [get]
|
||||
//
|
||||
// 响应体与 router.RegisterHealthRoute 收敛为同一 schema(H8d):
|
||||
// 恒 200 + {"status":"ok"},不走 response 业务信封,便于 K8s 探针直读。
|
||||
// 需要依赖探活(mysql/redis…失败 503)时改用 router.RegisterHealthRoute 传入 checks。
|
||||
func HealthCheck(c *gin.Context) {
|
||||
response.Success(c, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -34,6 +52,11 @@ func BindQuery(c *gin.Context, req any) error {
|
||||
return c.ShouldBindQuery(req)
|
||||
}
|
||||
|
||||
// MaxPage GetPage 允许的最大页码(M-D 修复:防深分页 DoS)。
|
||||
// 超大 page 产生超大 OFFSET,大表上为性能灾难/DoS 面。钳制到该上限;
|
||||
// 需更深度遍历的业务应改用游标/keyset 分页(框架不内置,避免功能扩张)。
|
||||
const MaxPage = 10000
|
||||
|
||||
// GetPage 获取分页参数
|
||||
func GetPage(c *gin.Context) (int, int) {
|
||||
page := c.DefaultQuery("page", "1")
|
||||
@@ -45,6 +68,10 @@ func GetPage(c *gin.Context) (int, int) {
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
// M-D 修复:钳制 page 上限,防 ?page=999999999 产生超大 OFFSET 拖垮 DB。
|
||||
if p > MaxPage {
|
||||
p = MaxPage
|
||||
}
|
||||
if ps < 1 {
|
||||
ps = 20
|
||||
}
|
||||
@@ -69,8 +96,6 @@ func GetIDFromPath(c *gin.Context, paramName string) (uint, bool) {
|
||||
// ===== 类型安全的参数获取函数 =====
|
||||
|
||||
// QueryInt 获取 Query 参数中的整数(带默认值)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 类型安全,避免每次手动转换
|
||||
func QueryInt(c *gin.Context, key string, def int) int {
|
||||
return utils.ToIntDefault(c.Query(key), def)
|
||||
}
|
||||
@@ -95,8 +120,6 @@ func QueryBool(c *gin.Context, key string, def bool) bool {
|
||||
}
|
||||
|
||||
// PathInt 从路径参数获取整数(带默认值)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: RESTful API 常用,如 /users/:id
|
||||
func PathInt(c *gin.Context, key string, def int) int {
|
||||
val := strings.Trim(c.Param(key), "/")
|
||||
return utils.ToIntDefault(val, def)
|
||||
@@ -115,8 +138,6 @@ func PathUint64(c *gin.Context, key string, def uint64) uint64 {
|
||||
}
|
||||
|
||||
// FormInt 获取 POST 表单参数中的整数(带默认值)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 传统表单提交常用
|
||||
func FormInt(c *gin.Context, key string, def int) int {
|
||||
return utils.ToIntDefault(c.PostForm(key), def)
|
||||
}
|
||||
@@ -159,18 +180,20 @@ func ParseInt(s string, defaultValue int) int {
|
||||
return utils.ToIntDefault(s, defaultValue)
|
||||
}
|
||||
|
||||
// BadRequest 返回 400 错误
|
||||
// BadRequest 返回参数/请求错误响应。
|
||||
//
|
||||
// 委托 response.FailWithCode(CodeFail),遵循当前响应模式(ModeBusiness 下 HTTP 200,
|
||||
// 错误信息通过 body 中的 code 表达;ModeREST 下 CodeFail 属业务失败不映射 HTTP 错误,仍 200),
|
||||
// 并写入 RequestID——与 response 模式系统一致,不再硬编 HTTP 状态码、不再丢失链路追踪。
|
||||
func BadRequest(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusBadRequest, response.Response{
|
||||
Code: response.CodeFail,
|
||||
Msg: msg,
|
||||
})
|
||||
response.FailWithCode(c, response.CodeFail, msg)
|
||||
}
|
||||
|
||||
// InternalError 返回 500 错误
|
||||
// InternalError 返回服务器错误响应。
|
||||
//
|
||||
// 委托 response.ServerError(CodeServerError),遵循当前响应模式(ModeBusiness 下 HTTP 200,
|
||||
// ModeREST 下 HTTP 500),并写入 RequestID——与 response 模式系统一致,
|
||||
// 不再硬编 HTTP 状态码、不再丢失链路追踪。
|
||||
func InternalError(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusInternalServerError, response.Response{
|
||||
Code: response.CodeServerError,
|
||||
Msg: msg,
|
||||
})
|
||||
response.ServerError(c, msg)
|
||||
}
|
||||
|
||||
+161
-8
@@ -1,12 +1,14 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/handler"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -29,6 +31,25 @@ func TestHealthCheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckSchemaConverged_H8d:handler.HealthCheck 响应体应与
|
||||
// router.RegisterHealthRoute 收敛为同一 schema(200 + {"status":"ok"}),
|
||||
// 不再走 response 业务信封 {code,msg,data}。
|
||||
func TestHealthCheckSchemaConverged_H8d(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/health", handler.HealthCheck)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/health", nil))
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"status":"ok"`) {
|
||||
t.Fatalf("HealthCheck body = %s, want {\"status\":\"ok\"}", body)
|
||||
}
|
||||
if strings.Contains(body, `"code"`) || strings.Contains(body, `"data"`) {
|
||||
t.Fatalf("HealthCheck should not use response envelope, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryInt(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
@@ -41,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 {
|
||||
@@ -164,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 {
|
||||
@@ -206,7 +227,26 @@ func TestGetIDFromPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// withResponseMode 切换响应模式并在测试结束后恢复为默认 ModeBusiness。
|
||||
func withResponseMode(t *testing.T, m response.Mode) {
|
||||
t.Helper()
|
||||
response.SetMode(m)
|
||||
t.Cleanup(func() { response.SetMode(response.ModeBusiness) })
|
||||
}
|
||||
|
||||
// decodeBody 解析统一响应体。
|
||||
func decodeBody(t *testing.T, w *httptest.ResponseRecorder) response.Response {
|
||||
t.Helper()
|
||||
var body response.Response
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v (body=%q)", err, w.Body.String())
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBadRequest(t *testing.T) {
|
||||
// 默认 ModeBusiness:所有失败响应 HTTP 200,错误经 body code 表达。
|
||||
withResponseMode(t, response.ModeBusiness)
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
handler.BadRequest(c, "参数错误")
|
||||
@@ -216,12 +256,21 @@ func TestBadRequest(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("BadRequest status = %d, want 400", w.Code)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("BadRequest (ModeBusiness) status = %d, want 200", w.Code)
|
||||
}
|
||||
body := decodeBody(t, w)
|
||||
if body.Code != response.CodeFail {
|
||||
t.Errorf("BadRequest code = %d, want %d", body.Code, response.CodeFail)
|
||||
}
|
||||
if body.Msg != "参数错误" {
|
||||
t.Errorf("BadRequest msg = %q, want %q", body.Msg, "参数错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalError(t *testing.T) {
|
||||
// 默认 ModeBusiness:服务器错误也走 HTTP 200,错误经 body code 表达。
|
||||
withResponseMode(t, response.ModeBusiness)
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
handler.InternalError(c, "服务器错误")
|
||||
@@ -231,8 +280,88 @@ func TestInternalError(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("InternalError (ModeBusiness) status = %d, want 200", w.Code)
|
||||
}
|
||||
body := decodeBody(t, w)
|
||||
if body.Code != response.CodeServerError {
|
||||
t.Errorf("InternalError code = %d, want %d", body.Code, response.CodeServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBadRequestRESTMode:ModeREST 下 CodeFail 属业务失败不映射 HTTP 错误,仍 200。
|
||||
// 修复前 BadRequest 硬编 400 绕过 Mode;修复后委托 response.FailWithCode 遵循 Mode。
|
||||
func TestBadRequestRESTMode(t *testing.T) {
|
||||
withResponseMode(t, response.ModeREST)
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
handler.BadRequest(c, "参数错误")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("BadRequest (ModeREST) status = %d, want 200 (CodeFail 不映射)", w.Code)
|
||||
}
|
||||
body := decodeBody(t, w)
|
||||
if body.Code != response.CodeFail {
|
||||
t.Errorf("BadRequest code = %d, want %d", body.Code, response.CodeFail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInternalErrorRESTMode:ModeREST 下 CodeServerError 映射 HTTP 500。
|
||||
func TestInternalErrorRESTMode(t *testing.T) {
|
||||
withResponseMode(t, response.ModeREST)
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
handler.InternalError(c, "服务器错误")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("InternalError status = %d, want 500", w.Code)
|
||||
t.Errorf("InternalError (ModeREST) status = %d, want 500", w.Code)
|
||||
}
|
||||
body := decodeBody(t, w)
|
||||
if body.Code != response.CodeServerError {
|
||||
t.Errorf("InternalError code = %d, want %d", body.Code, response.CodeServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBadRequestWritesRequestID:修复前 BadRequest 直接 c.JSON 不写 RequestID(丢链路);
|
||||
// 修复后委托 response 体系,经 writeResp 写入上下文中的 request_id。
|
||||
func TestBadRequestWritesRequestID(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(func(c *gin.Context) { c.Set("request_id", "req-123"); c.Next() })
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
handler.BadRequest(c, "参数错误")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
|
||||
|
||||
body := decodeBody(t, w)
|
||||
if body.RequestID != "req-123" {
|
||||
t.Errorf("BadRequest request_id = %q, want %q (修复前为空)", body.RequestID, "req-123")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInternalErrorWritesRequestID:同上,覆盖 InternalError。
|
||||
func TestInternalErrorWritesRequestID(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(func(c *gin.Context) { c.Set("request_id", "req-456"); c.Next() })
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
handler.InternalError(c, "服务器错误")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
|
||||
|
||||
body := decodeBody(t, w)
|
||||
if body.RequestID != "req-456" {
|
||||
t.Errorf("InternalError request_id = %q, want %q (修复前为空)", body.RequestID, "req-456")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,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)
|
||||
}
|
||||
}
|
||||
|
||||
+357
-87
@@ -6,11 +6,17 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Claims JWT 声明
|
||||
@@ -34,29 +40,119 @@ var (
|
||||
ErrTokenNotValidYet = errors.New("令牌尚未生效")
|
||||
//ErrTokenRevoked 令牌已被撤销
|
||||
ErrTokenRevoked = errors.New("令牌已被撤销")
|
||||
// ErrBlacklistUnavailable Redis 未初始化或不可用,黑名单功能失效(C9a 修复)。
|
||||
// Add 返回此错误使调用方(RefreshToken/InvalidateToken)感知黑名单不可用并 fail-closed,
|
||||
// 避免无 Redis 时静默成功致撤销/刷新失效、新旧 token 双有效。
|
||||
// IsBlacklisted 在无 Redis 时仍返回 false(验证侧 fail-open 是无 Redis 部署的固有局限,
|
||||
// 文档约束:安全敏感场景必须启用 Redis)。
|
||||
ErrBlacklistUnavailable = errors.New("token 黑名单不可用:Redis 未初始化")
|
||||
// ErrEmptySecret JWT 密钥为空(P0 修复)。空 secret 意味着以零长度 HMAC 密钥签发/校验,
|
||||
// 任何以 "" 签名的 token 都会通过——签发与校验一律 fail-closed 拒绝,杜绝该空密钥绕过。
|
||||
ErrEmptySecret = errors.New("jwt.secret 未配置:拒绝签发/校验(防空密钥导致任意 token 通过)")
|
||||
// ErrUnsupportedAlgorithm 配置了不支持的签名算法(P0 修复)。
|
||||
// 本实现仅支持 HMAC 族(HS256/HS384/HS512);RS256 等非对称算法暂不支持,
|
||||
// 不再静默回退 HS256——避免用户误以为在用非对称算法、实则 HMAC,并助长算法混淆攻击。
|
||||
ErrUnsupportedAlgorithm = errors.New("jwt: 不支持的签名算法(仅支持 HS256/HS384/HS512)")
|
||||
// ErrInvalidExpiry 过期时间非法。签发非正过期时间的 token 会立即失效或产生不可预期会话语义。
|
||||
ErrInvalidExpiry = errors.New("jwt: 过期时间必须大于 0")
|
||||
// ErrEmptyJTI 空 JTI 无法匹配任何正常 token,却会写入 jwt_bl: 这种永不命中的黑名单键。
|
||||
ErrEmptyJTI = errors.New("jwt: jti 不能为空")
|
||||
)
|
||||
|
||||
// generateJTI 生成唯一的 JWT ID
|
||||
func generateJTI() string {
|
||||
bytes := make([]byte, 16)
|
||||
rand.Read(bytes)
|
||||
return base64.URLEncoding.EncodeToString(bytes)
|
||||
// validMethods 允许的签名算法名(HMAC 族)。ParseWithClaims 传 jwt.WithValidMethods 固定算法,
|
||||
// 防算法混淆(alg confusion,P0):拒绝 alg=none 及非 HMAC 算法——否则若部署配置为非对称算法,
|
||||
// 攻击者可用公钥作为 HMAC 密钥伪造 token 通过校验。
|
||||
var validMethods = []string{"HS256", "HS384", "HS512"}
|
||||
|
||||
// secretKey 返回 HMAC 密钥字节;cfg 为 nil 或密钥为空时 fail-closed 返回 ErrEmptySecret(P0)。
|
||||
func secretKey(cfg *config.Config) ([]byte, error) {
|
||||
if cfg == nil || cfg.JWT.Secret == "" {
|
||||
return nil, ErrEmptySecret
|
||||
}
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
}
|
||||
|
||||
// TokenBlacklist Token 黑名单管理(使用 JTI 优化)
|
||||
type TokenBlacklist struct{}
|
||||
// hmacKeyfunc 构造校验签名方法为 HMAC 族并返回密钥的 jwt.Keyfunc(P0:防算法混淆 + 空密钥)。
|
||||
// 双重防护:① 断言 token.Method 为 *jwt.SigningMethodHMAC,拒绝非 HMAC(含 none/RS/ES);
|
||||
// ② 经 secretKey 拒绝空密钥。配合 ParseWithClaims 的 jwt.WithValidMethods(validMethods) 使用。
|
||||
func hmacKeyfunc(cfg *config.Config) jwt.Keyfunc {
|
||||
return func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("%w: 期望 HMAC,实际 alg=%v", ErrUnsupportedAlgorithm, token.Header["alg"])
|
||||
}
|
||||
return secretKey(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// generateJTI 生成唯一的 JWT ID
|
||||
func generateJTI() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("生成 JTI 失败: %w", err)
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// TokenBlacklist Token 黑名单管理(使用 JTI 优化)。
|
||||
// client 为 nil 时回退到 database.GetRedis(),兼容存量未注入场景。
|
||||
type TokenBlacklist struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewTokenBlacklist 创建黑名单实例,client 可为 nil(懒取全局 Redis)。
|
||||
func NewTokenBlacklist(client *redis.Client) *TokenBlacklist {
|
||||
return &TokenBlacklist{client: client}
|
||||
}
|
||||
|
||||
func (tb *TokenBlacklist) redisClient() *redis.Client {
|
||||
if tb != nil && tb.client != nil {
|
||||
return tb.client
|
||||
}
|
||||
return database.GetRedis()
|
||||
}
|
||||
|
||||
// blacklistOpTimeout 黑名单 Redis 操作的上下文超时(M-A 修复)。
|
||||
// Redis 客户端已配 ReadTimeout/WriteTimeout=3s(redis.go D7),但鉴权热路径(ParseToken
|
||||
// 每次调 IsBlacklisted)需更紧边界——显式 ctx 超时把鉴权阻塞上限收敛到 1s,避免 Redis
|
||||
// 挂起时每个鉴权请求被长时间拖住。健康 Redis 下 Exists/SET 为亚毫秒级,1s 余量充足。
|
||||
// 注:ctx 超时只约束命令往返,不影响 Set 的服务端 TTL(ttl 可远大于 1s)。
|
||||
const blacklistOpTimeout = 1 * time.Second
|
||||
|
||||
// BlacklistPolicy 控制解析 Token 时遇到黑名单查询错误的处理策略。
|
||||
// ParseToken 默认 BlacklistFailClosed(黑名单不可检查即拒绝);
|
||||
// 需显式 fail-open(仅无 Redis 或低安全场景)用 ParseTokenWithBlacklistPolicy(..., BlacklistFailOpen)。
|
||||
type BlacklistPolicy int
|
||||
|
||||
const (
|
||||
// BlacklistFailOpen 黑名单查询出错时放行 Token(仅用于无 Redis 或低安全场景,非默认)。
|
||||
BlacklistFailOpen BlacklistPolicy = iota
|
||||
// BlacklistFailClosed 黑名单查询出错时拒绝 Token(默认;ParseToken 即用此策略)。
|
||||
BlacklistFailClosed
|
||||
)
|
||||
|
||||
// blacklistCtx 创建带超时的 context 用于黑名单 Redis 操作(M-A)。
|
||||
func blacklistCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), blacklistOpTimeout)
|
||||
}
|
||||
|
||||
// Add 将 Token 的 JTI 加入黑名单
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 JTI(32字节)替代完整 Token(数百字节),大幅节省 Redis 内存
|
||||
// 参数: jti JWT ID,expiry Token 过期时间
|
||||
//
|
||||
// 无 Redis 时返回 ErrBlacklistUnavailable(C9a 修复):让调用方(RefreshToken/InvalidateToken)
|
||||
// 感知黑名单不可用并 fail-closed,避免无 Redis 时静默成功致撤销失效。
|
||||
func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
|
||||
if database.RedisClient == nil {
|
||||
// Redis 未启用,跳过黑名单
|
||||
return nil
|
||||
if strings.TrimSpace(jti) == "" {
|
||||
return ErrEmptyJTI
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
client := tb.redisClient()
|
||||
if client == nil {
|
||||
// Redis 未启用,黑名单不可用——fail-closed 让调用方决策。
|
||||
return ErrBlacklistUnavailable
|
||||
}
|
||||
|
||||
ctx, cancel := blacklistCtx()
|
||||
defer cancel()
|
||||
ttl := time.Until(expiry)
|
||||
if ttl <= 0 {
|
||||
// Token 已过期,无需加入黑名单
|
||||
@@ -65,32 +161,119 @@ func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
|
||||
|
||||
// 使用 JTI 作为键名(约24字节),而非完整 Token(数百字节)
|
||||
key := fmt.Sprintf("jwt_bl:%s", jti)
|
||||
return database.RedisClient.Set(ctx, key, "1", ttl).Err()
|
||||
return client.Set(ctx, key, "1", ttl).Err()
|
||||
}
|
||||
|
||||
// IsBlacklisted 检查 JTI 是否在黑名单中
|
||||
func (tb *TokenBlacklist) IsBlacklisted(jti string) bool {
|
||||
if database.RedisClient == nil {
|
||||
// Redis 未启用,不检查黑名单
|
||||
return false
|
||||
// IsBlacklisted 检查 JTI 是否在黑名单中。
|
||||
// 命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未启用或不可达返回 (false, ErrBlacklistUnavailable),由调用方决定 fail-open/fail-closed。
|
||||
func (tb *TokenBlacklist) IsBlacklisted(jti string) (bool, error) {
|
||||
client := tb.redisClient()
|
||||
if client == nil {
|
||||
return false, ErrBlacklistUnavailable
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := blacklistCtx()
|
||||
defer cancel()
|
||||
key := fmt.Sprintf("jwt_bl:%s", jti)
|
||||
return database.RedisClient.Exists(ctx, key).Val() > 0
|
||||
n, err := client.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// 全局黑名单实例
|
||||
var tokenBlacklist = &TokenBlacklist{}
|
||||
// Manager JWT 管理器(#10)。持有独立的 TokenBlacklist,
|
||||
// 支持多实例(如区分 user-token 与 refresh-token 黑名单)。
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
blacklist *TokenBlacklist
|
||||
}
|
||||
|
||||
// defaultManager 是全局默认 JWT 管理器的真实存储,经 atomic 读写(C9c)。
|
||||
var defaultManager atomic.Pointer[Manager]
|
||||
|
||||
func init() {
|
||||
defaultManager.Store(NewJWTManager())
|
||||
}
|
||||
|
||||
// currentManager 返回全局默认 JWT 管理器(atomic 读取,C9c)。
|
||||
// 正常情况下 init 后永不为 nil;防御性地在极罕见的 nil 情况下回退一个懒取 Redis 的实例。
|
||||
func currentManager() *Manager {
|
||||
if m := defaultManager.Load(); m != nil {
|
||||
return m
|
||||
}
|
||||
m := NewJWTManager()
|
||||
defaultManager.Store(m)
|
||||
return m
|
||||
}
|
||||
|
||||
// GetDefaultJWT 返回全局默认 JWT 管理器(并发安全,C9c/J1 修复)。
|
||||
// 替代已删除的 DefaultJWT 包级变量。
|
||||
func GetDefaultJWT() *Manager {
|
||||
return currentManager()
|
||||
}
|
||||
|
||||
// currentBlacklist 返回全局默认 Manager 持有的黑名单(atomic 读取 Manager 后经 Blacklist(),C9c)。
|
||||
func currentBlacklist() *TokenBlacklist {
|
||||
return currentManager().Blacklist()
|
||||
}
|
||||
|
||||
// NewJWTManager 创建 JWT 管理器实例(blacklist 懒取全局 Redis)。
|
||||
func NewJWTManager() *Manager {
|
||||
return &Manager{blacklist: NewTokenBlacklist(nil)}
|
||||
}
|
||||
|
||||
// NewJWTManagerWithRedis 创建 JWT 管理器并注入指定 Redis 客户端(用于多 Redis/测试隔离)。
|
||||
func NewJWTManagerWithRedis(client *redis.Client) *Manager {
|
||||
return &Manager{blacklist: NewTokenBlacklist(client)}
|
||||
}
|
||||
|
||||
// SetDefaultJWTManager 提升指定 Manager 为全局默认(atomic 置换,并发安全,J1 修复)。
|
||||
func SetDefaultJWTManager(m *Manager) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
defaultManager.Store(m)
|
||||
}
|
||||
|
||||
// Blacklist 返回 Manager 持有的黑名单实例。
|
||||
func (m *Manager) Blacklist() *TokenBlacklist {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.blacklist
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT Token
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动生成 JTI,支持高效黑名单机制
|
||||
func GenerateToken(userID uint, username, role, userType string) (string, error) {
|
||||
cfg := config.Get()
|
||||
var expiry time.Duration
|
||||
if cfg != nil {
|
||||
expiry = cfg.JWT.Expire
|
||||
}
|
||||
return generateTokenWithExpiry(cfg, userID, username, role, userType, expiry)
|
||||
}
|
||||
|
||||
func generateTokenWithExpiry(cfg *config.Config, userID uint, username, role, userType string, expiry time.Duration) (string, error) {
|
||||
if expiry <= 0 {
|
||||
return "", ErrInvalidExpiry
|
||||
}
|
||||
// P0:先校验密钥非空与算法受支持(fail-closed)。secretKey 亦守卫 cfg==nil,
|
||||
// 通过后 cfg 保证非空,后续访问 cfg.JWT.* 安全。
|
||||
key, err := secretKey(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method, err := signingMethod(cfg.JWT.Algorithm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 生成唯一的 JWT ID
|
||||
jti := generateJTI()
|
||||
jti, err := generateJTI()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
@@ -99,70 +282,131 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
|
||||
UserType: userType,
|
||||
JTI: jti,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(cfg.JWT.Expire) * time.Second)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "xlgo",
|
||||
Issuer: issuerOrDefault(cfg.JWT.Issuer),
|
||||
ID: jti, // 同时设置到 RegisteredClaims.ID
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWT.Secret))
|
||||
token := jwt.NewWithClaims(method, claims)
|
||||
return token.SignedString(key)
|
||||
}
|
||||
|
||||
// GenerateTokenWithCustomExpiry 生成带自定义过期时间的 Token
|
||||
func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
jti := generateJTI()
|
||||
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
UserType: userType,
|
||||
JTI: jti,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireSeconds) * time.Second)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "xlgo",
|
||||
ID: jti,
|
||||
},
|
||||
if expireSeconds <= 0 {
|
||||
return "", ErrInvalidExpiry
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWT.Secret))
|
||||
return generateTokenWithExpiry(cfg, userID, username, role, userType, time.Duration(expireSeconds)*time.Second)
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT Token
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 JTI 检查黑名单,效率更高
|
||||
// issuerOrDefault 返回配置的 issuer,未配置时回退 "xlgo"。
|
||||
func issuerOrDefault(issuer string) string {
|
||||
if issuer == "" {
|
||||
return "xlgo"
|
||||
}
|
||||
return issuer
|
||||
}
|
||||
|
||||
// signingMethod 根据 algorithm 配置返回 HMAC 签名方法。
|
||||
// 支持 HS256(默认,空值等价)/HS384/HS512;其它值(含 RS256 等非对称算法,暂不支持)
|
||||
// 返回 ErrUnsupportedAlgorithm,不再静默回退 HS256(P0:防"配 RS256 实得 HMAC"的算法混淆隐患)。
|
||||
func signingMethod(algorithm string) (jwt.SigningMethod, error) {
|
||||
switch strings.ToUpper(strings.TrimSpace(algorithm)) {
|
||||
case "", "HS256":
|
||||
return jwt.SigningMethodHS256, nil
|
||||
case "HS384":
|
||||
return jwt.SigningMethodHS384, nil
|
||||
case "HS512":
|
||||
return jwt.SigningMethodHS512, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnsupportedAlgorithm, algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
func parseOptions(cfg *config.Config) []jwt.ParserOption {
|
||||
issuer := "xlgo"
|
||||
if cfg != nil {
|
||||
issuer = issuerOrDefault(cfg.JWT.Issuer)
|
||||
}
|
||||
return []jwt.ParserOption{
|
||||
jwt.WithValidMethods(validMethods),
|
||||
jwt.WithIssuer(issuer),
|
||||
}
|
||||
}
|
||||
|
||||
func validateIssuer(cfg *config.Config, claims *Claims) error {
|
||||
want := "xlgo"
|
||||
if cfg != nil {
|
||||
want = issuerOrDefault(cfg.JWT.Issuer)
|
||||
}
|
||||
if claims == nil || claims.Issuer != want {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapParseTokenError(err error) error {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return ErrTokenExpired
|
||||
}
|
||||
if errors.Is(err, jwt.ErrTokenMalformed) {
|
||||
return ErrTokenMalformed
|
||||
}
|
||||
if errors.Is(err, jwt.ErrTokenNotValidYet) {
|
||||
return ErrTokenNotValidYet
|
||||
}
|
||||
if errors.Is(err, ErrEmptySecret) {
|
||||
return ErrEmptySecret
|
||||
}
|
||||
if errors.Is(err, ErrUnsupportedAlgorithm) {
|
||||
return ErrUnsupportedAlgorithm
|
||||
}
|
||||
return fmt.Errorf("%w: %w", ErrTokenInvalid, err)
|
||||
}
|
||||
|
||||
func checkTokenBlacklist(claims *Claims, policy BlacklistPolicy) error {
|
||||
if claims == nil || claims.JTI == "" {
|
||||
return nil
|
||||
}
|
||||
revoked, err := currentBlacklist().IsBlacklisted(claims.JTI)
|
||||
if err != nil {
|
||||
if policy == BlacklistFailClosed {
|
||||
return err
|
||||
}
|
||||
logger.Warn("JWT 黑名单检查失败,fail-open 策略放行 token", zap.String("jti", claims.JTI), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if revoked {
|
||||
return ErrTokenRevoked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT Token。默认 fail-closed:黑名单后端不可检查时返回
|
||||
// ErrBlacklistUnavailable,拒绝该 Token。需要显式 fail-open(仅无 Redis 或低安全场景)
|
||||
// 请用 ParseTokenWithBlacklistPolicy(token, BlacklistFailOpen)。
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailClosed)
|
||||
}
|
||||
|
||||
// ParseTokenWithBlacklistPolicy 使用显式黑名单查询策略解析 JWT Token。
|
||||
func ParseTokenWithBlacklistPolicy(tokenString string, policy BlacklistPolicy) (*Claims, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
})
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), parseOptions(cfg)...)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
if errors.Is(err, jwt.ErrTokenMalformed) {
|
||||
return nil, ErrTokenMalformed
|
||||
}
|
||||
if errors.Is(err, jwt.ErrTokenNotValidYet) {
|
||||
return nil, ErrTokenNotValidYet
|
||||
}
|
||||
return nil, ErrTokenInvalid
|
||||
return nil, mapParseTokenError(err)
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
// 使用 JTI 检查黑名单(更高效)
|
||||
if claims.JTI != "" && tokenBlacklist.IsBlacklisted(claims.JTI) {
|
||||
return nil, ErrTokenRevoked
|
||||
if err := checkTokenBlacklist(claims, policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -171,23 +415,26 @@ func ParseToken(tokenString string) (*Claims, error) {
|
||||
}
|
||||
|
||||
// InvalidateToken 使 Token 失效(加入黑名单)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 JTI 而非完整 Token,节省 Redis 内存
|
||||
func InvalidateToken(tokenString string) error {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
})
|
||||
opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation())
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...)
|
||||
|
||||
if err != nil {
|
||||
// Token 无效或已过期,无需加入黑名单
|
||||
// Token 签名/格式/issuer 无效,无需加入黑名单。
|
||||
return nil
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok {
|
||||
if claims.JTI != "" && claims.ExpiresAt != nil {
|
||||
return tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time)
|
||||
if err := validateIssuer(cfg, claims); err != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(claims.JTI) == "" {
|
||||
return ErrEmptyJTI
|
||||
}
|
||||
if claims.ExpiresAt != nil {
|
||||
return currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,22 +444,42 @@ func InvalidateToken(tokenString string) error {
|
||||
// InvalidateTokenByID 直接通过 JTI 使 Token 失效
|
||||
// 参数: jti JWT ID,expiry 过期时间
|
||||
func InvalidateTokenByID(jti string, expiry time.Time) error {
|
||||
return tokenBlacklist.Add(jti, expiry)
|
||||
if strings.TrimSpace(jti) == "" {
|
||||
return ErrEmptyJTI
|
||||
}
|
||||
return currentBlacklist().Add(jti, expiry)
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 Token
|
||||
//
|
||||
// 安全约束(C9b 修复):将旧 Token 加入黑名单的 Add 错误必须向上传播——若 Add 失败
|
||||
// (Redis 抖动或未启用)仍签发新 token,会导致旧 token 未拉黑、新旧 token 双有效,
|
||||
// 形成会话固定窗口。故 Add 失败时不签发新 token(fail-closed)。
|
||||
func RefreshToken(tokenString string) (string, error) {
|
||||
claims, err := ParseToken(tokenString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 将旧 Token 加入黑名单
|
||||
if claims.JTI != "" && claims.ExpiresAt != nil {
|
||||
tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time)
|
||||
// 将旧 Token 加入黑名单;失败则不签发新 token(C9b:禁止吞 Add 错误)。
|
||||
if strings.TrimSpace(claims.JTI) == "" {
|
||||
return "", ErrEmptyJTI
|
||||
}
|
||||
if claims.ExpiresAt != nil {
|
||||
if err := currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time); err != nil {
|
||||
return "", fmt.Errorf("刷新令牌失败:旧令牌撤销失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return GenerateToken(claims.UserID, claims.Username, claims.Role, claims.UserType)
|
||||
cfg := config.Get()
|
||||
var expiry time.Duration
|
||||
if cfg != nil {
|
||||
expiry = cfg.JWT.RefreshExpire
|
||||
}
|
||||
if expiry <= 0 && cfg != nil {
|
||||
expiry = cfg.JWT.Expire
|
||||
}
|
||||
return generateTokenWithExpiry(cfg, claims.UserID, claims.Username, claims.Role, claims.UserType, expiry)
|
||||
}
|
||||
|
||||
// GetJTI 从 Token 中提取 JTI(不验证签名)
|
||||
@@ -230,9 +497,10 @@ func GetJTI(tokenString string) (string, error) {
|
||||
return "", ErrTokenInvalid
|
||||
}
|
||||
|
||||
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
|
||||
func IsTokenRevoked(jti string) bool {
|
||||
return tokenBlacklist.IsBlacklisted(jti)
|
||||
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)。
|
||||
// 返回 (是否撤销, 错误);黑名单后端不可用时返回 (false, ErrBlacklistUnavailable)。
|
||||
func IsTokenRevoked(jti string) (bool, error) {
|
||||
return currentBlacklist().IsBlacklisted(jti)
|
||||
}
|
||||
|
||||
// GetClaimsFromToken 获取 Token 的 Claims(不验证过期)
|
||||
@@ -240,17 +508,19 @@ func IsTokenRevoked(jti string) bool {
|
||||
func GetClaimsFromToken(tokenString string) (*Claims, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
}, jwt.WithoutClaimsValidation())
|
||||
opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation())
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok {
|
||||
if err := validateIssuer(cfg, claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// c9cSetupConfig 注入测试用 JWT 配置(内部测试无法访问 jwt_test 的 setupTestConfig)。
|
||||
func c9cSetupConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
config.Set(&config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
|
||||
Expire: time.Hour,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestC9cConcurrentSetDefaultAndRead 验证 SetDefaultJWTManager(写)与包级函数
|
||||
// (读 currentBlacklist → currentManager → defaultManager.Load)并发无数据竞争(C9c)。
|
||||
// 修复前 tokenBlacklist 为裸包级指针,SetDefaultJWTManager 裸写、ParseToken/IsTokenRevoked/
|
||||
// InvalidateTokenByID 裸读,-race 必采到指针读写竞争。
|
||||
func TestC9cConcurrentSetDefaultAndRead(t *testing.T) {
|
||||
c9cSetupConfig(t)
|
||||
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) }) // 还原无 Redis 基线
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
// 预生成一个合法 token 供读者 ParseToken(JTI 随机,不会被下方 InvalidateTokenByID 的固定 jti 命中)
|
||||
token, err := GenerateToken(1, "u", "admin", "super_admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
// 写者:在"注入 Redis"与"无 Redis"Manager 间原子置换(模拟启动期/测试期 SetDefaultJWTManager)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
withRedis := NewJWTManagerWithRedis(client)
|
||||
withoutRedis := NewJWTManager()
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if i%2 == 0 {
|
||||
SetDefaultJWTManager(withRedis)
|
||||
} else {
|
||||
SetDefaultJWTManager(withoutRedis)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 读者:并发调用经 currentBlacklist() 的包级函数 + currentManager
|
||||
for range 4 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_, _ = ParseToken(token)
|
||||
_, _ = IsTokenRevoked("some-jti")
|
||||
_ = InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
|
||||
_ = currentManager()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 让竞争窗口运行一段时间,确保 -race 能采到(若有未加锁读)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestC9cCurrentManagerReflectsSwap 验证 SetDefaultJWTManager 原子置换后,
|
||||
// 包级函数经 currentManager 读到的是新 Manager 的 blacklist(C9c 一致性)。
|
||||
func TestC9cCurrentManagerReflectsSwap(t *testing.T) {
|
||||
c9cSetupConfig(t)
|
||||
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) })
|
||||
|
||||
// 基线:无 Redis,InvalidateTokenByID 返 ErrBlacklistUnavailable
|
||||
if err := InvalidateTokenByID("jti-1", time.Now().Add(time.Hour)); !errorIsBlacklistUnavailable(err) {
|
||||
t.Fatalf("baseline without Redis: want ErrBlacklistUnavailable, got %v", err)
|
||||
}
|
||||
|
||||
// 置换为注入 Redis 的 Manager
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
SetDefaultJWTManager(NewJWTManagerWithRedis(client))
|
||||
|
||||
// 置换后包级函数应读到新 blacklist(Redis 可用),InvalidateTokenByID 成功
|
||||
if err := InvalidateTokenByID("jti-2", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("after swap to Redis Manager: InvalidateTokenByID err = %v, want nil", err)
|
||||
}
|
||||
|
||||
// currentManager 应等于刚 Store 的 Manager
|
||||
m := currentManager()
|
||||
if m == nil {
|
||||
t.Fatal("currentManager should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestC9cDefaultJWTAliasConsistent 验证 GetDefaultJWT 与 defaultManager 真实存储一致。
|
||||
func TestC9cDefaultJWTAliasConsistent(t *testing.T) {
|
||||
t.Cleanup(func() { SetDefaultJWTManager(NewJWTManager()) })
|
||||
|
||||
// init 后 GetDefaultJWT 与 currentManager 应指向同一实例
|
||||
if GetDefaultJWT() != currentManager() {
|
||||
t.Fatal("GetDefaultJWT should equal currentManager after init")
|
||||
}
|
||||
|
||||
// SetDefaultJWTManager 后同步
|
||||
m := NewJWTManagerWithRedis(nil)
|
||||
SetDefaultJWTManager(m)
|
||||
if GetDefaultJWT() != m {
|
||||
t.Fatal("GetDefaultJWT should sync after SetDefaultJWTManager")
|
||||
}
|
||||
if currentManager() != m {
|
||||
t.Fatal("currentManager should reflect SetDefaultJWTManager")
|
||||
}
|
||||
}
|
||||
|
||||
// errorIsBlacklistUnavailable 避免内部测试依赖 errors.Is 包装判断。
|
||||
func errorIsBlacklistUnavailable(err error) bool {
|
||||
return err != nil && err.Error() == ErrBlacklistUnavailable.Error()
|
||||
}
|
||||
+391
-30
@@ -1,19 +1,23 @@
|
||||
package jwt_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
gojwt "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func setupTestConfig() {
|
||||
// 设置测试配置
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-12345",
|
||||
Expire: 3600, // 1小时
|
||||
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
|
||||
Expire: time.Hour, // 1小时
|
||||
},
|
||||
}
|
||||
config.Set(cfg)
|
||||
@@ -40,6 +44,7 @@ func TestGenerateToken(t *testing.T) {
|
||||
|
||||
func TestParseToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
setupMiniRedis(t)
|
||||
|
||||
// 先生成 token
|
||||
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
@@ -89,11 +94,13 @@ func TestParseTokenWrongSecret(t *testing.T) {
|
||||
// 修改 secret
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "different-secret",
|
||||
Secret: "different-secret-key-12345678901234567890",
|
||||
Expire: 3600,
|
||||
},
|
||||
}
|
||||
config.Set(cfg)
|
||||
if err := config.Set(cfg); err != nil {
|
||||
t.Fatalf("Set wrong secret config: %v", err)
|
||||
}
|
||||
|
||||
// 应该解析失败
|
||||
_, err := jwt.ParseToken(token)
|
||||
@@ -108,25 +115,27 @@ func TestRefreshToken(t *testing.T) {
|
||||
// 生成 token
|
||||
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
|
||||
// 刷新 token
|
||||
newToken, err := jwt.RefreshToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshToken error: %v", err)
|
||||
// 无 Redis 时 RefreshToken 必须 fail-closed(C9b 修复:旧 token 撤销失败不签发新 token)
|
||||
_, err := jwt.RefreshToken(token)
|
||||
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Errorf("RefreshToken without Redis should fail with ErrBlacklistUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if newToken == "" {
|
||||
t.Error("RefreshToken should return non-empty token")
|
||||
}
|
||||
|
||||
// 新 token 应可解析
|
||||
claims, err := jwt.ParseToken(newToken)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken new token error: %v", err)
|
||||
}
|
||||
|
||||
if claims.Username != "testuser" {
|
||||
t.Error("RefreshToken claims should match original")
|
||||
}
|
||||
// setupMiniRedis 启动 miniredis 并注入到 jwt 包级 tokenBlacklist(经 SetDefaultJWTManager)。
|
||||
// 测试结束 cleanup 还原为无 Redis 的默认 Manager,避免污染后续测试(-shuffle 下尤其关键)。
|
||||
func setupMiniRedis(t *testing.T) *miniredis.Miniredis {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
// 还原包级 tokenBlacklist 为无 Redis 默认 Manager,避免残留指向已关闭 miniredis 的 client。
|
||||
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
|
||||
})
|
||||
// 用注入 Redis 的 Manager 替换默认,使包级 tokenBlacklist 指向 miniredis。
|
||||
jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(client))
|
||||
return mr
|
||||
}
|
||||
|
||||
func TestClaimsStructure(t *testing.T) {
|
||||
@@ -169,15 +178,16 @@ func TestErrorDefinitions(t *testing.T) {
|
||||
func TestTokenBlacklist(t *testing.T) {
|
||||
tb := jwt.TokenBlacklist{}
|
||||
|
||||
// 无 Redis 时,Add 应返回 nil
|
||||
// 无 Redis 时,Add 应返回 ErrBlacklistUnavailable(C9a 修复:fail-closed)
|
||||
err := tb.Add("test-token", time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Errorf("TokenBlacklist.Add without Redis should return nil, got %v", err)
|
||||
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err)
|
||||
}
|
||||
|
||||
// 无 Redis 时,IsBlacklisted 应返回 false
|
||||
if tb.IsBlacklisted("test-token") {
|
||||
t.Error("TokenBlacklist.IsBlacklisted without Redis should return false")
|
||||
// 无 Redis 时,IsBlacklisted 返回 (false, ErrBlacklistUnavailable)(fail-closed:错误上抛)
|
||||
revoked, err := tb.IsBlacklisted("test-token")
|
||||
if revoked || !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Errorf("TokenBlacklist.IsBlacklisted without Redis = (%v, %v), want (false, ErrBlacklistUnavailable)", revoked, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +196,10 @@ func TestInvalidateToken(t *testing.T) {
|
||||
|
||||
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
|
||||
|
||||
// 无 Redis 时应返回 nil
|
||||
// 无 Redis 时应返回 ErrBlacklistUnavailable(C9a 修复:fail-closed,不再静默成功)
|
||||
err := jwt.InvalidateToken(token)
|
||||
if err != nil {
|
||||
t.Errorf("InvalidateToken without Redis should return nil, got %v", err)
|
||||
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Errorf("InvalidateToken without Redis should return ErrBlacklistUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,3 +225,354 @@ func splitToken(token string) []string {
|
||||
result = append(result, token[start:])
|
||||
return result
|
||||
}
|
||||
|
||||
// ===== C9b 回归:刷新令牌撤销闭环 =====
|
||||
|
||||
// 回归 C9b:RefreshToken 成功后,旧 token 必须被拉黑(ParseToken 返 ErrTokenRevoked),
|
||||
// 新 token 可用。旧实现丢弃 Add 错误仍签发新 token,旧 token 仍有效(双有效)。
|
||||
func TestRefreshTokenRevokesOldToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
setupMiniRedis(t)
|
||||
|
||||
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
|
||||
// 刷新
|
||||
newToken, err := jwt.RefreshToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshToken: %v", err)
|
||||
}
|
||||
if newToken == "" {
|
||||
t.Fatal("RefreshToken should return non-empty token")
|
||||
}
|
||||
|
||||
// 新 token 可解析
|
||||
newClaims, err := jwt.ParseToken(newToken)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken new token: %v", err)
|
||||
}
|
||||
if newClaims.Username != "testuser" {
|
||||
t.Error("new token claims should match original")
|
||||
}
|
||||
|
||||
// 旧 token 必须已被拉黑(C9b 核心)
|
||||
_, err = jwt.ParseToken(token)
|
||||
if !errors.Is(err, jwt.ErrTokenRevoked) {
|
||||
t.Errorf("old token after refresh err = %v, want ErrTokenRevoked (C9b: old must be blacklisted)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C9b:Redis 抖动(Add 失败)时 RefreshToken 必须 fail-closed,不签发新 token。
|
||||
// 旧实现丢弃 Add 错误仍签发新 token → 旧 token 未拉黑、新旧双有效。
|
||||
func TestRefreshTokenFailsOnRedisError(t *testing.T) {
|
||||
setupTestConfig()
|
||||
mr := setupMiniRedis(t)
|
||||
|
||||
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
|
||||
// 模拟 Redis 抖动:关闭 miniredis,使 Add 的 Set 失败。
|
||||
mr.Close()
|
||||
|
||||
_, err := jwt.RefreshToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("RefreshToken should fail when Redis unavailable (C9b: must not issue new token)")
|
||||
}
|
||||
// 不应是 ErrBlacklistUnavailable(那是无 Redis 路径),而是 Add 的 Set 错误包装。
|
||||
// 关键:未签发新 token,旧 token 未被拉黑(因 Add 失败)。
|
||||
}
|
||||
|
||||
// 回归 C9a/b:InvalidateToken 闭环——登出后 token 被拉黑、ParseToken 返 ErrTokenRevoked。
|
||||
func TestInvalidateTokenRevokesToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
setupMiniRedis(t)
|
||||
|
||||
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
|
||||
|
||||
// 登出前可解析
|
||||
if _, err := jwt.ParseToken(token); err != nil {
|
||||
t.Fatalf("ParseToken before invalidate: %v", err)
|
||||
}
|
||||
|
||||
// 登出(拉黑)
|
||||
if err := jwt.InvalidateToken(token); err != nil {
|
||||
t.Fatalf("InvalidateToken: %v", err)
|
||||
}
|
||||
|
||||
// 登出后必须被拉黑
|
||||
_, err := jwt.ParseToken(token)
|
||||
if !errors.Is(err, jwt.ErrTokenRevoked) {
|
||||
t.Errorf("ParseToken after invalidate err = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C9a:无 Redis 时 InvalidateTokenByID 返 ErrBlacklistUnavailable(fail-closed)。
|
||||
func TestInvalidateTokenByIDNoRedis(t *testing.T) {
|
||||
setupTestConfig()
|
||||
// 不 setupMiniRedis → tokenBlacklist 指向无 Redis 的 Manager
|
||||
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
|
||||
t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) }) // 还原基线
|
||||
|
||||
err := jwt.InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
|
||||
if !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Errorf("InvalidateTokenByID without Redis err = %v, want ErrBlacklistUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== P0 回归:算法混淆 / 空密钥 / 不支持算法 =====
|
||||
|
||||
// 回归 P0:alg=none 的 token 必须被拒(jwt.WithValidMethods 固定 HMAC 族)。
|
||||
// 这是算法混淆攻击的一种典型形态——攻击者去掉签名并将 alg 置为 none。
|
||||
func TestParseTokenRejectsAlgNone(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
claims := jwt.Claims{UserID: 1, Username: "attacker", Role: "admin", UserType: "super_admin"}
|
||||
// 构造 alg=none 的未签名 token。
|
||||
noneToken := gojwt.NewWithClaims(gojwt.SigningMethodNone, claims)
|
||||
signed, err := noneToken.SignedString(gojwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatalf("构造 none token 失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := jwt.ParseToken(signed); err == nil {
|
||||
t.Error("ParseToken 必须拒绝 alg=none 的 token(算法混淆防护)")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P0:空密钥时 GenerateToken/ParseToken 必须 fail-closed 返 ErrEmptySecret,
|
||||
// 杜绝以零长度 HMAC 密钥签发/校验导致任意 token 通过。
|
||||
func TestEmptySecretFailsClosed(t *testing.T) {
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
|
||||
t.Cleanup(setupTestConfig) // 还原基线,避免污染其它测试
|
||||
|
||||
_, err := jwt.GenerateToken(1, "u", "admin", "admin")
|
||||
if !errors.Is(err, jwt.ErrEmptySecret) {
|
||||
t.Errorf("空密钥 GenerateToken err = %v, want ErrEmptySecret", err)
|
||||
}
|
||||
|
||||
// 校验侧:任意 token 在空密钥下都不得通过。
|
||||
if _, err := jwt.ParseToken("a.b.c"); err == nil {
|
||||
t.Error("空密钥 ParseToken 不得通过任何 token")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P0:配置不支持的算法(如 RS256)时 GenerateToken 必须返 ErrUnsupportedAlgorithm,
|
||||
// 不再静默回退 HS256。
|
||||
func TestUnsupportedAlgorithmFailsClosed(t *testing.T) {
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
Algorithm: "RS256",
|
||||
Expire: time.Hour,
|
||||
}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
|
||||
_, err := jwt.GenerateToken(1, "u", "admin", "admin")
|
||||
if !errors.Is(err, jwt.ErrUnsupportedAlgorithm) {
|
||||
t.Errorf("RS256 GenerateToken err = %v, want ErrUnsupportedAlgorithm", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P0:显式支持的 HS384 仍可正常签发与校验(确认算法固定未误伤合法 HMAC 族)。
|
||||
func TestSupportedAlgorithmHS384(t *testing.T) {
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
Algorithm: "HS384",
|
||||
Expire: time.Hour,
|
||||
}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
setupMiniRedis(t)
|
||||
|
||||
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("HS384 GenerateToken: %v", err)
|
||||
}
|
||||
if _, err := jwt.ParseToken(token); err != nil {
|
||||
t.Errorf("HS384 ParseToken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenRejectsWrongIssuer(t *testing.T) {
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
Expire: time.Hour,
|
||||
Issuer: "trusted-issuer",
|
||||
}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
|
||||
claims := jwt.Claims{
|
||||
UserID: 1,
|
||||
Username: "attacker",
|
||||
Role: "admin",
|
||||
UserType: "super_admin",
|
||||
RegisteredClaims: gojwt.RegisteredClaims{
|
||||
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: gojwt.NewNumericDate(time.Now()),
|
||||
NotBefore: gojwt.NewNumericDate(time.Now()),
|
||||
Issuer: "evil-issuer",
|
||||
},
|
||||
}
|
||||
token := signTokenForTest(t, claims)
|
||||
|
||||
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
|
||||
t.Errorf("ParseToken wrong issuer err = %v, want ErrTokenInvalid", err)
|
||||
}
|
||||
if _, err := jwt.GetClaimsFromToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
|
||||
t.Errorf("GetClaimsFromToken wrong issuer err = %v, want ErrTokenInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenUsesRefreshExpire(t *testing.T) {
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
Expire: time.Hour,
|
||||
RefreshExpire: 4 * time.Hour,
|
||||
}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
setupMiniRedis(t)
|
||||
|
||||
token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
newToken, err := jwt.RefreshToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshToken: %v", err)
|
||||
}
|
||||
claims, err := jwt.ParseToken(newToken)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken refreshed token: %v", err)
|
||||
}
|
||||
ttl := time.Until(claims.ExpiresAt.Time)
|
||||
if ttl < 3*time.Hour || ttl > 4*time.Hour+time.Minute {
|
||||
t.Errorf("refreshed token ttl = %v, want about 4h", ttl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTokenWithCustomExpiryRejectsNonPositive(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
for _, seconds := range []int{0, -1} {
|
||||
if _, err := jwt.GenerateTokenWithCustomExpiry(1, "u", "admin", "admin", seconds); !errors.Is(err, jwt.ErrInvalidExpiry) {
|
||||
t.Errorf("GenerateTokenWithCustomExpiry(%d) err = %v, want ErrInvalidExpiry", seconds, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateTokenByIDRejectsEmptyJTI(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
if err := jwt.InvalidateTokenByID("", time.Now().Add(time.Hour)); !errors.Is(err, jwt.ErrEmptyJTI) {
|
||||
t.Errorf("InvalidateTokenByID empty err = %v, want ErrEmptyJTI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenRejectsEmptyJTI(t *testing.T) {
|
||||
setupTestConfig()
|
||||
setupMiniRedis(t)
|
||||
|
||||
token := signTokenForTest(t, jwt.Claims{
|
||||
UserID: 1,
|
||||
Username: "legacy",
|
||||
Role: "admin",
|
||||
UserType: "admin",
|
||||
RegisteredClaims: gojwt.RegisteredClaims{
|
||||
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: gojwt.NewNumericDate(time.Now()),
|
||||
NotBefore: gojwt.NewNumericDate(time.Now()),
|
||||
Issuer: "xlgo",
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := jwt.RefreshToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
|
||||
t.Errorf("RefreshToken empty JTI err = %v, want ErrEmptyJTI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateTokenRejectsEmptyJTI(t *testing.T) {
|
||||
setupTestConfig()
|
||||
setupMiniRedis(t)
|
||||
|
||||
token := signTokenForTest(t, jwt.Claims{
|
||||
UserID: 1,
|
||||
Username: "legacy",
|
||||
Role: "admin",
|
||||
UserType: "admin",
|
||||
RegisteredClaims: gojwt.RegisteredClaims{
|
||||
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: gojwt.NewNumericDate(time.Now()),
|
||||
NotBefore: gojwt.NewNumericDate(time.Now()),
|
||||
Issuer: "xlgo",
|
||||
},
|
||||
})
|
||||
|
||||
if err := jwt.InvalidateToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
|
||||
t.Errorf("InvalidateToken empty JTI err = %v, want ErrEmptyJTI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenEmptySecretReturnsSentinel(t *testing.T) {
|
||||
setupTestConfig()
|
||||
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken before empty secret: %v", err)
|
||||
}
|
||||
|
||||
config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
|
||||
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrEmptySecret) {
|
||||
t.Fatalf("ParseToken empty secret err = %v, want ErrEmptySecret", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenBlacklistPolicy(t *testing.T) {
|
||||
setupTestConfig()
|
||||
jwt.SetDefaultJWTManager(jwt.NewJWTManager())
|
||||
t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) })
|
||||
|
||||
token, err := jwt.GenerateToken(1, "test", "admin", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Fatalf("ParseToken default fail-closed should reject without Redis, err = %v, want ErrBlacklistUnavailable", err)
|
||||
}
|
||||
if _, err := jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen); err != nil {
|
||||
t.Fatalf("ParseTokenWithBlacklistPolicy fail-open should pass without Redis: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateTokenAllowsFutureNotBeforeToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
mr := setupMiniRedis(t)
|
||||
|
||||
claims := jwt.Claims{
|
||||
UserID: 1,
|
||||
Username: "external",
|
||||
Role: "admin",
|
||||
UserType: "admin",
|
||||
JTI: "future-jti",
|
||||
RegisteredClaims: gojwt.RegisteredClaims{
|
||||
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: gojwt.NewNumericDate(time.Now()),
|
||||
NotBefore: gojwt.NewNumericDate(time.Now().Add(10 * time.Minute)),
|
||||
Issuer: "xlgo",
|
||||
ID: "future-jti",
|
||||
},
|
||||
}
|
||||
token := signTokenForTest(t, claims)
|
||||
|
||||
if err := jwt.InvalidateToken(token); err != nil {
|
||||
t.Fatalf("InvalidateToken future nbf: %v", err)
|
||||
}
|
||||
if !mr.Exists("jwt_bl:future-jti") {
|
||||
t.Fatal("future nbf token JTI should be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
func signTokenForTest(t *testing.T, claims jwt.Claims) string {
|
||||
t.Helper()
|
||||
token, err := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims).SignedString([]byte("test-secret-key-1234567890123456789012"))
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
+12
-17
@@ -1,6 +1,8 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -12,24 +14,17 @@ var Field = struct {
|
||||
Bool func(key string, value bool) zap.Field
|
||||
Uint func(key string, value uint) zap.Field
|
||||
Float64 func(key string, value float64) zap.Field
|
||||
Duration func(key string, value interface{}) zap.Field
|
||||
Duration func(key string, value time.Duration) zap.Field
|
||||
Error func(err error) zap.Field
|
||||
}{
|
||||
String: zap.String,
|
||||
Int: zap.Int,
|
||||
Int64: zap.Int64,
|
||||
Bool: zap.Bool,
|
||||
Uint: zap.Uint,
|
||||
Float64: zap.Float64,
|
||||
Duration: func(key string, value interface{}) zap.Field {
|
||||
switch v := value.(type) {
|
||||
case zap.Field:
|
||||
return v
|
||||
default:
|
||||
return zap.Any(key, value)
|
||||
}
|
||||
},
|
||||
String: zap.String,
|
||||
Int: zap.Int,
|
||||
Int64: zap.Int64,
|
||||
Bool: zap.Bool,
|
||||
Uint: zap.Uint,
|
||||
Float64: zap.Float64,
|
||||
Duration: zap.Duration,
|
||||
Error: func(err error) zap.Field {
|
||||
return zap.Error(err)
|
||||
},
|
||||
return zap.Error(err)
|
||||
},
|
||||
}
|
||||
|
||||
+444
-80
@@ -1,8 +1,12 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
|
||||
@@ -12,17 +16,250 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// Logger 全局日志实例
|
||||
Logger *zap.Logger
|
||||
sugar *zap.SugaredLogger
|
||||
apiLog *zap.Logger
|
||||
dbLog *zap.Logger
|
||||
// 内部 atomic 存储——四个 logger 的真实源(H7 修复)。
|
||||
// 所有包级函数(Info/Debug/.../APILog/DBLog/Sync)经 atomic Load 读取,
|
||||
// 使请求 goroutine 不与 Init/Close 重新装配全局变量竞争。
|
||||
// 原实现用 m.mu(实例锁)保护包级全局变量,锁与被保护对象作用域错配,
|
||||
// 读侧无锁裸读 → 热重载 re-Init/Close 与请求日志存在数据竞争。
|
||||
loggerPtr atomic.Pointer[zap.Logger]
|
||||
sugarPtr atomic.Pointer[zap.SugaredLogger]
|
||||
apiLogPtr atomic.Pointer[zap.Logger]
|
||||
dbLogPtr atomic.Pointer[zap.Logger]
|
||||
|
||||
// defaultLoggerPtr 是包级 facade 使用的默认 manager 原子快照。
|
||||
// 保留导出的 DefaultLogger *LogManager 作为兼容变量;SetDefaultLogManager
|
||||
// 只替换这个内部指针,避免 facade 裸读/裸写导出变量。
|
||||
defaultLoggerPtr atomic.Pointer[LogManager]
|
||||
globalLogGeneration uint64
|
||||
|
||||
// globalMu 串行化包级 logger/writer 的发布与关闭。
|
||||
// 不能仅依赖 LogManager.mu:多个 LogManager 实例并发 Init/Close 时,
|
||||
// 实例锁无法保护 Logger/fileWriters 这类包级状态。
|
||||
globalMu sync.Mutex
|
||||
|
||||
// Logger 全局通用日志实例(兼容别名)。Init 之前为 Nop,调用安全。
|
||||
//
|
||||
// Deprecated: 此导出变量由 Init/Close 在 globalMu 下同步维护,但直接读它在 re-Init/Close
|
||||
// 期间非并发安全(L-E)。请改用包级函数 Info/Debug/Warn/Error/...(经 atomic 读取,
|
||||
// 并发安全)。保留仅为向后兼容,未来大版本可能移除。
|
||||
Logger = zap.NewNop()
|
||||
|
||||
// fileWriters 持有所有 lumberjack 实例引用,
|
||||
// Close() 调用时显式释放文件句柄。
|
||||
// 必要性:lumberjack 不依赖 GC 关闭文件,进程长跑或测试场景下
|
||||
// 不显式关闭会持有句柄导致 Windows 上无法删除日志目录。
|
||||
// 仅在 globalMu 下访问(Init/Close),无读侧竞争。
|
||||
fileWriters []*lumberjack.Logger
|
||||
)
|
||||
|
||||
// Init 初始化日志
|
||||
func Init(cfg *config.Config) error {
|
||||
// 确保日志目录存在
|
||||
if err := os.MkdirAll(cfg.Log.Dir, 0755); err != nil {
|
||||
func init() {
|
||||
// 初始化 atomic 存储为 Nop,保证包级函数在任何时刻 Load 均非 nil。
|
||||
nop := zap.NewNop()
|
||||
loggerPtr.Store(nop)
|
||||
sugarPtr.Store(nop.Sugar())
|
||||
apiLogPtr.Store(nop)
|
||||
dbLogPtr.Store(nop)
|
||||
defaultLoggerPtr.Store(DefaultLogger)
|
||||
}
|
||||
|
||||
// currentLogger 返回通用 logger 的 atomic 快照(永不 nil)。
|
||||
func currentLogger() *zap.Logger {
|
||||
if l := loggerPtr.Load(); l != nil {
|
||||
return l
|
||||
}
|
||||
return zap.NewNop() // 防御:init 后不可达
|
||||
}
|
||||
|
||||
// currentSugar 返回 sugared logger 的 atomic 快照(永不 nil)。
|
||||
func currentSugar() *zap.SugaredLogger {
|
||||
if s := sugarPtr.Load(); s != nil {
|
||||
return s
|
||||
}
|
||||
return zap.NewNop().Sugar() // 防御:init 后不可达
|
||||
}
|
||||
|
||||
// currentAPILog 返回 API logger 的 atomic 快照(永不 nil)。
|
||||
func currentAPILog() *zap.Logger {
|
||||
if l := apiLogPtr.Load(); l != nil {
|
||||
return l
|
||||
}
|
||||
return zap.NewNop()
|
||||
}
|
||||
|
||||
// currentDBLog 返回 DB logger 的 atomic 快照(永不 nil)。
|
||||
func currentDBLog() *zap.Logger {
|
||||
if l := dbLogPtr.Load(); l != nil {
|
||||
return l
|
||||
}
|
||||
return zap.NewNop()
|
||||
}
|
||||
|
||||
// LogManager 日志管理器(#10)。照 database.Manager 模式:
|
||||
// 实例化 + DefaultLogger 全局默认 + 包级 facade 代理。
|
||||
// 包级 Logger/sugar/apiLog/dbLog 由 Init 同步维护,下游 8 个包零改动。
|
||||
type LogManager struct {
|
||||
mu sync.Mutex
|
||||
cfg *config.Config
|
||||
// level 是所有 core 共享的 AtomicLevel,支持运行期 SetLevel 热切换(M19)。
|
||||
// Init 前为 nil,SetLevel 守卫之。
|
||||
level zap.AtomicLevel
|
||||
generation uint64
|
||||
}
|
||||
|
||||
// DefaultSnapshot 是默认 logger 全局状态的 opaque 快照。
|
||||
// App 初始化用它实现“先安装新 logger,后续失败可恢复旧 logger”的事务边界。
|
||||
type DefaultSnapshot struct {
|
||||
manager *LogManager
|
||||
logger *zap.Logger
|
||||
sugar *zap.SugaredLogger
|
||||
apiLog *zap.Logger
|
||||
dbLog *zap.Logger
|
||||
writers []*lumberjack.Logger
|
||||
generation uint64
|
||||
}
|
||||
|
||||
// DefaultLogger 默认日志管理器,包级 facade 代理到它。
|
||||
//
|
||||
// Deprecated: 直接替换该导出变量(logger.DefaultLogger = m)不会更新包级 facade
|
||||
// 使用的 atomic 快照,也不是并发安全的替换方式。请用 SetDefaultLogManager(m)。
|
||||
// 直接调用 DefaultLogger.Init/Close/SetLevel/GetLevel 仍保留兼容,并受 LogManager
|
||||
// 自身锁保护。
|
||||
var DefaultLogger = NewLogManager()
|
||||
|
||||
// NewLogManager 创建日志管理器实例。
|
||||
func NewLogManager() *LogManager { return &LogManager{} }
|
||||
|
||||
// GetDefaultLogManager 返回全局默认 LogManager(atomic 读取,并发安全)。
|
||||
func GetDefaultLogManager() *LogManager {
|
||||
if m := defaultLoggerPtr.Load(); m != nil {
|
||||
return m
|
||||
}
|
||||
if DefaultLogger != nil && defaultLoggerPtr.CompareAndSwap(nil, DefaultLogger) {
|
||||
return DefaultLogger
|
||||
}
|
||||
m := NewLogManager()
|
||||
if defaultLoggerPtr.CompareAndSwap(nil, m) {
|
||||
return m
|
||||
}
|
||||
return defaultLoggerPtr.Load()
|
||||
}
|
||||
|
||||
// SetLevel 运行期热切换日志级别(M19)。需先 Init;未 Init 时返回 false。
|
||||
// 影响所有 core(app/api/db/console)——它们共享同一 AtomicLevel。
|
||||
func (m *LogManager) SetLevel(l zapcore.Level) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.level == (zap.AtomicLevel{}) {
|
||||
return false
|
||||
}
|
||||
m.level.SetLevel(l)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetLevel 返回当前日志级别(未 Init 返回 InfoLevel)。
|
||||
func (m *LogManager) GetLevel() zapcore.Level {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.level == (zap.AtomicLevel{}) {
|
||||
return zapcore.InfoLevel
|
||||
}
|
||||
return m.level.Level()
|
||||
}
|
||||
|
||||
// SetLevel 包级 facade:运行期热切换默认日志级别(M19)。未 Init 时无操作返回 false。
|
||||
func SetLevel(l zapcore.Level) bool { return GetDefaultLogManager().SetLevel(l) }
|
||||
|
||||
// GetLevel 包级 facade:返回默认日志当前级别。
|
||||
func GetLevel() zapcore.Level { return GetDefaultLogManager().GetLevel() }
|
||||
|
||||
// SetDefaultLogManager 提升指定 LogManager 为全局默认(atomic.Store,并发安全)。
|
||||
func SetDefaultLogManager(m *LogManager) {
|
||||
if m != nil {
|
||||
defaultLoggerPtr.Store(m)
|
||||
}
|
||||
}
|
||||
|
||||
// SnapshotDefault 返回默认 logger 全局状态快照。
|
||||
// 快照仅用于 App 初始化回滚;调用方成功提交后必须 CloseDefaultSnapshot 释放旧 writers。
|
||||
func SnapshotDefault() *DefaultSnapshot {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
return &DefaultSnapshot{
|
||||
manager: GetDefaultLogManager(),
|
||||
logger: currentLogger(),
|
||||
sugar: currentSugar(),
|
||||
apiLog: currentAPILog(),
|
||||
dbLog: currentDBLog(),
|
||||
writers: append([]*lumberjack.Logger(nil), fileWriters...),
|
||||
generation: globalLogGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreDefaultSnapshot 恢复 SnapshotDefault 捕获的默认 logger 状态,并关闭当前新 writers。
|
||||
func RestoreDefaultSnapshot(s *DefaultSnapshot) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
globalMu.Lock()
|
||||
newLogger := currentLogger()
|
||||
newAPILog := currentAPILog()
|
||||
newDBLog := currentDBLog()
|
||||
newWriters := fileWriters
|
||||
loggerPtr.Store(s.logger)
|
||||
sugarPtr.Store(s.sugar)
|
||||
apiLogPtr.Store(s.apiLog)
|
||||
dbLogPtr.Store(s.dbLog)
|
||||
Logger = s.logger
|
||||
fileWriters = append([]*lumberjack.Logger(nil), s.writers...)
|
||||
globalLogGeneration = s.generation
|
||||
globalMu.Unlock()
|
||||
if s.manager != nil {
|
||||
SetDefaultLogManager(s.manager)
|
||||
}
|
||||
return errors.Join(syncLoggers(newLogger, newAPILog, newDBLog), closeFileWriters(newWriters))
|
||||
}
|
||||
|
||||
// CloseDefaultSnapshot 关闭 SnapshotDefault 捕获的旧 writers;用于新 logger 已提交成功后释放旧资源。
|
||||
func CloseDefaultSnapshot(s *DefaultSnapshot) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.Join(syncLoggers(s.logger, s.apiLog, s.dbLog), closeFileWriters(s.writers))
|
||||
}
|
||||
|
||||
// Init 初始化日志。
|
||||
//
|
||||
// 三个 logger 的分流策略:
|
||||
// - Logger(通用):写 console + logs/app.log
|
||||
// - APILog() :写 console + logs/api.log
|
||||
// - DBLog() :写 console + logs/database.log
|
||||
//
|
||||
// 关键修复(v1.0.3):旧实现把 apiCore 和 dbCore 都 Tee 进通用 Logger,
|
||||
// 导致每条 logger.Info(...) 都会同时落到 api.log + database.log + console
|
||||
// 三份,磁盘占用翻倍且分流形同虚设。新实现通用 Logger 只走独立的 app.log。
|
||||
func (m *LogManager) Init(cfg *config.Config) error {
|
||||
return m.init(cfg, true)
|
||||
}
|
||||
|
||||
// InitPreservingPrevious 初始化并发布 logger,但暂不关闭被替换的旧 writers。
|
||||
// App.Init 使用该方法配合 SnapshotDefault,在后续 hook 失败时可恢复旧 logger。
|
||||
func (m *LogManager) InitPreservingPrevious(cfg *config.Config) error {
|
||||
return m.init(cfg, false)
|
||||
}
|
||||
|
||||
func (m *LogManager) init(cfg *config.Config, closePrevious bool) error {
|
||||
if cfg == nil {
|
||||
return errors.New("logger: 配置为空")
|
||||
}
|
||||
if err := validateLogConfig(cfg.Log); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 确保日志目录存在(0750:owner+group 可访问,与 storage 目录权限一致)
|
||||
if err := os.MkdirAll(cfg.Log.Dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -42,132 +279,259 @@ func Init(cfg *config.Config) error {
|
||||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||
}
|
||||
|
||||
// 根据运行模式设置日志级别
|
||||
var level zapcore.Level
|
||||
// 根据运行模式设置日志级别(M19:用 AtomicLevel 支持运行期 SetLevel 热切换)
|
||||
level := zap.NewAtomicLevelAt(zapcore.DebugLevel)
|
||||
if cfg.IsProduction() {
|
||||
level = zapcore.WarnLevel
|
||||
} else {
|
||||
level = zapcore.DebugLevel
|
||||
level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
|
||||
}
|
||||
|
||||
// API 日志
|
||||
apiWriter := &lumberjack.Logger{
|
||||
Filename: filepath.Join(cfg.Log.Dir, "api.log"),
|
||||
MaxSize: cfg.Log.MaxSize,
|
||||
MaxBackups: cfg.Log.MaxBackups,
|
||||
MaxAge: cfg.Log.MaxAge,
|
||||
Compress: cfg.Log.Compress,
|
||||
}
|
||||
|
||||
// 数据库日志
|
||||
dbWriter := &lumberjack.Logger{
|
||||
Filename: filepath.Join(cfg.Log.Dir, "database.log"),
|
||||
MaxSize: cfg.Log.MaxSize,
|
||||
MaxBackups: cfg.Log.MaxBackups,
|
||||
MaxAge: cfg.Log.MaxAge,
|
||||
Compress: cfg.Log.Compress,
|
||||
}
|
||||
|
||||
// 控制台输出
|
||||
jsonEncoder := zapcore.NewJSONEncoder(encoderConfig)
|
||||
consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||||
consoleWriter := zapcore.AddSync(os.Stdout)
|
||||
consoleCore := zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), level)
|
||||
|
||||
// 创建核心
|
||||
apiCore := zapcore.NewCore(
|
||||
zapcore.NewJSONEncoder(encoderConfig),
|
||||
zapcore.AddSync(apiWriter),
|
||||
level,
|
||||
// 通用日志独立文件,避免与 api/db 日志重复写入
|
||||
appWriter := newRotatingWriter(cfg.Log, "app.log")
|
||||
apiWriter := newRotatingWriter(cfg.Log, "api.log")
|
||||
dbWriter := newRotatingWriter(cfg.Log, "database.log")
|
||||
|
||||
appCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(appWriter), level)
|
||||
apiCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(apiWriter), level)
|
||||
dbCore := zapcore.NewCore(jsonEncoder, zapcore.AddSync(dbWriter), level)
|
||||
|
||||
// 三个 logger 各写自己的文件 + console,互不 Tee
|
||||
newLogger := zap.New(
|
||||
zapcore.NewTee(appCore, consoleCore),
|
||||
zap.AddCaller(), zap.AddCallerSkip(1),
|
||||
)
|
||||
newAPILog := zap.New(
|
||||
zapcore.NewTee(apiCore, consoleCore),
|
||||
zap.AddCaller(), zap.AddCallerSkip(1),
|
||||
)
|
||||
newDBLog := zap.New(
|
||||
zapcore.NewTee(dbCore, consoleCore),
|
||||
zap.AddCaller(), zap.AddCallerSkip(1),
|
||||
)
|
||||
|
||||
dbCore := zapcore.NewCore(
|
||||
zapcore.NewJSONEncoder(encoderConfig),
|
||||
zapcore.AddSync(dbWriter),
|
||||
level,
|
||||
)
|
||||
// 全部构造成功后再原子替换全局变量,避免半初始化状态。
|
||||
// 先发布新 logger,再关闭旧 writer,避免请求 goroutine 在替换窗口内
|
||||
// 通过包级函数拿到仍指向已关闭 writer 的旧 logger。
|
||||
// H7:四个 logger 经 atomic.Pointer Store,读侧(请求 goroutine)无锁原子 load;
|
||||
// fileWriters 仅在 globalMu 下访问。Logger 兼容别名同步维护。
|
||||
globalMu.Lock()
|
||||
oldWriters := fileWriters
|
||||
globalLogGeneration++
|
||||
m.generation = globalLogGeneration
|
||||
loggerPtr.Store(newLogger)
|
||||
sugarPtr.Store(newLogger.Sugar())
|
||||
apiLogPtr.Store(newAPILog)
|
||||
dbLogPtr.Store(newDBLog)
|
||||
Logger = newLogger
|
||||
fileWriters = []*lumberjack.Logger{appWriter, apiWriter, dbWriter}
|
||||
globalMu.Unlock()
|
||||
|
||||
consoleCore := zapcore.NewCore(
|
||||
consoleEncoder,
|
||||
consoleWriter,
|
||||
level,
|
||||
)
|
||||
|
||||
// 合并核心
|
||||
core := zapcore.NewTee(apiCore, dbCore, consoleCore)
|
||||
|
||||
// 创建日志实例
|
||||
Logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
|
||||
sugar = Logger.Sugar()
|
||||
|
||||
// 创建专用日志实例
|
||||
apiLog = zap.New(apiCore, zap.AddCaller(), zap.AddCallerSkip(1))
|
||||
dbLog = zap.New(dbCore, zap.AddCaller(), zap.AddCallerSkip(1))
|
||||
m.level = level
|
||||
m.cfg = cfg
|
||||
|
||||
if closePrevious {
|
||||
return closeFileWriters(oldWriters)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync 同步日志
|
||||
func Sync() {
|
||||
if Logger != nil {
|
||||
_ = Logger.Sync()
|
||||
// Init 包级 facade:初始化日志(代理到 DefaultLogger)。
|
||||
func Init(cfg *config.Config) error {
|
||||
return GetDefaultLogManager().Init(cfg)
|
||||
}
|
||||
|
||||
func validateLogConfig(cfg config.LogConfig) error {
|
||||
if strings.TrimSpace(cfg.Dir) == "" {
|
||||
return errors.New("logger: 日志目录为空")
|
||||
}
|
||||
if cfg.MaxSize < 0 {
|
||||
return errors.New("logger: MaxSize 不能为负数")
|
||||
}
|
||||
if cfg.MaxBackups < 0 {
|
||||
return errors.New("logger: MaxBackups 不能为负数")
|
||||
}
|
||||
if cfg.MaxAge < 0 {
|
||||
return errors.New("logger: MaxAge 不能为负数")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newRotatingWriter 创建带 lumberjack 滚动归档的 writer
|
||||
func newRotatingWriter(cfg config.LogConfig, filename string) *lumberjack.Logger {
|
||||
return &lumberjack.Logger{
|
||||
Filename: filepath.Join(cfg.Dir, filename),
|
||||
MaxSize: cfg.MaxSize,
|
||||
MaxBackups: cfg.MaxBackups,
|
||||
MaxAge: cfg.MaxAge,
|
||||
Compress: cfg.Compress,
|
||||
}
|
||||
}
|
||||
|
||||
// closeFileWriters 关闭传入的 lumberjack writer,并聚合关闭错误。
|
||||
func closeFileWriters(writers []*lumberjack.Logger) error {
|
||||
var errs []error
|
||||
for _, w := range writers {
|
||||
if w != nil {
|
||||
if err := w.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func syncLoggers(loggers ...*zap.Logger) error {
|
||||
var errs []error
|
||||
for _, l := range loggers {
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
if err := l.Sync(); err != nil && !isHarmlessSyncError(err) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Sync 同步全部 logger 缓冲到底层 writer。
|
||||
//
|
||||
// 注意:在 Windows / 部分 *nix 平台上对 stdout/stderr 调用 Sync 会返回
|
||||
// "invalid argument" / "inappropriate ioctl for device",属于 zap 已知行为,
|
||||
// 这里把这类错误识别并忽略,只返回真实的写入失败。
|
||||
func (m *LogManager) Sync() error {
|
||||
return syncLoggers(currentLogger(), currentAPILog(), currentDBLog())
|
||||
}
|
||||
|
||||
// Sync 包级 facade:同步全部 logger 缓冲(代理到 DefaultLogger)。
|
||||
func Sync() error {
|
||||
return GetDefaultLogManager().Sync()
|
||||
}
|
||||
|
||||
// Close 关闭日志文件句柄,重置全局 logger 为 Nop。
|
||||
// 通常由 App.Shutdown 在 Sync 之后调用;测试场景需要清理临时目录时也应调用。
|
||||
//
|
||||
// 调用后再次写日志不会 panic(fall back to nop logger),但不会写入文件。
|
||||
// 如需重新启用,请再次调用 Init。
|
||||
func (m *LogManager) Close() error {
|
||||
m.mu.Lock()
|
||||
globalMu.Lock()
|
||||
if m.generation == 0 || m.generation != globalLogGeneration {
|
||||
m.level = zap.AtomicLevel{}
|
||||
m.cfg = nil
|
||||
m.generation = 0
|
||||
globalMu.Unlock()
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
oldLogger := currentLogger()
|
||||
oldAPILog := currentAPILog()
|
||||
oldDBLog := currentDBLog()
|
||||
oldWriters := fileWriters
|
||||
|
||||
// H7:重置为 Nop 经 atomic Store,与读侧一致;Logger 兼容别名同步。
|
||||
nop := zap.NewNop()
|
||||
loggerPtr.Store(nop)
|
||||
sugarPtr.Store(nop.Sugar())
|
||||
apiLogPtr.Store(nop)
|
||||
dbLogPtr.Store(nop)
|
||||
Logger = nop
|
||||
fileWriters = nil
|
||||
globalMu.Unlock()
|
||||
|
||||
m.level = zap.AtomicLevel{}
|
||||
m.cfg = nil
|
||||
m.generation = 0
|
||||
m.mu.Unlock()
|
||||
|
||||
return errors.Join(
|
||||
syncLoggers(oldLogger, oldAPILog, oldDBLog),
|
||||
closeFileWriters(oldWriters),
|
||||
)
|
||||
}
|
||||
|
||||
// Close 包级 facade:关闭日志文件句柄,重置为 Nop(代理到 DefaultLogger)。
|
||||
func Close() error {
|
||||
return GetDefaultLogManager().Close()
|
||||
}
|
||||
|
||||
// isHarmlessSyncError 识别 stdout/stderr Sync 在不同平台返回的预期错误。
|
||||
// 这些错误来自 console core,对真实 writer 无影响,可安全忽略。
|
||||
func isHarmlessSyncError(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, sub := range []string{
|
||||
"invalid argument", // Linux stdout
|
||||
"inappropriate ioctl for device", // macOS stdout
|
||||
"bad file descriptor", // Windows stdout
|
||||
} {
|
||||
if strings.Contains(msg, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Debug 调试日志
|
||||
func Debug(msg string, fields ...zap.Field) {
|
||||
Logger.Debug(msg, fields...)
|
||||
currentLogger().Debug(msg, fields...)
|
||||
}
|
||||
|
||||
// Info 信息日志
|
||||
func Info(msg string, fields ...zap.Field) {
|
||||
Logger.Info(msg, fields...)
|
||||
currentLogger().Info(msg, fields...)
|
||||
}
|
||||
|
||||
// Warn 警告日志
|
||||
func Warn(msg string, fields ...zap.Field) {
|
||||
Logger.Warn(msg, fields...)
|
||||
currentLogger().Warn(msg, fields...)
|
||||
}
|
||||
|
||||
// Error 错误日志
|
||||
func Error(msg string, fields ...zap.Field) {
|
||||
Logger.Error(msg, fields...)
|
||||
currentLogger().Error(msg, fields...)
|
||||
}
|
||||
|
||||
// Fatal 致命错误日志
|
||||
// Fatal 致命错误日志(仅供应用层使用,框架内部禁止调用)
|
||||
func Fatal(msg string, fields ...zap.Field) {
|
||||
Logger.Fatal(msg, fields...)
|
||||
currentLogger().Fatal(msg, fields...)
|
||||
}
|
||||
|
||||
// Debugf 格式化调试日志
|
||||
func Debugf(template string, args ...any) {
|
||||
sugar.Debugf(template, args...)
|
||||
currentSugar().Debugf(template, args...)
|
||||
}
|
||||
|
||||
// Infof 格式化信息日志
|
||||
func Infof(template string, args ...any) {
|
||||
sugar.Infof(template, args...)
|
||||
currentSugar().Infof(template, args...)
|
||||
}
|
||||
|
||||
// Warnf 格式化警告日志
|
||||
func Warnf(template string, args ...any) {
|
||||
sugar.Warnf(template, args...)
|
||||
currentSugar().Warnf(template, args...)
|
||||
}
|
||||
|
||||
// Errorf 格式化错误日志
|
||||
func Errorf(template string, args ...any) {
|
||||
sugar.Errorf(template, args...)
|
||||
currentSugar().Errorf(template, args...)
|
||||
}
|
||||
|
||||
// Fatalf 格式化致命错误日志
|
||||
// Fatalf 格式化致命错误日志(仅供应用层使用,框架内部禁止调用)
|
||||
func Fatalf(template string, args ...any) {
|
||||
sugar.Fatalf(template, args...)
|
||||
currentSugar().Fatalf(template, args...)
|
||||
}
|
||||
|
||||
// APILog API 专用日志
|
||||
// APILog 返回 API 专用日志器(写 logs/api.log + console)
|
||||
func APILog() *zap.Logger {
|
||||
return apiLog
|
||||
return currentAPILog()
|
||||
}
|
||||
|
||||
// DBLog 数据库专用日志
|
||||
// DBLog 返回数据库专用日志器(写 logs/database.log + console)
|
||||
func DBLog() *zap.Logger {
|
||||
return dbLog
|
||||
return currentDBLog()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// tmpCfg 构造一个指向临时目录的日志配置,用于 Init。
|
||||
func tmpCfg(dir string) *config.Config {
|
||||
return &config.Config{
|
||||
Server: config.ServerConfig{Mode: "production"},
|
||||
Log: config.LogConfig{
|
||||
Dir: dir,
|
||||
MaxSize: 1,
|
||||
MaxBackups: 1,
|
||||
MaxAge: 1,
|
||||
Compress: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestH7ConcurrentInitCloseAndRead 验证请求 goroutine 的包级日志读路径
|
||||
// 与 Init/Close 重新装配全局 logger 无数据竞争(-race)。
|
||||
//
|
||||
// H7 根因:原实现 Info/Error/APILog/DBLog/Sync 裸读包级 Logger/sugar/apiLog/dbLog,
|
||||
// 而 Init/Close 在 m.mu 下写这些变量——实例锁保护全局变量、读侧无锁 → re-Init/Close
|
||||
// 与请求日志竞争。修复后读路径经 atomic.Pointer Load。
|
||||
//
|
||||
// 红/绿验证:将 currentLogger/currentSugar/currentAPILog/currentDBLog 临时改为
|
||||
// 裸读 Logger/sugar/apiLog/dbLog(或等价地恢复包级裸读),-race 必复现 DATA RACE;
|
||||
// 恢复 atomic 后绿。
|
||||
func TestH7ConcurrentInitCloseAndRead(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := tmpCfg(dir)
|
||||
|
||||
// 确保默认 LogManager 起点干净。
|
||||
_ = GetDefaultLogManager().Close()
|
||||
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 写者:循环 Init/Close(重新装配全局 logger)。
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = GetDefaultLogManager().Init(cfg)
|
||||
_ = GetDefaultLogManager().Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// 读者:并发调用读路径(包级函数 + atomic 快照读取)。
|
||||
// 仅读取 logger 指针——这正是被竞态的访问;不调用 .Info/.Errorf 等写方法,
|
||||
// 避免对已被 re-Init 关闭的旧 writer 触发 lumberjack 重新打开同一文件,
|
||||
// 导致测试 TempDir 清理时句柄仍占用(框架不支持 re-Init 与在途写入并发,
|
||||
// 该约束非 H7 范围)。
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
// 这些调用内部经 atomic Load 读取包级 logger 指针。
|
||||
_ = currentLogger()
|
||||
_ = currentSugar()
|
||||
_ = currentAPILog()
|
||||
_ = currentDBLog()
|
||||
_ = APILog()
|
||||
_ = DBLog()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 运行窗口足以让 race detector 采到任何竞争。
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
// 收尾到 Nop,避免后续测试持有临时目录句柄。
|
||||
_ = GetDefaultLogManager().Close()
|
||||
}
|
||||
|
||||
// TestH7CurrentLoggerReflectsInitAndClose 验证 atomic 快照随 Init/Close 正确切换:
|
||||
// Init 后 currentLogger 写入文件;Close 后回到 Nop(不写文件)。
|
||||
func TestH7CurrentLoggerReflectsInitAndClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := tmpCfg(dir)
|
||||
|
||||
_ = GetDefaultLogManager().Close()
|
||||
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
|
||||
|
||||
// Init 前:Nop,调用安全且不写文件。
|
||||
before := currentLogger()
|
||||
if before == nil {
|
||||
t.Fatal("currentLogger nil before Init")
|
||||
}
|
||||
|
||||
if err := GetDefaultLogManager().Init(cfg); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
// Init 后:currentLogger 与 Logger 兼容别名一致,且为非 Nop 实例。
|
||||
after := currentLogger()
|
||||
if after == nil {
|
||||
t.Fatal("currentLogger nil after Init")
|
||||
}
|
||||
if after != Logger {
|
||||
t.Error("after Init, currentLogger() != Logger (compat alias out of sync)")
|
||||
}
|
||||
|
||||
// 写一条日志并 flush,验证落到文件(说明拿到的是真实 logger,非 Nop)。
|
||||
const mark = "H7_INIT_REFLECT_xyz"
|
||||
after.Info(mark)
|
||||
_ = GetDefaultLogManager().Sync()
|
||||
|
||||
data, err := readFile(t, filepath.Join(dir, "app.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("read app.log: %v", err)
|
||||
}
|
||||
if !strings.Contains(data, mark) {
|
||||
t.Errorf("app.log missing mark %q after Init (got Nop?)", mark)
|
||||
}
|
||||
|
||||
// Close 后:currentLogger 回到 Nop(与 Init 前实例不同,但同为 Nop 行为)。
|
||||
if err := GetDefaultLogManager().Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
closed := currentLogger()
|
||||
if closed == nil {
|
||||
t.Fatal("currentLogger nil after Close")
|
||||
}
|
||||
// Nop logger 写不报错也不落盘;验证 Close 后再写不新增 mark。
|
||||
closed.Info("H7_SHOULD_NOT_APPEAR_xyz")
|
||||
_ = GetDefaultLogManager().Sync()
|
||||
data2, _ := readFile(t, filepath.Join(dir, "app.log"))
|
||||
if strings.Contains(data2, "H7_SHOULD_NOT_APPEAR_xyz") {
|
||||
t.Error("wrote to file after Close (expected Nop)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestH7AtomicPointersNonNil 验证 init 后四个 atomic 快照永不 nil,
|
||||
// 且 APILog/DBLog 返回与内部 atomic 一致的实例。
|
||||
func TestH7AtomicPointersNonNil(t *testing.T) {
|
||||
if loggerPtr.Load() == nil {
|
||||
t.Error("loggerPtr nil after init")
|
||||
}
|
||||
if sugarPtr.Load() == nil {
|
||||
t.Error("sugarPtr nil after init")
|
||||
}
|
||||
if apiLogPtr.Load() == nil {
|
||||
t.Error("apiLogPtr nil after init")
|
||||
}
|
||||
if dbLogPtr.Load() == nil {
|
||||
t.Error("dbLogPtr nil after init")
|
||||
}
|
||||
if APILog() != apiLogPtr.Load() {
|
||||
t.Error("APILog() != apiLogPtr")
|
||||
}
|
||||
if DBLog() != dbLogPtr.Load() {
|
||||
t.Error("DBLog() != dbLogPtr")
|
||||
}
|
||||
if currentLogger() != loggerPtr.Load() {
|
||||
t.Error("currentLogger() != loggerPtr")
|
||||
}
|
||||
}
|
||||
|
||||
// TestH7DefaultLoggerCompatibility 锁定 M3 的兼容边界:
|
||||
// DefaultLogger 仍保持 *LogManager 类型,旧代码可继续直接调用其方法;
|
||||
// 包级 facade 的默认 manager 替换则必须走 SetDefaultLogManager 的 atomic 快照。
|
||||
func TestH7DefaultLoggerCompatibility(t *testing.T) {
|
||||
var _ *LogManager = DefaultLogger
|
||||
|
||||
old := GetDefaultLogManager()
|
||||
t.Cleanup(func() { SetDefaultLogManager(old) })
|
||||
|
||||
next := NewLogManager()
|
||||
SetDefaultLogManager(next)
|
||||
if got := GetDefaultLogManager(); got != next {
|
||||
t.Fatalf("GetDefaultLogManager() = %p, want %p", got, next)
|
||||
}
|
||||
}
|
||||
|
||||
// TestM3StaleManagerCloseDoesNotCloseCurrentLogger 回归:旧 manager 的 Close
|
||||
// 不得关闭另一个 manager 后续发布的全局 logger/writer。
|
||||
func TestM3StaleManagerCloseDoesNotCloseCurrentLogger(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
m1 := NewLogManager()
|
||||
m2 := NewLogManager()
|
||||
t.Cleanup(func() {
|
||||
_ = m1.Close()
|
||||
_ = m2.Close()
|
||||
SetDefaultLogManager(NewLogManager())
|
||||
})
|
||||
|
||||
if err := m1.Init(tmpCfg(dir1)); err != nil {
|
||||
t.Fatalf("m1.Init: %v", err)
|
||||
}
|
||||
if err := m2.Init(tmpCfg(dir2)); err != nil {
|
||||
t.Fatalf("m2.Init: %v", err)
|
||||
}
|
||||
if err := m1.Close(); err != nil {
|
||||
t.Fatalf("stale m1.Close: %v", err)
|
||||
}
|
||||
|
||||
const mark = "M3_CURRENT_LOGGER_SURVIVES_STALE_CLOSE"
|
||||
currentLogger().Info(mark)
|
||||
_ = syncLoggers(currentLogger())
|
||||
data, err := readFile(t, filepath.Join(dir2, "app.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("read current app.log: %v", err)
|
||||
}
|
||||
if !strings.Contains(data, mark) {
|
||||
t.Fatal("stale manager Close closed the current logger")
|
||||
}
|
||||
}
|
||||
|
||||
// TestH7DurationFieldFix 验证 H7b:Field.Duration 签名改为
|
||||
// func(key string, value time.Duration) zap.Field,且 key 不再被丢弃。
|
||||
//
|
||||
// 旧实现 func(key string, value interface{}) 在 case zap.Field 分支 return v
|
||||
// 丢弃 key,签名与实现矛盾。修复后直接委托 zap.Duration(key, value)。
|
||||
func TestH7DurationFieldFix(t *testing.T) {
|
||||
f := Field.Duration("elapsed", 5*time.Second)
|
||||
if f.Key != "elapsed" {
|
||||
t.Errorf("Duration key lost: got %q, want %q (H7b regression)", f.Key, "elapsed")
|
||||
}
|
||||
// zap.Duration 用 zapcore.DurationType 编码,Integer 字段承载纳秒。
|
||||
if f.Integer != int64(5*time.Second) {
|
||||
t.Errorf("Duration value mismatch: got %d, want %d", f.Integer, int64(5*time.Second))
|
||||
}
|
||||
|
||||
// 零值与其他字段不受影响。
|
||||
f2 := Field.Duration("d0", 0)
|
||||
if f2.Key != "d0" {
|
||||
t.Errorf("Duration zero key lost: %q", f2.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestH7DurationFieldSignature 编译期锁定 Field.Duration 的签名为
|
||||
// func(string, time.Duration) zap.Field(H7b 修复的核心:类型安全签名)。
|
||||
//
|
||||
// 旧签名为 func(string, interface{}) zap.Field,其 case zap.Field 分支 return v
|
||||
// 丢弃传入的 key。若回退旧签名,下方赋值将因参数类型不匹配(interface{} ≠ time.Duration)
|
||||
// 编译失败——即"修复前红、修复后绿"由编译器强制保证。
|
||||
func TestH7DurationFieldSignature(t *testing.T) {
|
||||
var _ func(string, time.Duration) zap.Field = Field.Duration
|
||||
}
|
||||
|
||||
// readFile 读取文件内容,文件不存在时返回空串(不报错)。
|
||||
func readFile(t *testing.T, path string) (string, error) {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// TestSetLevelHotSwap_M19:Init 后可运行期热切换日志级别(M19)。
|
||||
func TestSetLevelHotSwap_M19(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := tmpCfg(dir)
|
||||
_ = GetDefaultLogManager().Close()
|
||||
t.Cleanup(func() { _ = GetDefaultLogManager().Close() })
|
||||
|
||||
if err := GetDefaultLogManager().Init(cfg); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
// Init 后(tmpCfg 为 production 模式,默认 InfoLevel)可热切换到 ErrorLevel。
|
||||
before := GetDefaultLogManager().GetLevel()
|
||||
if before != zapcore.InfoLevel {
|
||||
t.Errorf("default level = %v, want InfoLevel (production)", before)
|
||||
}
|
||||
if ok := GetDefaultLogManager().SetLevel(zapcore.ErrorLevel); !ok {
|
||||
t.Fatal("SetLevel after Init should return true")
|
||||
}
|
||||
if got := GetDefaultLogManager().GetLevel(); got != zapcore.ErrorLevel {
|
||||
t.Errorf("after SetLevel, level = %v, want ErrorLevel", got)
|
||||
}
|
||||
|
||||
// 包级 facade 同步。
|
||||
SetLevel(zapcore.WarnLevel)
|
||||
if got := GetLevel(); got != zapcore.WarnLevel {
|
||||
t.Errorf("package GetLevel = %v, want WarnLevel", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestM3ConcurrentInitSetLevelAndClose 验证 Init/SetLevel/GetLevel/Close
|
||||
// 共享同一个 manager 临界区,不再出现 m.level 锁外写与锁内读写竞争(-race)。
|
||||
func TestM3ConcurrentInitSetLevelAndClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := tmpCfg(dir)
|
||||
m := NewLogManager()
|
||||
SetDefaultLogManager(m)
|
||||
t.Cleanup(func() {
|
||||
_ = m.Close()
|
||||
SetDefaultLogManager(NewLogManager())
|
||||
})
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = m.Init(cfg)
|
||||
_ = m.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
levels := []zapcore.Level{
|
||||
zapcore.DebugLevel,
|
||||
zapcore.InfoLevel,
|
||||
zapcore.WarnLevel,
|
||||
zapcore.ErrorLevel,
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = m.SetLevel(levels[i%len(levels)])
|
||||
_ = m.GetLevel()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestM3ConcurrentSetDefaultAndFacades 验证默认 LogManager 经 atomic.Pointer
|
||||
// 读写,SetDefaultLogManager 与包级 facade 并发时无裸全局指针竞态(-race)。
|
||||
func TestM3ConcurrentSetDefaultAndFacades(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
_ = Close()
|
||||
SetDefaultLogManager(NewLogManager())
|
||||
})
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
SetDefaultLogManager(NewLogManager())
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = GetLevel()
|
||||
_ = SetLevel(zapcore.WarnLevel)
|
||||
_ = Sync()
|
||||
_ = APILog()
|
||||
_ = DBLog()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestM3ConcurrentManagersInitClose 验证不同 LogManager 实例并发 Init/Close 时,
|
||||
// 包级 Logger/fileWriters 的发布与关闭由 globalMu 串行化,不再依赖实例锁保护包级状态。
|
||||
func TestM3ConcurrentManagersInitClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := tmpCfg(dir)
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
managers := make([]*LogManager, 3)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
managers[i] = NewLogManager()
|
||||
wg.Add(1)
|
||||
go func(m *LogManager) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
SetDefaultLogManager(m)
|
||||
_ = m.Init(cfg)
|
||||
_ = m.Close()
|
||||
}
|
||||
}(managers[i])
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = currentLogger()
|
||||
_ = currentSugar()
|
||||
_ = APILog()
|
||||
_ = DBLog()
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
for _, m := range managers {
|
||||
_ = m.Close()
|
||||
}
|
||||
_ = Close()
|
||||
SetDefaultLogManager(NewLogManager())
|
||||
}
|
||||
|
||||
// TestM3InitRejectsInvalidLogConfig 验证 logger.Init 对明显非法日志配置失败,
|
||||
// 避免空目录意外把 app.log 写到进程工作目录。
|
||||
func TestM3InitRejectsInvalidLogConfig(t *testing.T) {
|
||||
if err := NewLogManager().Init(&config.Config{}); err == nil {
|
||||
t.Fatal("Init with empty log dir should fail")
|
||||
}
|
||||
|
||||
cfg := tmpCfg(t.TempDir())
|
||||
cfg.Log.MaxAge = -1
|
||||
if err := NewLogManager().Init(cfg); err == nil {
|
||||
t.Fatal("Init with negative MaxAge should fail")
|
||||
}
|
||||
}
|
||||
+116
-2
@@ -1,8 +1,12 @@
|
||||
package logger_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
)
|
||||
|
||||
@@ -11,12 +15,12 @@ import (
|
||||
|
||||
func TestAPILog(t *testing.T) {
|
||||
_ = logger.APILog()
|
||||
// 未初始化时为 nil,这是预期行为
|
||||
// 未初始化时为 nop logger,调用安全
|
||||
}
|
||||
|
||||
func TestDBLog(t *testing.T) {
|
||||
_ = logger.DBLog()
|
||||
// 未初始化时为 nil,这是预期行为
|
||||
// 未初始化时为 nop logger,调用安全
|
||||
}
|
||||
|
||||
func TestFieldString(t *testing.T) {
|
||||
@@ -64,4 +68,114 @@ func TestFieldFloat64(t *testing.T) {
|
||||
func TestFieldError(t *testing.T) {
|
||||
_ = logger.Field.Error(nil)
|
||||
// zap.Field 是结构体,仅验证函数调用正常
|
||||
}
|
||||
|
||||
// initWithTempDir 使用临时目录初始化 logger,返回日志目录与读取文件内容的辅助函数。
|
||||
// 验证修复 #3 之后,三个 logger 各自只写自己的文件,互不串扰。
|
||||
//
|
||||
// 测试退出时通过 t.Cleanup 调用 logger.Close 释放文件句柄,
|
||||
// 避免 Windows 上 t.TempDir 因句柄占用而清理失败。
|
||||
func initWithTempDir(t *testing.T) (dir string, read func(name string) string) {
|
||||
t.Helper()
|
||||
dir = t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{Mode: "production"}, // 走 InfoLevel,避免 Debug 噪音
|
||||
Log: config.LogConfig{
|
||||
Dir: dir,
|
||||
MaxSize: 10,
|
||||
MaxBackups: 1,
|
||||
MaxAge: 1,
|
||||
Compress: false,
|
||||
},
|
||||
}
|
||||
if err := logger.Init(cfg); err != nil {
|
||||
t.Fatalf("logger.Init failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = logger.Close() })
|
||||
|
||||
read = func(name string) string {
|
||||
path := filepath.Join(dir, name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
return dir, read
|
||||
}
|
||||
|
||||
// TestLoggerNoCrossWriting 验证 Logger / APILog / DBLog 不会重复写入彼此的日志文件。
|
||||
//
|
||||
// 这是 v1.0.3 修复的核心:旧实现 Logger = Tee(apiCore, dbCore, consoleCore),
|
||||
// 导致 logger.Info(...) 同时写到 api.log + database.log + console 三份。
|
||||
func TestLoggerNoCrossWriting(t *testing.T) {
|
||||
_, read := initWithTempDir(t)
|
||||
|
||||
// 三个标记字符串足够独特,分别由三个 logger 写入
|
||||
const (
|
||||
appMark = "MARKER_APP_LOG_ONLY_xyz"
|
||||
apiMark = "MARKER_API_LOG_ONLY_xyz"
|
||||
dbMark = "MARKER_DB_LOG_ONLY_xyz"
|
||||
)
|
||||
|
||||
logger.Info(appMark)
|
||||
logger.APILog().Info(apiMark)
|
||||
logger.DBLog().Info(dbMark)
|
||||
|
||||
if err := logger.Sync(); err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
|
||||
app := read("app.log")
|
||||
api := read("api.log")
|
||||
db := read("database.log")
|
||||
|
||||
// 各自的日志文件必须包含自己的标记
|
||||
if !strings.Contains(app, appMark) {
|
||||
t.Errorf("app.log missing %q, got: %s", appMark, app)
|
||||
}
|
||||
if !strings.Contains(api, apiMark) {
|
||||
t.Errorf("api.log missing %q, got: %s", apiMark, api)
|
||||
}
|
||||
if !strings.Contains(db, dbMark) {
|
||||
t.Errorf("database.log missing %q, got: %s", dbMark, db)
|
||||
}
|
||||
|
||||
// 关键:禁止串写
|
||||
if strings.Contains(api, appMark) {
|
||||
t.Errorf("api.log should NOT contain %q (cross-writing bug regressed)", appMark)
|
||||
}
|
||||
if strings.Contains(db, appMark) {
|
||||
t.Errorf("database.log should NOT contain %q (cross-writing bug regressed)", appMark)
|
||||
}
|
||||
if strings.Contains(app, apiMark) {
|
||||
t.Errorf("app.log should NOT contain %q", apiMark)
|
||||
}
|
||||
if strings.Contains(db, apiMark) {
|
||||
t.Errorf("database.log should NOT contain %q", apiMark)
|
||||
}
|
||||
if strings.Contains(app, dbMark) {
|
||||
t.Errorf("app.log should NOT contain %q", dbMark)
|
||||
}
|
||||
if strings.Contains(api, dbMark) {
|
||||
t.Errorf("api.log should NOT contain %q", dbMark)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerInitNilConfig 验证 Init 对 nil 配置返回错误,不再 panic。
|
||||
func TestLoggerInitNilConfig(t *testing.T) {
|
||||
if err := logger.Init(nil); err == nil {
|
||||
t.Error("Init(nil) should return error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerSyncBeforeInit 验证未初始化时 Sync 不 panic(globals 仍为 Nop)。
|
||||
func TestLoggerSyncBeforeInit(t *testing.T) {
|
||||
// Nop logger 的 Sync 永远返回 nil
|
||||
if err := logger.Sync(); err != nil {
|
||||
t.Errorf("Sync on nop logger should be nil, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"level":"warn","time":"2026-07-02T23:25:17.643+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 1/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
|
||||
{"level":"warn","time":"2026-07-02T23:25:18.648+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 2/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
|
||||
{"level":"warn","time":"2026-07-02T23:25:20.651+0800","caller":"database/manager.go:427","msg":"数据库连接失败,第 3/5 次重试: dial tcp [::1]:3306: connectex: No connection could be made because the target machine actively refused it."}
|
||||
+136
-89
@@ -17,8 +17,26 @@ const (
|
||||
ContextKeyRole = "role"
|
||||
// ContextKeyUserType 用户类型上下文键
|
||||
ContextKeyUserType = "user_type"
|
||||
|
||||
// DefaultUserTypeSuperAdmin 默认超级管理员用户类型
|
||||
DefaultUserTypeSuperAdmin = "super_admin"
|
||||
// DefaultUserTypeAdmin 默认管理员用户类型
|
||||
DefaultUserTypeAdmin = "admin"
|
||||
// DefaultUserTypeStaff 默认员工用户类型
|
||||
DefaultUserTypeStaff = "staff"
|
||||
)
|
||||
|
||||
// AuthUser 当前认证用户
|
||||
type AuthUser struct {
|
||||
UserID uint
|
||||
Username string
|
||||
Role string
|
||||
UserType string
|
||||
}
|
||||
|
||||
// AuthChecker 自定义权限检查函数
|
||||
type AuthChecker func(user AuthUser, c *gin.Context) bool
|
||||
|
||||
// AuthRequired JWT 认证中间件
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
@@ -31,7 +49,7 @@ func AuthRequired() gin.HandlerFunc {
|
||||
|
||||
// 解析 Bearer Token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
response.Unauthorized(c, "认证格式错误")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -55,27 +73,17 @@ func AuthRequired() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// AdminRequired 管理员权限中间件
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
// RequireAuth 自定义权限中间件
|
||||
func RequireAuth(checker AuthChecker, messages ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
if !exists {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 安全的类型断言
|
||||
ut, ok := userType.(string)
|
||||
user, ok := GetAuthUser(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "用户信息异常")
|
||||
c.Abort()
|
||||
abortAuthContext(c)
|
||||
return
|
||||
}
|
||||
|
||||
if ut != "super_admin" && ut != "admin" {
|
||||
response.Fail(c, "无权限访问")
|
||||
c.Abort()
|
||||
if checker == nil || !checker(user, c) {
|
||||
abortForbidden(c, messages...)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,89 +91,114 @@ func AdminRequired() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireUserTypes 用户类型权限中间件
|
||||
func RequireUserTypes(userTypes ...string) gin.HandlerFunc {
|
||||
allowed := make(map[string]struct{}, len(userTypes))
|
||||
for _, userType := range userTypes {
|
||||
allowed[userType] = struct{}{}
|
||||
}
|
||||
|
||||
return RequireAuth(func(user AuthUser, c *gin.Context) bool {
|
||||
_, ok := allowed[user.UserType]
|
||||
return ok
|
||||
})
|
||||
}
|
||||
|
||||
// RequireRoles 角色权限中间件
|
||||
func RequireRoles(roles ...string) gin.HandlerFunc {
|
||||
allowed := make(map[string]struct{}, len(roles))
|
||||
for _, role := range roles {
|
||||
allowed[role] = struct{}{}
|
||||
}
|
||||
|
||||
return RequireAuth(func(user AuthUser, c *gin.Context) bool {
|
||||
_, ok := allowed[user.Role]
|
||||
return ok
|
||||
})
|
||||
}
|
||||
|
||||
// AdminRequired 管理员权限中间件
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return RequireUserTypes(DefaultUserTypeSuperAdmin, DefaultUserTypeAdmin)
|
||||
}
|
||||
|
||||
// SuperAdminRequired 超级管理员权限中间件
|
||||
func SuperAdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
if !exists {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 安全的类型断言
|
||||
ut, ok := userType.(string)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "用户信息异常")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if ut != "super_admin" {
|
||||
response.Fail(c, "需要超级管理员权限")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
return RequireUserTypes(DefaultUserTypeSuperAdmin)
|
||||
}
|
||||
|
||||
// StaffRequired 员工权限中间件
|
||||
func StaffRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
if !exists {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 安全的类型断言
|
||||
ut, ok := userType.(string)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "用户信息异常")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if ut != "staff" {
|
||||
response.Fail(c, "无权限访问")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
return RequireUserTypes(DefaultUserTypeStaff)
|
||||
}
|
||||
|
||||
// AnyUserRequired 任意用户权限中间件(允许 admin/super_admin/staff)
|
||||
// AnyUserRequired 任意用户权限中间件(默认允许 admin/super_admin/staff)
|
||||
func AnyUserRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
if !exists {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
return RequireUserTypes(DefaultUserTypeSuperAdmin, DefaultUserTypeAdmin, DefaultUserTypeStaff)
|
||||
}
|
||||
|
||||
// 安全的类型断言
|
||||
ut, ok := userType.(string)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "用户信息异常")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 允许所有内部用户类型
|
||||
if ut != "super_admin" && ut != "admin" && ut != "staff" {
|
||||
response.Fail(c, "无权限访问")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
func abortAuthContext(c *gin.Context) {
|
||||
if _, exists := c.Get(ContextKeyUserID); !exists {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
} else {
|
||||
response.Unauthorized(c, "用户信息异常")
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func abortForbidden(c *gin.Context, messages ...string) {
|
||||
if len(messages) > 0 && messages[0] != "" {
|
||||
response.Fail(c, messages[0])
|
||||
} else {
|
||||
response.FailWithError(c, response.ErrForbidden)
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
// GetAuthUser 从上下文获取认证用户
|
||||
func GetAuthUser(c *gin.Context) (AuthUser, bool) {
|
||||
userID, exists := c.Get(ContextKeyUserID)
|
||||
if !exists {
|
||||
return AuthUser{}, false
|
||||
}
|
||||
id, ok := userID.(uint)
|
||||
if !ok {
|
||||
return AuthUser{}, false
|
||||
}
|
||||
|
||||
username, exists := c.Get(ContextKeyUsername)
|
||||
if !exists {
|
||||
return AuthUser{}, false
|
||||
}
|
||||
name, ok := username.(string)
|
||||
if !ok {
|
||||
return AuthUser{}, false
|
||||
}
|
||||
|
||||
role, exists := c.Get(ContextKeyRole)
|
||||
if !exists {
|
||||
return AuthUser{}, false
|
||||
}
|
||||
r, ok := role.(string)
|
||||
if !ok {
|
||||
return AuthUser{}, false
|
||||
}
|
||||
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
if !exists {
|
||||
return AuthUser{}, false
|
||||
}
|
||||
ut, ok := userType.(string)
|
||||
if !ok {
|
||||
return AuthUser{}, false
|
||||
}
|
||||
|
||||
return AuthUser{
|
||||
UserID: id,
|
||||
Username: name,
|
||||
Role: r,
|
||||
UserType: ut,
|
||||
}, true
|
||||
}
|
||||
|
||||
// GetUserID 从上下文获取用户ID
|
||||
@@ -196,6 +229,20 @@ func GetUsername(c *gin.Context) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// GetRole 从上下文获取角色
|
||||
func GetRole(c *gin.Context) string {
|
||||
role, exists := c.Get(ContextKeyRole)
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
// 安全的类型断言
|
||||
r, ok := role.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// GetUserType 从上下文获取用户类型
|
||||
func GetUserType(c *gin.Context) string {
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func performAuthMiddlewareRequest(m gin.HandlerFunc, setup func(*gin.Context)) *httptest.ResponseRecorder {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
if setup != nil {
|
||||
setup(c)
|
||||
}
|
||||
})
|
||||
r.Use(m)
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func setAuthUser(userType, role string) func(*gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
c.Set(middleware.ContextKeyUserID, uint(1))
|
||||
c.Set(middleware.ContextKeyUsername, "tester")
|
||||
c.Set(middleware.ContextKeyRole, role)
|
||||
c.Set(middleware.ContextKeyUserType, userType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredAcceptsCaseInsensitiveBearer_M8(t *testing.T) {
|
||||
setupMiddlewareMiniRedis(t)
|
||||
if err := config.Set(&config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
Expire: time.Hour,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set config: %v", err)
|
||||
}
|
||||
token, err := jwt.GenerateToken(1, "tester", "owner", "tenant_admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(middleware.AuthRequired())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "bearer "+token)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"ok":true`) {
|
||||
t.Fatalf("expected request to pass, got body %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireUserTypes(t *testing.T) {
|
||||
w := performAuthMiddlewareRequest(middleware.RequireUserTypes("tenant_admin"), setAuthUser("tenant_admin", "owner"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"ok":true`) {
|
||||
t.Fatalf("expected request to pass, got body %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireUserTypesRejectsOtherTypes(t *testing.T) {
|
||||
w := performAuthMiddlewareRequest(middleware.RequireUserTypes("tenant_admin"), setAuthUser("staff", "owner"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected business error over status 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"code":403`) {
|
||||
t.Fatalf("expected forbidden response, got body %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRoles(t *testing.T) {
|
||||
w := performAuthMiddlewareRequest(middleware.RequireRoles("owner"), setAuthUser("tenant_admin", "owner"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"ok":true`) {
|
||||
t.Fatalf("expected request to pass, got body %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthCustomChecker(t *testing.T) {
|
||||
checker := func(user middleware.AuthUser, c *gin.Context) bool {
|
||||
return user.UserID == 1 && user.UserType == "merchant" && user.Role == "owner"
|
||||
}
|
||||
|
||||
w := performAuthMiddlewareRequest(middleware.RequireAuth(checker), setAuthUser("merchant", "owner"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"ok":true`) {
|
||||
t.Fatalf("expected request to pass, got body %s", w.Body.String())
|
||||
}
|
||||
|
||||
w = performAuthMiddlewareRequest(middleware.RequireAuth(checker, "需要商户所有者权限"), setAuthUser("merchant", "staff"))
|
||||
if !strings.Contains(w.Body.String(), "需要商户所有者权限") {
|
||||
t.Fatalf("expected custom forbidden message, got body %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRoleShortcuts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mw gin.HandlerFunc
|
||||
userType string
|
||||
wantPass bool
|
||||
}{
|
||||
{"admin allows super admin", middleware.AdminRequired(), middleware.DefaultUserTypeSuperAdmin, true},
|
||||
{"admin allows admin", middleware.AdminRequired(), middleware.DefaultUserTypeAdmin, true},
|
||||
{"admin rejects staff", middleware.AdminRequired(), middleware.DefaultUserTypeStaff, false},
|
||||
{"super admin allows super admin", middleware.SuperAdminRequired(), middleware.DefaultUserTypeSuperAdmin, true},
|
||||
{"super admin rejects admin", middleware.SuperAdminRequired(), middleware.DefaultUserTypeAdmin, false},
|
||||
{"staff allows staff", middleware.StaffRequired(), middleware.DefaultUserTypeStaff, true},
|
||||
{"any allows staff", middleware.AnyUserRequired(), middleware.DefaultUserTypeStaff, true},
|
||||
{"any rejects external", middleware.AnyUserRequired(), "external", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := performAuthMiddlewareRequest(tt.mw, setAuthUser(tt.userType, "role"))
|
||||
body := w.Body.String()
|
||||
if tt.wantPass && !strings.Contains(body, `"ok":true`) {
|
||||
t.Fatalf("expected pass, got body %s", body)
|
||||
}
|
||||
if !tt.wantPass && !strings.Contains(body, `"code":403`) {
|
||||
t.Fatalf("expected forbidden, got body %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthRejectsMissingContext(t *testing.T) {
|
||||
w := performAuthMiddlewareRequest(middleware.RequireUserTypes("admin"), nil)
|
||||
if !strings.Contains(w.Body.String(), `"code":401`) || !strings.Contains(w.Body.String(), "请先登录") {
|
||||
t.Fatalf("expected unauthorized login response, got body %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthRejectsMalformedContext(t *testing.T) {
|
||||
w := performAuthMiddlewareRequest(middleware.RequireUserTypes("admin"), func(c *gin.Context) {
|
||||
c.Set(middleware.ContextKeyUserID, uint(1))
|
||||
c.Set(middleware.ContextKeyUsername, "tester")
|
||||
c.Set(middleware.ContextKeyRole, "owner")
|
||||
c.Set(middleware.ContextKeyUserType, 123)
|
||||
})
|
||||
if !strings.Contains(w.Body.String(), `"code":401`) || !strings.Contains(w.Body.String(), "用户信息异常") {
|
||||
t.Fatalf("expected malformed user response, got body %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
setAuthUser("tenant_admin", "owner")(c)
|
||||
|
||||
user, ok := middleware.GetAuthUser(c)
|
||||
if !ok {
|
||||
t.Fatal("expected auth user")
|
||||
}
|
||||
if user.UserID != 1 || user.Username != "tester" || user.UserType != "tenant_admin" || user.Role != "owner" {
|
||||
t.Fatalf("unexpected auth user: %+v", user)
|
||||
}
|
||||
if middleware.GetRole(c) != "owner" {
|
||||
t.Fatalf("expected role owner, got %q", middleware.GetRole(c))
|
||||
}
|
||||
|
||||
c.Set(middleware.ContextKeyUserID, "bad")
|
||||
if _, ok := middleware.GetAuthUser(c); ok {
|
||||
t.Fatal("expected malformed auth user to fail")
|
||||
}
|
||||
}
|
||||
+100
-37
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -10,8 +11,9 @@ import (
|
||||
)
|
||||
|
||||
// CORS 跨域中间件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 支持从配置文件读取 CORS 配置,生产环境更安全
|
||||
// 支持从配置文件读取 CORS 配置;遵循 W3C CORS 规范:
|
||||
// 当 AllowCredentials=true 时,Access-Control-Allow-Origin 必须回显为具体 Origin,
|
||||
// 不能使用 "*"(浏览器会拒绝携带凭证的响应)。
|
||||
func CORS() gin.HandlerFunc {
|
||||
return CORSWithConfig(nil)
|
||||
}
|
||||
@@ -30,64 +32,71 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc {
|
||||
corsConfig = &cfg.CORS
|
||||
}
|
||||
|
||||
// 是否允许携带凭证(影响 Origin 回显策略)
|
||||
allowCredentials := corsConfig != nil && corsConfig.AllowCredentials
|
||||
|
||||
// 获取允许的域名列表
|
||||
allowedOrigins := getAllowedOrigins(cfg, corsConfig)
|
||||
|
||||
// 检查是否在白名单中
|
||||
// 匹配 Origin
|
||||
// 注意:当 allowCredentials=true 且匹配到通配符时,
|
||||
// 必须回显具体 Origin 而非 "*",否则浏览器会拒绝响应。
|
||||
allowedOrigin := ""
|
||||
matchedWildcard := false
|
||||
if origin != "" {
|
||||
for _, ao := range allowedOrigins {
|
||||
if ao == "*" {
|
||||
allowedOrigin = "*"
|
||||
matchedWildcard = true
|
||||
break
|
||||
}
|
||||
// 支持通配符匹配(如 *.example.com)
|
||||
if strings.HasPrefix(ao, "*.") {
|
||||
domain := ao[2:]
|
||||
if strings.HasSuffix(origin, domain) {
|
||||
allowedOrigin = origin
|
||||
break
|
||||
}
|
||||
}
|
||||
if ao == origin {
|
||||
if matchOrigin(origin, ao) {
|
||||
allowedOrigin = origin
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配到白名单
|
||||
// 处理通配符 + 兜底策略
|
||||
if allowedOrigin == "" {
|
||||
// 开发环境允许所有来源
|
||||
if cfg != nil && cfg.IsDevelopment() {
|
||||
allowedOrigin = "*"
|
||||
} else if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
|
||||
// 配置了通配符
|
||||
allowedOrigin = "*"
|
||||
if matchedWildcard {
|
||||
if allowCredentials && origin != "" {
|
||||
// AllowCredentials=true 时 spec 禁止 "*",回显具体 Origin
|
||||
allowedOrigin = origin
|
||||
} else {
|
||||
allowedOrigin = "*"
|
||||
}
|
||||
} else if cfg != nil && cfg.IsDevelopment() && origin != "" && isLocalhostOrigin(origin) {
|
||||
// 开发环境兜底:仅对 localhost 来源回显具体 Origin(C7b 修复)。
|
||||
// 旧实现无条件回显任意 Origin,若同时 AllowCredentials=true 则构成凭据型反射。
|
||||
allowedOrigin = origin
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 CORS 响应头
|
||||
// 仅在 origin 匹配时发送 CORS 头(C7 收尾:未匹配 origin 不发 Allow-Methods/Headers 等,
|
||||
// 收敛信息泄露——避免向未授权 origin 暴露 API 允许的方法/头清单)。
|
||||
if allowedOrigin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", allowedOrigin)
|
||||
// Origin 不是 "*" 时,下游缓存(CDN / 网关)必须按 Origin 区分缓存
|
||||
if allowedOrigin != "*" {
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
|
||||
methods := corsConfig.GetAllowedMethods()
|
||||
headers := corsConfig.GetAllowedHeaders()
|
||||
exposedHeaders := corsConfig.GetExposedHeaders()
|
||||
maxAge := corsConfig.GetMaxAge()
|
||||
|
||||
c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", "))
|
||||
c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", "))
|
||||
c.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
|
||||
c.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
|
||||
}
|
||||
|
||||
// 从配置获取允许的方法、请求头等
|
||||
methods := corsConfig.GetAllowedMethods()
|
||||
headers := corsConfig.GetAllowedHeaders()
|
||||
exposedHeaders := corsConfig.GetExposedHeaders()
|
||||
maxAge := corsConfig.GetMaxAge()
|
||||
|
||||
c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", "))
|
||||
c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", "))
|
||||
c.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
|
||||
c.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
|
||||
|
||||
// 是否允许携带凭证
|
||||
if corsConfig != nil && corsConfig.AllowCredentials {
|
||||
// 仅在显式启用且 Origin 不是 "*" 时才发 Allow-Credentials
|
||||
// (CORS 规范:Allow-Origin: * 时禁止携带凭证)
|
||||
if allowCredentials && allowedOrigin != "" && allowedOrigin != "*" {
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
} else {
|
||||
c.Header("Access-Control-Allow-Credentials", "true") // 默认允许
|
||||
}
|
||||
|
||||
// 处理预检请求
|
||||
@@ -100,9 +109,63 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// matchOrigin 判断 origin 是否匹配允许的 Origin 模式 ao。
|
||||
//
|
||||
// 支持三种模式(C7a 修复):
|
||||
// 1. 精确匹配:ao == origin(scheme+host+port 全等)。
|
||||
// 2. 通配子域:ao 形如 "*.example.com",匹配 example.com 的任意子域(含 a.example.com、
|
||||
// a.b.example.com),但**不匹配 apex example.com 自身**,也**不匹配 notexample.com、
|
||||
// evil-example.com 等后缀相同但非真实子域的域名**。旧实现用 strings.HasSuffix(origin, domain)
|
||||
// 未锚定 host 边界,导致上述绕过。
|
||||
// 3. 通配 apex+子域:ao 形如 "*.example.com" 经本函数仅匹配子域;若需同时允许 apex,
|
||||
// 配置中需显式列出 "https://example.com"。
|
||||
//
|
||||
// 解析 origin 的 host 做真实子域边界判断(而非字符串后缀),杜绝 notexample.com 类绕过。
|
||||
func matchOrigin(origin, ao string) bool {
|
||||
if origin == "" || ao == "" {
|
||||
return false
|
||||
}
|
||||
// 精确匹配。
|
||||
if origin == ao {
|
||||
return true
|
||||
}
|
||||
// 通配子域 *.domain。
|
||||
if strings.HasPrefix(ao, "*.") {
|
||||
wildcardDomain := strings.ToLower(strings.TrimSuffix(ao[2:], ".")) // 如 "example.com"
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
// 去端口、去尾点(FQDN 尾点表示 a.example.com. 应等同 a.example.com)。
|
||||
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
|
||||
// host 必须是 *.domain 的真实子域:host == "x." + wildcardDomain,
|
||||
// 且 x 非空、不含点(即直接子域)或为多级子域(a.b.example.com)。
|
||||
// 关键:host 必须以 "." + wildcardDomain 结尾(锚定边界),杜绝 notexample.com。
|
||||
if !strings.HasSuffix(host, "."+wildcardDomain) {
|
||||
return false
|
||||
}
|
||||
// 排除 host == wildcardDomain 自身(apex 不由通配匹配,需显式配置)。
|
||||
if host == wildcardDomain {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isLocalhostOrigin 判断 origin 是否为 localhost 来源(开发态兜底用,C7b 修复)。
|
||||
// 仅允许 localhost / 127.0.0.1 / ::1 的任意端口,杜绝开发态回显任意 Origin。
|
||||
func isLocalhostOrigin(origin string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
host := strings.TrimSuffix(u.Hostname(), ".")
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
// getAllowedOrigins 获取允许的域名列表
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 优先使用配置文件,其次使用环境变量,最后使用默认值
|
||||
// 优先使用配置文件,生产环境必须显式配置,开发环境提供 localhost 兜底。
|
||||
func getAllowedOrigins(cfg *config.Config, corsConfig *config.CORSConfig) []string {
|
||||
// 优先使用 CORS 配置
|
||||
if corsConfig != nil && len(corsConfig.AllowedOrigins) > 0 {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package middleware
|
||||
|
||||
import "testing"
|
||||
|
||||
// 回归 C7a:通配符子域匹配必须锚定真实子域边界,拒绝后缀相同但非子域的域名。
|
||||
// 旧实现 strings.HasSuffix(origin, domain) 接受 notexample.com / evil-example.com。
|
||||
func TestMatchOriginWildcardBoundary(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
origin string
|
||||
ao string
|
||||
want bool
|
||||
}{
|
||||
// 精确匹配
|
||||
{"exact", "https://example.com", "https://example.com", true},
|
||||
{"exact with port", "https://example.com:8443", "https://example.com:8443", true},
|
||||
{"exact mismatch", "https://example.com", "https://other.com", false},
|
||||
|
||||
// 通配子域 *.example.com
|
||||
{"subdomain ok", "https://a.example.com", "*.example.com", true},
|
||||
{"deep subdomain ok", "https://a.b.example.com", "*.example.com", true},
|
||||
{"subdomain with port ok", "https://a.example.com:3000", "*.example.com", true},
|
||||
{"apex not matched by wildcard", "https://example.com", "*.example.com", false},
|
||||
// C7a 核心:后缀相同但非真实子域必须拒绝
|
||||
{"notexample.com rejected", "https://notexample.com", "*.example.com", false},
|
||||
{"evil-example.com rejected", "https://evil-example.com", "*.example.com", false},
|
||||
{"villainexample.com rejected", "https://villainexample.com", "*.example.com", false},
|
||||
// 端口/协议不同的 origin host 仍按 host 判断
|
||||
{"http subdomain ok", "http://a.example.com", "*.example.com", true},
|
||||
|
||||
// 通配仅匹配 host,scheme 不同的精确配置不被通配覆盖
|
||||
{"different scheme exact not wildcard", "http://example.com", "https://example.com", false},
|
||||
|
||||
// 边界
|
||||
{"empty origin", "", "*.example.com", false},
|
||||
{"empty ao", "https://a.example.com", "", false},
|
||||
{"malformed origin", "://:bad", "*.example.com", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := matchOrigin(c.origin, c.ao)
|
||||
if got != c.want {
|
||||
t.Errorf("matchOrigin(%q, %q) = %v, want %v", c.origin, c.ao, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C7a:通配大小写不敏感(host 归一化为小写比较)。
|
||||
func TestMatchOriginCaseInsensitive(t *testing.T) {
|
||||
if !matchOrigin("https://A.Example.COM", "*.example.com") {
|
||||
t.Error("matchOrigin should be case-insensitive on host")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C7a:trailing dot FQDN(a.example.com.)应等同 a.example.com 被接受(功能正确性)。
|
||||
func TestMatchOriginTrailingDot(t *testing.T) {
|
||||
if !matchOrigin("https://a.example.com.", "*.example.com") {
|
||||
t.Error("trailing-dot subdomain should match wildcard")
|
||||
}
|
||||
if !matchOrigin("https://a.example.com", "*.example.com.") {
|
||||
t.Error("trailing-dot wildcard should match plain subdomain")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 C7b:isLocalhostOrigin 仅允许 localhost/127.0.0.1/::1,拒绝任意其他来源。
|
||||
func TestIsLocalhostOrigin(t *testing.T) {
|
||||
allowed := []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1",
|
||||
"http://[::1]:4000",
|
||||
"http://[::1]",
|
||||
}
|
||||
for _, o := range allowed {
|
||||
if !isLocalhostOrigin(o) {
|
||||
t.Errorf("isLocalhostOrigin(%q) = false, want true", o)
|
||||
}
|
||||
}
|
||||
denied := []string{
|
||||
"https://evil.com",
|
||||
"https://localhost.evil.com", // 后缀含 localhost 但非 localhost host
|
||||
"https://notlocalhost.com",
|
||||
"https://example.com",
|
||||
"",
|
||||
"://bad",
|
||||
}
|
||||
for _, o := range denied {
|
||||
if isLocalhostOrigin(o) {
|
||||
t.Errorf("isLocalhostOrigin(%q) = true, want false", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
-49
@@ -2,13 +2,17 @@ package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,6 +24,8 @@ const (
|
||||
CSRFCookieName = "csrf_token"
|
||||
// CSRFFormField 表单字段名称
|
||||
CSRFFormField = "_csrf"
|
||||
// CSRFMaxBodyBytes JSON body 中读取 CSRF token 的最大字节数,防止 pre-auth OOM。
|
||||
CSRFMaxBodyBytes = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
// CSRFConfig CSRF 配置
|
||||
@@ -44,6 +50,12 @@ type CSRFConfig struct {
|
||||
Path string
|
||||
// MaxAge Cookie 有效期(秒)
|
||||
MaxAge int
|
||||
// SessionCookie 为 true 时显式使用会话 Cookie(MaxAge=0)。
|
||||
// 为保持 CSRFWithConfig(CSRFConfig{}) 的默认行为,MaxAge=0 且本字段为 false 时仍使用默认 1 小时。
|
||||
SessionCookie bool
|
||||
// MaxBodyBytes 从 JSON body 提取 token 时允许读取的最大字节数。
|
||||
// <=0 时使用默认 1MiB。Header/Form token 不受此限制。
|
||||
MaxBodyBytes int64
|
||||
// ErrorFunc 错误处理函数
|
||||
ErrorFunc func(c *gin.Context)
|
||||
// SkipFunc 跳过检查函数
|
||||
@@ -52,21 +64,25 @@ type CSRFConfig struct {
|
||||
|
||||
// DefaultCSRFConfig 默认 CSRF 配置
|
||||
var DefaultCSRFConfig = CSRFConfig{
|
||||
TokenLength: CSRFTokenLength,
|
||||
HeaderName: CSRFHeaderName,
|
||||
CookieName: CSRFCookieName,
|
||||
FormField: CSRFFormField,
|
||||
Secure: false,
|
||||
HTTPOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
MaxAge: 3600, // 1 小时
|
||||
ErrorFunc: defaultCSRFError,
|
||||
SkipFunc: nil,
|
||||
TokenLength: CSRFTokenLength,
|
||||
HeaderName: CSRFHeaderName,
|
||||
CookieName: CSRFCookieName,
|
||||
FormField: CSRFFormField,
|
||||
Secure: false,
|
||||
HTTPOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
MaxAge: 3600, // 1 小时
|
||||
MaxBodyBytes: CSRFMaxBodyBytes,
|
||||
ErrorFunc: defaultCSRFError,
|
||||
SkipFunc: nil,
|
||||
}
|
||||
|
||||
// generateCSRFToken 生成 CSRF Token
|
||||
func generateCSRFToken(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
length = CSRFTokenLength
|
||||
}
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
@@ -80,15 +96,13 @@ func defaultCSRFError(c *gin.Context) {
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
// CSRF 创建 CSRF 中间件
|
||||
func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
cfg := DefaultCSRFConfig
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
}
|
||||
func csrfBodyTooLarge(c *gin.Context) {
|
||||
response.Custom(c, http.StatusRequestEntityTooLarge, response.CodeFileTooLarge, "请求体过大", nil)
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if cfg.TokenLength == 0 {
|
||||
func normalizeCSRFConfig(cfg CSRFConfig) CSRFConfig {
|
||||
if cfg.TokenLength <= 0 {
|
||||
cfg.TokenLength = CSRFTokenLength
|
||||
}
|
||||
if cfg.HeaderName == "" {
|
||||
@@ -97,9 +111,45 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
if cfg.CookieName == "" {
|
||||
cfg.CookieName = CSRFCookieName
|
||||
}
|
||||
if cfg.FormField == "" {
|
||||
cfg.FormField = CSRFFormField
|
||||
}
|
||||
if cfg.Path == "" {
|
||||
cfg.Path = "/"
|
||||
}
|
||||
if cfg.SameSite == 0 {
|
||||
cfg.SameSite = DefaultCSRFConfig.SameSite
|
||||
}
|
||||
if cfg.MaxAge == 0 && !cfg.SessionCookie {
|
||||
cfg.MaxAge = DefaultCSRFConfig.MaxAge
|
||||
}
|
||||
if cfg.MaxBodyBytes <= 0 {
|
||||
cfg.MaxBodyBytes = CSRFMaxBodyBytes
|
||||
}
|
||||
if cfg.ErrorFunc == nil {
|
||||
cfg.ErrorFunc = defaultCSRFError
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func pathMatchesSkip(path, prefix string) bool {
|
||||
if prefix == "" {
|
||||
return false
|
||||
}
|
||||
if prefix == "/" {
|
||||
return true
|
||||
}
|
||||
prefix = strings.TrimRight(prefix, "/")
|
||||
return path == prefix || strings.HasPrefix(path, prefix+"/")
|
||||
}
|
||||
|
||||
// CSRF 创建 CSRF 中间件
|
||||
func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
cfg := DefaultCSRFConfig
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
}
|
||||
cfg = normalizeCSRFConfig(cfg)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// 检查是否跳过
|
||||
@@ -120,6 +170,7 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
cookieToken = token
|
||||
|
||||
// 设置 Cookie
|
||||
c.SetSameSite(cfg.SameSite)
|
||||
c.SetCookie(
|
||||
cfg.CookieName,
|
||||
token,
|
||||
@@ -151,18 +202,28 @@ func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
clientToken = c.PostForm(cfg.FormField)
|
||||
}
|
||||
|
||||
// 最后从 JSON body 获取
|
||||
// 最后从 JSON body 获取。
|
||||
// P1 #9:用 ShouldBindBodyWith(binding.JSON) 而非 ShouldBindJSON——后者会读干
|
||||
// c.Request.Body,导致下游 handler 再 ShouldBindJSON 拿到空 body(EOF)。前者把原始
|
||||
// body 缓存进 gin context,下游可重复读取。
|
||||
if clientToken == "" {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err == nil {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, cfg.MaxBodyBytes)
|
||||
if err := c.ShouldBindBodyWith(&body, binding.JSON); err == nil {
|
||||
if token, ok := body[cfg.FormField].(string); ok {
|
||||
clientToken = token
|
||||
}
|
||||
} else {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
csrfBodyTooLarge(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 Token 是否匹配
|
||||
if clientToken == "" || clientToken != cookieToken {
|
||||
// 验证 Token 是否匹配(H-7: 恒定时间比较防时序侧信道)
|
||||
if len(clientToken) == 0 || subtle.ConstantTimeCompare([]byte(clientToken), []byte(cookieToken)) != 1 {
|
||||
cfg.ErrorFunc(c)
|
||||
return
|
||||
}
|
||||
@@ -187,10 +248,18 @@ func GetCSRFToken(c *gin.Context) string {
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
return token.(string)
|
||||
// H-7: comma-ok 防裸断言 panic。csrf_token 虽由本包以 string 写入,
|
||||
// 但 gin context 是共享 map,下游误写其他类型即 panic。
|
||||
s, ok := token.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CSRFToken 返回 CSRF Token 的处理器(用于 API 模式)
|
||||
// CSRFToken 返回当前请求上下文中的 CSRF Token;若上下文无 Token 则现场生成一个返回。
|
||||
// 适用于 Cookie/双重提交模式(与 CSRF/DoubleSubmitCookie 中间件配合),
|
||||
// 不与 CSRFForAPI 配套--API 模式的可校验 Token 请用 GenerateAPIToken 颁发。
|
||||
func CSRFToken(c *gin.Context) {
|
||||
token := GetCSRFToken(c)
|
||||
if token == "" {
|
||||
@@ -213,7 +282,7 @@ func CSRFWithSkip(skipPaths []string) gin.HandlerFunc {
|
||||
cfg.SkipFunc = func(c *gin.Context) bool {
|
||||
path := c.Request.URL.Path
|
||||
for _, p := range skipPaths {
|
||||
if strings.HasPrefix(path, p) {
|
||||
if pathMatchesSkip(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -223,11 +292,13 @@ func CSRFWithSkip(skipPaths []string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// CSRFForAPI 适用于 API 的 CSRF 中间件(不使用 Cookie)
|
||||
// 客户端需要先调用 /csrf-token 获取 Token
|
||||
// 客户端需要先调用 GenerateAPIToken 获取 Token,随后在每个非安全方法请求的
|
||||
// X-CSRF-Token 头中携带。Token 单次消费(验证通过即删除)且受 TTL 约束,
|
||||
// 防止重放与内存无限增长。
|
||||
//
|
||||
// 注意:存储为进程内内存,仅适用于单实例部署。多实例请自行用 Redis
|
||||
// SETEX + GETDEL 实现等价语义。
|
||||
func CSRFForAPI() gin.HandlerFunc {
|
||||
tokens := make(map[string]bool)
|
||||
var mu sync.RWMutex
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// 安全方法不需要验证
|
||||
if isSafeMethod(c.Request.Method) {
|
||||
@@ -243,12 +314,25 @@ func CSRFForAPI() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 Token
|
||||
mu.RLock()
|
||||
valid := tokens[clientToken]
|
||||
mu.RUnlock()
|
||||
// 单次消费 + TTL:校验通过即删除,防止同一 token 被重放。
|
||||
// 写锁内完成“查—删—过期清理”以保证原子性。
|
||||
apiTokensMu.Lock()
|
||||
issuedAt, ok := apiTokens[clientToken]
|
||||
if ok {
|
||||
delete(apiTokens, clientToken)
|
||||
}
|
||||
// 懒清理:map 较大时顺带淘汰过期项,避免内存无限增长。
|
||||
if len(apiTokens) > 256 {
|
||||
now := time.Now()
|
||||
for t, at := range apiTokens {
|
||||
if now.Sub(at) > apiTokenTTL {
|
||||
delete(apiTokens, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
apiTokensMu.Unlock()
|
||||
|
||||
if !valid {
|
||||
if !ok || time.Since(issuedAt) > apiTokenTTL {
|
||||
response.Fail(c, "CSRF Token 无效")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -259,6 +343,7 @@ func CSRFForAPI() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// GenerateAPIToken 生成 API CSRF Token(用于 API 模式)
|
||||
// 颁发的 Token 写入进程内存储,供 CSRFForAPI 校验。
|
||||
func GenerateAPIToken(c *gin.Context) {
|
||||
token, err := generateCSRFToken(CSRFTokenLength)
|
||||
if err != nil {
|
||||
@@ -266,23 +351,34 @@ func GenerateAPIToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 存储 Token(实际应用中应使用 Redis)
|
||||
// 这里简化为内存存储
|
||||
mu.Lock()
|
||||
tokens[token] = true
|
||||
mu.Unlock()
|
||||
apiTokensMu.Lock()
|
||||
cleanupExpiredAPITokensLocked(time.Now())
|
||||
apiTokens[token] = time.Now()
|
||||
apiTokensMu.Unlock()
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"csrf_token": token,
|
||||
})
|
||||
}
|
||||
|
||||
// 内存存储(用于 API 模式)
|
||||
// API 模式 CSRF Token 存储:token -> 颁发时间。
|
||||
// 受 apiTokensMu 保护,单次消费 + TTL(见 CSRFForAPI)。
|
||||
var (
|
||||
tokens = make(map[string]bool)
|
||||
mu sync.RWMutex
|
||||
apiTokens = make(map[string]time.Time)
|
||||
apiTokensMu sync.RWMutex
|
||||
)
|
||||
|
||||
// apiTokenTTL API 模式 CSRF Token 有效期
|
||||
const apiTokenTTL = 30 * time.Minute
|
||||
|
||||
func cleanupExpiredAPITokensLocked(now time.Time) {
|
||||
for t, at := range apiTokens {
|
||||
if now.Sub(at) > apiTokenTTL {
|
||||
delete(apiTokens, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CSRFExempt 标记路由不需要 CSRF 保护
|
||||
// 使用方法:在路由组上使用此中间件
|
||||
func CSRFExempt() gin.HandlerFunc {
|
||||
@@ -298,12 +394,16 @@ func CSRFWithExempt(config ...CSRFConfig) gin.HandlerFunc {
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
}
|
||||
cfg = normalizeCSRFConfig(cfg)
|
||||
|
||||
originalSkipFunc := cfg.SkipFunc
|
||||
cfg.SkipFunc = func(c *gin.Context) bool {
|
||||
// 检查是否标记为 exempt
|
||||
if exempt, exists := c.Get("csrf_exempt"); exists && exempt.(bool) {
|
||||
return true
|
||||
// 检查是否标记为 exempt(P1 #9:comma-ok 防裸断言 panic——gin context 为共享 map,
|
||||
// 下游若误以非 bool 写入 csrf_exempt,exempt.(bool) 会 panic)。
|
||||
if exempt, exists := c.Get("csrf_exempt"); exists {
|
||||
if b, ok := exempt.(bool); ok && b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 调用原始 SkipFunc
|
||||
if originalSkipFunc != nil {
|
||||
@@ -325,7 +425,10 @@ func DoubleSubmitCookie() gin.HandlerFunc {
|
||||
cookieToken, err := c.Cookie(CSRFCookieName)
|
||||
if err != nil || cookieToken == "" {
|
||||
token, _ := generateCSRFToken(CSRFTokenLength)
|
||||
c.SetCookie(CSRFCookieName, token, 3600, "/", "", false, true)
|
||||
// 双重提交模式要求前端 JS 读取 cookie 并回填 X-CSRF-Token 头,
|
||||
// 故 HttpOnly 必须为 false(与 CSRF() cookie 模式相反)。
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(CSRFCookieName, token, 3600, "/", "", false, false)
|
||||
c.Set("csrf_token", token)
|
||||
} else {
|
||||
c.Set("csrf_token", cookieToken)
|
||||
@@ -350,8 +453,8 @@ func DoubleSubmitCookie() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 Cookie 和 Header 中的 Token 是否一致
|
||||
if cookieToken != headerToken {
|
||||
// 验证 Cookie 和 Header 中的 Token 是否一致(H-7: 恒定时间比较防时序侧信道)
|
||||
if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) != 1 {
|
||||
response.Fail(c, "CSRF Token 不匹配")
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
)
|
||||
|
||||
// 回归 C6b:TTL 过期分支。直接向包级存储注入一个“已过期”的 token,
|
||||
// 断言 CSRFForAPI 拒绝它(即使该 token 从未被消费过)。
|
||||
// 这条分支(time.Since(issuedAt) > apiTokenTTL)无法靠单次消费用例覆盖,
|
||||
// 必须单独构造过期时间戳。
|
||||
func TestCSRFForAPITTLExpiry(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRFForAPI())
|
||||
r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||||
|
||||
expiredToken := "expired-test-token"
|
||||
apiTokensMu.Lock()
|
||||
// 注入一个早于 TTL 的颁发时间,模拟 token 已过期。
|
||||
apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second))
|
||||
apiTokensMu.Unlock()
|
||||
defer func() {
|
||||
apiTokensMu.Lock()
|
||||
delete(apiTokens, expiredToken)
|
||||
apiTokensMu.Unlock()
|
||||
}()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/action", nil)
|
||||
req.Header.Set("X-CSRF-Token", expiredToken)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp response.Response
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response %q: %v", w.Body.String(), err)
|
||||
}
|
||||
if resp.Code == response.CodeSuccess {
|
||||
t.Errorf("expired token must be rejected, got code=%d (success)", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归 P1 #9:CSRF 从 JSON body 取 token 后,下游 handler 仍能读到完整请求体。
|
||||
// 修复前用 ShouldBindJSON 读干 body,下游再绑定拿到 EOF;改用 ShouldBindBodyWith 缓存 body。
|
||||
func TestCSRFJSONBodyPreservedForDownstream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRF())
|
||||
|
||||
var gotData string
|
||||
r.POST("/action", func(c *gin.Context) {
|
||||
// 下游再次绑定 body,应能读到完整内容(而非被 CSRF 中间件读空)。
|
||||
var payload struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := c.ShouldBindBodyWith(&payload, binding.JSON); err != nil {
|
||||
c.JSON(500, gin.H{"err": err.Error()})
|
||||
return
|
||||
}
|
||||
gotData = payload.Data
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// token 仅放在 JSON body 里(不放 header/form),触发中间件读 body 的分支。
|
||||
const tok = "csrf-token-abc-1234567890"
|
||||
body := `{"_csrf":"` + tok + `","data":"hello-downstream"}`
|
||||
req := httptest.NewRequest("POST", "/action", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// cookie 携带同一 token,使 CSRF 校验通过。
|
||||
req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: tok})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if gotData != "hello-downstream" {
|
||||
t.Errorf("downstream read data=%q, want 'hello-downstream' (P1 #9: body must survive CSRF)", gotData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFJSONBodyTooLargeRejected(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRF(CSRFConfig{MaxBodyBytes: 16}))
|
||||
r.POST("/action", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||||
|
||||
body := `{"_csrf":"token","data":"` + strings.Repeat("x", 64) + `"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: "token"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want 413; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFNegativeTokenLengthFallsBack(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRF(CSRFConfig{TokenLength: -1}))
|
||||
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"token": GetCSRFToken(c)}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(w.Result().Cookies()) == 0 {
|
||||
t.Fatal("expected csrf cookie to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFCookieSameSiteLax(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRF())
|
||||
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == CSRFCookieName {
|
||||
if c.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("csrf cookie not set")
|
||||
}
|
||||
|
||||
func TestCSRFPartialConfigKeepsCookieDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRF(CSRFConfig{TokenLength: -1}))
|
||||
r.GET("/form", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/form", nil))
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == CSRFCookieName {
|
||||
if c.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax for partial config", c.SameSite)
|
||||
}
|
||||
if c.MaxAge != DefaultCSRFConfig.MaxAge {
|
||||
t.Fatalf("MaxAge = %d, want %d for partial config", c.MaxAge, DefaultCSRFConfig.MaxAge)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("csrf cookie not set")
|
||||
}
|
||||
|
||||
func TestDoubleSubmitCookieSameSiteLax(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(DoubleSubmitCookie())
|
||||
r.GET("/get", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/get", nil))
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == CSRFCookieName {
|
||||
if c.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("csrf cookie not set")
|
||||
}
|
||||
|
||||
func TestCSRFWithSkipAnchorsPathBoundary(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CSRFWithSkip([]string{"/api"}))
|
||||
r.POST("/api", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
r.POST("/api/users", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
r.POST("/apix", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
|
||||
for _, path := range []string{"/api", "/api/users"} {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("%s status = %d, want skipped 204", path, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/apix", nil))
|
||||
if w.Code == http.StatusNoContent {
|
||||
t.Fatal("/apix was skipped by /api prefix; want CSRF rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAPITokenCleansExpiredTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
expiredToken := "expired-before-issue"
|
||||
apiTokensMu.Lock()
|
||||
apiTokens[expiredToken] = time.Now().Add(-(apiTokenTTL + time.Second))
|
||||
apiTokensMu.Unlock()
|
||||
defer func() {
|
||||
apiTokensMu.Lock()
|
||||
delete(apiTokens, expiredToken)
|
||||
apiTokensMu.Unlock()
|
||||
}()
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/csrf-token", GenerateAPIToken)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/csrf-token", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
apiTokensMu.RLock()
|
||||
_, exists := apiTokens[expiredToken]
|
||||
apiTokensMu.RUnlock()
|
||||
if exists {
|
||||
t.Fatal("GenerateAPIToken did not clean expired token")
|
||||
}
|
||||
}
|
||||
+109
-46
@@ -3,6 +3,8 @@ package middleware
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -43,9 +45,11 @@ func Logger() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// LoggerWithConfig 使用自定义配置的日志中间件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 可配置是否记录请求体/响应体,跳过静态资源路径,慢请求警告
|
||||
func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
|
||||
// 统一封顶:MaxBodyLength<=0 时回退默认值,确保请求/响应 body 捕获均有上限(防 OOM)
|
||||
if cfg.MaxBodyLength <= 0 {
|
||||
cfg.MaxBodyLength = DefaultLoggerConfig.MaxBodyLength
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
// 检查是否跳过此路径
|
||||
path := c.Request.URL.Path
|
||||
@@ -59,26 +63,17 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
|
||||
// 记录请求体(可选)
|
||||
var requestBody []byte
|
||||
if cfg.LogRequestBody && c.Request.Body != nil {
|
||||
requestBody, _ = io.ReadAll(c.Request.Body)
|
||||
// 恢复请求体供后续处理
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||
// 限制记录长度
|
||||
if len(requestBody) > cfg.MaxBodyLength {
|
||||
requestBody = requestBody[:cfg.MaxBodyLength]
|
||||
}
|
||||
requestBody = readBodyBounded(c, cfg.MaxBodyLength)
|
||||
}
|
||||
|
||||
// 记录响应体(可选)
|
||||
var responseBody []byte
|
||||
if cfg.LogResponseBody {
|
||||
// 使用 ResponseWriter 包装器捕获响应体
|
||||
blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
|
||||
// 使用 ResponseWriter 包装器捕获响应体(缓冲区封顶,防止大响应 OOM)
|
||||
blw := &bodyLogWriter{body: bytes.NewBufferString(""), maxLen: cfg.MaxBodyLength, ResponseWriter: c.Writer}
|
||||
c.Writer = blw
|
||||
c.Next()
|
||||
responseBody = blw.body.Bytes()
|
||||
if len(responseBody) > cfg.MaxBodyLength {
|
||||
responseBody = responseBody[:cfg.MaxBodyLength]
|
||||
}
|
||||
} else {
|
||||
c.Next()
|
||||
}
|
||||
@@ -90,7 +85,7 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
zap.String("query", c.Request.URL.RawQuery),
|
||||
zap.String("query", filterSensitiveQuery(c.Request.URL.RawQuery)),
|
||||
zap.String("ip", c.ClientIP()),
|
||||
zap.Duration("latency", latency),
|
||||
zap.String("user-agent", c.Request.UserAgent()),
|
||||
@@ -126,32 +121,69 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
|
||||
if latency > cfg.SlowRequestThreshold {
|
||||
// 慢请求警告
|
||||
fields = append(fields, zap.Bool("slow_request", true))
|
||||
logger.APILog().Warn("Slow API Request", fields...)
|
||||
logger.APILog().Warn("慢请求", fields...)
|
||||
} else if status >= 500 {
|
||||
logger.APILog().Error("API Request Error", fields...)
|
||||
logger.APILog().Error("请求错误", fields...)
|
||||
} else if status >= 400 {
|
||||
logger.APILog().Warn("API Request Client Error", fields...)
|
||||
logger.APILog().Warn("客户端请求错误", fields...)
|
||||
} else {
|
||||
logger.APILog().Info("API Request", fields...)
|
||||
logger.APILog().Info("API 请求", fields...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readBodyBounded 读取请求体用于日志记录,封顶 maxLen 字节以防 OOM。
|
||||
//
|
||||
// 仅向内存读入最多 maxLen+1 字节(+1 用于检测截断),其余部分不读入内存;
|
||||
// 通过 io.MultiReader 把「已读前缀 + 原始 body 剩余」复原为 c.Request.Body,
|
||||
// 因此下游处理器仍能拿到完整请求体。返回的日志副本截断为 maxLen。
|
||||
func readBodyBounded(c *gin.Context, maxLen int) []byte {
|
||||
if maxLen <= 0 {
|
||||
maxLen = DefaultLoggerConfig.MaxBodyLength
|
||||
}
|
||||
// io.ReadAll 永远返回非 nil 切片(即使出错也带已读部分)。LimitReader 封顶至 maxLen+1 字节。
|
||||
read, _ := io.ReadAll(io.LimitReader(c.Request.Body, int64(maxLen)+1))
|
||||
// 复原完整请求体供后续处理:已读前缀 + 原始 body 剩余部分(出错时保留已读字节,下游可重试读取剩余)
|
||||
c.Request.Body = io.NopCloser(io.MultiReader(bytes.NewReader(read), c.Request.Body))
|
||||
// 日志副本截断到 maxLen
|
||||
if len(read) > maxLen {
|
||||
return read[:maxLen]
|
||||
}
|
||||
return read
|
||||
}
|
||||
|
||||
// bodyLogWriter 响应体记录包装器
|
||||
type bodyLogWriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
body *bytes.Buffer
|
||||
maxLen int // 缓冲区上限(字节),<=0 表示不限制
|
||||
}
|
||||
|
||||
// Write 捕获响应体
|
||||
func (w *bodyLogWriter) Write(b []byte) (int, error) {
|
||||
// appendBounded 向缓冲区追加字节,但不超过 maxLen 上限,防止大响应 OOM。
|
||||
func (w *bodyLogWriter) appendBounded(b []byte) {
|
||||
if w.maxLen <= 0 {
|
||||
w.body.Write(b)
|
||||
return
|
||||
}
|
||||
remaining := w.maxLen - w.body.Len()
|
||||
if remaining <= 0 {
|
||||
return
|
||||
}
|
||||
if len(b) > remaining {
|
||||
b = b[:remaining]
|
||||
}
|
||||
w.body.Write(b)
|
||||
}
|
||||
|
||||
// Write 捕获响应体(缓冲区封顶,完整响应仍写入下游 ResponseWriter)
|
||||
func (w *bodyLogWriter) Write(b []byte) (int, error) {
|
||||
w.appendBounded(b)
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// WriteString 捕获字符串响应
|
||||
func (w *bodyLogWriter) WriteString(s string) (int, error) {
|
||||
w.body.WriteString(s)
|
||||
w.appendBounded([]byte(s))
|
||||
return w.ResponseWriter.WriteString(s)
|
||||
}
|
||||
|
||||
@@ -166,7 +198,7 @@ func shouldSkipPath(path string, cfg LoggerConfig) bool {
|
||||
|
||||
// 检查路径前缀
|
||||
for _, prefix := range cfg.SkipPathPrefixes {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
if pathPrefixMatch(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -174,29 +206,60 @@ func shouldSkipPath(path string, cfg LoggerConfig) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// filterSensitiveFields 过滤敏感字段(密码、token等)
|
||||
func filterSensitiveFields(body []byte) string {
|
||||
bodyStr := string(body)
|
||||
|
||||
// 过滤常见敏感字段(简单字符串替换)
|
||||
sensitiveFields := []string{
|
||||
"password", "passwd", "pwd",
|
||||
"token", "access_token", "refresh_token",
|
||||
"secret", "api_key", "apikey",
|
||||
"credit_card", "card_number",
|
||||
func pathPrefixMatch(path, prefix string) bool {
|
||||
if prefix == "" {
|
||||
return false
|
||||
}
|
||||
cleanPrefix := strings.TrimRight(prefix, "/")
|
||||
if cleanPrefix == "" {
|
||||
return path == "/"
|
||||
}
|
||||
return path == cleanPrefix || strings.HasPrefix(path, cleanPrefix+"/")
|
||||
}
|
||||
|
||||
for _, field := range sensitiveFields {
|
||||
// 检查是否包含敏感字段
|
||||
keyPattern := `"` + field + `":`
|
||||
if strings.Contains(bodyStr, keyPattern) {
|
||||
// 简单替换:将值替换为 [FILTERED]
|
||||
// 注意:这是一个简化的实现,复杂的 JSON 可能需要更精确的处理
|
||||
bodyStr = strings.ReplaceAll(bodyStr, keyPattern, keyPattern+"\"[FILTERED]\"")
|
||||
// filterSensitiveFields 过滤敏感字段(密码、token等)
|
||||
// sensitiveFieldsRE M1 修复:编译期正则,匹配 "key":"value" 整体并将 value 替换为 [FILTERED]。
|
||||
// 支持 JSON 字符串中标准转义(\" \\ \/ \b \f \n \r \t \uXXXX)。
|
||||
var sensitiveFieldsRE = regexp.MustCompile(
|
||||
`"(password|passwd|pwd|token|access_token|refresh_token|secret|api_key|apikey|credit_card|card_number)"\s*:\s*"((?:[^"\\]|\\.)*)"`,
|
||||
)
|
||||
|
||||
// filterSensitiveFields M1 修复后实现:将敏感字段的值替换为 [FILTERED],完整移除原始值。
|
||||
// 对非 JSON 输入原样返回。
|
||||
func filterSensitiveFields(body []byte) string {
|
||||
return sensitiveFieldsRE.ReplaceAllString(string(body), `"$1":"[FILTERED]"`)
|
||||
}
|
||||
|
||||
// sensitiveQueryKeys 需要在访问日志中脱敏的 query 参数名(小写匹配,P1 #14)。
|
||||
// 与 sensitiveFieldsRE 覆盖的字段保持一致。
|
||||
var sensitiveQueryKeys = map[string]struct{}{
|
||||
"password": {}, "passwd": {}, "pwd": {}, "token": {},
|
||||
"access_token": {}, "refresh_token": {}, "secret": {},
|
||||
"api_key": {}, "apikey": {}, "credit_card": {}, "card_number": {},
|
||||
}
|
||||
|
||||
// filterSensitiveQuery 脱敏 query 字符串中的敏感参数值(P1 #14):
|
||||
// 如 ?access_token=xxx 会被记为 access_token=%5BFILTERED%5D,防止令牌等随访问日志落盘。
|
||||
// 无敏感键时原样返回(不重排);解析失败时整体标记以免原文泄露。
|
||||
func filterSensitiveQuery(rawQuery string) string {
|
||||
if rawQuery == "" {
|
||||
return ""
|
||||
}
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return "[redacted: unparsable query]"
|
||||
}
|
||||
changed := false
|
||||
for k := range values {
|
||||
if _, ok := sensitiveQueryKeys[strings.ToLower(k)]; ok {
|
||||
values[k] = []string{"[FILTERED]"}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
return bodyStr
|
||||
if !changed {
|
||||
return rawQuery
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
// LoggerForAPI API 专用日志中间件(更详细)
|
||||
@@ -226,7 +289,7 @@ func LoggerMinimal() gin.HandlerFunc {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 跳过健康检查和静态资源
|
||||
if path == "/health" || strings.HasPrefix(path, "/public") || strings.HasPrefix(path, "/static") {
|
||||
if path == "/health" || pathPrefixMatch(path, "/public") || pathPrefixMatch(path, "/static") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -234,7 +297,7 @@ func LoggerMinimal() gin.HandlerFunc {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
logger.APILog().Info("Request",
|
||||
logger.APILog().Info("请求",
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
@@ -242,4 +305,4 @@ func LoggerMinimal() gin.HandlerFunc {
|
||||
zap.Duration("latency", time.Since(start)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newCtxWithBody 构造一个带请求体的 gin.Context,仅用于 logger 内部测试。
|
||||
func newCtxWithBody(body []byte) *gin.Context {
|
||||
req := httptest.NewRequest("POST", "/", bytes.NewReader(body))
|
||||
c := &gin.Context{}
|
||||
c.Request = req
|
||||
return c
|
||||
}
|
||||
|
||||
// TestReadBodyBounded_TruncatesLogCopy 复现 H3:日志副本必须封顶到 maxLen,
|
||||
// 而不是把整个 body 读入内存后再截断(原 io.ReadAll 无上限 → OOM)。
|
||||
func TestReadBodyBounded_TruncatesLogCopy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
bodyLen int
|
||||
maxLen int
|
||||
}{
|
||||
{"smaller_than_limit", 100, 1024},
|
||||
{"equal_to_limit", 1024, 1024},
|
||||
{"larger_than_limit", 100_000, 1024},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := bytes.Repeat([]byte("a"), tc.bodyLen)
|
||||
c := newCtxWithBody(body)
|
||||
|
||||
got := readBodyBounded(c, tc.maxLen)
|
||||
|
||||
want := tc.bodyLen
|
||||
if want > tc.maxLen {
|
||||
want = tc.maxLen
|
||||
}
|
||||
if len(got) != want {
|
||||
t.Fatalf("log copy len = %d, want %d (maxLen=%d, bodyLen=%d)", len(got), want, tc.maxLen, tc.bodyLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadBodyBounded_RestoresFullBody 是 H3 修复的闭环断言:
|
||||
// 即使日志副本被截断,下游处理器仍必须能读到完整请求体(io.MultiReader 复原)。
|
||||
func TestReadBodyBounded_RestoresFullBody(t *testing.T) {
|
||||
const maxLen = 64
|
||||
body := []byte(strings.Repeat("ABCDEFGH", 1000)) // 8000 字节,远超 maxLen
|
||||
c := newCtxWithBody(body)
|
||||
|
||||
logCopy := readBodyBounded(c, maxLen)
|
||||
|
||||
// 日志副本封顶
|
||||
if len(logCopy) != maxLen {
|
||||
t.Fatalf("log copy len = %d, want %d", len(logCopy), maxLen)
|
||||
}
|
||||
|
||||
// 下游必须拿到完整原始 body
|
||||
restored, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read restored body: %v", err)
|
||||
}
|
||||
if !bytes.Equal(restored, body) {
|
||||
t.Fatalf("downstream body corrupted: got len=%d, want len=%d", len(restored), len(body))
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadBodyBounded_PreservesSmallBodyExactly 小于上限时日志副本与原始一致。
|
||||
func TestReadBodyBounded_PreservesSmallBodyExactly(t *testing.T) {
|
||||
body := []byte(`{"user":"alice","password":"secret"}`)
|
||||
c := newCtxWithBody(body)
|
||||
|
||||
got := readBodyBounded(c, 1024)
|
||||
if !bytes.Equal(got, body) {
|
||||
t.Fatalf("log copy = %q, want %q", got, body)
|
||||
}
|
||||
|
||||
restored, _ := io.ReadAll(c.Request.Body)
|
||||
if !bytes.Equal(restored, body) {
|
||||
t.Fatalf("downstream body = %q, want %q", restored, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLogWriter_Bounded 复现 H3 响应侧:响应体捕获缓冲区必须封顶,
|
||||
// 完整响应仍写入下游 ResponseWriter。
|
||||
func TestBodyLogWriter_Bounded(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const maxLen = 32
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
w := &bodyLogWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: new(bytes.Buffer),
|
||||
maxLen: maxLen,
|
||||
}
|
||||
|
||||
large := bytes.Repeat([]byte("Z"), 10_000)
|
||||
n, err := w.Write(large)
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if n != len(large) {
|
||||
t.Fatalf("Write returned %d, want %d (下游必须收到完整响应)", n, len(large))
|
||||
}
|
||||
if w.body.Len() > maxLen {
|
||||
t.Fatalf("captured buffer len = %d, must be <= %d (OOM 防护失效)", w.body.Len(), maxLen)
|
||||
}
|
||||
if w.body.Len() != maxLen {
|
||||
t.Fatalf("captured buffer len = %d, want exactly %d", w.body.Len(), maxLen)
|
||||
}
|
||||
// 下游 ResponseWriter 收到完整内容
|
||||
if rec.Body.Len() != len(large) {
|
||||
t.Fatalf("downstream response len = %d, want %d", rec.Body.Len(), len(large))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLogWriter_NoLimit maxLen<=0 时不限制(向后兼容)。
|
||||
func TestBodyLogWriter_NoLimit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
w := &bodyLogWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: new(bytes.Buffer),
|
||||
maxLen: 0,
|
||||
}
|
||||
data := []byte("hello world")
|
||||
if _, err := w.Write(data); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if !bytes.Equal(w.body.Bytes(), data) {
|
||||
t.Fatalf("captured = %q, want %q", w.body.Bytes(), data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLogWriter_MultiWriteAccumulation 多次小写累积不超过 maxLen,
|
||||
// 下游仍收到完整拼接结果。
|
||||
func TestBodyLogWriter_MultiWriteAccumulation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const maxLen = 32
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
w := &bodyLogWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: new(bytes.Buffer),
|
||||
maxLen: maxLen,
|
||||
}
|
||||
chunks := [][]byte{[]byte("0123456789"), []byte("abcdefghij"), []byte("ABCDEFGHIJ"), []byte("zzzzzzzzzz")}
|
||||
var downstream bytes.Buffer
|
||||
for _, ch := range chunks {
|
||||
n, err := w.Write(ch)
|
||||
if err != nil || n != len(ch) {
|
||||
t.Fatalf("Write chunk %q: n=%d err=%v", ch, n, err)
|
||||
}
|
||||
downstream.Write(ch)
|
||||
}
|
||||
if w.body.Len() > maxLen {
|
||||
t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen)
|
||||
}
|
||||
if w.body.Len() != maxLen {
|
||||
t.Fatalf("captured len = %d, want exactly %d", w.body.Len(), maxLen)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), downstream.Bytes()) {
|
||||
t.Fatalf("downstream = %q, want %q", rec.Body.Bytes(), downstream.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLogWriter_WriteStringBounded WriteString 路径同样封顶。
|
||||
func TestBodyLogWriter_WriteStringBounded(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const maxLen = 16
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
w := &bodyLogWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: new(bytes.Buffer),
|
||||
maxLen: maxLen,
|
||||
}
|
||||
large := strings.Repeat("S", 500)
|
||||
n, err := w.WriteString(large)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteString: %v", err)
|
||||
}
|
||||
if n != len(large) {
|
||||
t.Fatalf("WriteString returned %d, want %d", n, len(large))
|
||||
}
|
||||
if w.body.Len() > maxLen {
|
||||
t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen)
|
||||
}
|
||||
if rec.Body.Len() != len(large) {
|
||||
t.Fatalf("downstream len = %d, want %d", rec.Body.Len(), len(large))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterSensitiveQuery P1 #14:query 中的敏感参数值必须脱敏,非敏感原样保留。
|
||||
func TestFilterSensitiveQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
contains string // 期望结果包含
|
||||
absent string // 期望结果不含(敏感原值)
|
||||
}{
|
||||
{"empty", "", "", ""},
|
||||
{"no sensitive", "page=1&size=20", "page=1", ""},
|
||||
{"access_token redacted", "access_token=supersecret123&page=1", "FILTERED", "supersecret123"},
|
||||
{"password redacted", "user=alice&password=hunter2", "FILTERED", "hunter2"},
|
||||
{"case insensitive key", "Token=abc.def.ghi", "FILTERED", "abc.def.ghi"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := filterSensitiveQuery(tc.in)
|
||||
if tc.contains != "" && !strings.Contains(got, tc.contains) {
|
||||
t.Errorf("filterSensitiveQuery(%q) = %q, want contains %q", tc.in, got, tc.contains)
|
||||
}
|
||||
if tc.absent != "" && strings.Contains(got, tc.absent) {
|
||||
t.Errorf("filterSensitiveQuery(%q) = %q, must NOT contain sensitive value %q", tc.in, got, tc.absent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerWithConfig_NormalizesMaxBodyLength MaxBodyLength<=0 时归一化为默认值,
|
||||
// 确保响应侧捕获缓冲区仍有上限(H3 复审 MEDIUM:消除请求/响应侧 maxLen<=0 不对称)。
|
||||
func TestLoggerWithConfig_NormalizesMaxBodyLength(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
cfg := LoggerConfig{
|
||||
LogRequestBody: true,
|
||||
LogResponseBody: true,
|
||||
MaxBodyLength: 0, // 手滑置 0,应被归一化为默认 1024
|
||||
SkipPaths: []string{},
|
||||
SkipPathPrefixes: []string{},
|
||||
SlowRequestThreshold: 500 * time.Millisecond,
|
||||
}
|
||||
r.Use(LoggerWithConfig(cfg))
|
||||
r.POST("/", func(c *gin.Context) {
|
||||
body, _ := io.ReadAll(c.Request.Body)
|
||||
c.Data(200, "text/plain", body)
|
||||
})
|
||||
|
||||
// 请求体 5000 字节(> 默认 1024),下游应仍得完整 body
|
||||
reqBody := bytes.Repeat([]byte("a"), 5000)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/", bytes.NewReader(reqBody))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Body.Len() != len(reqBody) {
|
||||
t.Fatalf("downstream body len = %d, want %d (请求体必须完整复原)", w.Body.Len(), len(reqBody))
|
||||
}
|
||||
if !bytes.Equal(w.Body.Bytes(), reqBody) {
|
||||
t.Fatalf("downstream body corrupted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerSkipPathPrefixesAnchorsBoundary_M8(t *testing.T) {
|
||||
cfg := LoggerConfig{SkipPathPrefixes: []string{"/api"}}
|
||||
if !shouldSkipPath("/api", cfg) {
|
||||
t.Fatal("/api should be skipped")
|
||||
}
|
||||
if !shouldSkipPath("/api/users", cfg) {
|
||||
t.Fatal("/api/users should be skipped")
|
||||
}
|
||||
if shouldSkipPath("/api2/users", cfg) {
|
||||
t.Fatal("/api2/users should not be skipped by /api prefix")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
// #18 Prometheus 指标。三个标配:
|
||||
// - http_requests_total: 请求计数(按 method/route/status 维度)
|
||||
// - http_request_duration_seconds: 请求耗时直方图(按 method/route 维度)
|
||||
// - http_requests_in_flight: 当前在飞请求数
|
||||
|
||||
var (
|
||||
httpRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "http_requests_total",
|
||||
Help: "HTTP 请求总数。",
|
||||
},
|
||||
[]string{"method", "route", "status"},
|
||||
)
|
||||
|
||||
httpRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "http_request_duration_seconds",
|
||||
Help: "HTTP 请求耗时(秒)。",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
},
|
||||
[]string{"method", "route"},
|
||||
)
|
||||
|
||||
httpRequestsInFlight = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "http_requests_in_flight",
|
||||
Help: "正在处理的 HTTP 请求数。",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
// Metrics Prometheus 指标中间件(#18)。记录请求计数、耗时、在飞数。
|
||||
// route 标签用 c.FullPath()(路由模板,如 /api/v1/users/:id),避免高基数。
|
||||
func Metrics() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
httpRequestsInFlight.Inc()
|
||||
defer httpRequestsInFlight.Dec() // M2 修复:defer 保证 panic 时也递减,不依赖 Recover 中间件顺序
|
||||
start := time.Now()
|
||||
|
||||
c.Next()
|
||||
|
||||
elapsed := time.Since(start).Seconds()
|
||||
status := strconv.Itoa(c.Writer.Status())
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = "not_found"
|
||||
}
|
||||
method := c.Request.Method
|
||||
|
||||
httpRequestsTotal.WithLabelValues(method, route, status).Inc()
|
||||
httpRequestDuration.WithLabelValues(method, route).Observe(elapsed)
|
||||
}
|
||||
}
|
||||
+1070
-3
File diff suppressed because it is too large
Load Diff
+422
-172
@@ -2,13 +2,17 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RateLimiter 速率限制器(内存版,单实例使用)
|
||||
@@ -20,15 +24,22 @@ type RateLimiter struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
nowFunc func() time.Time // 时间源(默认 time.Now,测试可注入可控时钟)
|
||||
}
|
||||
|
||||
type visitor struct {
|
||||
lastSeen time.Time
|
||||
count int
|
||||
// windowStart 当前固定窗口的起点。仅在新窗口开始时设置,放行时不变更(H4a 修复)。
|
||||
// 旧实现每次放行都更新 lastSeen,导致 time.Since(lastSeen) > window 重置分支对持续
|
||||
// 客户端永不成立、count 单调累加,稳态客户端(低于 rate)被误限流。
|
||||
windowStart time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
// NewRateLimiter 创建速率限制器(内存版)
|
||||
// NewRateLimiter 创建速率限制器(内存版)。
|
||||
// rate<=0 或 window<=0 时 panic。内部启动 cleanup goroutine,调用方负责在不再使用时
|
||||
// 调用 (*RateLimiter).Stop 释放,否则 goroutine 泄漏。
|
||||
func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
|
||||
mustValidRateLimit(rate, window)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
limiter := &RateLimiter{
|
||||
visitors: make(map[string]*visitor),
|
||||
@@ -36,6 +47,7 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
|
||||
window: window,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
nowFunc: time.Now,
|
||||
}
|
||||
|
||||
limiter.wg.Add(1)
|
||||
@@ -44,32 +56,67 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
|
||||
return limiter
|
||||
}
|
||||
|
||||
// Allow 检查是否允许请求
|
||||
func mustValidRateLimit(rate int, window time.Duration) {
|
||||
if rate <= 0 {
|
||||
panic("rate limiter: rate must be positive")
|
||||
}
|
||||
if window <= 0 {
|
||||
panic("rate limiter: window must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
// now 返回当前时间(可被测试注入 nowFunc 覆盖)。
|
||||
func (rl *RateLimiter) now() time.Time {
|
||||
if rl.nowFunc != nil {
|
||||
return rl.nowFunc()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// SetNowFunc 注入时间源(默认 time.Now),供测试用可控时钟验证窗口语义。
|
||||
// 生产代码通常无需调用。
|
||||
func (rl *RateLimiter) SetNowFunc(f func() time.Time) {
|
||||
rl.mu.Lock()
|
||||
rl.nowFunc = f
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
|
||||
// Allow 检查是否允许请求。
|
||||
//
|
||||
// 固定窗口语义(H4a 修复):windowStart 是当前窗口起点,仅在窗口过期重置时变更;
|
||||
// 放行时不再更新 windowStart。窗口内 count 达到 rate 即拒绝,窗口过期则重置为新窗口。
|
||||
// 旧实现每次放行更新 lastSeen,致重置分支对持续客户端永不成立、稳态客户端被误限流。
|
||||
//
|
||||
// 注意:固定窗口算法允许窗口边界突发(两窗口交界处瞬时可达 2×rate)。
|
||||
// 如需平滑限流(无突发),请用 Redis 版 RedisRateLimiter(滑动窗口)。
|
||||
func (rl *RateLimiter) Allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := rl.now()
|
||||
v, exists := rl.visitors[ip]
|
||||
if !exists {
|
||||
rl.visitors[ip] = &visitor{
|
||||
lastSeen: time.Now(),
|
||||
count: 1,
|
||||
windowStart: now,
|
||||
count: 1,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if time.Since(v.lastSeen) > rl.window {
|
||||
// 窗口过期:开新窗口,count 重置为 1。
|
||||
if now.Sub(v.windowStart) > rl.window {
|
||||
v.windowStart = now
|
||||
v.count = 1
|
||||
v.lastSeen = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
// 当前窗口内已达上限:拒绝(不更新 windowStart)。
|
||||
if v.count >= rl.rate {
|
||||
return false
|
||||
}
|
||||
|
||||
// 放行:count++,windowStart 不变(固定窗口语义)。
|
||||
v.count++
|
||||
v.lastSeen = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -87,7 +134,8 @@ func (rl *RateLimiter) cleanupVisitors() {
|
||||
case <-ticker.C:
|
||||
rl.mu.Lock()
|
||||
for ip, v := range rl.visitors {
|
||||
if time.Since(v.lastSeen) > rl.window {
|
||||
// 窗口起点超 window 未活跃即淘汰(H4a:windowStart 是窗口起点,不再被放行更新)。
|
||||
if rl.now().Sub(v.windowStart) > rl.window {
|
||||
delete(rl.visitors, ip)
|
||||
}
|
||||
}
|
||||
@@ -104,13 +152,23 @@ func (rl *RateLimiter) Stop() {
|
||||
|
||||
// ===== Redis 分布式限流器 =====
|
||||
|
||||
// H4c: Redis 限流器故障相关错误。
|
||||
var (
|
||||
// ErrRedisRateLimiterUnavailable Redis 未启用(database.GetRedis() == nil)。
|
||||
// fail-closed 限流器在此情况下拒绝请求;fail-open 限流器放行。
|
||||
ErrRedisRateLimiterUnavailable = errors.New("redis rate limiter: redis client unavailable")
|
||||
// ErrRedisRateLimiterUnexpectedResult Redis 返回非预期的结果类型(非 int64)。
|
||||
// fail-closed 限流器拒绝;fail-open 限流器放行。旧实现裸断言会 panic。
|
||||
ErrRedisRateLimiterUnexpectedResult = errors.New("redis rate limiter: unexpected result type")
|
||||
)
|
||||
|
||||
// RedisRateLimiter Redis 分布式限流器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 多实例部署时限流共享,使用滑动窗口算法
|
||||
type RedisRateLimiter struct {
|
||||
keyPrefix string // 键名前缀
|
||||
rate int // 每分钟允许的请求数
|
||||
window time.Duration // 时间窗口
|
||||
keyPrefix string // 键名前缀
|
||||
rate int // 每分钟允许的请求数
|
||||
window time.Duration // 时间窗口
|
||||
failClosed atomic.Bool // H4c/H-6: Redis 错误/断言失败时是否拒绝(true=安全型 fail-closed)。atomic 以支持运行期 SetFailClosed 并发安全切换。
|
||||
client *redis.Client // Phase 5: 注入的 redis client(nil 回退 database.GetRedis(),照 jwt.TokenBlacklist 模型)。App 经 WithRedisClient 注入 per-App client 实现隔离。
|
||||
}
|
||||
|
||||
// slidingWindowLua 滑动窗口限流 Lua 脚本
|
||||
@@ -137,21 +195,78 @@ else
|
||||
end
|
||||
`
|
||||
|
||||
// NewRedisRateLimiter 创建 Redis 分布式限流器
|
||||
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
|
||||
return &RedisRateLimiter{
|
||||
keyPrefix: keyPrefix,
|
||||
rate: rate,
|
||||
window: window,
|
||||
// RedisRateLimiterOption 配置 RedisRateLimiter 的可选策略。
|
||||
type RedisRateLimiterOption func(*RedisRateLimiter)
|
||||
|
||||
// WithFailClosed 设置 Redis 故障时 fail-closed(拒绝请求)。
|
||||
// 不传或传 false 为 fail-open(放行,兼容默认)。安全敏感场景(登录防爆破等)应传 true。
|
||||
func WithFailClosed(failClosed bool) RedisRateLimiterOption {
|
||||
return func(rl *RedisRateLimiter) {
|
||||
rl.failClosed.Store(failClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow 检查是否允许请求
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用滑动窗口算法,精确限流
|
||||
// WithRedisClient 注入指定 Redis 客户端(Phase 5,多 Redis/测试隔离)。
|
||||
// 不传则回退 database.GetRedis()(全局)。App 可经 app.RedisClient() 取自身 client 注入。
|
||||
func WithRedisClient(client *redis.Client) RedisRateLimiterOption {
|
||||
return func(rl *RedisRateLimiter) {
|
||||
rl.client = client
|
||||
}
|
||||
}
|
||||
|
||||
// NewRedisRateLimiter 创建 Redis 分布式限流器。
|
||||
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景传 WithFailClosed(true)。
|
||||
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) *RedisRateLimiter {
|
||||
mustValidRateLimit(rate, window)
|
||||
rl := &RedisRateLimiter{
|
||||
keyPrefix: keyPrefix,
|
||||
rate: rate,
|
||||
window: window,
|
||||
// failClosed 零值 false(fail-open,兼容默认)
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(rl)
|
||||
}
|
||||
}
|
||||
return rl
|
||||
}
|
||||
|
||||
// SetFailClosed 设置 Redis 故障时的策略:true=拒绝(安全型),false=放行(兼容默认)。
|
||||
// 供已创建的限流器切换策略。并发安全(atomic.Store),可与并发 Allow 调用共存。
|
||||
func (rl *RedisRateLimiter) SetFailClosed(failClosed bool) {
|
||||
rl.failClosed.Store(failClosed)
|
||||
}
|
||||
|
||||
// redisClient 返回注入的 redis client,未注入则回退 database.GetRedis()(jwt 模型)。
|
||||
// M-C 教训:取一次复用,避免 nil 检查与 Eval 各调一次 GetRedis() 之间的 CloseRedis 竞态。
|
||||
func (rl *RedisRateLimiter) redisClient() *redis.Client {
|
||||
if rl != nil && rl.client != nil {
|
||||
return rl.client
|
||||
}
|
||||
return database.GetRedis()
|
||||
}
|
||||
|
||||
// Allow 检查是否允许请求。
|
||||
//
|
||||
// H4c 修复:
|
||||
// - result.(int64) 改 comma-ok,断言失败返 ErrRedisRateLimiterUnexpectedResult 而非 panic。
|
||||
// - Redis 错误/断言失败时按 failClosed 策略决定:fail-closed 返 (false, err) 拒绝,
|
||||
// fail-open 返 (true, err) 放行(兼容旧行为)。中间件层据此 allowed 值决定放行/拒绝,
|
||||
// 不再无条件 fail-open--登录防爆破等安全场景用 fail-closed 限流器即可在 Redis 故障时拒绝。
|
||||
//
|
||||
// Redis 未启用(redisClient() == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable),
|
||||
// fail-open 返 (true, nil)(兼容旧行为)。安全场景必须确保 Redis 已启用。
|
||||
//
|
||||
// Phase 5:redisClient() 注入优先、全局兜底,App 经 WithRedisClient 注入 per-App client。
|
||||
func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
// Redis 未启用,默认允许
|
||||
// M-C 修复:取一次复用。原实现 nil 检查与 Eval 各调一次 GetRedis(),
|
||||
// 两次之间若 CloseRedis,第二次返回 nil -> nil.Eval panic。
|
||||
rdb := rl.redisClient()
|
||||
if rdb == nil {
|
||||
if rl.failClosed.Load() {
|
||||
return false, ErrRedisRateLimiterUnavailable
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -159,18 +274,30 @@ func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool,
|
||||
now := float64(time.Now().UnixMilli())
|
||||
windowMs := float64(rl.window.Milliseconds())
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
|
||||
result, err := rdb.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
|
||||
if err != nil {
|
||||
return true, err // 出错时允许请求,避免影响业务
|
||||
if rl.failClosed.Load() {
|
||||
return false, err
|
||||
}
|
||||
return true, err // 出错时允许请求,避免影响业务(兼容旧行为)
|
||||
}
|
||||
|
||||
count := result.(int64)
|
||||
// H4c: comma-ok 断言,避免 Redis 返回非 int64 时 panic。
|
||||
count, ok := result.(int64)
|
||||
if !ok {
|
||||
if rl.failClosed.Load() {
|
||||
return false, ErrRedisRateLimiterUnexpectedResult
|
||||
}
|
||||
return true, ErrRedisRateLimiterUnexpectedResult
|
||||
}
|
||||
return count == 0, nil
|
||||
}
|
||||
|
||||
// GetCount 获取当前窗口内的请求数
|
||||
func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
// M-C 修复(Phase 5:改 redisClient):取一次复用(原三次调用存在 nil-deref 窗口)。
|
||||
rdb := rl.redisClient()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -179,138 +306,287 @@ func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (in
|
||||
windowStart := now - rl.window.Milliseconds()
|
||||
|
||||
// 移除旧记录并获取当前计数
|
||||
database.RedisClient.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
|
||||
return database.RedisClient.ZCard(ctx, key).Result()
|
||||
rdb.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
|
||||
return rdb.ZCard(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Reset 重置限流计数
|
||||
func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error {
|
||||
if database.RedisClient == nil {
|
||||
// M-C 修复(Phase 5:改 redisClient):取一次复用(原两次调用存在 nil-deref 窗口)。
|
||||
rdb := rl.redisClient()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
key := rl.keyPrefix + ":" + identifier
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
return rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ===== 全局限速器 =====
|
||||
// ===== 全局限速器 Registry(Phase 5 实例化) =====
|
||||
//
|
||||
// 内存限流器(RateLimiter)持 cleanup goroutine,进程级共享会在多 App 下互相污染
|
||||
// (App A Shutdown 经 StopRateLimiters 把 App B 的 loginLimiter 也停了)。故照
|
||||
// cache.CacheManager / cron.Scheduler 模式引入 RateLimitRegistry:实例化 + 全局默认
|
||||
// + 包级 facade 代理,App 持自己的 Registry。
|
||||
//
|
||||
// 包级 LoginRateLimit() 等改为"请求时解析当前默认 Registry"懒创建限流器,使 App.Init
|
||||
// swap 后请求落到 App 的 Registry(避免 pre-Init 注册到 init Registry 被 swap 丢弃)。
|
||||
|
||||
var (
|
||||
loginLimiter *RateLimiter
|
||||
apiLimiter *RateLimiter
|
||||
uploadLimiter *RateLimiter
|
||||
redisLimiters map[string]*RedisRateLimiter
|
||||
limitersMu sync.Mutex
|
||||
)
|
||||
// RateLimitRegistry 持一组内存限流器,支持 per-App 隔离。
|
||||
type RateLimitRegistry struct {
|
||||
mu sync.Mutex
|
||||
loginLimiter *RateLimiter
|
||||
apiLimiter *RateLimiter
|
||||
uploadLimiter *RateLimiter
|
||||
customLimiters []*RateLimiter
|
||||
}
|
||||
|
||||
// NewRateLimitRegistry 创建限流器注册表实例。
|
||||
func NewRateLimitRegistry() *RateLimitRegistry {
|
||||
return &RateLimitRegistry{}
|
||||
}
|
||||
|
||||
func (r *RateLimitRegistry) loginLimiterInstance() *RateLimiter {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.loginLimiter == nil {
|
||||
r.loginLimiter = NewRateLimiter(10, time.Minute)
|
||||
}
|
||||
return r.loginLimiter
|
||||
}
|
||||
|
||||
func (r *RateLimitRegistry) apiLimiterInstance() *RateLimiter {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.apiLimiter == nil {
|
||||
r.apiLimiter = NewRateLimiter(100, time.Minute)
|
||||
}
|
||||
return r.apiLimiter
|
||||
}
|
||||
|
||||
func (r *RateLimitRegistry) uploadLimiterInstance() *RateLimiter {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.uploadLimiter == nil {
|
||||
r.uploadLimiter = NewRateLimiter(20, time.Minute)
|
||||
}
|
||||
return r.uploadLimiter
|
||||
}
|
||||
|
||||
// registerCustom 登记自定义限流器供 Stop 统一停止(H4b)。
|
||||
func (r *RateLimitRegistry) registerCustom(l *RateLimiter) {
|
||||
r.mu.Lock()
|
||||
r.customLimiters = append(r.customLimiters, l)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// Init 预初始化标准限流器(可选;不调则首次请求时懒创建)。先停旧的同名限流器释放 goroutine。
|
||||
func (r *RateLimitRegistry) Init() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.loginLimiter != nil {
|
||||
r.loginLimiter.Stop()
|
||||
}
|
||||
if r.apiLimiter != nil {
|
||||
r.apiLimiter.Stop()
|
||||
}
|
||||
if r.uploadLimiter != nil {
|
||||
r.uploadLimiter.Stop()
|
||||
}
|
||||
r.loginLimiter = NewRateLimiter(10, time.Minute)
|
||||
r.apiLimiter = NewRateLimiter(100, time.Minute)
|
||||
r.uploadLimiter = NewRateLimiter(20, time.Minute)
|
||||
}
|
||||
|
||||
// Stop 停止注册表中所有限流器(释放 cleanup goroutine)。幂等。
|
||||
func (r *RateLimitRegistry) Stop() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.loginLimiter != nil {
|
||||
r.loginLimiter.Stop()
|
||||
r.loginLimiter = nil
|
||||
}
|
||||
if r.apiLimiter != nil {
|
||||
r.apiLimiter.Stop()
|
||||
r.apiLimiter = nil
|
||||
}
|
||||
if r.uploadLimiter != nil {
|
||||
r.uploadLimiter.Stop()
|
||||
r.uploadLimiter = nil
|
||||
}
|
||||
for _, l := range r.customLimiters {
|
||||
l.Stop()
|
||||
}
|
||||
r.customLimiters = nil
|
||||
}
|
||||
|
||||
// --- App-bound 中间件(捕获此 Registry,不查全局默认,多 App per-App 隔离) ---
|
||||
//
|
||||
// 与包级 LoginRateLimit() 等的区别:包级在请求时查 GetDefaultRateLimitRegistry()(多 App 下
|
||||
// 仅最后 Init 的 App 是全局默认,故包级 facade 不提供 per-App 计数隔离);本组方法捕获 r,
|
||||
// 始终用此 Registry 的 limiter。多 App 场景用 app.RateLimitRegistry().LoginRateLimit() 装配路由。
|
||||
|
||||
// LoginRateLimit 返回绑定到此 Registry 的登录限流中间件。
|
||||
func (r *RateLimitRegistry) LoginRateLimit() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rateLimitAllow(c, r.loginLimiterInstance())
|
||||
}
|
||||
}
|
||||
|
||||
// APIRateLimit 返回绑定到此 Registry 的普通 API 限流中间件。
|
||||
func (r *RateLimitRegistry) APIRateLimit() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rateLimitAllow(c, r.apiLimiterInstance())
|
||||
}
|
||||
}
|
||||
|
||||
// UploadRateLimit 返回绑定到此 Registry 的上传限流中间件。
|
||||
func (r *RateLimitRegistry) UploadRateLimit() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rateLimitAllow(c, r.uploadLimiterInstance())
|
||||
}
|
||||
}
|
||||
|
||||
// CustomRateLimit 返回绑定到此 Registry 的自定义限流中间件。limiter 在首次请求时创建并
|
||||
// 登记到此 Registry(r.registerCustom),由 App.Shutdown 的 registry.Stop() 收口,无泄漏。
|
||||
func (r *RateLimitRegistry) CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
|
||||
var (
|
||||
once sync.Once
|
||||
limiter *RateLimiter
|
||||
)
|
||||
return func(c *gin.Context) {
|
||||
once.Do(func() {
|
||||
limiter = NewRateLimiter(rate, window)
|
||||
r.registerCustom(limiter)
|
||||
})
|
||||
rateLimitAllow(c, limiter)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultRateLimitRegistry 全局默认 Registry(atomic,照 cache/cron 模式)。
|
||||
var defaultRateLimitRegistry atomic.Pointer[RateLimitRegistry]
|
||||
|
||||
func init() {
|
||||
redisLimiters = make(map[string]*RedisRateLimiter)
|
||||
defaultRateLimitRegistry.Store(NewRateLimitRegistry())
|
||||
}
|
||||
|
||||
// InitRateLimiters 初始化限速器
|
||||
func InitRateLimiters() {
|
||||
limitersMu.Lock()
|
||||
defer limitersMu.Unlock()
|
||||
|
||||
// 先停止旧的限流器
|
||||
if loginLimiter != nil {
|
||||
loginLimiter.Stop()
|
||||
}
|
||||
if apiLimiter != nil {
|
||||
apiLimiter.Stop()
|
||||
}
|
||||
if uploadLimiter != nil {
|
||||
uploadLimiter.Stop()
|
||||
}
|
||||
|
||||
// 内存限流器(单实例)
|
||||
loginLimiter = NewRateLimiter(10, time.Minute)
|
||||
apiLimiter = NewRateLimiter(100, time.Minute)
|
||||
uploadLimiter = NewRateLimiter(20, time.Minute)
|
||||
// GetDefaultRateLimitRegistry 返回全局默认 Registry。
|
||||
func GetDefaultRateLimitRegistry() *RateLimitRegistry {
|
||||
return defaultRateLimitRegistry.Load()
|
||||
}
|
||||
|
||||
// StopRateLimiters 停止所有限速器(应用关闭时调用)
|
||||
func StopRateLimiters() {
|
||||
limitersMu.Lock()
|
||||
defer limitersMu.Unlock()
|
||||
|
||||
if loginLimiter != nil {
|
||||
loginLimiter.Stop()
|
||||
loginLimiter = nil
|
||||
}
|
||||
if apiLimiter != nil {
|
||||
apiLimiter.Stop()
|
||||
apiLimiter = nil
|
||||
}
|
||||
if uploadLimiter != nil {
|
||||
uploadLimiter.Stop()
|
||||
uploadLimiter = nil
|
||||
// SwapDefaultRateLimitRegistry 置为全局默认,返回被替换的旧(照 Swap 模式)。nil 忽略,返回当前默认。
|
||||
func SwapDefaultRateLimitRegistry(r *RateLimitRegistry) *RateLimitRegistry {
|
||||
if r == nil {
|
||||
return defaultRateLimitRegistry.Load()
|
||||
}
|
||||
return defaultRateLimitRegistry.Swap(r)
|
||||
}
|
||||
|
||||
// RateLimit 通用速率限制中间件(内存版)
|
||||
// rateLimitAllow 公共放行/拒绝逻辑(内存版)。
|
||||
func rateLimitAllow(c *gin.Context, rl *RateLimiter) {
|
||||
if rl == nil {
|
||||
response.Custom(c, http.StatusServiceUnavailable, response.CodeServiceUnavailable,
|
||||
"限流器未初始化", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !rl.Allow(c.ClientIP()) {
|
||||
response.RateLimit(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// RateLimit 通用速率限制中间件(内存版,用户自持 limiter 时使用)。
|
||||
func RateLimit(limiter *RateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
if !limiter.Allow(ip) {
|
||||
response.RateLimit(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
rateLimitAllow(c, limiter)
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRateLimit 登录接口速率限制
|
||||
// LoginRateLimit 登录接口速率限制。限流器在首次请求时从当前默认 Registry 懒创建,
|
||||
// 故 App.Init swap 后用的是 App 的 Registry(避免 pre-Init 注册到 init Registry 被丢弃)。
|
||||
func LoginRateLimit() gin.HandlerFunc {
|
||||
limitersMu.Lock()
|
||||
if loginLimiter == nil {
|
||||
loginLimiter = NewRateLimiter(10, time.Minute)
|
||||
return func(c *gin.Context) {
|
||||
rateLimitAllow(c, GetDefaultRateLimitRegistry().loginLimiterInstance())
|
||||
}
|
||||
limiter := loginLimiter
|
||||
limitersMu.Unlock()
|
||||
|
||||
return RateLimit(limiter)
|
||||
}
|
||||
|
||||
// APIRateLimit 普通 API 速率限制
|
||||
// APIRateLimit 普通 API 速率限制。
|
||||
func APIRateLimit() gin.HandlerFunc {
|
||||
limitersMu.Lock()
|
||||
if apiLimiter == nil {
|
||||
apiLimiter = NewRateLimiter(100, time.Minute)
|
||||
return func(c *gin.Context) {
|
||||
rateLimitAllow(c, GetDefaultRateLimitRegistry().apiLimiterInstance())
|
||||
}
|
||||
limiter := apiLimiter
|
||||
limitersMu.Unlock()
|
||||
|
||||
return RateLimit(limiter)
|
||||
}
|
||||
|
||||
// UploadRateLimit 上传接口速率限制
|
||||
// UploadRateLimit 上传接口速率限制。
|
||||
func UploadRateLimit() gin.HandlerFunc {
|
||||
limitersMu.Lock()
|
||||
if uploadLimiter == nil {
|
||||
uploadLimiter = NewRateLimiter(20, time.Minute)
|
||||
return func(c *gin.Context) {
|
||||
rateLimitAllow(c, GetDefaultRateLimitRegistry().uploadLimiterInstance())
|
||||
}
|
||||
limiter := uploadLimiter
|
||||
limitersMu.Unlock()
|
||||
|
||||
return RateLimit(limiter)
|
||||
}
|
||||
|
||||
// CustomRateLimit 自定义速率限制(内存版)
|
||||
// CustomRateLimit 自定义速率限制(内存版)。限流器在首次请求时创建并登记到当前默认
|
||||
// Registry(供 Stop 统一停止,避免 cleanup goroutine 泄漏,H4b)。
|
||||
func CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
|
||||
limiter := NewRateLimiter(rate, window)
|
||||
return RateLimit(limiter)
|
||||
var (
|
||||
once sync.Once
|
||||
limiter *RateLimiter
|
||||
)
|
||||
return func(c *gin.Context) {
|
||||
once.Do(func() {
|
||||
limiter = NewRateLimiter(rate, window)
|
||||
GetDefaultRateLimitRegistry().registerCustom(limiter)
|
||||
})
|
||||
rateLimitAllow(c, limiter)
|
||||
}
|
||||
}
|
||||
|
||||
// InitRateLimiters 初始化默认 Registry 的标准限流器(可选,不调则懒创建)。
|
||||
func InitRateLimiters() {
|
||||
GetDefaultRateLimitRegistry().Init()
|
||||
}
|
||||
|
||||
// StopRateLimiters 停止默认 Registry 的所有限流器。注意:仅停默认 Registry--
|
||||
// App 持自己的 Registry 时应在 Shutdown 调 app 自己的 Stop(Phase 5)。
|
||||
func StopRateLimiters() {
|
||||
GetDefaultRateLimitRegistry().Stop()
|
||||
}
|
||||
|
||||
// ===== Redis 分布式限流中间件 =====
|
||||
|
||||
// RedisRateLimit Redis 分布式限流中间件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 多实例部署时共享限流状态
|
||||
// 参数: keyPrefix 键名前缀(如 "login_limit"),rate 每分钟请求数
|
||||
func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
|
||||
// redisLimitDecision 处理 RedisRateLimiter.Allow 的结果,按 allowed 值决定放行/拒绝。
|
||||
//
|
||||
// H4c: 不再无条件 fail-open。Allow 已按 limiter 的 failClosed 策略把 Redis 故障翻成
|
||||
// allowed 值--fail-closed 时 allowed=false(拒绝),fail-open 时 allowed=true(放行)。
|
||||
// 故 err 与 allowed 的组合语义:
|
||||
// - err==nil, allowed==true -> 放行
|
||||
// - err==nil, allowed==false -> 真超限,返 429(response.RateLimit)
|
||||
// - err!=nil, allowed==false -> fail-closed 限流器在 Redis 故障下拒绝,返 503(服务不可用)
|
||||
// - err!=nil, allowed==true -> fail-open 限流器在 Redis 故障下放行(兼容旧行为)
|
||||
func redisLimitDecision(c *gin.Context, allowed bool, err error) {
|
||||
if err != nil && !allowed {
|
||||
// fail-closed:Redis 故障时拒绝(防限流静默失效)。返 503 区别于真实超限的 429。
|
||||
response.Custom(c, http.StatusServiceUnavailable, response.CodeServiceUnavailable,
|
||||
"限流服务暂时不可用", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
response.RateLimit(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// RedisRateLimit Redis 分布式限流中间件。
|
||||
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景(登录防爆破等)
|
||||
// 传 WithFailClosed(true),Redis 故障时拒绝以防限流失效。
|
||||
func RedisRateLimit(keyPrefix string, rate int, opts ...RedisRateLimiterOption) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
@@ -322,83 +598,57 @@ func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
|
||||
// }
|
||||
|
||||
allowed, err := limiter.Allow(c.Request.Context(), identifier)
|
||||
if err != nil {
|
||||
// Redis 错误时允许请求,避免影响业务
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
response.RateLimit(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
redisLimitDecision(c, allowed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流
|
||||
// 参数: keyPrefix 键名前缀,rate 每分钟请求数,identifierFunc 标识获取函数
|
||||
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
|
||||
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流。
|
||||
// 参数: keyPrefix 键名前缀,rate 1 分钟窗口内允许的请求数,identifierFunc 标识获取函数。
|
||||
// identifierFunc 为 nil 或返回空串时回退到 c.ClientIP()。默认 fail-open,传 WithFailClosed(true) 切换。
|
||||
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string, opts ...RedisRateLimiterOption) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := identifierFunc(c)
|
||||
identifier := ""
|
||||
if identifierFunc != nil {
|
||||
identifier = identifierFunc(c)
|
||||
}
|
||||
if identifier == "" {
|
||||
identifier = c.ClientIP()
|
||||
}
|
||||
|
||||
allowed, err := limiter.Allow(c.Request.Context(), identifier)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
response.RateLimit(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
redisLimitDecision(c, allowed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRedisRateLimit 登录接口 Redis 分布式限流
|
||||
// LoginRedisRateLimit 登录接口 Redis 分布式限流(fail-closed)。
|
||||
//
|
||||
// H4c: 登录防爆破场景必须 fail-closed--Redis 故障时若 fail-open 则限流失效、
|
||||
// 攻击者可借 Redis 抖动窗口无限爆破。改为 fail-closed:Redis 故障时返 503 拒绝。
|
||||
func LoginRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimit("login_limit", 10)
|
||||
return RedisRateLimit("login_limit", 10, WithFailClosed(true))
|
||||
}
|
||||
|
||||
// APIRedisRateLimit API Redis 分布式限流
|
||||
// APIRedisRateLimit API Redis 分布式限流(fail-open,避免影响业务)。
|
||||
func APIRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimit("api_limit", 100)
|
||||
}
|
||||
|
||||
// UploadRedisRateLimit 上传接口 Redis 分布式限流
|
||||
// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-closed)。
|
||||
// 上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效。
|
||||
func UploadRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimit("upload_limit", 20)
|
||||
return RedisRateLimit("upload_limit", 20, WithFailClosed(true))
|
||||
}
|
||||
|
||||
// CustomRedisRateLimit 自定义 Redis 分布式限流
|
||||
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, window)
|
||||
// CustomRedisRateLimit 自定义 Redis 分布式限流。
|
||||
// 默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed。
|
||||
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, window, opts...)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
|
||||
allowed, err := limiter.Allow(c.Request.Context(), identifier)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
response.RateLimit(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
redisLimitDecision(c, allowed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// TestRedisRateLimiterInjectedClientPriority 固化 Phase 5(jwt 模型):
|
||||
// RedisRateLimiter.redisClient() 注入优先、全局兜底。
|
||||
func TestRedisRateLimiterInjectedClientPriority(t *testing.T) {
|
||||
mrG := miniredis.RunT(t)
|
||||
clientG := redis.NewClient(&redis.Options{Addr: mrG.Addr()})
|
||||
t.Cleanup(func() { _ = clientG.Close() })
|
||||
origGlobal := database.SetTestRedisClient(clientG)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(origGlobal) })
|
||||
|
||||
mrA := miniredis.RunT(t)
|
||||
clientA := redis.NewClient(&redis.Options{Addr: mrA.Addr()})
|
||||
t.Cleanup(func() { _ = clientA.Close() })
|
||||
|
||||
// 注入优先
|
||||
rl := NewRedisRateLimiter("test", 10, time.Minute, WithRedisClient(clientA))
|
||||
if got := rl.redisClient(); got != clientA {
|
||||
t.Fatalf("injected redisClient() = %v, want clientA", got)
|
||||
}
|
||||
|
||||
// 未注入 -> 全局兜底
|
||||
rl2 := NewRedisRateLimiter("test", 10, time.Minute)
|
||||
if got := rl2.redisClient(); got != clientG {
|
||||
t.Fatalf("fallback redisClient() = %v, want clientG (global)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapDefaultRateLimitRegistryReturnsOld 固化 Phase 5:Swap 返回被替换的旧 Registry。
|
||||
func TestSwapDefaultRateLimitRegistryReturnsOld(t *testing.T) {
|
||||
orig := defaultRateLimitRegistry.Load()
|
||||
defer SwapDefaultRateLimitRegistry(orig)
|
||||
|
||||
first := NewRateLimitRegistry()
|
||||
if prev := SwapDefaultRateLimitRegistry(first); prev != orig {
|
||||
t.Fatalf("Swap returned %v, want orig", prev)
|
||||
}
|
||||
second := NewRateLimitRegistry()
|
||||
if returned := SwapDefaultRateLimitRegistry(second); returned != first {
|
||||
t.Fatalf("Swap returned %v, want first", returned)
|
||||
}
|
||||
if defaultRateLimitRegistry.Load() != second {
|
||||
t.Fatal("Swap did not install second as default")
|
||||
}
|
||||
if got := SwapDefaultRateLimitRegistry(nil); got != second {
|
||||
t.Fatalf("Swap(nil) = %v, want second (current default)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitRegistryStopIdempotent 固化 Phase 5:Stop 幂等,重复调用不 panic。
|
||||
func TestRateLimitRegistryStopIdempotent(t *testing.T) {
|
||||
r := NewRateLimitRegistry()
|
||||
_ = r.loginLimiterInstance()
|
||||
_ = r.apiLimiterInstance()
|
||||
_ = r.uploadLimiterInstance()
|
||||
r.registerCustom(NewRateLimiter(5, time.Minute))
|
||||
r.Stop()
|
||||
r.Stop() // 幂等
|
||||
}
|
||||
|
||||
// TestRateLimitRegistryIsolation 固化 Phase 5:两个 Registry 实例的 limiter 互相独立。
|
||||
func TestRateLimitRegistryIsolation(t *testing.T) {
|
||||
rA := NewRateLimitRegistry()
|
||||
rB := NewRateLimitRegistry()
|
||||
defer rA.Stop()
|
||||
defer rB.Stop()
|
||||
|
||||
la := rA.loginLimiterInstance()
|
||||
lb := rB.loginLimiterInstance()
|
||||
if la == lb {
|
||||
t.Fatal("two registries returned the same loginLimiter instance (not isolated)")
|
||||
}
|
||||
|
||||
// rA 用尽 10 次;rB 不受影响
|
||||
for i := 0; i < 10; i++ {
|
||||
if !la.Allow("1.1.1.1") {
|
||||
t.Fatalf("rA request %d should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
// rA 第 11 次应被拒绝(count=10 >= rate=10)
|
||||
if la.Allow("1.1.1.1") {
|
||||
t.Fatal("rA 11th should be rejected (count reached limit)")
|
||||
}
|
||||
// rB 的 loginLimiter 独立计数,1.1.1.1 仍可放行
|
||||
if !lb.Allow("1.1.1.1") {
|
||||
t.Fatal("rB loginLimiter should be independent of rA (still allowed)")
|
||||
}}
|
||||
+33
-12
@@ -12,8 +12,6 @@ import (
|
||||
)
|
||||
|
||||
// Recover panic恢复中间件,捕获panic并返回统一错误响应
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 生产环境必备,防止panic导致服务崩溃
|
||||
func Recover() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
@@ -22,7 +20,7 @@ func Recover() gin.HandlerFunc {
|
||||
requestID := GetRequestID(c)
|
||||
|
||||
// 记录错误日志
|
||||
logger.Error("Panic recovered",
|
||||
logger.Error("panic 已恢复",
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("error", fmt.Sprintf("%v", err)),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
@@ -30,9 +28,26 @@ func Recover() gin.HandlerFunc {
|
||||
zap.String("stack", string(debug.Stack())),
|
||||
)
|
||||
|
||||
// 返回错误响应
|
||||
response.FailWithCode(c, response.CodeServerError, "服务器内部错误")
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
// 返回错误响应。
|
||||
// 必须用 response.Custom 显式写 HTTP 500:FailWithCode 经 writeResp→httpStatusFor,
|
||||
// 在默认 ModeBusiness 下对 CodeServerError 返回 200,c.JSON(200,...) 会先 flush
|
||||
// 锁定状态,随后 AbortWithStatus(500) 因 w.Written()==true 沦为 no-op,
|
||||
// 导致客户端收到 HTTP 200 + body code:500,网关/APM 按 status 看不到 panic。
|
||||
// Custom 不受 Mode 影响,直接写 500 且保留 RequestID。
|
||||
//
|
||||
// M-B 修复:若 panic 发生在响应已开始写出之后(流式/部分写),c.Writer.Written()==true,
|
||||
// gin 拒绝再次写响应,response.Custom 沦为 no-op,客户端会收到截断响应而非 500。
|
||||
// 此时不再尝试写 500(无效),仅 Abort + 记录,避免无意义的二次写入。
|
||||
if !c.Writer.Written() {
|
||||
response.Custom(c, http.StatusInternalServerError, response.CodeServerError, "服务器内部错误", nil)
|
||||
} else {
|
||||
logger.Warn("panic 发生在响应已开始写入后,无法写入 500,客户端将收到截断响应",
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.Int("bytes_written", c.Writer.Size()),
|
||||
)
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
@@ -40,8 +55,6 @@ func Recover() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// RecoverWithDetail panic恢复中间件(详细版本,返回更多信息)
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 开发环境使用,返回详细错误信息便于调试
|
||||
// 注意: 生产环境不应使用,会暴露敏感信息
|
||||
func RecoverWithDetail() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
@@ -49,7 +62,7 @@ func RecoverWithDetail() gin.HandlerFunc {
|
||||
if err := recover(); err != nil {
|
||||
requestID := GetRequestID(c)
|
||||
|
||||
logger.Error("Panic recovered",
|
||||
logger.Error("panic 已恢复",
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("error", fmt.Sprintf("%v", err)),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
@@ -57,9 +70,17 @@ func RecoverWithDetail() gin.HandlerFunc {
|
||||
zap.String("stack", string(debug.Stack())),
|
||||
)
|
||||
|
||||
// 开发环境返回详细错误
|
||||
response.FailWithCode(c, response.CodeServerError, fmt.Sprintf("Panic: %v", err))
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
// 开发环境返回详细错误。同样用 Custom 显式写 500(见 Recover 注释)。
|
||||
// M-B 修复:响应已写出时不重复写 500(同 Recover)。
|
||||
if !c.Writer.Written() {
|
||||
response.Custom(c, http.StatusInternalServerError, response.CodeServerError, fmt.Sprintf("服务器内部错误: %v", err), nil)
|
||||
} else {
|
||||
logger.Warn("panic 发生在响应已开始写入后,无法写入 500,客户端将收到截断响应",
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
)
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
|
||||
+27
-4
@@ -5,12 +5,35 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 日志追踪、问题排查必备,简洁高效
|
||||
// 请求 ID 校验约束(M15 修复:不无条件信任客户端 X-Request-ID,防头注入/日志伪造)。
|
||||
const (
|
||||
// requestIDMaxLen 客户端传入请求 ID 的最大长度。超长视为非法并重新生成,避免日志膨胀/注入。
|
||||
requestIDMaxLen = 128
|
||||
)
|
||||
|
||||
// sanitizeRequestID 校验客户端传入的 X-Request-ID:仅允许可见 ASCII(0x20-0x7e)且
|
||||
// 不含 CR/LF(防 HTTP 头注入与日志换行伪造),长度不超过 requestIDMaxLen。
|
||||
// 非法或为空时返回 "",由调用方重新生成。
|
||||
func sanitizeRequestID(id string) string {
|
||||
if len(id) == 0 || len(id) > requestIDMaxLen {
|
||||
return ""
|
||||
}
|
||||
for i := 0; i < len(id); i++ {
|
||||
c := id[i]
|
||||
if c < 0x20 || c > 0x7e {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪。
|
||||
//
|
||||
// 客户端可经 X-Request-ID 头传入 ID 用于链路串联,但会做校验(M15 修复):
|
||||
// 仅接受可见 ASCII、无换行、长度 ≤128 的值,否则忽略并重新生成——防头注入与日志伪造。
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
requestID := sanitizeRequestID(c.GetHeader("X-Request-ID"))
|
||||
if requestID == "" {
|
||||
requestID = utils.UUID()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Timeout 请求级超时中间件(#19)。
|
||||
//
|
||||
// 与 http.Server.ReadTimeout(连接级)不同,这是业务级超时:
|
||||
// 为每个请求的 context 设置 deadline,下游 GORM / Redis / HTTP 调用
|
||||
// 走 c.Request.Context() 即可级联取消,避免单个慢请求拖垮协程。
|
||||
//
|
||||
// ⚠️ 软超时语义(M14 文档化):本中间件仅注入带 deadline 的 ctx,不会主动中断 handler。
|
||||
// 生效前提是下游实际消费 ctx——GORM/Redis/HTTP 客户端等会响应 ctx 取消;但若 handler
|
||||
// 是纯 CPU 循环、阻塞于不查 ctx 的调用、或不读 c.Request.Context(),则 deadline 到期
|
||||
// 也不会停。需要硬中断的场景请配合 http.Server.WriteTimeout 或在 handler 内显式 select ctx.Done。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// r.Use(middleware.Timeout(5 * time.Second))
|
||||
//
|
||||
// d <= 0 时不启用超时(直接 Next)。
|
||||
func Timeout(d time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if d <= 0 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
|
||||
defer cancel()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestTimeoutAllowsFastHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(middleware.Timeout(time.Second))
|
||||
r.GET("/ok", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/ok", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutCancelsSlowHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(middleware.Timeout(50 * time.Millisecond))
|
||||
r.GET("/slow", func(c *gin.Context) {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
// 超时取消生效
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
t.Error("handler should have been cancelled by timeout")
|
||||
}
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/slow", nil))
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("request did not return in time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutZeroDisables(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(middleware.Timeout(0)) // 不启用
|
||||
ctxCancelled := false
|
||||
r.GET("/x", func(c *gin.Context) {
|
||||
ctxCancelled = c.Request.Context() == context.Background()
|
||||
_ = ctxCancelled
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/x", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user