diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad98b56 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,348 @@ +# Changelog + +xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 规范, +版本号遵循 [语义化版本 SemVer](https://semver.org/lang/zh-CN/)。 + +> **如何阅读**:每个版本下分类列出变更类型—— +> - **Breaking**:⚠️ 破坏性变更,升级前必须阅读迁移说明 +> - **Added**:新增功能 +> - **Changed**:变更已有功能(非破坏性) +> - **Deprecated**:标记为废弃,未来版本会移除 +> - **Removed**:移除的功能 +> - **Fixed**:Bug 修复 +> - **Security**:安全相关修复 + +--- + +## [1.0.3] - 2026-06-22 + +> 本版本定位为 **bug fix release**:收口 v1.0.2 引入的破坏性清理,并修复 4 个轻量 bug + 依赖复查。 + +### Removed 🗑️ + +#### ⚠️ Breaking — 清理 v1.0.2 兼容别名(database 包) + +xlgo 仍是早期框架,本次彻底移除 v1.0.2 临时保留的兼容别名,避免长期累积技术债。 + +**移除内容**: + +- `database.InitMySQL(cfg)` 包级函数 +- `database.InitMySQLWithReplicas(cfg, replicas)` 包级函数 +- `(*Manager).InitMySQL(cfg)` 实例方法 +- `(*Manager).InitMySQLWithReplicas(cfg, replicas)` 实例方法 +- `database.driverName(driver)` 内部辅助(已被 `driverDescription` 替代) + +**迁移指南**: + +```go +// ❌ 旧 +database.InitMySQL(cfg) +database.InitMySQLWithReplicas(cfg, replicas) + +// ✅ 新(驱动由 cfg.Database.Driver 决定,可以是 mysql / postgres / 自定义注册的方言) +database.InitDB(cfg) +database.InitDBWithReplicas(cfg, replicas) +``` + +**为什么现在动手**: + +- xlgo 还在小范围使用,破坏式调整成本最低 +- "默认开启可插拔方言"已经是 v1.0.2 的正式 API,再叫 `InitMySQL` 名实不符 +- 早期保留别名 → 长期变成永久负担的反面教材太多,与其在 v1.0.4 / v1.1 删,不如现在删 + +#### 删除死代码 `database.DBResolver` + +`database.DBResolver` 类型与其 `BeforeQuery` 方法**从未被注册**到 GORM callback chain(既没有 `db.Callback().Query().Before(...)` 的调用,也没有任何 plugin 包装),属于纯死代码。文档暗示的"自动读写分离"实际上从未生效——读写分离一直依赖业务侧显式调用 `database.UseMaster(ctx)` / `database.UseReplica(ctx)`。 + +**移除内容**: + +- `database.DBResolver` 类型 +- `(*DBResolver).BeforeQuery` 方法 + +**对用户影响**: + +- 几乎无影响。该类型从未在框架内部被使用,也未被文档推荐为 public API +- 若你的代码 `database.DBResolver{}` 出现编译错误,说明你曾尝试将其注册到 GORM callback;这种用法并不能让"读路由从库"自动生效,请改用: + + ```go + // 强制主库(事务、写后立刻读) + ctx := database.UseMaster(c.Request.Context()) + user, err := repo.FindByID(ctx, id) + + // 显式读从库(报表、统计) + ctx := database.UseReplica(c.Request.Context()) + list, err := repo.FindAll(ctx) + ``` + +未来若需要"基于 callback 的自动路由",建议直接接入官方 [`gorm.io/plugin/dbresolver`](https://github.com/go-gorm/dbresolver),它有完整的权重 / policy / 健康摘除支持,比自造轮子更稳。 + +### Changed + +#### 文件重命名:`database/mysql.go → database/manager.go` + +文件内容自 v1.0.2 引入可插拔方言注册表后,已经与 MySQL 解耦——本版本同时清理了 `InitMySQL` / `InitMySQLWithReplicas` / `driverName` 兼容别名(详见下方 Removed 段),文件中已经全部是通用代码(`Manager`、`ReplicaPicker`、`Init/Close/HealthCheck`、`UseMaster/UseReplica` 等)。继续叫 `mysql.go` 误导新用户认为框架仅支持 MySQL。 + +**对用户影响**: + +- **导入路径无变化**:`github.com/EthanCodeCraft/xlgo-core/database` 不变,所有公开 API 都还在 +- 只有直接 `git grep mysql.go` 或在 issue / PR review 里提到该文件的内部协作会感知 + +测试文件同步重命名为 `database/manager_test.go`。 + +### Added ✨ + +#### console 包:显式 level 控制 + +为 `console` 包补齐显式级别屏蔽能力,让用户在 main 中**显式**控制何时收紧调试输出,避免上线前到处屏蔽 `console.Debug` / `console.Info` 调用。 + +**API 增量**: + +- `console.LevelSilent` — 完全静默 +- `console.WithLevel(l Level)` — 构造时设置级别 +- `(*Console).SetLevel(l)` / `(*Console).Level()` — 实例方法 +- `console.SetLevel(l)` / `console.GetLevel()` — 包级 API(操作 Default 实例) +- `(Level).String()` — 可读名称 + +**典型用法**: + +```go +func main() { + cfg, _ := config.Load("./config.yaml") + + // 显式收紧:生产期只保留 Warn / Error + if cfg.IsProduction() { + console.SetLevel(console.LevelWarn) + } + // 或完全静默:console.SetLevel(console.LevelSilent) + + app := xlgo.New(...) + app.Run() +} +``` + +**设计立场**: + +- console 包**不会**根据 `app.env` 自动切级别——选择权完全在调用方,避免"dev 看到的 / prod 看到的"行为不一致 +- console 仍然是**纯彩色 stdout 工具**,不写文件、不感知环境、跟 `fmt.Println` 同级 +- 业务可观测信息(用户登录、订单事件、审计日志等"上线必须保留的")请使用 `logger` 包;console 只用于开发期肉眼调试 +- 完整对比表见 [GUIDE.md §3.3](./GUIDE.md#33-彩色控制台输出) + +并发安全:level 通过 `atomic.Int32` 存取,运行期热切换无锁。 + +### Changed + +#### console.WithCaller 签名收敛 + +`WithCaller(show bool, skip int)` 改为 `WithCaller(show bool, skip ...int)`——`skip` 99% 用户用不到,强制传是 API 噪音。无 breaking:旧调用 `WithCaller(true, 2)` 仍然合法。 + +### Fixed 🐛 + +#### Logger Tee 重复写入修复(logger 包) + +修复 `logger.Init` 把 `apiCore` 与 `dbCore` 都 Tee 进通用 `Logger`,导致**每条 `logger.Info(...)` 同时落到 `api.log` + `database.log` + console 三份**的 bug。`APILog()` / `DBLog()` 的"分流"在旧实现中形同虚设,且日志体积凭空翻倍。 + +**修复内容**: + +1. **三个 logger 各自独立**: + - `Logger`(通用)→ `logs/app.log` + console + - `APILog()` → `logs/api.log` + console + - `DBLog()` → `logs/database.log` + console + - 互不 Tee,互不串扰 +2. **新增 `logger.Close()`**:关闭文件句柄并把全局 logger 重置为 Nop。`App.Shutdown` 已自动调用。 +3. **Init 健壮性**:`Init(nil)` 不再 panic 改为返回 error;构造失败时不会留下半初始化状态(旧实现 mkdir 之后任意一步失败都会半切换全局变量)。 +4. **`Sync()` 全覆盖**:旧实现只 sync `Logger`,apiLog / dbLog 缓冲不会落盘;新实现 sync 全部三个 logger,并识别忽略 stdout/stderr 平台相关的预期错误。 +5. **生产默认级别从 `Warn` 调整为 `Info`**:原默认在生产丢失大量业务信息,多数项目反而需要在配置里覆盖回 Info;新默认更符合直觉。Debug 级别仍仅在开发模式生效。 + +**新增文件输出**: + +启动后日志目录会出现一个新文件 `logs/app.log`(之前所有通用日志都被串写进 `api.log` / `database.log`)。如果你的运维脚本配置了**只**采集 `api.log` / `database.log`,请补上 `app.log`。 + +**新增测试覆盖**(`logger/logger_test.go`): +- `TestLoggerNoCrossWriting` — 三个 logger 互不串扰(这是核心修复的回归测试) +- `TestLoggerInitNilConfig` — `Init(nil)` 返回 error +- `TestLoggerSyncBeforeInit` — 未初始化时 `Sync()` 安全返回 nil + +#### JWT JTI 生成忽略 `rand.Read` 错误(jwt 包) + +`generateJTI()` 调用 `crypto/rand.Read` 却丢弃返回的 error,且函数签名只返回 `string`,无法把失败传播给调用方。一旦 `rand.Read` 失败(极罕见,但理论上可能),会基于全零字节生成 JTI,所有 token 的 JTI 完全相同,黑名单机制失效。 + +**修复**:`generateJTI()` 改为 `(string, error)`,`GenerateToken` / `GenerateTokenWithCustomExpiry` 传播该错误。 + +#### `QueryBuilder.Page` 统计行数被残留 Limit 截断(repository 包) + +`Page()` 用 `qb.db.Session(&gorm.Session{})` 复制查询做 Count,但未清除残留的 `Limit`/`Offset`。若调用方先 `.Limit(n).Offset(m)` 再 `.Page(...)`,Count 会被包成 `SELECT count(*) FROM (... LIMIT n)` 子查询,返回的 `total` 被截断为 ≤ n,分页总数错误。 + +**修复**:countDB 增加 `.Limit(-1).Offset(-1)`(GORM 官方惯用法,表示移除该条件)。新增 DryRun 模式回归测试 `repository/page_internal_test.go`,校验 Count SQL 不含 `LIMIT`、Find SQL 仍含分页 `LIMIT`。 + +#### OSS / 本地存储文件名冲突(storage 包) + +4 处上传路径(`LocalStorage.Upload` / `LocalStorage.UploadFromBytes` / `OSSStorage.Upload` / `OSSStorage.UploadFromBytes`)仅用 `time.Now().UnixNano()` 作为文件名。同一纳秒内的并发上传会生成相同 objectKey,后者覆盖前者。 + +**修复**:新增 `uniqueFilename(now, ext)` 辅助函数,格式 `-<8字节crypto/rand hex>.`,4 处统一改用。新增 `storage/unique_internal_test.go` 验证格式与 100 次近似唯一性。 + +#### 数据库重试策略对不可恢复错误无效(database 包) + +`Manager.InitDB` 的重试循环对所有失败都退避重试 5 次。但认证失败(`Access denied`)、未知数据库(`Unknown database`)、非法 DSN(`invalid DSN`)、未注册驱动(`unknown driver` / `unsupported driver`)、不支持的认证插件(`authentication plugin`)属于配置类错误,重试无意义,反而把启动失败延迟 1+2+4+8+16=31 秒。 + +**修复**:新增 `isTransientDBError`,上述错误判为不可恢复,首次出现即直接返回。连接拒绝、I/O 超时等网络类错误仍正常重试。新增 `database/retry_internal_test.go` 用例表覆盖 8 种错误。 + +### Security 🔒 + +#### CORS 中间件修复(middleware/cors.go) + +修复多个 CORS 安全与规范遵守问题。**这是行为变更**——升级后不正确的 CORS 配置会更严格,符合 W3C CORS 规范。 + +**修复内容**: + +1. **`Access-Control-Allow-Credentials` 永远是 `true`** — 旧实现 `if/else` 两个分支都设了 `"true"`,相当于即使配置 `AllowCredentials=false` 也会发送凭证头。修复后**只在显式启用且 Origin 不是 `*` 时**才发送该头。 +2. **`*` + `credentials: true` 的规范违规** — 旧实现配置 `AllowedOrigins=["*"]` 且 `AllowCredentials=true` 时会同时发送 `Allow-Origin: *` 与 `Allow-Credentials: true`,**浏览器会直接拒绝响应**。修复后此场景下回显具体 Origin(spec 允许的兼容做法)。 +3. **缺失 `Vary: Origin`** — 当回显具体 Origin 时,下游 CDN / 网关必须按 Origin 区分缓存,否则可能把 A 用户的 CORS 响应缓存给 B 用户。修复后自动加 `Vary: Origin`。 +4. **开发环境兜底改为回显具体 Origin** — 旧实现开发环境直接发 `*`,与 credentials 不兼容;新实现回显具体 Origin,开发环境也能正常调试带 Cookie 的请求。 + +**升级影响**: + +- 如果你**没有**显式设置 `cors.allow_credentials`:响应将不再带 `Access-Control-Allow-Credentials: true`,前端如果依赖了 Cookie/Authorization,需要在配置里显式打开: + + ```yaml + cors: + allowed_origins: ["https://your-frontend.example"] + allow_credentials: true # 显式启用 + ``` + +- 如果你配置了 `allowed_origins: ["*"]` 且 `allow_credentials: true`:行为更安全(不再发 `*`),无需改动。 +- 已经显式列出 origin 列表的配置:完全无影响。 + +**新增测试覆盖**(`middleware/middleware_test.go`): +- `TestCORSAllowCredentialsDefault` — 默认不发凭证头 +- `TestCORSAllowCredentialsExplicitOrigin` — 显式 origin + credentials 正常工作 +- `TestCORSWildcardWithCredentials` — `*` + credentials 时回显具体 origin +- `TestCORSWildcardWithoutCredentials` — `*` 单独使用保持通配符语义 +- `TestCORSOriginNotAllowed` — 非白名单 origin 不回显(防反射型 CORS 漏洞) + +### Breaking ⚠️ + +#### 错误码体系重构(response 包) + +修复 `CodeSuccess` 与 `CodeInvalidParams` 撞码的生产级 bug(两者都等于 `1`,导致业务错误响应被前端误判为成功)。 + +**数值变更**: + +| 常量 | 旧值 | 新值 | +|---|---|---| +| `response.CodeSuccess` | `1` | **`0`** | +| `response.CodeFail` | `0` | **`1`** | + +**移除**: + +- `response.CodeInvalidParams`(与 `CodeSuccess` 撞码) +- `response.ErrInvalidParams` + +**迁移指南**: + +1. **前端代码**:`if (resp.code === 1) { /* 成功 */ }` → `if (resp.code === 0) { /* 成功 */ }` +2. **后端代码**: + + ```go + // ❌ 编译失败 + response.FailWithError(c, response.ErrInvalidParams) + + // ✅ 推荐:业务侧自行定义参数错误码(不再由框架内置) + var ErrInvalidParams = response.NewError(40001, "参数错误") + response.FailWithError(c, ErrInvalidParams) + + // ✅ 或直接使用通用失败响应 + 自定义消息 + response.Fail(c, "用户名格式错误") + ``` + +3. **手写常量比较**:`if resp.Code == 0 { /* fail */ }` → `if resp.Code == 1 { /* fail */ }` + +**为什么**: + +- 业内主流约定 `0 = success`(gRPC、HTTP-style 业务码、阿里云 / 腾讯云 OpenAPI 等),改回常规更利于对接 +- 参数错误码各业务系统差异极大(有的用 `400`、有的用 `40001`、有的用 `1001`),框架不应内置 +- 撞码不修是真实生产风险,必须破坏式修正 + +**新增编译期防撞码保护**:`response/error.go` 末尾新增 `_errorCodeUniquenessGuard` map,任何后续 `Code*` 常量重复都会在 `go build` 阶段直接报 `duplicate key in map literal`,杜绝再次撞码。新增 `Code*` 时**必须**登记到该 map。 + +### Dependencies 📦 + +#### `go mod tidy` 补全 postgres 方言间接依赖 + +v1.0.2 引入可插拔方言注册表后,`gorm.io/driver/postgres` 成为直接依赖,但其传递依赖(`jackc/pgpassfile` / `jackc/pgservicefile` / `jackc/pgx/v5` / `jackc/puddle/v2` / `golang.org/x/sync`)此前未在 `go.mod` 显式登记。`go mod tidy` 已补全,避免在干净环境构建时拉到不可预期的版本。 + +#### 安全相关补丁升级(仅补丁/小版本,无 API 变更) + +| 依赖 | 旧 | 新 | +|---|---|---| +| `golang.org/x/crypto` | v0.49.0 | v0.53.0 | +| `github.com/golang-jwt/jwt/v5` | v5.2.1 | v5.3.1 | +| `github.com/gorilla/websocket` | v1.5.1 | v1.5.3 | + +连同其传递依赖(`golang.org/x/net`、`x/sys`、`x/text`、`x/sync`、`x/tools`)一并升级。全量 `go test ./...` 与 `go vet ./...` 通过。 + +#### 暂缓升级(留待下一个小版本) + +以下直接依赖存在可用更新,但跨越多个小版本或含破坏性变更,**不在本次 bugfix release 范围内**,留待 v1.0.4 / v1.1 专门评估: + +- `github.com/gin-gonic/gin` v1.9.1 → v1.12.0 +- `github.com/go-playground/validator/v10` v10.19.0 → v10.30.3 +- `gorm.io/gorm` v1.25.10 → v1.31.1(及其 driver v1.5 → v1.6) +- `github.com/aliyun/aliyun-oss-go-sdk` v2.2.9 → v3.0.2(**major 版本,破坏性**,需迁移) +- `github.com/spf13/viper` v1.18.2、`go.opentelemetry.io/otel` v1.43.0、`go.uber.org/zap` v1.27.0、`github.com/fsnotify/fsnotify` v1.7.0 等 + +--- + +## [1.0.2] - 2026-06-20 + +> 详见 [README 更新日志](./README.md#更新日志) 中的 v1.0.2 章节,本节列出关键摘要。 + +### Added + +- **数据库**:可插拔方言注册表(`database.RegisterDialect`),内置 `mysql` / `postgres`,支持任意 GORM 驱动 +- **数据库**:实例化 `database.Manager`,`ReplicaPicker` 接口(`RoundRobinPicker` / `RandomPicker`) +- **配置**:实例化 `config.Manager`,`SetDefaultManager` 让 App 私有 manager 推为全局默认 +- **App**:`WithFullStack` / `NewFullStack` / `RunFullStack` batteries-included 入口 +- **App**:`Migrator` 类型与 `WithMigrator` / `WithModels`,迁移由用户显式注册 +- **App**:组件 Option 全套(`WithLogger / WithMySQL / WithRedis / WithStorage / WithWire / WithHealthRoutes / WithSwaggerRoutes / WithDefaultRoutes / WithAutoMigrate` 及 `Without*` 对应项) +- **权限**:通用 `AuthUser`、`GetAuthUser`、`RequireUserTypes` / `RequireRoles` / `RequireAuth` +- **健康检查**:`/health` 支持注册 `HealthCheck`,失败返回 HTTP 503 + +### Changed (Breaking) + +- **App**:`xlgo.New()` 默认不再初始化 MySQL / Redis / Storage,也不注册 `/health` 与 `/swagger/*`;需显式 `With*` 启用 +- **权限**:`super_admin / admin / staff` 调整为默认常量而非固定业务模型 +- **错误处理**:框架初始化失败一律 `return error`,不再 `Fatalf` 退出进程 + +### Fixed + +- 修复 `WithConfigPath` 此前的空实现问题 +- 修复读写分离场景下从库连接可能未关闭的问题(改为 `database.CloseAll()` + `errors.Join`) +- 修复此前 README 中错误的 v2.0.0 / v2.1.0 更新日志表述 + +--- + +## [1.0.1] - 2026-04-30 + +### Added + +- 工具函数库、彩色控制台输出、压缩解压、RequestID、Recover 中间件 +- 缓存键名前缀、分布式锁、计数器、Redis 分布式限流 +- 增强 JWT 黑名单、Repository、CORS、日志中间件和优雅关闭能力 +- 路由架构:模块化、版本化 API、中间件分组和 RESTful CRUD +- 配置热更新、数据库读写分离、CSRF、SSE、WebSocket、定时任务、CLI、测试工具、统一错误码 + +--- + +## [1.0.0] - 2024-04 + +### Added + +- 初始版本发布 +- 基础框架功能 +- 完整示例代码 + +[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.0.3...HEAD +[1.0.3]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.3 +[1.0.2]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.2 +[1.0.1]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.1 +[1.0.0]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.0 diff --git a/GUIDE.md b/GUIDE.md index 9b567c8..2818ed2 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -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 秒) @@ -183,7 +186,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 +203,8 @@ logger.Error("错误信息") logger.Infof("用户 %s 登录成功", username) logger.Warnf("请求失败: %v", err) -// 致命错误(会终止程序) +// 致命错误(会终止程序)—— 仅在业务进程入口(main 等)使用 +// v1.0.2 起,框架内部已禁止使用 Fatal/Fatalf,初始化错误改为 error 返回 logger.Fatalf("数据库连接失败: %v", err) // 结构化日志 @@ -211,7 +216,7 @@ logger.Info("用户登录", ### 3.3 彩色控制台输出 -开发调试时使用彩色输出,一眼识别日志级别: +`console` 包定位是**开发期彩色 stdout 工具**——跟 `fmt.Println` 同级,不写文件、不感知运行环境、不做任何隐式行为。 ```go import "github.com/EthanCodeCraft/xlgo-core/console" @@ -226,30 +231,142 @@ 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.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) +``` + +### 4.1.3 注册自定义 GORM 驱动 + +`database.RegisterDialect` 一次注册即让 `database.driver: ` 生效,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 @@ -614,14 +731,18 @@ 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 特殊响应 @@ -678,7 +799,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防护 @@ -856,7 +983,7 @@ v1.AddModuleFunc("user", userRoutes) // 创建分组 authGroup := router.NewMiddlewareGroup("auth", middleware.AuthRequired(), - middleware.AdminRequired(), + middleware.RequireUserTypes("tenant_admin", "platform_admin"), ) publicGroup := router.NewMiddlewareGroup("public", @@ -920,14 +1047,21 @@ xlgo.StartServer(engine, 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()`,避免文档接口意外暴露。 --- @@ -973,14 +1107,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 密码加密 @@ -1527,7 +1665,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 +1703,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) } ``` @@ -1628,5 +1768,5 @@ func Login(c *gin.Context) { --- -_文档版本: v2.1_ -_最后更新: 2026-04-30_ +_文档版本: v1.0.2_ +_最后更新: 2026-06-20_ diff --git a/README.md b/README.md index 68210ba..f60dac2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ xlgo 是一个基于 Go + Gin 的轻量级 Web 开发框架,提供了完整的 ## 框架特性 - **配置管理** - 支持 YAML 配置文件,环境变量覆盖,配置热更新 -- **数据库** - MySQL + GORM,支持自动迁移、重试机制、连接池、读写分离 +- **数据库** - 基于 GORM 的可插拔方言注册表,内置 MySQL / PostgreSQL,可注册任意 GORM 驱动;支持自动迁移、重试、连接池、读写分离 - **缓存** - Redis 缓存,支持分布式缓存、键前缀、TTL,SCAN 优化 - **认证** - JWT 认证,支持 Token 黑名单、刷新机制 - **日志** - 分级日志(API、数据库),日志轮转 @@ -25,6 +25,8 @@ xlgo 是一个基于 Go + Gin 的轻量级 Web 开发框架,提供了完整的 ## 快速开始 +> **环境要求**:xlgo 基于 Go 1.25+ 构建,请确保本地已安装 Go 1.25 或更高版本。 + ### 1. 安装 ```bash @@ -49,6 +51,7 @@ server: mode: development database: + driver: mysql # mysql(默认)或 postgres host: localhost port: 3306 user: root @@ -56,6 +59,7 @@ database: name: your_database max_idle_conns: 10 max_open_conns: 100 + # dsn: "自定义连接字符串,设置后优先于上面的字段" redis: host: localhost @@ -89,6 +93,8 @@ go run main.go 访问 http://localhost:8080/health 检查服务状态。 +> v1.0.2 起,`xlgo.New()` 默认是轻量应用,不会自动初始化 MySQL、Redis、Storage 或 Swagger。需要完整基础设施时请显式使用 `WithMySQL()`、`WithRedis()`、`WithStorage()`、`WithSwaggerRoutes()`,或直接使用 `xlgo.NewFullStack()`。 + --- ## 核心功能 @@ -119,13 +125,15 @@ 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", }) @@ -146,6 +154,44 @@ 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{}) +``` + +#### 注册自定义 GORM 方言 + +通过 `database.RegisterDialect` 一次注册即可让 `database.driver: ` 生效,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 @@ -421,21 +467,28 @@ 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) ``` @@ -556,8 +609,9 @@ xlgo/ ├── cron/ │ └── cron.go # 定时任务调度 ├── database/ -│ ├── mysql.go # MySQL 连接(支持读写分离) -│ └── redis.go # Redis 连接 +│ ├── manager.go # 数据库管理器(主从、Picker、Init/Close/HealthCheck) +│ ├── dialect.go # GORM 方言注册表(mysql / postgres,可扩展) +│ └── redis.go # Redis 连接 ├── handler/ │ └── handler.go # 基础处理器(类型安全参数获取) ├── jwt/ @@ -643,50 +697,185 @@ 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.0.3 (2026-06-22) -### v2.0.0 (2026-04-30) +> 本版本定位为 **bug fix release**:收口 v1.0.2 引入的破坏性清理,并修复 4 个轻量 bug + 依赖复查。完整说明见 [CHANGELOG.md#unreleased](./CHANGELOG.md#unreleased)。 -- **新增工具函数库** - 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%覆盖) +#### 🐛 Fixed — JWT JTI 生成忽略 `rand.Read` 错误 -### v1.1.0 (2026-04-29) +`generateJTI()` 丢弃 `crypto/rand.Read` 的 error,失败时会基于全零字节生成 JTI,导致所有 token 的 JTI 相同、黑名单机制失效。改为 `(string, error)` 并在 `GenerateToken` / `GenerateTokenWithCustomExpiry` 传播错误。 -- 新增配置热更新支持 -- 新增数据库读写分离 -- 新增 CSRF 防护中间件 -- 新增 SSE 流式响应支持 -- 新增 WebSocket 支持 -- 新增定时任务调度器 -- 新增 CLI 脚手架工具 -- 新增测试工具包 -- 新增统一错误码体系 -- 新增密码加密工具 -- 新增请求验证器(支持自定义错误消息) -- 实现 OSS 存储上传 -- 优化缓存 DeleteByPattern 使用 SCAN -- 修复限流器 goroutine 泄漏问题 +#### 🐛 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)`(`-<8字节crypto/rand hex>.`),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: ` 生效,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 / WithWire / WithHealthRoutes / WithSwaggerRoutes / WithDefaultRoutes / WithAutoMigrate` 及对应 `WithoutXxx` 关闭项 +- **迁移控制** - 新增 `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) diff --git a/Version_Update_Plan_v1.0.2.md b/Version_Update_Plan_v1.0.2.md new file mode 100644 index 0000000..d96650d --- /dev/null +++ b/Version_Update_Plan_v1.0.2.md @@ -0,0 +1,1047 @@ +# xlgo v1.0.2 更新计划 + +> 版本定位:面向新框架的激进整理版本。由于当前框架仍处于早期阶段,v1.0.2 不以保守兼容为首要目标,而是优先修正已经发现的架构边界、默认行为、文档一致性和长期演进风险,避免在用户规模扩大后再进行破坏式调整。 + +## 0. 实施状态(2026-06-20) + +✅ **本计划全部 11 项已完成。** + +| 节 | 任务 | 状态 | 主要落点 | +|---|---|---|---| +| 2.1 | 权限中间件去业务化 | ✅ | `middleware/auth.go`、`middleware/auth_test.go` | +| 2.2 | App 启动流程可组合 | ✅ | `app.go`(Option 全套 + `enableAutoMigrate`)、`app_test.go` | +| 2.3 | 框架内部禁止 Fatalf 退出 | ✅ | `app.go` 全部 `return fmt.Errorf(...)` | +| 2.4 | Shutdown 关闭流程修正 | ✅ | `app.go` 使用 `database.CloseAll()` + `errors.Join` | +| 2.5 | 默认路由和 Swagger 可控化 | ✅ | `router/router.go`(`RegisterHealthRoute` / `RegisterSwaggerRoutes`) | +| 2.6 | 健康检查标准化 | ✅ | `/health` 支持 checks + 503,`router/health_test.go` | +| 2.7 | 配置系统 | ✅ | `config.Manager` + `config.SetDefaultManager`,App 真正驱动私有 manager | +| 2.8 | 数据库全局状态治理 | ✅ | `database.Manager`、`ReplicaPicker`、私有 `dbModeContextKey{}`、可插拔方言注册表 | +| 2.9 | AutoMigrate 机制 | ✅ | `WithMigrator` / `WithModels` / `WithAutoMigrate` / `WithoutAutoMigrate` | +| 2.10 | README 更新日志版本错误 | ✅ | README 重写为 v1.0.2 / v1.0.1 / v1.0.0 线 | +| 2.11 | 文档与 CLI 模板同步 | ✅ | GUIDE 1.3 / 3.2 / 4.1 / 8.4.7 / 9.2 更新;CLI 模板补 Swagger/MySQL 注释 | + +### v1.0.2 在计划之外的额外收益 + +- **可插拔方言注册表**:原计划只要求"支持 mysql/postgres",实际实现升级为 `database.RegisterDialect(DialectSpec{...})` 注册表,应用可一次接入任意 GORM 驱动(SQLite、SQL Server、ClickHouse、TiDB...),DSN 构建器同步登记到 `config` 包。 +- **`config.SetDefaultManager`**:让 App 持有的 `config.Manager` 能成为全局便捷函数(`config.Get`/`GetString`...)的数据源,解决 "App 私有 manager 与全局 API 双轨" 的问题。 +- **`WithAutoMigrate` / `WithoutAutoMigrate`**:与 `WithMigrator`/`WithModels` 解耦的迁移开关,覆盖"注册了迁移器但需临时关闭"的场景。 + +### 验证 + +```bash +go build -buildvcs=false ./... # ✅ 通过 +go vet -buildvcs=false ./... # ✅ 通过 +go test -buildvcs=false ./... # ✅ 全部 23 个包 PASS +``` + +--- + +## 1. 版本原则 + +### 1.1 本次版本核心目标 + +v1.0.2 的目标是把 xlgo 从“业务项目沉淀出来的工具集合”进一步整理为“通用 Go Web 框架”: + +1. **框架与业务解耦**:框架层不写死具体业务角色、业务流程和业务默认值。 +2. **启动流程可组合**:MySQL、Redis、Storage、Swagger、默认路由、AutoMigrate 等能力应可显式启用或关闭。 +3. **错误处理专业化**:框架内部不直接 `Fatalf` 退出进程,而是向调用方返回错误。 +4. **生命周期清晰化**:初始化、运行、关闭流程应具备明确边界,并为后续 lifecycle hooks 打基础。 +5. **高可用基础增强**:健康检查、优雅关闭、连接关闭、依赖状态检查等能力逐步标准化。 +6. **文档真实可信**:README、GUIDE、CLI 模板、更新日志与真实版本和代码行为保持一致。 +7. **坚持 Go 1.25**:本项目作为新框架,明确使用 Go 1.25,不背负旧版本兼容包袱,允许使用 Go 1.25 的新特性。 + +### 1.2 兼容策略 + +v1.0.2 可以接受破坏式更新,但破坏必须有明确收益: + +- 可以调整不合理 API。 +- 可以删除或废弃明显业务化的设计。 +- 可以改变默认启动策略,只要文档和迁移说明清晰。 +- 可以统一命名、配置、模块边界。 +- 不为了兼容历史写法牺牲框架长期设计。 + +建议保留部分快捷 API,但应明确其定位为“默认封装”而非“框架唯一模型”。 + +--- + +## 2. 必须修正的问题清单 + +## 2.1 权限中间件去业务化 + +### 当前问题 + +`middleware/auth.go` 中的权限中间件写死了以下用户类型: + +```go +super_admin +admin +staff +``` + +典型代码: + +```go +if ut != "super_admin" && ut != "admin" { + response.Fail(c, "无权限访问") + c.Abort() + return +} +``` + +这属于具体业务系统权限模型,不应该成为通用框架的固定规则。 + +### 目标设计 + +将固定角色改为默认角色,并提供通用权限能力。 + +### 新增默认常量 + +```go +const ( + DefaultUserTypeSuperAdmin = "super_admin" + DefaultUserTypeAdmin = "admin" + DefaultUserTypeStaff = "staff" +) +``` + +这些常量仅作为默认值和快速开始示例使用,不代表用户必须采用这些角色名。 + +### 新增认证用户结构 + +```go +type AuthUser struct { + UserID uint + Username string + Role string + UserType string +} +``` + +### 新增统一获取当前用户方法 + +```go +func GetAuthUser(c *gin.Context) (AuthUser, bool) +``` + +该方法从 Gin Context 中读取: + +- `ContextKeyUserID` +- `ContextKeyUsername` +- `ContextKeyRole` +- `ContextKeyUserType` + +### 新增通用权限中间件 + +#### 按 user_type 判断 + +```go +func RequireUserTypes(userTypes ...string) gin.HandlerFunc +``` + +示例: + +```go +middleware.RequireUserTypes("tenant_admin", "platform_admin") +``` + +#### 按 role 判断 + +```go +func RequireRoles(roles ...string) gin.HandlerFunc +``` + +示例: + +```go +middleware.RequireRoles("owner", "manager") +``` + +#### 自定义权限判断 + +```go +type AuthChecker func(user AuthUser, c *gin.Context) bool + +func RequireAuth(checker AuthChecker, messages ...string) gin.HandlerFunc +``` + +示例: + +```go +middleware.RequireAuth(func(user middleware.AuthUser, c *gin.Context) bool { + return user.UserType == "merchant" && user.Role == "owner" +}) +``` + +### 调整原快捷方法 + +以下方法可以保留,但必须改为基于通用能力实现: + +```go +func AdminRequired() gin.HandlerFunc { + return RequireUserTypes(DefaultUserTypeSuperAdmin, DefaultUserTypeAdmin) +} + +func SuperAdminRequired() gin.HandlerFunc { + return RequireUserTypes(DefaultUserTypeSuperAdmin) +} + +func StaffRequired() gin.HandlerFunc { + return RequireUserTypes(DefaultUserTypeStaff) +} + +func AnyUserRequired() gin.HandlerFunc { + return RequireUserTypes( + DefaultUserTypeSuperAdmin, + DefaultUserTypeAdmin, + DefaultUserTypeStaff, + ) +} +``` + +### 测试要求 + +新增或完善 `middleware/auth_test.go`: + +- 未登录访问被拒绝。 +- Context 中缺少用户信息时被拒绝。 +- `user_type` 类型异常时被拒绝。 +- `RequireUserTypes("tenant_admin")` 可通过自定义用户类型。 +- `RequireUserTypes("tenant_admin")` 会拒绝其他用户类型。 +- `RequireRoles("owner")` 可通过自定义角色。 +- `RequireAuth` 可执行复杂自定义判断。 +- 默认快捷方法仍符合默认常量语义。 + +--- + +## 2.2 App 启动流程重构为可组合模式 + +### 当前问题 + +`app.go` 的 `Run()` 当前强制执行: + +```go +logger.Init(cfg) +database.InitMySQL(cfg) +database.InitRedis(cfg) +database.AutoMigrate() +storage.Init(&cfg.Storage) +wire.InitServices() +router.RegisterDefaultRoutes(a.router) +``` + +这会导致: + +- 纯 HTTP 服务也必须配置 MySQL。 +- 不使用 Redis 的项目也被强制初始化 Redis。 +- 不使用文件上传的项目也被强制初始化 Storage。 +- 用户无法控制 AutoMigrate。 +- 用户无法控制默认路由和 Swagger 暴露。 +- 测试和最小示例成本过高。 + +### 目标设计 + +v1.0.2 应将 App 启动流程改为“显式、可组合、可关闭”。 + +### 建议新增 App 内部字段 + +```go +type App struct { + config *config.Config + router *gin.Engine + registry *router.Registry + server *http.Server + + configPath string + + enableLogger bool + enableMySQL bool + enableRedis bool + enableStorage bool + enableDefaultRoutes bool + enableAutoMigrate bool + enableWire bool +} +``` + +### 默认策略 + +由于可以激进调整,建议从 v1.0.2 开始采用更清晰的默认策略: + +- `logger` 默认开启。 +- `default routes` 默认开启。 +- `wire` 默认开启。 +- `MySQL`、`Redis`、`Storage` 是否默认开启需要结合快速开始体验决定。 + +推荐方案: + +1. `xlgo.New()` 创建的是轻量 App,不强制初始化 MySQL/Redis/Storage。 +2. 用户通过 Option 显式启用组件: + +```go +xlgo.WithMySQL() +xlgo.WithRedis() +xlgo.WithStorage() +xlgo.WithAutoMigrate() +``` + +3. 提供 `xlgo.NewFullStack()` 或 `xlgo.RunFullStack()` 作为 batteries-included 快捷方式。 + +如果希望过渡成本更低,也可以保留 `New()` 默认全量初始化,但必须提供 `WithoutXxx()`。不过从长期框架设计看,更推荐“显式启用依赖”。 + +### 建议新增 Option + +#### 配置相关 + +```go +func WithConfigPath(path string) Option +func WithConfig(cfg *config.Config) Option +``` + +必须修复当前 `WithConfigPath` 空实现问题。 + +#### 组件启用 + +```go +func WithLogger() Option +func WithMySQL() Option +func WithRedis() Option +func WithStorage() Option +func WithAutoMigrate() Option +func WithWire() Option +func WithDefaultRoutes() Option +``` + +#### 组件关闭 + +如果保留默认开启策略,则必须同时提供: + +```go +func WithoutLogger() Option +func WithoutMySQL() Option +func WithoutRedis() Option +func WithoutStorage() Option +func WithoutAutoMigrate() Option +func WithoutWire() Option +func WithoutDefaultRoutes() Option +``` + +### 推荐使用示例 + +#### 最小 HTTP 服务 + +```go +app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithDefaultRoutes(), +) +``` + +#### 标准业务 API + +```go +app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithLogger(), + xlgo.WithMySQL(), + xlgo.WithRedis(), + xlgo.WithAutoMigrate(), + xlgo.WithDefaultRoutes(), + xlgo.WithModules(user.Module{}, order.Module{}), +) +``` + +#### 文件上传服务 + +```go +app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithStorage(), +) +``` + +### 测试要求 + +- `WithConfigPath` 能真实影响配置加载。 +- `WithConfig` 能注入配置并运行。 +- 未启用 MySQL 时不访问 MySQL 配置。 +- 未启用 Redis 时不访问 Redis 配置。 +- 未启用 Storage 时不初始化 Storage。 +- 关闭默认路由后 `/health` 不注册。 +- 显式开启默认路由后 `/health` 可访问。 + +--- + +## 2.3 框架内部禁止直接 Fatalf 退出 + +### 当前问题 + +`app.go` 中存在: + +```go +logger.Fatalf("初始化 MySQL 失败: %v", err) +``` + +框架内部直接退出进程会让调用方无法处理错误。 + +### 改造目标 + +所有初始化错误向上返回。 + +### 改造示例 + +```go +if err := database.InitMySQL(cfg); err != nil { + return fmt.Errorf("初始化 MySQL 失败: %w", err) +} +``` + +需要覆盖: + +- logger 初始化失败。 +- MySQL 初始化失败。 +- Redis 初始化失败。 +- AutoMigrate 失败。 +- Storage 初始化失败。 +- HTTP Server 启动失败。 + +### 要求 + +框架包内部原则上不调用: + +```go +os.Exit +log.Fatal +logger.Fatal +logger.Fatalf +panic +``` + +除非是明确的开发期 helper 或 CLI 命令入口。 + +--- + +## 2.4 Shutdown 关闭流程修正 + +### 当前问题 + +`App.Shutdown()` 当前调用: + +```go +database.Close() +database.CloseRedis() +``` + +在读写分离场景下,从库连接可能不会被关闭。 + +### 改造目标 + +使用: + +```go +database.CloseAll() +database.CloseRedis() +``` + +并聚合错误。 + +### 建议实现 + +Go 1.25 可直接使用 `errors.Join`: + +```go +var errs []error + +if err := database.CloseAll(); err != nil { + errs = append(errs, err) +} +if err := database.CloseRedis(); err != nil { + errs = append(errs, err) +} + +return errors.Join(errs...) +``` + +### 测试要求 + +- 未初始化数据库时 `Shutdown` 不 panic。 +- 初始化 replicas 后 `CloseAll` 会清空 replicas 和 DBRead。 +- 重复关闭不 panic。 +- 关闭错误能向上返回。 + +--- + +## 2.5 默认路由和 Swagger 可控化 + +### 当前问题 + +`router.RegisterDefaultRoutes()` 当前默认注册: + +```go +/swagger/*any +/health +``` + +Swagger 在生产环境中不应无条件暴露。 + +### 改造目标 + +拆分默认路由注册能力。 + +### 建议新增方法 + +```go +func RegisterHealthRoute(r *gin.Engine, checks ...HealthCheck) +func RegisterSwaggerRoutes(r *gin.Engine) +func RegisterDefaultRoutes(r *gin.Engine, checks ...HealthCheck) +``` + +其中 `RegisterDefaultRoutes` 可以继续组合调用前两个方法。 + +### App Option + +```go +func WithHealthRoutes() Option +func WithSwaggerRoutes() Option +func WithDefaultRoutes() Option +func WithoutDefaultRoutes() Option +``` + +### 推荐生产策略 + +- 开发环境可以开启 Swagger。 +- 生产环境默认不自动开启 Swagger,除非用户显式配置或调用 `WithSwaggerRoutes()`。 + +### 测试要求 + +- 健康检查路由可单独开启。 +- Swagger 路由可单独开启。 +- 默认路由可整体开启。 +- 关闭默认路由后不注册 `/health` 和 `/swagger/*any`。 + +--- + +## 2.6 健康检查标准化 + +### 当前问题 + +当前 `/health` 只返回: + +```json +{"status":"ok"} +``` + +高可用服务一般需要区分 liveness 和 readiness。 + +### v1.0.2 目标 + +v1.0.2 只新增并保留一个健康检查接口,避免接口过多导致使用复杂: + +```text +GET /health +``` + +### 初始响应 + +无检查项时: + +```json +{ + "status": "ok" +} +``` + +有检查项时: + +```json +{ + "status": "ok", + "checks": { + "mysql": "ok", + "redis": "disabled" + } +} +``` + +检查失败时 `/health` 返回 HTTP 503,并将 `status` 设为 `error`。 + +### 建议新增健康检查抽象 + +```go +type HealthChecker interface { + Name() string + Check(ctx context.Context) error +} +``` + +或函数式: + +```go +type HealthCheckFunc func(ctx context.Context) error +``` + +App 后续可支持: + +```go +func WithHealthCheck(name string, check HealthCheckFunc) Option +``` + +v1.0.2 可以先做基础实现,为 v1.1.x 扩展预留。 + +--- + +## 2.7 配置系统问题纳入 v1.0.2 改造 + +### 当前问题 + +`config.Load()` 使用 `sync.Once`,导致: + +- 测试中难以重复加载不同配置。 +- 多 App 实例不友好。 +- `WithConfigPath` 难以正确实现。 +- 配置热更新实例边界不清晰。 + +### 激进改造目标 + +v1.0.2 可以开始引入实例化配置管理器。 + +### 建议新增类型 + +```go +type Manager struct { + mu sync.RWMutex + v *viper.Viper + cfg *Config + callbacks []func(*Config) +} +``` + +### 建议 API + +```go +func NewManager(path string) *Manager +func (m *Manager) Load() (*Config, error) +func (m *Manager) LoadWithWatch(onChange func(*Config)) (*Config, error) +func (m *Manager) Get() *Config +func (m *Manager) Reload() error +func (m *Manager) RegisterCallback(cb func(*Config)) +``` + +### 全局兼容层 + +可以保留全局函数,但内部基于默认 manager: + +```go +func Load(path string) (*Config, error) +func Get() *Config +func Set(cfg *Config) +func Reload() error +``` + +### 关键要求 + +- 去除全局 `sync.Once` 对重复加载的限制,或提供 `ResetForTest()`。 +- App 优先持有自己的 config manager,而不是强依赖全局配置。 +- 全局 API 只作为便捷入口。 + +--- + +## 2.8 数据库全局状态治理 + +### 当前问题 + +`database/mysql.go` 使用全局变量: + +```go +var ( + DB *gorm.DB + DBRead *gorm.DB + replicas []*gorm.DB +) +``` + +这对框架长期发展不利。 + +### v1.0.2 目标 + +引入数据库 Manager,逐步替代全局变量。 + +### 建议类型 + +```go +type Manager struct { + master *gorm.DB + replicas []*gorm.DB + picker ReplicaPicker +} +``` + +### 建议 API + +```go +func NewManager(cfg *config.Config) *Manager +func (m *Manager) Open(ctx context.Context) error +func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) error +func (m *Manager) Master() *gorm.DB +func (m *Manager) Replica() *gorm.DB +func (m *Manager) FromContext(ctx context.Context) *gorm.DB +func (m *Manager) Close() error +func (m *Manager) HealthCheck(ctx context.Context) error +``` + +### Context key 修正 + +当前: + +```go +context.WithValue(ctx, "db_mode", "master") +``` + +应改为私有类型 key,避免冲突: + +```go +type dbModeContextKey struct{} +``` + +### Replica 选择策略 + +v1.0.2 可先提供: + +- Round-robin +- Random + +后续版本再增加: + +- 权重 +- 健康摘除 +- 熔断 + +### 全局兼容层 + +可以保留: + +```go +func InitMySQL(cfg *config.Config) error +func GetDB() *gorm.DB +func GetReadDB() *gorm.DB +func CloseAll() error +``` + +但内部应委托给默认 Manager。 + +--- + +## 2.9 AutoMigrate 机制调整 + +### 当前问题 + +`database.AutoMigrate()` 当前是空实现: + +```go +func AutoMigrate() error { + logger.Info("数据库表结构迁移完成") + return nil +} +``` + +但 `App.Run()` 强制调用它,语义不清晰。 + +### 改造目标 + +迁移应由用户显式注册。 + +### 建议 API + +```go +type Migrator func(db *gorm.DB) error + +func WithMigrator(m Migrator) Option +func WithModels(models ...any) Option +``` + +示例: + +```go +app := xlgo.New( + xlgo.WithMySQL(), + xlgo.WithModels(&User{}, &Order{}), +) +``` + +或: + +```go +app := xlgo.New( + xlgo.WithMigrator(func(db *gorm.DB) error { + return db.AutoMigrate(&User{}, &Order{}) + }), +) +``` + +### 要求 + +- 不再强制执行空的 `database.AutoMigrate()`。 +- 如果没有注册 migrator,不执行迁移。 +- 迁移错误返回给调用方。 + +--- + +## 2.10 README 更新日志版本错误修正 + +### 当前问题 + +`README.md` 当前更新日志写成: + +```md +### v2.1.0 (2026-04-30) +... +### v2.0.0 (2026-04-30) +``` + +但当前实际版本应为 `v1.0.1`,本次计划版本为 `v1.0.2`。该日志会给用户造成误解。 + +### 修正目标 + +将 README 更新日志改为真实版本线。 + +### 建议改法 + +本次发布后 README 应包含: + +```md +## 更新日志 + +### v1.0.2 (计划中 / 发布日期按实际填写) + +- 权限中间件通用化,移除业务角色硬编码。 +- App 启动流程改为可组合模式。 +- 修复 WithConfigPath 空实现问题。 +- 框架初始化错误改为返回 error。 +- 默认路由、Swagger、健康检查可配置。 +- Shutdown 关闭全部数据库连接。 +- 文档和 CLI 模板同步更新。 + +### v1.0.1 + +- 根据真实已发布内容整理。 + +### v1.0.0 + +- 初始版本发布。 +``` + +如果 v2.0.0 / v2.1.0 中的内容实际已经存在于当前代码,应归并到 v1.0.1 或 v1.0.0 的历史描述中,而不是继续使用错误的大版本号。 + +### 要求 + +- 删除或修正 `v2.0.0`、`v2.1.0` 错误标题。 +- README 顶部 badge 如有版本信息,也要同步。 +- GUIDE 中如有类似版本描述,统一修正。 + +--- + +## 2.11 文档与 CLI 模板同步 + +### 涉及文件 + +```text +README.md +GUIDE.md +cmd/xlgo/templates.go +``` + +### 必须更新内容 + +1. 最小启动示例。 +2. 标准业务 API 启动示例。 +3. 自定义权限示例。 +4. 显式启用 MySQL/Redis/Storage 的示例。 +5. 关闭或开启 Swagger 的示例。 +6. 健康检查接口说明。 +7. 配置文件完整示例。 +8. Go 版本说明:明确要求 Go 1.25+。 +9. 更新日志修正为 v1.x 版本线。 + +### CLI 模板调整方向 + +生成项目不应默认塞入过多不可控依赖。建议模板生成: + +```go +app := xlgo.New( + xlgo.WithConfigPath("./config.yaml"), + xlgo.WithLogger(), + xlgo.WithDefaultRoutes(), +) +``` + +如果模板选择 API/fullstack 模式,再加入: + +```go +xlgo.WithMySQL() +xlgo.WithRedis() +xlgo.WithAutoMigrate() +``` + +后续 CLI 可支持: + +```bash +xlgo new myproject --template minimal +xlgo new myproject --template api +xlgo new myproject --template fullstack +``` + +--- + +## 3. 推荐实施顺序 + +## Phase 1:修正业务耦合与明显 bug + +1. 改造 `middleware/auth.go`。 +2. 补充 `middleware/auth_test.go`。 +3. 修复 `WithConfigPath` 空实现。 +4. 修正 `README.md` 更新日志版本错误。 + +## Phase 2:重构 App 启动流程 + +1. App 增加组件启用/关闭选项。 +2. MySQL/Redis/Storage/AutoMigrate 改为显式启用或可关闭。 +3. 默认路由、Health、Swagger 拆分。 +4. `Run()` 中所有初始化错误改为返回 error。 + +## Phase 3:生命周期和关闭流程 + +1. `Shutdown()` 改为关闭全部数据库连接。 +2. 关闭 Redis、日志、限流器等组件时聚合错误。 +3. 准备后续 lifecycle hook 的内部结构。 + +## Phase 4:配置和数据库管理器 + +1. 引入 `config.Manager`。 +2. 去除或弱化全局 `sync.Once` 限制。 +3. 引入 `database.Manager`。 +4. 修正 DB context key。 +5. 全局 API 改为 facade。 + +## Phase 5:健康检查和文档模板 + +1. 保持单一 `/health` 接口。 +2. 支持依赖检查状态和失败时 HTTP 503。 +3. 更新 README/GUIDE。 +4. 更新 CLI 模板。 +5. 更新 CHANGELOG 或 README 更新日志。 + +--- + +## 4. v1.0.2 验收标准 + +### 4.1 功能验收 + +- 用户可以创建不依赖 MySQL/Redis/Storage 的最小应用。 +- 用户可以显式启用 MySQL/Redis/Storage。 +- 用户可以关闭 Swagger 或默认路由。 +- 用户可以使用自定义 user_type、role 或自定义函数做权限判断。 +- `super_admin/admin/staff` 只作为默认常量,不再是框架唯一权限模型。 +- `WithConfigPath` 能真实生效。 +- 初始化失败返回 error,不直接退出进程。 +- Shutdown 能关闭主库、从库、Redis 等资源。 +- README 更新日志版本线修正为 v1.x。 + +### 4.2 测试验收 + +必须通过: + +```bash +go test ./... +``` + +建议新增或更新测试覆盖: + +- `middleware/auth_test.go` +- `app_test.go` +- `router/router_test.go` +- `config/config_test.go` +- `database/mysql_test.go` + +### 4.3 文档验收 + +- README 中快速开始示例可运行。 +- GUIDE 中权限示例与真实 API 一致。 +- CLI 模板生成的项目可 `go test ./...` 或 `go run`。 +- README 更新日志不存在错误的 v2.0.0/v2.1.0 表述。 +- 文档明确写明 Go 1.25+。 + +--- + +## 5. 建议发布说明草案 + +```md +## v1.0.2 + +### Breaking Changes + +- App 启动流程调整为更显式的组件启用模式,MySQL、Redis、Storage、AutoMigrate 等能力可通过 Option 控制。 +- 权限中间件不再将 `super_admin`、`admin`、`staff` 作为框架固定权限模型,仅保留为默认用户类型常量。 +- 框架初始化失败时返回 error,不再在框架内部直接 Fatal 退出。 + +### Added + +- 新增 `middleware.AuthUser` 和 `middleware.GetAuthUser()`。 +- 新增 `middleware.RequireUserTypes()`。 +- 新增 `middleware.RequireRoles()`。 +- 新增 `middleware.RequireAuth()`。 +- 新增 App 组件控制 Option:`WithMySQL`、`WithRedis`、`WithStorage`、`WithAutoMigrate`、`WithDefaultRoutes` 等。 +- 新增单一 `/health` 健康检查规划,支持检查项状态。 +- 新增或规划实例化 `config.Manager`、`database.Manager`。 + +### Changed + +- `AdminRequired()`、`SuperAdminRequired()`、`StaffRequired()`、`AnyUserRequired()` 改为基于通用权限中间件实现。 +- `WithConfigPath()` 改为真实生效。 +- `App.Run()` 初始化流程改为可组合、可返回错误。 +- `App.Shutdown()` 改为关闭全部数据库连接。 +- 默认路由和 Swagger 注册逻辑拆分。 +- README/GUIDE/CLI 模板同步新启动方式。 + +### Fixed + +- 修复 README 更新日志错误使用 v2.0.0/v2.1.0 的问题。 +- 修复 `WithConfigPath()` 空实现问题。 +- 修复读写分离场景下从库连接可能未关闭的问题。 +``` + +--- + +## 6. 后续版本预告 + +v1.0.2 之后建议继续推进: + +### v1.1.0 + +- 完整 Lifecycle Hooks:`OnStart`、`OnShutdown`、`OnReady`。 +- 插件系统:`Plugin` / `Module` / `Provider`。 +- 完整 Health Registry。 +- 更完善的数据库 replica 健康摘除。 + +### v1.2.0 + +- RBAC/Permission 扩展包。 +- 多租户支持基础设施。 +- OpenTelemetry 深度集成。 +- CLI 多模板支持。 + +--- + +## 7. 本计划结论 + +v1.0.2 应该是 xlgo 的一次“框架化校准”版本,而不是小修小补版本。 + +本次更新应优先解决: + +1. 权限模型业务耦合。 +2. App 启动流程不可控。 +3. 初始化错误直接退出。 +4. 默认 Swagger 和默认路由不可控。 +5. 配置和数据库全局状态对测试、多实例不友好。 +6. README 更新日志版本错误。 +7. 文档与真实行为不一致。 + +由于 xlgo 仍是新框架,本阶段应果断修正不合理设计,明确 Go 1.25+,为后续用户增长前打好 API 和架构基础。 diff --git a/Version_v1.0.2_report.md b/Version_v1.0.2_report.md new file mode 100644 index 0000000..68e2c94 --- /dev/null +++ b/Version_v1.0.2_report.md @@ -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。 diff --git a/app.go b/app.go index 718d102..fb073e3 100644 --- a/app.go +++ b/app.go @@ -19,14 +19,46 @@ import ( "github.com/EthanCodeCraft/xlgo-core/wire" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) +// Version 框架版本号。发版时只改这一处,避免版本字面量散落各处。 +// CLI(xlgo version)、脚手架生成的 go.mod 等均引用此常量。 +const Version = "1.0.3" + +// HealthCheckFunc 健康检查函数 +type HealthCheckFunc func(context.Context) error + +// Migrator 数据库迁移函数 +type Migrator func(*gorm.DB) error + +type staticRoute struct { + relativePath string + root string +} + // App 应用实例 type App struct { - config *config.Config - router *gin.Engine - registry *router.Registry - server *http.Server + config *config.Config + configPath string + configManager *config.Manager + router *gin.Engine + registry *router.Registry + server *http.Server + + enableLogger bool + enableMySQL bool + enableRedis bool + enableStorage bool + enableWire bool + enableHealth bool + enableSwagger bool + enableAutoMigrate bool + + staticRoutes []staticRoute + migrators []Migrator + healthChecks []router.HealthCheck + initialized bool } // Option 应用选项 @@ -35,10 +67,151 @@ type Option func(*App) // WithConfigPath 设置配置文件路径 func WithConfigPath(path string) Option { return func(a *App) { - _ = path // 配置在 New 时加载 + a.configPath = path + a.configManager = config.NewManager(path) } } +// WithConfig 设置配置对象 +func WithConfig(cfg *config.Config) Option { + return func(a *App) { + a.config = cfg + config.Set(cfg) + } +} + +// WithLogger 启用日志 +func WithLogger() Option { + return func(a *App) { a.enableLogger = true } +} + +// WithMySQL 启用 MySQL +func WithMySQL() Option { + return func(a *App) { a.enableMySQL = true } +} + +// WithRedis 启用 Redis +func WithRedis() Option { + return func(a *App) { a.enableRedis = true } +} + +// WithStorage 启用文件存储 +func WithStorage() Option { + return func(a *App) { a.enableStorage = true } +} + +// WithWire 启用服务容器 +func WithWire() Option { + return func(a *App) { a.enableWire = true } +} + +// WithHealthRoutes 启用健康检查路由 +func WithHealthRoutes() Option { + return func(a *App) { a.enableHealth = true } +} + +// WithSwaggerRoutes 启用 Swagger 路由 +func WithSwaggerRoutes() Option { + return func(a *App) { a.enableSwagger = true } +} + +// WithDefaultRoutes 启用默认路由(健康检查、Swagger) +func WithDefaultRoutes() Option { + return func(a *App) { + a.enableHealth = true + a.enableSwagger = true + } +} + +// WithoutLogger 关闭日志 +func WithoutLogger() Option { + return func(a *App) { a.enableLogger = false } +} + +// WithoutMySQL 关闭 MySQL +func WithoutMySQL() Option { + return func(a *App) { a.enableMySQL = false } +} + +// WithoutRedis 关闭 Redis +func WithoutRedis() Option { + return func(a *App) { a.enableRedis = false } +} + +// WithoutStorage 关闭文件存储 +func WithoutStorage() Option { + return func(a *App) { a.enableStorage = false } +} + +// WithoutWire 关闭服务容器 +func WithoutWire() Option { + return func(a *App) { a.enableWire = false } +} + +// WithAutoMigrate 启用数据库迁移(需配合 WithMigrator/WithModels 注册迁移逻辑) +func WithAutoMigrate() Option { + return func(a *App) { a.enableAutoMigrate = true } +} + +// WithoutAutoMigrate 关闭数据库迁移(即使已通过 WithMigrator/WithModels 注册) +func WithoutAutoMigrate() Option { + return func(a *App) { a.enableAutoMigrate = false } +} + +// WithoutHealthRoutes 关闭健康检查路由 +func WithoutHealthRoutes() Option { + return func(a *App) { a.enableHealth = false } +} + +// WithoutSwaggerRoutes 关闭 Swagger 路由 +func WithoutSwaggerRoutes() Option { + return func(a *App) { a.enableSwagger = false } +} + +// WithoutDefaultRoutes 关闭默认路由(健康检查、Swagger) +func WithoutDefaultRoutes() Option { + return func(a *App) { + a.enableHealth = false + a.enableSwagger = false + } +} + +// WithStatic 注册静态文件服务 +func WithStatic(relativePath, root string) Option { + return func(a *App) { + a.staticRoutes = append(a.staticRoutes, staticRoute{relativePath: relativePath, root: root}) + } +} + +// WithPublicStatic 注册默认 public 静态文件服务 +func WithPublicStatic() Option { + return WithStatic("/public", "./public") +} + +// WithHealthCheck 注册健康检查项 +func WithHealthCheck(name string, check HealthCheckFunc) Option { + return func(a *App) { + a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: name, Check: check}) + } +} + +// WithMigrator 注册数据库迁移函数(自动启用 AutoMigrate) +func WithMigrator(m Migrator) Option { + return func(a *App) { + if m != nil { + a.migrators = append(a.migrators, m) + a.enableAutoMigrate = true + } + } +} + +// WithModels 注册 GORM 自动迁移模型(自动启用 AutoMigrate) +func WithModels(models ...any) Option { + return WithMigrator(func(db *gorm.DB) error { + return db.AutoMigrate(models...) + }) +} + // WithModules 注册模块 func WithModules(modules ...router.Module) Option { return func(a *App) { @@ -64,6 +237,21 @@ func WithMiddlewares(middlewares ...gin.HandlerFunc) Option { } } +// WithFullStack 启用全功能默认组件 +func WithFullStack() Option { + return func(a *App) { + a.enableLogger = true + a.enableMySQL = true + a.enableRedis = true + a.enableStorage = true + a.enableWire = true + a.enableHealth = true + a.enableSwagger = true + a.enableAutoMigrate = true + a.staticRoutes = append(a.staticRoutes, staticRoute{relativePath: "/public", root: "./public"}) + } +} + // New 创建新应用 func New(opts ...Option) *App { app := &App{} @@ -77,72 +265,142 @@ func New(opts ...Option) *App { return app } -// Run 启动应用 -func (a *App) Run() error { - // 加载配置 - cfg := config.Get() - if cfg == nil { - return config.ErrConfigNotLoaded - } - a.config = cfg +// NewFullStack 创建启用默认全功能组件的应用 +func NewFullStack(opts ...Option) *App { + all := append([]Option{WithFullStack()}, opts...) + return New(all...) +} - // 初始化日志 - if err := logger.Init(cfg); err != nil { +// RunFullStack 创建并启动启用默认全功能组件的应用 +func RunFullStack(opts ...Option) error { + return NewFullStack(opts...).Run() +} + +// Init 初始化应用,不启动 HTTP 监听 +func (a *App) Init() error { + if a.initialized { + return nil + } + + cfg, err := a.resolveConfig() + if err != nil { return err } - - // 初始化数据库 - if err := database.InitMySQL(cfg); err != nil { - logger.Fatalf("初始化 MySQL 失败: %v", err) - } - - // 初始化 Redis - if err := database.InitRedis(cfg); err != nil { - logger.Fatalf("初始化 Redis 失败: %v", err) - } - - // 自动迁移数据库表 - if err := database.AutoMigrate(); err != nil { - logger.Fatalf("数据库迁移失败: %v", err) - } - - // 初始化存储 - if err := storage.Init(&cfg.Storage); err != nil { - logger.Fatalf("初始化存储失败: %v", err) - } - - // 初始化服务容器 - wire.InitServices() - - // 设置 Gin 模式 if cfg.IsProduction() { gin.SetMode(gin.ReleaseMode) } - // 设置基础中间件 + if a.enableLogger { + if err := logger.Init(cfg); err != nil { + return fmt.Errorf("初始化日志失败: %w", err) + } + } + + if a.enableMySQL { + if err := database.InitDB(cfg); err != nil { + return fmt.Errorf("初始化数据库失败: %w", err) + } + a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "mysql", Check: func(context.Context) error { + status := database.HealthCheck() + if !status["master"] { + return errors.New("mysql master unavailable") + } + return nil + }}) + } + + if a.enableRedis { + if err := database.InitRedis(cfg); err != nil { + return fmt.Errorf("初始化 Redis 失败: %w", err) + } + a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "redis", Check: database.HealthCheckRedis}) + } + + if a.enableStorage { + if err := storage.Init(&cfg.Storage); err != nil { + return fmt.Errorf("初始化存储失败: %w", err) + } + } + + if a.enableWire { + wire.InitServices() + } + + if a.enableAutoMigrate && len(a.migrators) > 0 { + if !a.enableMySQL { + return errors.New("注册了数据库迁移但未启用 MySQL") + } + db := database.GetDB() + if db == nil { + return errors.New("MySQL 未初始化,无法执行数据库迁移") + } + for _, migrator := range a.migrators { + if err := migrator(db); err != nil { + return fmt.Errorf("数据库迁移失败: %w", err) + } + } + } + a.router.Use(gin.Recovery()) a.router.Use(middleware.Recover()) - // 静态文件服务 - a.router.Static("/public", "./public") + for _, staticRoute := range a.staticRoutes { + a.router.Static(staticRoute.relativePath, staticRoute.root) + } - // 注册默认路由(健康检查、Swagger) - router.RegisterDefaultRoutes(a.router) + if a.enableSwagger { + router.RegisterSwaggerRoutes(a.router) + } + if a.enableHealth { + router.RegisterHealthRoute(a.router, a.healthChecks...) + } - // 应用用户注册的路由 a.registry.Apply() + a.initialized = true + return nil +} - // 启动服务器(优雅关闭) +func (a *App) resolveConfig() (*config.Config, error) { + if a.config != nil { + return a.config, nil + } + if a.configManager == nil && a.configPath != "" { + a.configManager = config.NewManager(a.configPath) + } + if a.configManager != nil { + cfg, err := a.configManager.Load() + if err != nil { + return nil, err + } + // 让 App 持有的 manager 成为全局默认, + // 使 config.Get / config.GetString 等便捷函数仍能取到正确配置。 + config.SetDefaultManager(a.configManager) + a.config = cfg + return cfg, nil + } + cfg := config.Get() + if cfg == nil { + return nil, config.ErrConfigNotLoaded + } + a.config = cfg + return cfg, nil +} + +// Run 启动应用 +func (a *App) Run() error { + if err := a.Init(); err != nil { + return err + } return a.StartServer() } // StartServer 启动 HTTP 服务器(支持优雅关闭) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 监听系统信号,实现优雅关闭,等待请求处理完成 func (a *App) StartServer() error { + if a.config == nil { + return config.ErrConfigNotLoaded + } port := a.config.Server.Port - // 创建 HTTP 服务器 a.server = &http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: a.router, @@ -151,58 +409,67 @@ func (a *App) StartServer() error { IdleTimeout: 60 * time.Second, } - // 启动服务器(非阻塞) + serverErr := make(chan error, 1) go func() { logger.Infof("服务器启动,监听端口 %d", port) if err := a.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.Fatalf("服务器启动失败: %v", err) + serverErr <- err + return } + serverErr <- nil }() - // 等待中断信号 quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + defer signal.Stop(quit) - // 阻塞等待信号 - sig := <-quit - logger.Infof("收到信号 %v,开始优雅关闭...", sig) - - // 执行关闭流程 - return a.Shutdown() + select { + case err := <-serverErr: + if err != nil { + return fmt.Errorf("服务器启动失败: %w", err) + } + return nil + case sig := <-quit: + logger.Infof("收到信号 %v,开始优雅关闭...", sig) + return a.Shutdown() + } } // Shutdown 优雅关闭应用 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 按顺序关闭各组件,等待请求处理完成 func (a *App) Shutdown() error { - // 创建关闭上下文(最多等待 30 秒) - ctx, cancel := context.WithTimeout(context.Background(), 30 * time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // 1. 停止 HTTP 服务器 + var errs []error if a.server != nil { logger.Info("关闭 HTTP 服务器...") if err := a.server.Shutdown(ctx); err != nil { logger.Warnf("HTTP 服务器关闭超时: %v", err) - a.server.Close() + errs = append(errs, err) + if closeErr := a.server.Close(); closeErr != nil { + errs = append(errs, closeErr) + } } } - // 2. 停止限流器 logger.Info("停止限流器...") middleware.StopRateLimiters() - // 3. 关闭数据库连接 logger.Info("关闭数据库连接...") - database.Close() - database.CloseRedis() - - // 4. 同步日志 - logger.Info("同步日志...") - logger.Sync() + if err := database.CloseAll(); err != nil { + errs = append(errs, err) + } + if err := database.CloseRedis(); err != nil { + errs = append(errs, err) + } + logger.Info("关闭日志...") + // 先记录最后一条 "应用已优雅关闭",再 Close(关闭后写日志会 fall back 到 nop) logger.Info("应用已优雅关闭") - return nil + if err := logger.Close(); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) } // GetRegistry 获取路由注册中心(用于动态注册) @@ -235,11 +502,10 @@ func StartServerWithPort(r *gin.Engine, port int) error { } // GracefulShutdown 优雅关闭辅助函数 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 可独立使用的优雅关闭函数 func GracefulShutdown(server *http.Server, timeout time.Duration, cleanupFuncs ...func()) error { quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(quit) <-quit logger.Info("收到关闭信号,开始优雅关闭...") @@ -247,13 +513,15 @@ func GracefulShutdown(server *http.Server, timeout time.Duration, cleanupFuncs . ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - // 关闭 HTTP 服务器 + var errs []error if err := server.Shutdown(ctx); err != nil { logger.Warnf("服务器关闭超时: %v", err) - server.Close() + errs = append(errs, err) + if closeErr := server.Close(); closeErr != nil { + errs = append(errs, closeErr) + } } - // 执行清理函数 for _, cleanup := range cleanupFuncs { if cleanup != nil { cleanup() @@ -261,5 +529,5 @@ func GracefulShutdown(server *http.Server, timeout time.Duration, cleanupFuncs . } logger.Info("应用已优雅关闭") - return nil -} \ No newline at end of file + return errors.Join(errs...) +} diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..2381991 --- /dev/null +++ b/app_test.go @@ -0,0 +1,243 @@ +package xlgo_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + 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")) + } +} diff --git a/cache/keybuilder.go b/cache/keybuilder.go index 308a65d..0a70222 100644 --- a/cache/keybuilder.go +++ b/cache/keybuilder.go @@ -9,8 +9,6 @@ import ( ) // KeyBuilder 缓存键名构建器 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 多项目共用 Redis 时,通过前缀避免 key 冲突 // 使用场景: 多个小项目共用一台 Redis 服务器,每个项目设置不同前缀 type KeyBuilder struct { prefix string // 项目/站点前缀,如 "site_a" @@ -22,8 +20,6 @@ type KeyBuilder struct { type KeyBuilderOption func(*KeyBuilder) // WithPrefix 设置前缀(项目/站点别名) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 核心配置,用于区分不同项目 // 示例: WithPrefix("site_a") -> 所有 key 自动添加 "site_a" 前缀 func WithPrefix(prefix string) KeyBuilderOption { return func(kb *KeyBuilder) { @@ -32,8 +28,6 @@ func WithPrefix(prefix string) KeyBuilderOption { } // WithSeparator 设置分隔符 -// 评分: ⭐⭐⭐⭐ -// 理由: 可自定义分隔符,适应不同命名风格 // 示例: WithSeparator(":") -> "site_a:user:1" func WithSeparator(separator string) KeyBuilderOption { return func(kb *KeyBuilder) { @@ -42,8 +36,6 @@ func WithSeparator(separator string) KeyBuilderOption { } // WithCacheType 设置缓存类型标识 -// 评分: ⭐⭐⭐⭐ -// 理由: 区分缓存用途,便于管理 // 示例: WithCacheType("session") -> "session_site_a_user:1" func WithCacheType(cacheType string) KeyBuilderOption { return func(kb *KeyBuilder) { @@ -52,8 +44,6 @@ func WithCacheType(cacheType string) KeyBuilderOption { } // NewKeyBuilder 创建键名构建器 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 链式配置,灵活易用 func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder { kb := &KeyBuilder{ prefix: "", @@ -67,8 +57,6 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder { } // Build 构建完整键名 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 自动拼接前缀,避免手动拼接错误 // 格式: {cacheType}{separator}{prefix}{separator}{key} // 示例: kb.Build("user:1") -> "cache_site_a_user:1" func (kb *KeyBuilder) Build(key string) string { @@ -84,8 +72,6 @@ func (kb *KeyBuilder) Build(key string) string { } // BuildTemp 构建临时缓存键名(带过期时间) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 区分临时和永久缓存,便于批量管理 // 格式: "temp{separator}{prefix}{separator}{key}" func (kb *KeyBuilder) BuildTemp(key string) string { parts := []string{"temp"} @@ -97,8 +83,6 @@ func (kb *KeyBuilder) BuildTemp(key string) string { } // BuildPerm 构建永久缓存键名(不带过期时间) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 永久存储的数据使用不同标识 // 格式: "perm{separator}{prefix}{separator}{key}" func (kb *KeyBuilder) BuildPerm(key string) string { parts := []string{"perm"} @@ -110,8 +94,6 @@ func (kb *KeyBuilder) BuildPerm(key string) string { } // BuildLock 构建分布式锁键名 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 锁使用独立命名空间,避免与业务缓存冲突 // 格式: "lock{separator}{prefix}{separator}{key}" func (kb *KeyBuilder) BuildLock(key string) string { parts := []string{"lock"} @@ -123,8 +105,6 @@ func (kb *KeyBuilder) BuildLock(key string) string { } // BuildCounter 构建计数器键名 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 计数器使用独立命名空间 // 格式: "counter{separator}{prefix}{separator}{key}" func (kb *KeyBuilder) BuildCounter(key string) string { parts := []string{"counter"} @@ -136,8 +116,6 @@ func (kb *KeyBuilder) BuildCounter(key string) string { } // BuildSession 构建会话键名 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 会话缓存统一管理 // 格式: "session{separator}{prefix}{separator}{key}" func (kb *KeyBuilder) BuildSession(key string) string { parts := []string{"session"} @@ -149,8 +127,6 @@ func (kb *KeyBuilder) BuildSession(key string) string { } // BuildPattern 构建匹配模式(用于 SCAN/Keys) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 批量查询/删除时使用 // 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*" func (kb *KeyBuilder) BuildPattern(pattern string) string { return kb.Build(pattern) @@ -162,8 +138,6 @@ func (kb *KeyBuilder) GetPrefix() string { } // SetPrefix 动态设置前缀 -// 评分: ⭐⭐⭐⭐ -// 理由: 运行时可切换前缀,适用于多租户场景 func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder { kb.prefix = prefix return kb @@ -174,8 +148,6 @@ func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder { var globalKeyBuilder *KeyBuilder // InitKeyBuilder 初始化全局键名构建器 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 应用启动时配置,全局统一命名 // 参数: prefix 站点别名,如果为空则自动从配置读取 // 示例: // @@ -196,8 +168,6 @@ func InitKeyBuilder(prefix string, opts ...KeyBuilderOption) { } // AutoInitKeyBuilder 自动从配置初始化键名构建器 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 无需手动传入参数,完全依赖配置文件 // 配置示例: // // app: @@ -208,8 +178,6 @@ func AutoInitKeyBuilder(opts ...KeyBuilderOption) { } // GetKeyBuilder 获取全局键名构建器 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 如果未初始化,自动从配置创建 func GetKeyBuilder() *KeyBuilder { if globalKeyBuilder == nil { // 自动从配置初始化 @@ -219,8 +187,6 @@ func GetKeyBuilder() *KeyBuilder { } // K 快捷构建键名(使用全局构建器) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 简短易用,减少代码量 // 示例: cache.K("user:1") -> 自动添加前缀 func K(key string) string { return GetKeyBuilder().Build(key) @@ -254,8 +220,6 @@ 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) diff --git a/cache/lock.go b/cache/lock.go index 141eb9e..d0aa156 100644 --- a/cache/lock.go +++ b/cache/lock.go @@ -58,8 +58,6 @@ end ` // NewLock 创建分布式锁 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 使用 UUID 作为 Token,保证锁的安全释放 // 参数: key 锁名称,ttl 锁定时长 // 返回: LockToken 用于后续解锁或续期 func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, error) { @@ -93,8 +91,6 @@ 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 { return ErrRedisNotReady @@ -126,8 +122,6 @@ func UnlockByKey(ctx context.Context, key string) error { } // ExtendLock 续期锁 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 长任务执行时防止锁过期被其他客户端抢占 // 参数: token 锁令牌,ttl 新的过期时间 func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error { if database.RedisClient == nil { @@ -153,8 +147,6 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error } // TryLock 尝试获取锁,失败时等待重试 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 高并发场景常用,避免立即失败 func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval time.Duration, maxRetry int) (*LockToken, error) { for i := 0; i < maxRetry; i++ { token, err := NewLock(ctx, key, ttl) @@ -170,8 +162,6 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t } // WithLock 使用分布式锁执行函数(自动管理锁) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 自动获取、续期、释放锁,避免忘记释放 // 参数: key 锁名称,ttl 锁定时长,fn 业务函数 // 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() error) error { @@ -188,8 +178,6 @@ func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() erro } // WithLockAutoExtend 使用分布式锁执行函数(自动续期) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 长任务执行时自动续期,防止锁过期 // 参数: key 锁名称,initialTTL 初始锁定时长,extendInterval 续期间隔,fn 业务函数 func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func() error) error { token, err := NewLock(ctx, key, initialTTL) diff --git a/cmd/xlgo/commands.go b/cmd/xlgo/commands.go index 7df00e7..885db07 100644 --- a/cmd/xlgo/commands.go +++ b/cmd/xlgo/commands.go @@ -9,6 +9,8 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" + + xlgo "github.com/EthanCodeCraft/xlgo-core" ) func createProject(name string) { @@ -56,7 +58,7 @@ func createProject(name string) { files := map[string]string{ name + "/main.go": templates.Main, name + "/config.yaml": templates.Config, - name + "/go.mod": fmt.Sprintf(templates.GoMod, module), + name + "/go.mod": fmt.Sprintf(templates.GoMod, module, xlgo.Version), name + "/Makefile": templates.Makefile, name + "/.gitignore": templates.Gitignore, name + "/handler/home.go": templates.Handler, @@ -125,6 +127,7 @@ func createHandler(name, nameTitle string) { nameTitle, name, name, nameTitle, nameTitle, name, name, nameTitle, ) + content = replaceModuleImports(content) writeFile(path, content) fmt.Printf("✓ 创建处理器: %s\n", path) @@ -142,6 +145,7 @@ func createRepository(name, nameTitle string) { nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, ) + content = replaceModuleImports(content) writeFile(path, content) fmt.Printf("✓ 创建仓库: %s\n", path) @@ -177,6 +181,7 @@ func createService(name, nameTitle string) { nameTitle, nameTitle, nameTitle, nameTitle, ) + content = replaceModuleImports(content) writeFile(path, content) fmt.Printf("✓ 创建服务: %s\n", path) diff --git a/cmd/xlgo/main.go b/cmd/xlgo/main.go index bf7e09c..fd0e201 100644 --- a/cmd/xlgo/main.go +++ b/cmd/xlgo/main.go @@ -3,6 +3,8 @@ package main import ( "fmt" "os" + + xlgo "github.com/EthanCodeCraft/xlgo-core" ) func printUsage() { @@ -47,7 +49,7 @@ func main() { 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() diff --git a/cmd/xlgo/templates.go b/cmd/xlgo/templates.go index 4ff33fc..f889005 100644 --- a/cmd/xlgo/templates.go +++ b/cmd/xlgo/templates.go @@ -18,26 +18,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 +38,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}}!"}) + }) } `, @@ -165,6 +78,7 @@ server: mode: development database: + driver: mysql # mysql(默认)或 postgres host: localhost port: 3306 user: root @@ -172,6 +86,7 @@ database: name: {{.NameLower}} max_idle_conns: 10 max_open_conns: 100 + # dsn: "自定义连接字符串,设置后优先于上面的字段" redis: host: localhost @@ -198,24 +113,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 +186,8 @@ config.local.yaml Handler: `package handler import ( + "github.com/EthanCodeCraft/xlgo-core/response" "github.com/gin-gonic/gin" - - "{{.Module}}/response" ) // HealthCheck 健康检查 @@ -305,8 +209,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,19 +292,20 @@ 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()), } } @@ -418,9 +323,11 @@ func (r *%sRepository) FindByName(ctx context.Context, name string) (*model.%s, // 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: 禁用 diff --git a/cmd/xlgo/utils.go b/cmd/xlgo/utils.go index 99d6b9c..12558aa 100644 --- a/cmd/xlgo/utils.go +++ b/cmd/xlgo/utils.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) func writeFile(path, content string) { @@ -22,3 +23,26 @@ func fileExists(path string) bool { _, err := os.Stat(path) 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 +} diff --git a/compress/compress.go b/compress/compress.go index 35e6224..21b71e4 100644 --- a/compress/compress.go +++ b/compress/compress.go @@ -13,8 +13,6 @@ import ( ) // GzipCompress 压缩数据 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: API响应压缩、数据传输常用 func GzipCompress(data []byte) ([]byte, error) { var buf bytes.Buffer gz := gzip.NewWriter(&buf) @@ -29,8 +27,6 @@ func GzipCompress(data []byte) ([]byte, error) { } // GzipDecompress 解压缩数据 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: GzipCompress 的反向操作 func GzipDecompress(data []byte) ([]byte, error) { buf := bytes.NewReader(data) gz, err := gzip.NewReader(buf) @@ -42,8 +38,6 @@ func GzipDecompress(data []byte) ([]byte, error) { } // GzipCompressFile 压缩文件 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 日志归档、文件传输常用 func GzipCompressFile(src, dst string) error { srcFile, err := os.Open(src) if err != nil { @@ -71,8 +65,6 @@ func GzipCompressFile(src, dst string) error { } // GzipDecompressFile 解压文件 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: GzipCompressFile 的反向操作 func GzipDecompressFile(src, dst string) error { srcFile, err := os.Open(src) if err != nil { @@ -97,8 +89,6 @@ func GzipDecompressFile(src, dst string) error { } // Zip 压缩文件或目录 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 批量打包下载、备份常用 // 参数: zipPath 目标zip文件路径,paths 要压缩的文件或目录列表 func Zip(zipPath string, paths []string) error { // 创建目标目录 @@ -166,8 +156,6 @@ func Zip(zipPath string, paths []string) error { } // Unzip 解压 zip 文件 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: Zip 的反向操作 // 参数: zipPath zip文件路径,dstDir 目标目录 func Unzip(zipPath, dstDir string) error { reader, err := zip.OpenReader(zipPath) diff --git a/config/config.go b/config/config.go index 55e82c1..c32c60a 100644 --- a/config/config.go +++ b/config/config.go @@ -29,8 +29,6 @@ type Config struct { } // AppConfig 应用配置 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 多站点共用 Redis/日志时,通过 SiteName 区分来源 // 使用场景: // - 缓存键名前缀: cache:{site_name}:user:1 // - 日志标识: [site_a] 2024-01-01 10:00:00 ... @@ -47,8 +45,6 @@ type AppConfig struct { } // GetSiteName 获取站点别名,如果未设置则返回空字符串 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 安全获取,避免空指针 func (c *AppConfig) GetSiteName() string { if c == nil { return "" @@ -57,8 +53,6 @@ func (c *AppConfig) GetSiteName() string { } // GetCachePrefix 获取缓存键名前缀 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 统一的缓存前缀生成方法 func (c *AppConfig) GetCachePrefix() string { return c.GetSiteName() } @@ -93,23 +87,112 @@ type ServerConfig struct { Mode string `mapstructure:"mode"` // development 或 production } -// DatabaseConfig 数据库配置 -type DatabaseConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - User string `mapstructure:"user"` - Password string `mapstructure:"password"` - Name string `mapstructure:"name"` - MaxIdleConns int `mapstructure:"max_idle_conns"` - MaxOpenConns int `mapstructure:"max_open_conns"` +// 数据库驱动常量 +const ( + DriverMySQL = "mysql" + DriverPostgres = "postgres" +) + +// DSNBuilder 根据 DatabaseConfig 生成连接字符串 +type DSNBuilder func(*DatabaseConfig) string + +var ( + dsnBuildersMu sync.RWMutex + dsnBuilders = map[string]DSNBuilder{} +) + +// RegisterDSNBuilder 为指定驱动注册 DSN 构建器(驱动名大小写不敏感)。 +// aliases 用于注册同一驱动的别名,例如 postgres 的 "postgresql"、"pg"。 +// 通常由 database 包通过 database.RegisterDialect 间接调用, +// 应用代码也可直接使用以扩展自定义驱动。 +func RegisterDSNBuilder(name string, builder DSNBuilder, aliases ...string) { + if builder == nil { + return + } + dsnBuildersMu.Lock() + defer dsnBuildersMu.Unlock() + for _, n := range append([]string{name}, aliases...) { + key := strings.ToLower(strings.TrimSpace(n)) + if key != "" { + dsnBuilders[key] = builder + } + } } -// DSN 返回 MySQL 连接字符串 +// LookupDSNBuilder 查找已注册的 DSN 构建器 +func LookupDSNBuilder(name string) (DSNBuilder, bool) { + key := strings.ToLower(strings.TrimSpace(name)) + dsnBuildersMu.RLock() + defer dsnBuildersMu.RUnlock() + b, ok := dsnBuilders[key] + return b, ok +} + +// RegisteredDrivers 返回所有已注册 DSN 构建器的驱动名(用于诊断) +func RegisteredDrivers() []string { + dsnBuildersMu.RLock() + defer dsnBuildersMu.RUnlock() + names := make([]string, 0, len(dsnBuilders)) + for k := range dsnBuilders { + names = append(names, k) + } + return names +} + +func init() { + // 内置 MySQL / PostgreSQL 的 DSN 构建器 + RegisterDSNBuilder(DriverMySQL, func(c *DatabaseConfig) string { return c.MySQLDSN() }) + RegisterDSNBuilder(DriverPostgres, func(c *DatabaseConfig) string { return c.PostgresDSN() }, "postgresql", "pg") +} + +// DatabaseConfig 数据库配置 +type DatabaseConfig struct { + // Driver 数据库驱动,支持 mysql(默认)与 postgres + Driver string `mapstructure:"driver"` + // Host 数据库主机 + Host string `mapstructure:"host"` + // Port 数据库端口 + Port int `mapstructure:"port"` + // User 数据库用户名 + User string `mapstructure:"user"` + // Password 数据库密码 + Password string `mapstructure:"password"` + // Name 数据库名 + Name string `mapstructure:"name"` + // CustomDSN 自定义连接字符串,设置后优先于由 Host/Port 等字段生成的 DSN + CustomDSN string `mapstructure:"dsn"` + // MaxIdleConns 最大空闲连接数 + MaxIdleConns int `mapstructure:"max_idle_conns"` + // MaxOpenConns 最大打开连接数 + MaxOpenConns int `mapstructure:"max_open_conns"` +} + +// DSN 根据驱动返回连接字符串。设置了 CustomDSN 时优先返回 CustomDSN; +// 未指定 Driver 时按 MySQL 处理(向后兼容)。 +// 若驱动通过 RegisterDSNBuilder 注册过自定义构建器,则使用注册的构建器。 func (c *DatabaseConfig) DSN() string { + if c.CustomDSN != "" { + return c.CustomDSN + } + if builder, ok := LookupDSNBuilder(c.Driver); ok { + return builder(c) + } + // 未注册时回退到 MySQL(保持向后兼容) + return c.MySQLDSN() +} + +// MySQLDSN 返回 MySQL 连接字符串 +func (c *DatabaseConfig) MySQLDSN() string { return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", c.User, c.Password, c.Host, c.Port, c.Name) } +// PostgresDSN 返回 PostgreSQL 连接字符串 +func (c *DatabaseConfig) PostgresDSN() string { + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai", + c.Host, c.Port, c.User, c.Password, c.Name) +} + // RedisConfig Redis 配置 type RedisConfig struct { Host string `mapstructure:"host"` @@ -229,104 +312,104 @@ func (c *CORSConfig) GetMaxAge() int { return c.MaxAge } -var ( - globalConfig *Config - configOnce sync.Once - configMutex sync.RWMutex - loadErr error - viperInstance *viper.Viper - callbacks []func(*Config) - callbacksMu sync.RWMutex -) +// Manager 配置管理器 +type Manager struct { + mu sync.RWMutex + path string + v *viper.Viper + cfg *Config + callbacks []func(*Config) +} -// Load 加载配置文件(使用 sync.Once 确保只加载一次) -func Load(configPath string) (*Config, error) { - configOnce.Do(func() { - v := viper.New() - v.SetConfigFile(configPath) - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - v.AutomaticEnv() +var defaultManager = NewManager("") - if err := v.ReadInConfig(); err != nil { - loadErr = fmt.Errorf("读取配置文件失败: %w", err) - return - } +// NewManager 创建配置管理器 +func NewManager(configPath string) *Manager { + return &Manager{path: configPath} +} - var cfg Config - if err := v.Unmarshal(&cfg); err != nil { - loadErr = fmt.Errorf("解析配置文件失败: %w", err) - return - } +func newViper(configPath string) *viper.Viper { + v := viper.New() + v.SetConfigFile(configPath) + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + return v +} - configMutex.Lock() - globalConfig = &cfg - viperInstance = v - configMutex.Unlock() - }) - - if loadErr != nil { - return nil, loadErr +// Load 加载配置文件 +func (m *Manager) Load() (*Config, error) { + if m == nil || m.path == "" { + return nil, ErrConfigNotLoaded } - return Get(), nil + v := newViper(m.path) + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("读取配置文件失败: %w", err) + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("解析配置文件失败: %w", err) + } + + m.mu.Lock() + m.v = v + m.cfg = &cfg + m.mu.Unlock() + + return &cfg, nil } // LoadWithWatch 加载配置文件并启用热更新 -func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) { - cfg, err := Load(configPath) +func (m *Manager) LoadWithWatch(onChange func(*Config)) (*Config, error) { + cfg, err := m.Load() if err != nil { return nil, err } - - // 注册回调 if onChange != nil { - RegisterCallback(onChange) + m.RegisterCallback(onChange) } - - // 启动文件监听 - if err := StartWatcher(); err != nil { + if err := m.StartWatcher(); err != nil { return nil, fmt.Errorf("启动配置监听失败: %w", err) } - return cfg, nil } // RegisterCallback 注册配置变更回调 -func RegisterCallback(cb func(*Config)) { - callbacksMu.Lock() - defer callbacksMu.Unlock() - callbacks = append(callbacks, cb) +func (m *Manager) RegisterCallback(cb func(*Config)) { + if m == nil || cb == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.callbacks = append(m.callbacks, cb) } // StartWatcher 启动配置文件监听 -func StartWatcher() error { - configMutex.RLock() - v := viperInstance - configMutex.RUnlock() - - if v == nil { - return fmt.Errorf("配置未加载") +func (m *Manager) StartWatcher() error { + if m == nil { + return ErrConfigNotLoaded } - // 使用 viper 内置的文件监听 - v.WatchConfig() + m.mu.RLock() + v := m.v + m.mu.RUnlock() + if v == nil { + return ErrConfigNotLoaded + } - // 设置配置变更回调 + v.WatchConfig() v.OnConfigChange(func(e fsnotify.Event) { var newCfg Config if err := v.Unmarshal(&newCfg); err != nil { return } - configMutex.Lock() - globalConfig = &newCfg - configMutex.Unlock() - - // 触发所有回调 - callbacksMu.RLock() - cbs := make([]func(*Config), len(callbacks)) - copy(cbs, callbacks) - callbacksMu.RUnlock() + m.mu.Lock() + m.cfg = &newCfg + cbs := make([]func(*Config), len(m.callbacks)) + copy(cbs, m.callbacks) + m.mu.Unlock() for _, cb := range cbs { cb(&newCfg) @@ -337,44 +420,50 @@ func StartWatcher() error { } // StopWatcher 停止配置文件监听 -func StopWatcher() { - configMutex.RLock() - v := viperInstance - configMutex.RUnlock() +func (m *Manager) StopWatcher() {} - if v != nil { - // viper 的 WatchConfig 没有直接的停止方法 - // 但会在 viper 实例销毁时自动停止 +// Get 获取配置 +func (m *Manager) Get() *Config { + if m == nil { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + return m.cfg +} + +// GetViper 获取 viper 实例 +func (m *Manager) GetViper() *viper.Viper { + if m == nil { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + return m.v +} + +// Set 手动设置配置 +func (m *Manager) Set(cfg *Config) { + if m == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.cfg = cfg + if cfg == nil { + m.v = nil } } -// Get 获取全局配置(使用读锁保护) -func Get() *Config { - configMutex.RLock() - defer configMutex.RUnlock() - return globalConfig -} - -// GetViper 获取 viper 实例(用于扩展配置) -func GetViper() *viper.Viper { - configMutex.RLock() - defer configMutex.RUnlock() - return viperInstance -} - -// Set 手动设置配置(用于测试或动态修改) -func Set(cfg *Config) { - configMutex.Lock() - defer configMutex.Unlock() - globalConfig = cfg -} - // Reload 重新加载配置文件 -func Reload() error { - configMutex.RLock() - v := viperInstance - configMutex.RUnlock() +func (m *Manager) Reload() error { + if m == nil { + return ErrConfigNotLoaded + } + m.mu.RLock() + v := m.v + m.mu.RUnlock() if v == nil { return ErrConfigNotLoaded } @@ -388,15 +477,11 @@ func Reload() error { return fmt.Errorf("解析配置文件失败: %w", err) } - configMutex.Lock() - globalConfig = &newCfg - configMutex.Unlock() - - // 触发回调 - callbacksMu.RLock() - cbs := make([]func(*Config), len(callbacks)) - copy(cbs, callbacks) - callbacksMu.RUnlock() + m.mu.Lock() + m.cfg = &newCfg + cbs := make([]func(*Config), len(m.callbacks)) + copy(cbs, m.callbacks) + m.mu.Unlock() for _, cb := range cbs { cb(&newCfg) @@ -405,44 +490,99 @@ func Reload() error { return nil } +// Load 加载配置文件 +func Load(configPath string) (*Config, error) { + defaultManager = NewManager(configPath) + return defaultManager.Load() +} + +// LoadWithWatch 加载配置文件并启用热更新 +func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) { + defaultManager = NewManager(configPath) + return defaultManager.LoadWithWatch(onChange) +} + +// RegisterCallback 注册配置变更回调 +func RegisterCallback(cb func(*Config)) { + defaultManager.RegisterCallback(cb) +} + +// StartWatcher 启动配置文件监听 +func StartWatcher() error { + return defaultManager.StartWatcher() +} + +// StopWatcher 停止配置文件监听 +func StopWatcher() { + defaultManager.StopWatcher() +} + +// Get 获取全局配置 +func Get() *Config { + return defaultManager.Get() +} + +// GetViper 获取 viper 实例(用于扩展配置) +func GetViper() *viper.Viper { + return defaultManager.GetViper() +} + +// Set 手动设置配置(用于测试或动态修改) +func Set(cfg *Config) { + defaultManager.Set(cfg) +} + +// Reload 重新加载配置文件 +func Reload() error { + return defaultManager.Reload() +} + +// SetDefaultManager 替换全局默认配置管理器。 +// 主要供应用层(如 App)在持有自己的 Manager 时使用, +// 使 config.Get / config.GetString 等便捷函数仍然能取到正确的配置。 +// 传入 nil 表示重置为空管理器。 +func SetDefaultManager(m *Manager) { + if m == nil { + defaultManager = NewManager("") + return + } + defaultManager = m +} + // GetString 获取字符串配置 func GetString(key string) string { - configMutex.RLock() - defer configMutex.RUnlock() - if viperInstance == nil { + v := GetViper() + if v == nil { return "" } - return viperInstance.GetString(key) + return v.GetString(key) } // GetInt 获取整数配置 func GetInt(key string) int { - configMutex.RLock() - defer configMutex.RUnlock() - if viperInstance == nil { + v := GetViper() + if v == nil { return 0 } - return viperInstance.GetInt(key) + return v.GetInt(key) } // GetBool 获取布尔配置 func GetBool(key string) bool { - configMutex.RLock() - defer configMutex.RUnlock() - if viperInstance == nil { + v := GetViper() + if v == nil { return false } - return viperInstance.GetBool(key) + return v.GetBool(key) } // GetStringMap 获取字符串映射配置 func GetStringMap(key string) map[string]any { - configMutex.RLock() - defer configMutex.RUnlock() - if viperInstance == nil { + v := GetViper() + if v == nil { return nil } - return viperInstance.GetStringMap(key) + return v.GetStringMap(key) } // IsDevelopment 是否开发环境 @@ -478,8 +618,6 @@ func (c *Config) GetAppName() string { } // GetSiteName 获取站点别名 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 全局统一获取站点别名,用于缓存前缀、日志标识等 func (c *Config) GetSiteName() string { if c == nil { return "" diff --git a/config/config_test.go b/config/config_test.go index 27e8444..676c018 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -90,6 +90,38 @@ 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=disable 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 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", @@ -216,6 +248,62 @@ 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{ diff --git a/console/console.go b/console/console.go index 9f805d8..b2a05a0 100644 --- a/console/console.go +++ b/console/console.go @@ -6,11 +6,12 @@ import ( "os" "runtime" "strings" + "sync/atomic" "time" ) // Level 日志级别 -type Level int +type Level int32 const ( LevelDebug Level = iota @@ -18,8 +19,22 @@ const ( LevelSuccess LevelWarn 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 +49,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 +94,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 +112,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 +133,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 // 时间 diff --git a/console/console_test.go b/console/console_test.go index b12d4b3..3f445c2 100644 --- a/console/console_test.go +++ b/console/console_test.go @@ -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) + } + } +} diff --git a/database/dialect.go b/database/dialect.go new file mode 100644 index 0000000..dcdbcee --- /dev/null +++ b/database/dialect.go @@ -0,0 +1,139 @@ +package database + +import ( + "fmt" + "strings" + "sync" + + "github.com/EthanCodeCraft/xlgo-core/config" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// 内置驱动常量(更多驱动可通过 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 拼接连接字符串。可选—— + // 不提供时使用 cfg.MySQLDSN() 兜底(适合自定义驱动通过 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 兜底(向后兼容)。 +func Dialector(cfg *config.Config) gorm.Dialector { + return dialectorForDSN(cfg.Database.Driver, cfg.Database.DSN()) +} + +// dialectorForDSN 根据驱动名和 DSN 返回 Dialector +func dialectorForDSN(driver, dsn string) gorm.Dialector { + if f, ok := LookupDialect(driver); ok { + return f(dsn) + } + // 未注册时回退到 MySQL,与 config.DSN() 的回退保持一致 + return mysql.Open(dsn) +} + +// 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, fallback=%s)", key, DriverMySQL) +} + +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() }, + }) +} diff --git a/database/manager.go b/database/manager.go new file mode 100644 index 0000000..9a52b10 --- /dev/null +++ b/database/manager.go @@ -0,0 +1,473 @@ +package database + +import ( + "context" + "errors" + "fmt" + "math/rand" + "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{} + +const ( + dbModeMaster = "master" + dbModeReplica = "replica" +) + +// ReplicaPicker 从库选择策略 +type ReplicaPicker interface { + Pick(replicas []*gorm.DB) *gorm.DB +} + +// RoundRobinPicker 轮询选择从库 +type RoundRobinPicker struct { + counter uint64 +} + +// Pick 轮询选择一个从库 +func (p *RoundRobinPicker) Pick(replicas []*gorm.DB) *gorm.DB { + if len(replicas) == 0 { + return nil + } + n := atomic.AddUint64(&p.counter, 1) + return replicas[int(n-1)%len(replicas)] +} + +// RandomPicker 随机选择从库 +type RandomPicker struct{} + +// Pick 随机选择一个从库 +func (p *RandomPicker) Pick(replicas []*gorm.DB) *gorm.DB { + if len(replicas) == 0 { + return nil + } + return replicas[rand.Intn(len(replicas))] +} + +// Manager 数据库管理器,持有主库与从库连接实例 +type Manager struct { + cfg *config.Config + master *gorm.DB + replicas []*gorm.DB + picker ReplicaPicker + mu sync.Mutex +} + +// NewManager 创建数据库管理器 +func NewManager(cfg *config.Config) *Manager { + return &Manager{cfg: cfg, picker: &RandomPicker{}} +} + +// 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 返回主库实例 +func (m *Manager) Master() *gorm.DB { + return m.master +} + +// Replicas 返回所有从库实例 +func (m *Manager) Replicas() []*gorm.DB { + return m.replicas +} + +// Replica 按策略选择一个从库;无从库时返回主库 +func (m *Manager) Replica() *gorm.DB { + if len(m.replicas) == 0 { + return m.master + } + m.mu.Lock() + defer m.mu.Unlock() + if m.picker != nil { + if db := m.picker.Pick(m.replicas); db != nil { + return db + } + } + return m.replicas[0] +} + +// FromContext 根据上下文选择数据库 +func (m *Manager) FromContext(ctx context.Context) *gorm.DB { + 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 { + if m.cfg == nil { + return errors.New("数据库配置未设置") + } + return m.InitDB(m.cfg) +} + +// OpenWithReplicas 打开主库与从库连接 +func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) error { + if m.cfg == nil { + return errors.New("数据库配置未设置") + } + return m.InitDBWithReplicas(m.cfg, replicaDSNs) +} + +// Close 关闭主库与全部从库连接 +func (m *Manager) Close() error { + var errs []error + + if m.master != nil { + sqlDB, err := m.master.DB() + if err != nil { + errs = append(errs, err) + } else if err := sqlDB.Close(); err != nil { + errs = append(errs, err) + } + } + + for _, replica := range m.replicas { + if replica == nil { + continue + } + sqlDB, err := replica.DB() + if err != nil { + errs = append(errs, err) + continue + } + if err := sqlDB.Close(); err != nil { + errs = append(errs, err) + } + } + + m.master = nil + m.replicas = nil + + return errors.Join(errs...) +} + +// HealthCheck 健康检查,主库不可达时返回错误 +func (m *Manager) HealthCheck(ctx context.Context) error { + if m.master == nil { + return errors.New("database master not initialized") + } + sqlDB, err := m.master.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) +} + +// DefaultManager 默认数据库管理器 +var DefaultManager = &Manager{picker: &RandomPicker{}} + +// InitDB 初始化数据库连接(带重试机制),驱动由配置决定 +func (m *Manager) InitDB(cfg *config.Config) error { + var err error + m.cfg = cfg + + // 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 + + var lastErr error + for i := range maxRetries { + // 连接主库 + m.master, err = gorm.Open(Dialector(cfg), gormConfig) + if err == nil { + sqlDB, err := m.master.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("数据库主库连接成功", + 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 + } + } else { + lastErr = err + } + } else { + lastErr = err + // 不可恢复的错误(认证失败、未知数据库、DSN 非法等)直接返回,不必重试 + if !isTransientDBError(err) { + return fmt.Errorf("数据库连接失败(不可恢复): %w", err) + } + } + + logger.Warnf("数据库连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, lastErr) + time.Sleep(retryDelay) + retryDelay *= 2 + if retryDelay > 30*time.Second { + retryDelay = 30 * time.Second + } + } + + return fmt.Errorf("数据库连接失败(重试 %d 次): %w", maxRetries, lastErr) +} + +// isTransientDBError 判断数据库连接错误是否值得重试。 +// 认证失败、未知数据库、非法 DSN/驱动等属于配置类错误,重试无意义,直接返回更友好。 +func isTransientDBError(err error) bool { + if err == nil { + return true + } + msg := err.Error() + nonTransient := []string{ + "Access denied", // MySQL 认证失败(用户名/密码错误) + "authentication plugin", // MySQL 认证插件不支持 + "Unknown database", // MySQL 目标库不存在 + "invalid DSN", // DSN 语法错误 + "unknown driver", // 驱动未注册 + "unsupported driver", // 驱动不支持 + } + for _, sub := range nonTransient { + if strings.Contains(msg, sub) { + return false + } + } + return true +} + +// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定 +// replicaDSNs: 从库连接字符串列表(需与主库驱动匹配) +func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error { + // 先初始化主库 + if err := m.InitDB(cfg); err != nil { + return err + } + + m.replicas = nil + + // 初始化从库 + 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(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) + continue + } + + sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns) + sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns / 2) // 从库连接数可适当减少 + sqlDB.SetConnMaxLifetime(time.Hour) + + if err := sqlDB.Ping(); err != nil { + logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err) + continue + } + + m.replicas = append(m.replicas, replicaDB) + logger.Info("数据库从库连接成功", zap.Int("index", i+1)) + } + } + + return nil +} + +// InitDB 初始化数据库连接(带重试机制),驱动由配置决定 +func InitDB(cfg *config.Config) error { + return DefaultManager.InitDB(cfg) +} + +// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定 +func InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error { + return DefaultManager.InitDBWithReplicas(cfg, replicaDSNs) +} + +// GetReadDB 获取读库实例(按策略选择从库) +func GetReadDB() *gorm.DB { + return DefaultManager.Replica() +} + +// GetWriteDB 获取写库实例(主库) +func GetWriteDB() *gorm.DB { + return DefaultManager.Master() +} + +// GetDB 获取数据库实例(默认主库,兼容旧代码) +func GetDB() *gorm.DB { + return DefaultManager.Master() +} + +// GetReplicas 获取所有从库实例 +func GetReplicas() []*gorm.DB { + return DefaultManager.Replicas() +} + +// SetReplicaPicker 设置默认管理器的从库选择策略 +func SetReplicaPicker(p ReplicaPicker) { + DefaultManager.SetPicker(p) +} + +// UseMaster 强制使用主库(用于事务或需要实时数据的场景) +func UseMaster(ctx context.Context) context.Context { + return context.WithValue(ctx, dbModeContextKey{}, dbModeMaster) +} + +// UseReplica 强制使用从库(用于报表查询等场景) +func UseReplica(ctx context.Context) context.Context { + return context.WithValue(ctx, dbModeContextKey{}, dbModeReplica) +} + +// GetDBFromContext 根据上下文选择数据库 +func GetDBFromContext(ctx context.Context) *gorm.DB { + return DefaultManager.FromContext(ctx) +} + +// AutoMigrate 自动迁移数据库表结构(由应用通过 WithMigrator/WithModels 注册) +func AutoMigrate() error { + logger.Info("数据库表结构迁移完成") + return nil +} + +// Close 关闭主库连接(兼容旧代码,从库连接请使用 CloseAll) +func Close() error { + if DefaultManager.master == nil { + return nil + } + sqlDB, err := DefaultManager.master.DB() + if err != nil { + return err + } + err = sqlDB.Close() + DefaultManager.master = nil + return err +} + +// CloseAll 关闭所有数据库连接(包括从库) +func CloseAll() error { + return DefaultManager.Close() +} + +// Transaction 事务操作(自动使用主库) +func Transaction(fn func(tx *gorm.DB) error) error { + if DefaultManager.master == nil { + return errors.New("数据库未初始化") + } + return DefaultManager.master.Transaction(fn) +} + +// TransactionWithContext 带上下文的事务操作 +func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error { + if DefaultManager.master == nil { + return errors.New("数据库未初始化") + } + return DefaultManager.master.WithContext(ctx).Transaction(fn) +} + +// ReadQuery 读查询(自动路由到从库) +func ReadQuery(ctx context.Context, model any, query string, args ...any) error { + db := GetDBFromContext(ctx) + if db == nil { + return errors.New("数据库未初始化") + } + return db.WithContext(ctx).Where(query, args...).Find(model).Error +} + +// WriteQuery 写查询(强制使用主库) +func WriteQuery(ctx context.Context, model any, query string, args ...any) error { + if DefaultManager.master == nil { + return errors.New("数据库未初始化") + } + return DefaultManager.master.WithContext(ctx).Where(query, args...).Find(model).Error +} + +// HealthCheck 健康检查 +func HealthCheck() map[string]bool { + result := make(map[string]bool) + + // 检查主库 + if DefaultManager.master != nil { + sqlDB, err := DefaultManager.master.DB() + if err == nil && sqlDB.Ping() == nil { + result["master"] = true + } else { + result["master"] = false + } + } else { + result["master"] = false + } + + // 检查从库 + for i, replica := range DefaultManager.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 +} diff --git a/database/manager_test.go b/database/manager_test.go new file mode 100644 index 0000000..c8cb610 --- /dev/null +++ b/database/manager_test.go @@ -0,0 +1,216 @@ +package database_test + +import ( + "context" + "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.DefaultManager.HealthCheck(context.Background()); err == nil { + t.Fatal("expected error when health checking uninitialized master") + } +} + +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) + } + + // 未知驱动回退到 mysql + unknownCfg := &config.Config{Database: config.DatabaseConfig{ + Driver: "no-such-driver", Host: "localhost", Port: 3306, + User: "root", Password: "pass", Name: "db", + }} + if name := database.Dialector(unknownCfg).Name(); name != "mysql" { + t.Fatalf("expected fallback to mysql for unknown driver, got %q", name) + } +} + +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) + } + } +} diff --git a/database/mysql.go b/database/mysql.go deleted file mode 100644 index b17e52d..0000000 --- a/database/mysql.go +++ /dev/null @@ -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 -} diff --git a/database/redis.go b/database/redis.go index eeddc61..0b2df2b 100644 --- a/database/redis.go +++ b/database/redis.go @@ -36,10 +36,20 @@ func InitRedis(cfg *config.Config) error { // CloseRedis 关闭 Redis 连接 func CloseRedis() error { - if RedisClient != nil { - return RedisClient.Close() + if RedisClient == nil { + return nil } - return nil + err := RedisClient.Close() + RedisClient = nil + return err +} + +// HealthCheckRedis Redis 健康检查 +func HealthCheckRedis(ctx context.Context) error { + if RedisClient == nil { + return fmt.Errorf("Redis 未初始化") + } + return RedisClient.Ping(ctx).Err() } // GetRedis 获取 Redis 客户端 diff --git a/database/redis_test.go b/database/redis_test.go new file mode 100644 index 0000000..7aa2a41 --- /dev/null +++ b/database/redis_test.go @@ -0,0 +1,23 @@ +package database_test + +import ( + "context" + "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") + } +} diff --git a/database/retry_internal_test.go b/database/retry_internal_test.go new file mode 100644 index 0000000..dcba687 --- /dev/null +++ b/database/retry_internal_test.go @@ -0,0 +1,31 @@ +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}, + } + 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) + } + }) + } +} diff --git a/go.mod b/go.mod index 6e92767..c6e4525 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,9 @@ require ( 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/redis/go-redis/v9 v9.5.1 github.com/spf13/viper v1.18.2 github.com/swaggo/files v1.0.1 @@ -21,11 +21,12 @@ require ( 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 ( @@ -51,6 +52,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 @@ -80,10 +85,11 @@ require ( go.uber.org/multierr v1.10.0 // 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 diff --git a/go.sum b/go.sum index 796f9be..1c56b20 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,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 +75,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= @@ -196,24 +204,24 @@ 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 +231,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 +241,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 +279,9 @@ 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= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/handler/handler.go b/handler/handler.go index 9266cfc..2f573fc 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -69,8 +69,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 +93,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 +111,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) } diff --git a/jwt/jwt.go b/jwt/jwt.go index af5c10d..03c4b45 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -37,18 +37,18 @@ var ( ) // generateJTI 生成唯一的 JWT ID -func generateJTI() string { +func generateJTI() (string, error) { bytes := make([]byte, 16) - rand.Read(bytes) - return base64.URLEncoding.EncodeToString(bytes) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("生成 JTI 失败: %w", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil } // TokenBlacklist Token 黑名单管理(使用 JTI 优化) type TokenBlacklist struct{} // Add 将 Token 的 JTI 加入黑名单 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 使用 JTI(32字节)替代完整 Token(数百字节),大幅节省 Redis 内存 // 参数: jti JWT ID,expiry Token 过期时间 func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error { if database.RedisClient == nil { @@ -84,13 +84,14 @@ func (tb *TokenBlacklist) IsBlacklisted(jti string) bool { var tokenBlacklist = &TokenBlacklist{} // GenerateToken 生成 JWT Token -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 自动生成 JTI,支持高效黑名单机制 func GenerateToken(userID uint, username, role, userType string) (string, error) { cfg := config.Get() // 生成唯一的 JWT ID - jti := generateJTI() + jti, err := generateJTI() + if err != nil { + return "", err + } claims := Claims{ UserID: userID, @@ -115,7 +116,10 @@ func GenerateToken(userID uint, username, role, userType string) (string, error) func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) { cfg := config.Get() - jti := generateJTI() + jti, err := generateJTI() + if err != nil { + return "", err + } claims := Claims{ UserID: userID, @@ -137,8 +141,6 @@ func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, } // ParseToken 解析 JWT Token -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 使用 JTI 检查黑名单,效率更高 func ParseToken(tokenString string) (*Claims, error) { cfg := config.Get() @@ -171,8 +173,6 @@ func ParseToken(tokenString string) (*Claims, error) { } // InvalidateToken 使 Token 失效(加入黑名单) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 使用 JTI 而非完整 Token,节省 Redis 内存 func InvalidateToken(tokenString string) error { cfg := config.Get() diff --git a/logger/field.go b/logger/field.go index 5d16200..407c488 100644 --- a/logger/field.go +++ b/logger/field.go @@ -30,6 +30,6 @@ var Field = struct { } }, Error: func(err error) zap.Field { - return zap.Error(err) - }, + return zap.Error(err) + }, } diff --git a/logger/logger.go b/logger/logger.go index 5698338..13b31c9 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -1,8 +1,10 @@ package logger import ( + "errors" "os" "path/filepath" + "strings" "github.com/EthanCodeCraft/xlgo-core/config" @@ -12,17 +14,36 @@ import ( ) var ( - // Logger 全局日志实例 - Logger *zap.Logger - sugar *zap.SugaredLogger - apiLog *zap.Logger - dbLog *zap.Logger + // Logger 全局通用日志实例。Init 之前为 Nop,调用安全。 + Logger = zap.NewNop() + sugar = Logger.Sugar() + apiLog = zap.NewNop() + dbLog = zap.NewNop() + + // fileWriters 持有所有 lumberjack 实例引用, + // Close() 调用时显式释放文件句柄。 + // 必要性:lumberjack 不依赖 GC 关闭文件,进程长跑或测试场景下 + // 不显式关闭会持有句柄导致 Windows 上无法删除日志目录。 + fileWriters []*lumberjack.Logger ) -// Init 初始化日志 +// 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 Init(cfg *config.Config) error { + if cfg == nil { + return errors.New("logger: config is nil") + } + // 确保日志目录存在 - if err := os.MkdirAll(cfg.Log.Dir, 0755); err != nil { + if err := os.MkdirAll(cfg.Log.Dir, 0o755); err != nil { return err } @@ -43,75 +64,126 @@ func Init(cfg *config.Config) error { } // 根据运行模式设置日志级别 - var level zapcore.Level + level := zapcore.DebugLevel if cfg.IsProduction() { - level = zapcore.WarnLevel - } else { - level = zapcore.DebugLevel + level = 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, - ) - - consoleCore := zapcore.NewCore( - consoleEncoder, - consoleWriter, - level, - ) - - // 合并核心 - core := zapcore.NewTee(apiCore, dbCore, consoleCore) - - // 创建日志实例 - Logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1)) + // 全部构造成功后再原子替换全局变量,避免半初始化状态。 + // 同时关闭旧 writer 释放句柄(重复 Init 场景,主要服务于测试)。 + closeFileWriters() + Logger = newLogger sugar = Logger.Sugar() - - // 创建专用日志实例 - apiLog = zap.New(apiCore, zap.AddCaller(), zap.AddCallerSkip(1)) - dbLog = zap.New(dbCore, zap.AddCaller(), zap.AddCallerSkip(1)) + apiLog = newAPILog + dbLog = newDBLog + fileWriters = []*lumberjack.Logger{appWriter, apiWriter, dbWriter} return nil } -// Sync 同步日志 -func Sync() { - if Logger != nil { - _ = Logger.Sync() +// 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() { + for _, w := range fileWriters { + if w != nil { + _ = w.Close() + } + } + fileWriters = nil +} + +// Sync 同步全部 logger 缓冲到底层 writer。 +// +// 注意:在 Windows / 部分 *nix 平台上对 stdout/stderr 调用 Sync 会返回 +// "invalid argument" / "inappropriate ioctl for device",属于 zap 已知行为, +// 这里把这类错误识别并忽略,只返回真实的写入失败。 +func Sync() error { + var errs []error + for _, l := range []*zap.Logger{Logger, apiLog, dbLog} { + if l == nil { + continue + } + if err := l.Sync(); err != nil && !isHarmlessSyncError(err) { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// Close 关闭日志文件句柄,重置全局 logger 为 Nop。 +// 通常由 App.Shutdown 在 Sync 之后调用;测试场景需要清理临时目录时也应调用。 +// +// 调用后再次写日志不会 panic(fall back to nop logger),但不会写入文件。 +// 如需重新启用,请再次调用 Init。 +func Close() error { + syncErr := Sync() + + closeFileWriters() + + Logger = zap.NewNop() + sugar = Logger.Sugar() + apiLog = zap.NewNop() + dbLog = zap.NewNop() + + return syncErr +} + +// 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...) @@ -132,7 +204,7 @@ func Error(msg string, fields ...zap.Field) { Logger.Error(msg, fields...) } -// Fatal 致命错误日志 +// Fatal 致命错误日志(仅供应用层使用,框架内部禁止调用) func Fatal(msg string, fields ...zap.Field) { Logger.Fatal(msg, fields...) } @@ -157,17 +229,17 @@ func Errorf(template string, args ...any) { sugar.Errorf(template, args...) } -// Fatalf 格式化致命错误日志 +// Fatalf 格式化致命错误日志(仅供应用层使用,框架内部禁止调用) func Fatalf(template string, args ...any) { sugar.Fatalf(template, args...) } -// APILog API 专用日志 +// APILog 返回 API 专用日志器(写 logs/api.log + console) func APILog() *zap.Logger { return apiLog } -// DBLog 数据库专用日志 +// DBLog 返回数据库专用日志器(写 logs/database.log + console) func DBLog() *zap.Logger { return dbLog } diff --git a/logger/logger_test.go b/logger/logger_test.go index 8f0f4c4..ff5cf71 100644 --- a/logger/logger_test.go +++ b/logger/logger_test.go @@ -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) + } } \ No newline at end of file diff --git a/middleware/auth.go b/middleware/auth.go index c170aad..41b5aaf 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -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) { @@ -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) diff --git a/middleware/auth_test.go b/middleware/auth_test.go new file mode 100644 index 0000000..dc673e5 --- /dev/null +++ b/middleware/auth_test.go @@ -0,0 +1,160 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "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 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") + } +} diff --git a/middleware/cors.go b/middleware/cors.go index 9de7368..ea5fe53 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -10,8 +10,9 @@ import ( ) // CORS 跨域中间件 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 支持从配置文件读取 CORS 配置,生产环境更安全 +// 支持从配置文件读取 CORS 配置;遵循 W3C CORS 规范: +// 当 AllowCredentials=true 时,Access-Control-Allow-Origin 必须回显为具体 Origin, +// 不能使用 "*"(浏览器会拒绝携带凭证的响应)。 func CORS() gin.HandlerFunc { return CORSWithConfig(nil) } @@ -30,15 +31,21 @@ 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) @@ -56,20 +63,28 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc { } } - // 如果没有匹配到白名单 + // 处理通配符 + 兜底策略 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 != "" { + // 开发环境兜底:回显具体 Origin(兼容 credentials) + allowedOrigin = origin } } // 设置 CORS 响应头 if allowedOrigin != "" { c.Header("Access-Control-Allow-Origin", allowedOrigin) + // Origin 不是 "*" 时,下游缓存(CDN / 网关)必须按 Origin 区分缓存 + if allowedOrigin != "*" { + c.Header("Vary", "Origin") + } } // 从配置获取允许的方法、请求头等 @@ -83,11 +98,10 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc { 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") // 默认允许 } // 处理预检请求 @@ -101,8 +115,7 @@ func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc { } // getAllowedOrigins 获取允许的域名列表 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 优先使用配置文件,其次使用环境变量,最后使用默认值 +// 优先使用配置文件,生产环境必须显式配置,开发环境提供 localhost 兜底。 func getAllowedOrigins(cfg *config.Config, corsConfig *config.CORSConfig) []string { // 优先使用 CORS 配置 if corsConfig != nil && len(corsConfig.AllowedOrigins) > 0 { diff --git a/middleware/logger.go b/middleware/logger.go index 4070c40..05450a0 100644 --- a/middleware/logger.go +++ b/middleware/logger.go @@ -43,8 +43,6 @@ func Logger() gin.HandlerFunc { } // LoggerWithConfig 使用自定义配置的日志中间件 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 可配置是否记录请求体/响应体,跳过静态资源路径,慢请求警告 func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc { return func(c *gin.Context) { // 检查是否跳过此路径 diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go index 2758765..4d65189 100644 --- a/middleware/middleware_test.go +++ b/middleware/middleware_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/EthanCodeCraft/xlgo-core/config" "github.com/EthanCodeCraft/xlgo-core/middleware" "github.com/gin-gonic/gin" ) @@ -257,6 +258,130 @@ func TestCORSOptions(t *testing.T) { } } +// CORS 规范要求:AllowCredentials=false 时不应发送 Access-Control-Allow-Credentials 头。 +// 历史 bug:旧实现在 if/else 两个分支都设了 "true",相当于永远允许凭证。 +func TestCORSAllowCredentialsDefault(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"https://example.com"}, + AllowCredentials: false, + })) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://example.com") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Errorf("Access-Control-Allow-Credentials = %q, want empty when AllowCredentials=false", got) + } +} + +// AllowCredentials=true 且 Origin 在白名单内:发送凭证头并回显具体 Origin。 +func TestCORSAllowCredentialsExplicitOrigin(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"https://example.com"}, + AllowCredentials: true, + })) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://example.com") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\"", got) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" { + t.Errorf("Access-Control-Allow-Origin = %q, want \"https://example.com\"", got) + } + if got := w.Header().Get("Vary"); got != "Origin" { + t.Errorf("Vary = %q, want \"Origin\"", got) + } +} + +// CORS 规范:AllowCredentials=true 时禁止使用 "*",必须回显具体 Origin。 +func TestCORSWildcardWithCredentials(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowCredentials: true, + })) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://anywhere.example") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got == "*" { + t.Errorf("Access-Control-Allow-Origin must NOT be \"*\" when credentials are allowed, got %q", got) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://anywhere.example" { + t.Errorf("Access-Control-Allow-Origin = %q, want echoed origin", got) + } + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\"", got) + } +} + +// AllowedOrigins=["*"] 且 AllowCredentials=false:保持 "*" 通配符语义。 +func TestCORSWildcardWithoutCredentials(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowCredentials: false, + })) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://anywhere.example") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want \"*\"", got) + } + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Errorf("Access-Control-Allow-Credentials = %q, want empty (AllowCredentials=false)", got) + } +} + +// Origin 不在白名单时不应回显,避免反射型 CORS 漏洞。 +func TestCORSOriginNotAllowed(t *testing.T) { + r := setupTestRouter() + r.Use(middleware.CORSWithConfig(&config.CORSConfig{ + AllowedOrigins: []string{"https://allowed.example"}, + AllowCredentials: true, + })) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "https://evil.example") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Access-Control-Allow-Origin = %q, want empty for non-whitelisted origin", got) + } + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Errorf("Access-Control-Allow-Credentials = %q, want empty for non-whitelisted origin", got) + } +} + // ===== RateLimit Tests ===== func TestRateLimit(t *testing.T) { diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index 2795da7..7724f51 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -105,8 +105,6 @@ func (rl *RateLimiter) Stop() { // ===== Redis 分布式限流器 ===== // RedisRateLimiter Redis 分布式限流器 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 多实例部署时限流共享,使用滑动窗口算法 type RedisRateLimiter struct { keyPrefix string // 键名前缀 rate int // 每分钟允许的请求数 @@ -147,8 +145,6 @@ func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *Redi } // Allow 检查是否允许请求 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 使用滑动窗口算法,精确限流 func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) { if database.RedisClient == nil { // Redis 未启用,默认允许 @@ -306,8 +302,6 @@ func CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc { // ===== Redis 分布式限流中间件 ===== // RedisRateLimit Redis 分布式限流中间件 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 多实例部署时共享限流状态 // 参数: keyPrefix 键名前缀(如 "login_limit"),rate 每分钟请求数 func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc { limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute) diff --git a/middleware/recover.go b/middleware/recover.go index 1d56e18..c69bdd1 100644 --- a/middleware/recover.go +++ b/middleware/recover.go @@ -12,8 +12,6 @@ import ( ) // Recover panic恢复中间件,捕获panic并返回统一错误响应 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 生产环境必备,防止panic导致服务崩溃 func Recover() gin.HandlerFunc { return func(c *gin.Context) { defer func() { @@ -40,8 +38,6 @@ func Recover() gin.HandlerFunc { } // RecoverWithDetail panic恢复中间件(详细版本,返回更多信息) -// 评分: ⭐⭐⭐⭐ -// 理由: 开发环境使用,返回详细错误信息便于调试 // 注意: 生产环境不应使用,会暴露敏感信息 func RecoverWithDetail() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/middleware/requestid.go b/middleware/requestid.go index d1e98ec..58edb1e 100644 --- a/middleware/requestid.go +++ b/middleware/requestid.go @@ -6,8 +6,6 @@ import ( ) // RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 日志追踪、问题排查必备,简洁高效 func RequestID() gin.HandlerFunc { return func(c *gin.Context) { requestID := c.GetHeader("X-Request-ID") diff --git a/repository/page_internal_test.go b/repository/page_internal_test.go new file mode 100644 index 0000000..622edf8 --- /dev/null +++ b/repository/page_internal_test.go @@ -0,0 +1,72 @@ +package repository + +import ( + "strings" + "testing" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +type pageCountModel struct { + gorm.Model + Name string +} + +// newDryRunDB 用 mysql 驱动 + DryRun 模式构造一个不实际连接数据库的 GORM 实例, +// 仅用于校验生成的 SQL。SkipInitializeWithVersion 避免初始化时执行 SELECT VERSION()。 +func newDryRunDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(mysql.New(mysql.Config{ + DSN: "root:root@tcp(127.0.0.1:3306)/test?parseTime=true", + SkipInitializeWithVersion: true, + }), &gorm.Config{ + DryRun: true, + DisableAutomaticPing: true, + Logger: gormlogger.Default.LogMode(gormlogger.Silent), + SkipDefaultTransaction: true, + }) + if err != nil { + t.Fatalf("open dry-run db: %v", err) + } + return db +} + +// TestQueryBuilderPageCountStripsLimit 验证 Page 的 Count 不受残留 Limit/Offset 影响。 +// 修复前:count SQL 会被包成子查询并带上 LIMIT,导致统计行数被截断。 +// 修复后:count SQL 不应包含 LIMIT。 +func TestQueryBuilderPageCountStripsLimit(t *testing.T) { + db := newDryRunDB(t) + repo := NewBaseRepo[pageCountModel](db) + + qb := repo.NewQueryBuilder(). + Where("name = ?", "foo"). + Limit(10). + Offset(5) + + // 复刻 Page 内部的统计路径(已修复) + countDB := qb.db.Session(&gorm.Session{}).Limit(-1).Offset(-1) + var total int64 + result := countDB.Session(&gorm.Session{}).Count(&total) + sql := result.Statement.SQL.String() + + if strings.Contains(strings.ToUpper(sql), "LIMIT") { + t.Errorf("count SQL should not contain LIMIT (residual limit would truncate total), got: %s", sql) + } +} + +// TestQueryBuilderPageFindKeepsLimit 验证查询路径仍然带分页 Limit(回归保护)。 +func TestQueryBuilderPageFindKeepsLimit(t *testing.T) { + db := newDryRunDB(t) + repo := NewBaseRepo[pageCountModel](db) + + qb := repo.NewQueryBuilder().Where("name = ?", "foo") + var models []pageCountModel + result := qb.db.Session(&gorm.Session{}).Limit(10).Offset(5).Find(&models) + sql := strings.ToUpper(result.Statement.SQL.String()) + + if !strings.Contains(sql, "LIMIT") { + t.Errorf("find SQL should contain LIMIT for pagination, got: %s", sql) + } +} diff --git a/repository/repository.go b/repository/repository.go index 7aa73e9..8c8487a 100644 --- a/repository/repository.go +++ b/repository/repository.go @@ -96,8 +96,6 @@ func (r *BaseRepo[T]) GetDB() *gorm.DB { // ===== 扩展查询功能 ===== // FindOne 条件查询单条记录 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 灵活的条件查询,避免每次写原生 SQL func (r *BaseRepo[T]) FindOne(ctx context.Context, query string, args ...any) (*T, error) { var model T err := r.db.WithContext(ctx).Where(query, args...).First(&model).Error @@ -150,8 +148,6 @@ type PageResult[T any] struct { } // FindPage 分页查询 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 最常用的分页查询封装 func (r *BaseRepo[T]) FindPage(ctx context.Context, page, pageSize int) (*PageResult[T], error) { var models []T var total int64 @@ -406,7 +402,8 @@ func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageR var total int64 // 复制 query 用于统计(避免影响原查询) - countDB := qb.db.Session(&gorm.Session{}) + // 清除残留的 Limit/Offset,否则统计行数会被截断(GORM 中 Limit(-1)/Offset(-1) 表示移除该条件) + countDB := qb.db.Session(&gorm.Session{}).Limit(-1).Offset(-1) if err := countDB.WithContext(ctx).Count(&total).Error; err != nil { return nil, err } diff --git a/response/error.go b/response/error.go index 245f47c..eaa1d68 100644 --- a/response/error.go +++ b/response/error.go @@ -8,11 +8,16 @@ import ( // 业务错误码定义 // 格式:模块(2位) + 功能(2位) + 错误类型(2位) +// +// 约定: +// - CodeSuccess = 0:成功 +// - CodeFail = 1:通用失败 +// - 其余 framework 内置错误码使用 HTTP 风格(401/403/404/429/500/503)或 5 位业务码段 +// - 参数错误等业务化错误码请由业务项目自行定义,框架不再内置 CodeInvalidParams const ( // 通用错误 00xxxx - CodeSuccess = 1 // 成功 - CodeFail = 0 // 通用失败 - CodeInvalidParams = 1 // 参数错误 + CodeSuccess = 0 // 成功 + CodeFail = 1 // 通用失败 CodeUnauthorized = 401 // 未授权 CodeForbidden = 403 // 无权限 CodeNotFound = 404 // 资源不存在 @@ -98,7 +103,6 @@ func (e *Error) ToResponse() Response { // 预定义错误 var ( - ErrInvalidParams = NewError(CodeInvalidParams, "参数错误") ErrUnauthorized = NewError(CodeUnauthorized, "请先登录") ErrForbidden = NewError(CodeForbidden, "无权限访问") ErrNotFound = NewError(CodeNotFound, "资源不存在") @@ -136,6 +140,49 @@ var ( ErrBusinessRule = NewError(CodeBusinessRuleError, "业务规则错误") ) +// _errorCodeUniquenessGuard 在编译期保证所有内置 Code* 不重复。 +// Go spec: 同一个常量 key 在 map 字面量中出现两次会触发 +// "duplicate key in map literal" 编译错误,从而把"错误码不能撞" +// 这件事写进类型系统,比 init() 里 panic 检查更早、更安全。 +// +// 维护规则:新增 Code* 常量时,**必须**把它登记到这里。 +// 注意:变量名故意以下划线开头并赋给 _,避免 unused 报错且不会进入 runtime。 +var _ = map[int]string{ + CodeSuccess: "CodeSuccess", + CodeFail: "CodeFail", + CodeUnauthorized: "CodeUnauthorized", + CodeForbidden: "CodeForbidden", + CodeNotFound: "CodeNotFound", + CodeRateLimit: "CodeRateLimit", + CodeServerError: "CodeServerError", + CodeServiceUnavailable: "CodeServiceUnavailable", + + CodeUserNotFound: "CodeUserNotFound", + CodeUserAlreadyExists: "CodeUserAlreadyExists", + CodeUserDisabled: "CodeUserDisabled", + CodePasswordWrong: "CodePasswordWrong", + CodePasswordWeak: "CodePasswordWeak", + CodePhoneInvalid: "CodePhoneInvalid", + CodeEmailInvalid: "CodeEmailInvalid", + CodeLoginFailed: "CodeLoginFailed", + CodeTokenExpired: "CodeTokenExpired", + CodeTokenInvalid: "CodeTokenInvalid", + + CodeFileNotFound: "CodeFileNotFound", + CodeFileTooLarge: "CodeFileTooLarge", + CodeFileTypeInvalid: "CodeFileTypeInvalid", + CodeFileUploadFailed: "CodeFileUploadFailed", + + CodeDataNotFound: "CodeDataNotFound", + CodeDataAlreadyExists: "CodeDataAlreadyExists", + CodeDataInvalid: "CodeDataInvalid", + CodeDataConflict: "CodeDataConflict", + + CodeOperationFailed: "CodeOperationFailed", + CodeOperationTimeout: "CodeOperationTimeout", + CodeBusinessRuleError: "CodeBusinessRuleError", +} + // FailWithError 使用预定义错误响应 func FailWithError(c *gin.Context, err *Error) { c.JSON(200, Response{ diff --git a/response/response.go b/response/response.go index 62d7509..3e48320 100644 --- a/response/response.go +++ b/response/response.go @@ -113,8 +113,6 @@ func Page(c *gin.Context, items any, total int64, page, pageSize int) { } // Download 文件下载响应 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 文件下载封装,自动设置响应头 func Download(c *gin.Context, filename string, data []byte) { c.Header("Content-Type", "application/octet-stream") c.Header("Content-Disposition", "attachment; filename="+filename) diff --git a/response/response_test.go b/response/response_test.go index ec16c07..e2d924c 100644 --- a/response/response_test.go +++ b/response/response_test.go @@ -28,10 +28,10 @@ func TestSuccess(t *testing.T) { t.Errorf("Success status = %d", w.Code) } - // 验证响应体包含 code=1 + // 验证响应体包含 code=0 body := w.Body.String() - if !contains(body, `"code":1`) { - t.Errorf("Success body should contain code:1, got %s", body) + if !contains(body, `"code":0`) { + t.Errorf("Success body should contain code:0, got %s", body) } } @@ -65,8 +65,8 @@ func TestFail(t *testing.T) { } body := w.Body.String() - if !contains(body, `"code":0`) { - t.Errorf("Fail body should contain code:0, got %s", body) + if !contains(body, `"code":1`) { + t.Errorf("Fail body should contain code:1, got %s", body) } } @@ -262,11 +262,11 @@ func TestRedirect(t *testing.T) { func TestErrorCodes(t *testing.T) { // 测试错误码定义 - if response.CodeSuccess != 1 { - t.Errorf("CodeSuccess = %d, want 1", response.CodeSuccess) + if response.CodeSuccess != 0 { + t.Errorf("CodeSuccess = %d, want 0", response.CodeSuccess) } - if response.CodeFail != 0 { - t.Errorf("CodeFail = %d, want 0", response.CodeFail) + if response.CodeFail != 1 { + t.Errorf("CodeFail = %d, want 1", response.CodeFail) } if response.CodeUnauthorized == 0 { t.Error("CodeUnauthorized should not be 0") @@ -285,13 +285,13 @@ func TestErrorCodes(t *testing.T) { func TestResponseStructure(t *testing.T) { // 测试 Response 结构体 resp := response.Response{ - Code: 1, + Code: 0, Msg: "成功", Data: gin.H{"id": 1}, RequestID: "test-123", } - if resp.Code != 1 { + if resp.Code != 0 { t.Error("Response Code failed") } if resp.Msg != "成功" { diff --git a/router/health_test.go b/router/health_test.go new file mode 100644 index 0000000..2cc546b --- /dev/null +++ b/router/health_test.go @@ -0,0 +1,80 @@ +package router_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/EthanCodeCraft/xlgo-core/router" +) + +func TestRegisterHealthRoute(t *testing.T) { + r := setupTestRouter() + router.RegisterHealthRoute(r) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), `"status":"ok"`) { + t.Fatalf("expected ok health response, got %s", w.Body.String()) + } +} + +func TestRegisterHealthRouteWithChecks(t *testing.T) { + r := setupTestRouter() + router.RegisterHealthRoute(r, + router.HealthCheck{Name: "mysql", Check: func(context.Context) error { return nil }}, + router.HealthCheck{Name: "redis", Disabled: true}, + ) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, `"mysql":"ok"`) || !strings.Contains(body, `"redis":"disabled"`) { + t.Fatalf("expected check statuses, got %s", body) + } +} + +func TestRegisterHealthRouteWithFailingCheck(t *testing.T) { + r := setupTestRouter() + router.RegisterHealthRoute(r, router.HealthCheck{Name: "mysql", Check: func(context.Context) error { return errors.New("down") }}) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected status 503, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), `"status":"error"`) { + t.Fatalf("expected error health response, got %s", w.Body.String()) + } +} + +func TestRegisterDefaultRoutes(t *testing.T) { + r := setupTestRouter() + router.RegisterDefaultRoutes(r) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected health status 200, got %d", w.Code) + } + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil) + r.ServeHTTP(w, req) + if w.Code == http.StatusNotFound { + t.Fatal("expected swagger route to be registered") + } +} diff --git a/router/router.go b/router/router.go index 68a4689..36656a5 100644 --- a/router/router.go +++ b/router/router.go @@ -1,21 +1,64 @@ package router import ( + "context" + "net/http" + "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) +// HealthCheck 健康检查项 +type HealthCheck struct { + Name string + Check func(context.Context) error + Disabled bool +} + +// RegisterHealthRoute 注册健康检查路由 +func RegisterHealthRoute(r *gin.Engine, checks ...HealthCheck) { + r.GET("/health", func(c *gin.Context) { + if len(checks) == 0 { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + return + } + + status := "ok" + code := http.StatusOK + result := make(map[string]string, len(checks)) + ctx := c.Request.Context() + for _, check := range checks { + if check.Name == "" { + continue + } + if check.Disabled || check.Check == nil { + result[check.Name] = "disabled" + continue + } + if err := check.Check(ctx); err != nil { + result[check.Name] = "error" + status = "error" + code = http.StatusServiceUnavailable + continue + } + result[check.Name] = "ok" + } + + c.JSON(code, gin.H{"status": status, "checks": result}) + }) +} + +// RegisterSwaggerRoutes 注册 Swagger 文档路由 +func RegisterSwaggerRoutes(r *gin.Engine) { + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) +} + // RegisterDefaultRoutes 注册框架默认路由(健康检查、Swagger) // 用户可以选择使用或不使用这些默认路由 -func RegisterDefaultRoutes(r *gin.Engine) { - // Swagger 文档路由 - r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) - - // 健康检查 - r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) - }) +func RegisterDefaultRoutes(r *gin.Engine, checks ...HealthCheck) { + RegisterSwaggerRoutes(r) + RegisterHealthRoute(r, checks...) } // DefaultModule 默认路由模块(可用于 WithModules) @@ -28,7 +71,7 @@ func (m *defaultModule) Register(r *gin.RouterGroup) { // 作为模块注册时,路由在根路径 r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) } diff --git a/storage/storage.go b/storage/storage.go index 531888d..5b99614 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -2,6 +2,8 @@ package storage import ( "bytes" + "crypto/rand" + "errors" "fmt" "io" "mime/multipart" @@ -27,6 +29,15 @@ type Storage interface { Exists(path string) bool } +// uniqueFilename 生成带随机后缀的唯一文件名,避免同一纳秒内并发上传导致文件名冲突。 +// 格式: -<8字节随机hex>. +func uniqueFilename(now time.Time, ext string) string { + randBytes := make([]byte, 8) + // crypto/rand 失败极少见;退化为全零也能由纳秒时间戳保证基本唯一性 + _, _ = rand.Read(randBytes) + return fmt.Sprintf("%d-%x%s", now.UnixNano(), randBytes, ext) +} + // LocalStorage 本地存储 type LocalStorage struct { path string @@ -57,7 +68,7 @@ func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string // 生成唯一文件名 ext := filepath.Ext(file.Filename) - filename := fmt.Sprintf("%d%s", now.UnixNano(), ext) + filename := uniqueFilename(now, ext) dst := filepath.Join(fullPath, filename) // 打开源文件 @@ -107,7 +118,7 @@ func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (st if ext == "" { ext = ".bin" } - uniqueFilename := fmt.Sprintf("%d%s", now.UnixNano(), ext) + uniqueFilename := uniqueFilename(now, ext) dst := filepath.Join(fullPath, uniqueFilename) // 创建目标文件 @@ -201,7 +212,7 @@ func (s *OSSStorage) Upload(file *multipart.FileHeader, subdir string) (string, now := time.Now() datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day()) ext := filepath.Ext(file.Filename) - objectKey := fmt.Sprintf("%s/%d%s", filepath.Join(subdir, datePath), now.UnixNano(), ext) + objectKey := fmt.Sprintf("%s/%s", filepath.Join(subdir, datePath), uniqueFilename(now, ext)) // 打开源文件 src, err := file.Open() @@ -228,7 +239,7 @@ func (s *OSSStorage) UploadFromBytes(data []byte, filename, subdir string) (stri if ext == "" { ext = ".bin" } - objectKey := fmt.Sprintf("%s/%d%s", filepath.Join(subdir, datePath), now.UnixNano(), ext) + objectKey := fmt.Sprintf("%s/%s", filepath.Join(subdir, datePath), uniqueFilename(now, ext)) // 上传到 OSS if err := s.bucket.PutObject(objectKey, bytes.NewReader(data)); err != nil { @@ -285,6 +296,8 @@ func (s *OSSStorage) Exists(path string) bool { return err == nil } +var ErrStorageNotInitialized = errors.New("storage not initialized") + // 全局存储实例 var storage Storage @@ -307,32 +320,60 @@ func Init(cfg *config.StorageConfig) error { return nil } +// GetStorage 获取全局存储实例 +func GetStorage() Storage { + return storage +} + +// SetStorage 设置全局存储实例 +func SetStorage(s Storage) { + storage = s +} + // Upload 上传文件 func Upload(file *multipart.FileHeader, subdir string) (string, error) { + if storage == nil { + return "", ErrStorageNotInitialized + } return storage.Upload(file, subdir) } // UploadFromBytes 从字节数组上传文件 func UploadFromBytes(data []byte, filename, subdir string) (string, error) { + if storage == nil { + return "", ErrStorageNotInitialized + } return storage.UploadFromBytes(data, filename, subdir) } // GetURL 获取文件访问 URL func GetURL(path string) string { + if storage == nil { + return "" + } return storage.GetURL(path) } // Delete 删除文件 func Delete(path string) error { + if storage == nil { + return ErrStorageNotInitialized + } return storage.Delete(path) } // Get 获取文件内容 func Get(path string) ([]byte, error) { + if storage == nil { + return nil, ErrStorageNotInitialized + } return storage.Get(path) } // Exists 检查文件是否存在 func Exists(path string) bool { + if storage == nil { + return false + } return storage.Exists(path) } diff --git a/storage/storage_test.go b/storage/storage_test.go index 767c6b1..558d2c1 100644 --- a/storage/storage_test.go +++ b/storage/storage_test.go @@ -1,6 +1,7 @@ package storage_test import ( + "errors" "os" "path/filepath" "testing" @@ -24,6 +25,26 @@ func init() { logger.Init(cfg) } +func TestStorageNotInitialized(t *testing.T) { + storage.SetStorage(nil) + + if _, err := storage.UploadFromBytes([]byte("test"), "test.txt", "docs"); !errors.Is(err, storage.ErrStorageNotInitialized) { + t.Fatalf("expected ErrStorageNotInitialized, got %v", err) + } + if err := storage.Delete("missing"); !errors.Is(err, storage.ErrStorageNotInitialized) { + t.Fatalf("expected ErrStorageNotInitialized, got %v", err) + } + if _, err := storage.Get("missing"); !errors.Is(err, storage.ErrStorageNotInitialized) { + t.Fatalf("expected ErrStorageNotInitialized, got %v", err) + } + if url := storage.GetURL("missing"); url != "" { + t.Fatalf("expected empty URL, got %q", url) + } + if storage.Exists("missing") { + t.Fatal("expected Exists false without storage") + } +} + func TestLocalStorage(t *testing.T) { cfg := &config.LocalStorageConfig{ Path: "/tmp/uploads", diff --git a/storage/unique_internal_test.go b/storage/unique_internal_test.go new file mode 100644 index 0000000..7b7dcb1 --- /dev/null +++ b/storage/unique_internal_test.go @@ -0,0 +1,34 @@ +package storage + +import ( + "strings" + "testing" + "time" +) + +func TestUniqueFilename(t *testing.T) { + now := time.Now() + + name := uniqueFilename(now, ".jpg") + if !strings.HasSuffix(name, ".jpg") { + t.Errorf("uniqueFilename should preserve extension, got %q", name) + } + if !strings.Contains(name, "-") { + t.Errorf("uniqueFilename should contain random separator, got %q", name) + } + + // 同一时刻多次生成应彼此不同(随机后缀防冲突) + seen := make(map[string]struct{}, 100) + for i := 0; i < 100; i++ { + seen[uniqueFilename(now, ".jpg")] = struct{}{} + } + if len(seen) < 99 { + t.Errorf("expected near-100%% uniqueness, got %d distinct out of 100", len(seen)) + } + + // 空扩展名也应可用 + nameNoExt := uniqueFilename(now, "") + if nameNoExt == "" { + t.Error("uniqueFilename should return non-empty with empty ext") + } +} diff --git a/trace/trace.go b/trace/trace.go index 0378844..cb3545d 100644 --- a/trace/trace.go +++ b/trace/trace.go @@ -42,7 +42,7 @@ type Config struct { // DefaultConfig 默认配置 var DefaultConfig = Config{ ServiceName: "xlgo-service", - ServiceVersion: "1.0.0", + ServiceVersion: "1.0.0", // 应用自身版本(非框架版本 xlgo.Version);建议业务侧覆盖为实际应用版本 Environment: "development", ExporterType: "otlp-http", Endpoint: "localhost:4318", @@ -58,8 +58,6 @@ var tracerProvider *sdktrace.TracerProvider var tracer trace.Tracer // Init 初始化链路追踪 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: OpenTelemetry 是行业标准,支持多种导出器 func Init(cfg Config) error { if !cfg.Enabled { // 设置 Noop Tracer @@ -148,8 +146,6 @@ func Close(ctx context.Context) error { } // Middleware Gin 中间件 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 自动为每个请求创建 Span,记录关键信息 func Middleware(serviceName string) gin.HandlerFunc { return func(c *gin.Context) { // 从请求中提取 TraceContext @@ -216,8 +212,6 @@ func GetTraceID(c *gin.Context) string { } // StartSpan 创建子 Span -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 用于记录业务逻辑中的关键操作 func StartSpan(c *gin.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { ctx := GetContext(c) return tracer.Start(ctx, name, @@ -235,8 +229,6 @@ func StartSpanFromContext(ctx context.Context, name string, attrs ...attribute.K } // RecordError 记录错误 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 自动将错误信息关联到 Span func RecordError(c *gin.Context, err error) { ctx := GetContext(c) span := trace.SpanFromContext(ctx) diff --git a/utils/convert.go b/utils/convert.go index 5e10308..033e249 100644 --- a/utils/convert.go +++ b/utils/convert.go @@ -5,16 +5,12 @@ import ( ) // ToInt 字符串转 int,失败返回 0 -// 评分: ⭐⭐⭐⭐ -// 理由: 简化类型转换,避免每次处理错误 func ToInt(s string) int { n, _ := strconv.Atoi(s) return n } // ToIntDefault 字符串转 int,失败返回默认值 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 提供默认值,更灵活 func ToIntDefault(s string, def int) int { n, err := strconv.Atoi(s) if err != nil { @@ -24,16 +20,12 @@ func ToIntDefault(s string, def int) int { } // ToInt64 字符串转 int64,失败返回 0 -// 评分: ⭐⭐⭐⭐ -// 理由: 大整数转换常用 func ToInt64(s string) int64 { n, _ := strconv.ParseInt(s, 10, 64) return n } // ToInt64Default 字符串转 int64,失败返回默认值 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 提供默认值,更灵活 func ToInt64Default(s string, def int64) int64 { n, err := strconv.ParseInt(s, 10, 64) if err != nil { @@ -43,16 +35,12 @@ func ToInt64Default(s string, def int64) int64 { } // ToUint64 字符串转 uint64,失败返回 0 -// 评分: ⭐⭐⭐⭐ -// 理由: 无符号整数转换 func ToUint64(s string) uint64 { n, _ := strconv.ParseUint(s, 10, 64) return n } // ToUint64Default 字符串转 uint64,失败返回默认值 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 提供默认值 func ToUint64Default(s string, def uint64) uint64 { n, err := strconv.ParseUint(s, 10, 64) if err != nil { @@ -62,16 +50,12 @@ func ToUint64Default(s string, def uint64) uint64 { } // ToFloat64 字符串转 float64,失败返回 0 -// 评分: ⭐⭐⭐⭐ -// 理由: 浮点数转换常用 func ToFloat64(s string) float64 { n, _ := strconv.ParseFloat(s, 64) return n } // ToFloat64Default 字符串转 float64,失败返回默认值 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 提供默认值 func ToFloat64Default(s string, def float64) float64 { n, err := strconv.ParseFloat(s, 64) if err != nil { @@ -81,22 +65,16 @@ func ToFloat64Default(s string, def float64) float64 { } // ToString 整数转字符串 -// 评分: ⭐⭐⭐⭐ -// 理由: 简化 strconv.Itoa 调用 func ToString(n int) string { return strconv.Itoa(n) } // ToString64 int64 转字符串 -// 评分: ⭐⭐⭐⭐ -// 理由: 大整数转字符串 func ToString64(n int64) string { return strconv.FormatInt(n, 10) } // CalcPageCount 计算总页数 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 分页查询必备,避免除零错误 func CalcPageCount(total, pageSize int64) int64 { if total <= 0 || pageSize <= 0 { return 0 @@ -105,8 +83,6 @@ func CalcPageCount(total, pageSize int64) int64 { } // CalcOffset 计算分页偏移量 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 分页查询必备,自动处理页码边界 func CalcOffset(page, pageSize int) int { if page <= 0 { page = 1 diff --git a/utils/crypto.go b/utils/crypto.go index 758ea4b..366546f 100644 --- a/utils/crypto.go +++ b/utils/crypto.go @@ -11,16 +11,12 @@ import ( ) // MD5 计算字符串的 MD5 哈希值 -// 评分: ⭐⭐⭐⭐ -// 理由: 常用哈希函数,但 MD5 已不安全,仅用于非安全场景(如文件校验) // 注意: 不应用于密码存储 func MD5(s string) string { return MD5Bytes([]byte(s)) } // MD5Bytes 计算字节数组的 MD5 哈希值 -// 评分: ⭐⭐⭐⭐ -// 理由: 文件哈希常用 func MD5Bytes(data []byte) string { h := md5.New() h.Write(data) @@ -28,8 +24,6 @@ func MD5Bytes(data []byte) string { } // SHA1 计算字符串的 SHA1 哈希值 -// 评分: ⭐⭐⭐ -// 理由: SHA1 也已不安全,仅用于兼容旧系统 // 注意: 不应用于密码存储 func SHA1(s string) string { h := sha1.New() @@ -38,8 +32,6 @@ func SHA1(s string) string { } // SHA256 计算字符串的 SHA256 哈希值 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 安全哈希算法,适用于数据完整性校验 func SHA256(s string) string { h := sha256.New() h.Write([]byte(s)) @@ -47,8 +39,6 @@ func SHA256(s string) string { } // SHA256Bytes 计算字节数组的 SHA256 哈希值 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 文件哈希常用 func SHA256Bytes(data []byte) string { h := sha256.New() h.Write(data) @@ -56,8 +46,6 @@ func SHA256Bytes(data []byte) string { } // HashFile 计算文件的哈希值 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 大文件哈希,支持分块读取避免内存溢出 func HashFile(path string, newHash func() hash.Hash) (string, error) { data, err := ReadFile(path) if err != nil { @@ -69,36 +57,26 @@ func HashFile(path string, newHash func() hash.Hash) (string, error) { } // Base64Encode Base64 编码 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 常用编码函数 func Base64Encode(data []byte) string { return base64.StdEncoding.EncodeToString(data) } // Base64Decode Base64 解码 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 常用解码函数 func Base64Decode(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) } // Base64URLEncode URL 安全的 Base64 编码 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: URL 传输常用,替换 +/ 为 -_ func Base64URLEncode(data []byte) string { return base64.URLEncoding.EncodeToString(data) } // Base64URLDecode URL 安全的 Base64 解码 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: URL 传输常用 func Base64URLDecode(s string) ([]byte, error) { return base64.URLEncoding.DecodeString(s) } // Nl2br 将换行符替换为
标签 -// 评分: ⭐⭐⭐ -// 理由: 特定场景使用(富文本显示),但现代前端框架通常自行处理 func Nl2br(s string, isXhtml bool) string { var br string if isXhtml { diff --git a/utils/datetime.go b/utils/datetime.go index 7faa6df..c6b199c 100644 --- a/utils/datetime.go +++ b/utils/datetime.go @@ -6,85 +6,61 @@ import ( ) // NowUnix 返回当前秒级时间戳 -// 评分: ⭐⭐⭐⭐ -// 理由: 简单封装,语义清晰,避免每次写 time.Now().Unix() func NowUnix() int64 { return time.Now().Unix() } // NowTimestamp 返回当前毫秒时间戳 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: API接口常用毫秒时间戳,封装后更易用 func NowTimestamp() int64 { return time.Now().UnixMilli() } // FromUnix 秒级时间戳转 time.Time -// 评分: ⭐⭐⭐⭐ -// 理由: 时间戳转换常用,封装后语义清晰 func FromUnix(unix int64) time.Time { return time.Unix(unix, 0) } // FromTimestamp 毫秒时间戳转 time.Time -// 评分: ⭐⭐⭐⭐ -// 理由: 毫秒时间戳转换,API交互常用 func FromTimestamp(timestamp int64) time.Time { return time.UnixMilli(timestamp) } // FormatTime 格式化时间 -// 评分: ⭐⭐⭐⭐ -// 理由: 常用时间格式化封装,支持自定义布局 func FormatTime(t time.Time, layout string) string { return t.Format(layout) } // ParseTime 解析时间字符串 -// 评分: ⭐⭐⭐⭐ -// 理由: 时间解析封装,统一错误处理 func ParseTime(timeStr, layout string) (time.Time, error) { return time.Parse(layout, timeStr) } // FormatDateTime 格式化为标准日期时间 "2006-01-02 15:04:05" -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 预设常用格式,避免记忆 Go 的时间布局 func FormatDateTime(t time.Time) string { return t.Format("2006-01-02 15:04:05") } // FormatDate 格式化为日期 "2006-01-02" -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 预设常用格式 func FormatDate(t time.Time) string { return t.Format("2006-01-02") } // FormatTimeOnly 格式化为时间 "15:04:05" -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 预设常用格式 func FormatTimeOnly(t time.Time) string { return t.Format("15:04:05") } // StartOfDay 返回指定时间当天的开始时间 (00:00:00) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 统计报表、日期范围查询常用 func StartOfDay(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } // EndOfDay 返回指定时间当天的结束时间 (23:59:59.999999999) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 统计报表、日期范围查询常用 func EndOfDay(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location()) } // StartOfWeek 返回指定时间当周的开始时间(周一为第一天) -// 评分: ⭐⭐⭐⭐ -// 理由: 周报表统计常用 func StartOfWeek(t time.Time) time.Time { weekday := int(t.Weekday()) if weekday == 0 { @@ -95,30 +71,22 @@ func StartOfWeek(t time.Time) time.Time { } // StartOfMonth 返回指定时间当月的开始时间 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 月度统计常用 func StartOfMonth(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) } // EndOfMonth 返回指定时间当月的结束时间 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 月度统计常用 func EndOfMonth(t time.Time) time.Time { return time.Date(t.Year(), t.Month()+1, 0, 23, 59, 59, 999999999, t.Location()) } // GetDateInt 返回 yyyyMMdd 格式的日期整数 -// 评分: ⭐⭐⭐ -// 理由: 特定场景使用(如分区键),但返回 int 可能不够通用 func GetDateInt(t time.Time) int { ret, _ := strconv.Atoi(t.Format("20060102")) return ret } // ParseDateInt 将 yyyyMMdd 格式的整数转为时间 -// 评分: ⭐⭐⭐ -// 理由: GetDateInt 的反向操作 func ParseDateInt(date int) time.Time { year := date / 10000 month := (date % 10000) / 100 diff --git a/utils/file.go b/utils/file.go index 228abf5..7784525 100644 --- a/utils/file.go +++ b/utils/file.go @@ -8,16 +8,12 @@ import ( ) // FileExists 检查文件是否存在 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 常用检查,语义清晰 func FileExists(path string) bool { _, err := os.Stat(path) return err == nil } // DirExists 检查目录是否存在 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 区分文件和目录检查,更精确 func DirExists(path string) bool { info, err := os.Stat(path) if err != nil { @@ -27,15 +23,11 @@ func DirExists(path string) bool { } // EnsureDir 确保目录存在,不存在则创建 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 日志、上传等场景常用,避免每次判断 func EnsureDir(path string) error { return os.MkdirAll(path, 0755) } // ReadFile 读取文件内容 -// 评分: ⭐⭐⭐⭐ -// 理由: 简化 os.ReadFile,增加存在性检查 func ReadFile(path string) ([]byte, error) { if !FileExists(path) { return nil, fmt.Errorf("file not found: %s", path) @@ -44,8 +36,6 @@ func ReadFile(path string) ([]byte, error) { } // WriteFile 写入文件内容(覆盖) -// 评分: ⭐⭐⭐⭐ -// 理由: 简化文件写入,自动创建目录 func WriteFile(path string, data []byte) error { dir := filepath.Dir(path) if err := EnsureDir(dir); err != nil { @@ -55,8 +45,6 @@ func WriteFile(path string, data []byte) error { } // AppendFile 追加内容到文件 -// 评分: ⭐⭐⭐⭐ -// 理由: 日志追加场景常用 func AppendFile(path string, data []byte) error { dir := filepath.Dir(path) if err := EnsureDir(dir); err != nil { @@ -72,8 +60,6 @@ func AppendFile(path string, data []byte) error { } // CopyFile 复制文件 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 文件操作常用,使用 io.Copy 高效复制 func CopyFile(dst, src string) error { srcFile, err := os.Open(src) if err != nil { @@ -98,8 +84,6 @@ func CopyFile(dst, src string) error { } // FileSize 获取文件大小 -// 评分: ⭐⭐⭐⭐ -// 理由: 上传文件大小检查常用 func FileSize(path string) (int64, error) { info, err := os.Stat(path) if err != nil { @@ -109,8 +93,6 @@ func FileSize(path string) (int64, error) { } // RemoveFile 删除文件(忽略不存在的错误) -// 评分: ⭐⭐⭐⭐ -// 理由: 清理临时文件时常用,避免额外判断 func RemoveFile(path string) error { err := os.Remove(path) if os.IsNotExist(err) { diff --git a/utils/http.go b/utils/http.go index 4d1b721..d40398d 100644 --- a/utils/http.go +++ b/utils/http.go @@ -17,8 +17,6 @@ import ( ) // HTTPClient HTTP 客户端封装 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 链式调用设计优雅,Transport 复用,连接池优化 type HTTPClient struct { client *http.Client transport *http.Transport @@ -56,8 +54,6 @@ var DefaultHTTPClientConfig = HTTPClientConfig{ } // NewHTTPClient 创建 HTTP 客户端 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: Transport 在初始化时创建,连接池可复用 func NewHTTPClient() *HTTPClient { cfg := DefaultHTTPClientConfig return NewHTTPClientWithConfig(cfg) @@ -93,8 +89,6 @@ func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient { } // SetTimeout 设置超时时间 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 链式调用,动态调整超时 func (c *HTTPClient) SetTimeout(timeout time.Duration) *HTTPClient { c.timeout = timeout c.client.Timeout = timeout @@ -330,8 +324,6 @@ func (c *HTTPClient) DoWithResponse(req *http.Request) (*http.Response, error) { } // Close 关闭客户端(释放连接池资源) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 清理资源,避免连接泄漏 func (c *HTTPClient) Close() { c.transport.CloseIdleConnections() } diff --git a/utils/random.go b/utils/random.go index 3354d65..5770431 100644 --- a/utils/random.go +++ b/utils/random.go @@ -23,8 +23,6 @@ const ( ) // RandString 生成指定长度的随机字符串(字母+数字) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 使用 sync.Pool 复用 rand.Source,性能优秀;位运算优化高效 func RandString(n int) string { if n <= 0 { return "" @@ -48,8 +46,6 @@ func RandString(n int) string { } // RandDigit 生成指定长度的随机数字字符串 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 同 RandString,适用于验证码、订单号等场景 func RandDigit(n int) string { if n <= 0 { return "" @@ -73,8 +69,6 @@ func RandDigit(n int) string { } // RandInt 返回 [min, max) 范围内的随机整数 -// 评分: ⭐⭐⭐⭐ -// 理由: 实用函数,自动处理 min > max 的情况 func RandInt(min, max int) int { if min == max { return min @@ -88,8 +82,6 @@ func RandInt(min, max int) int { } // RandInt64 返回 [min, max) 范围内的随机 int64 -// 评分: ⭐⭐⭐⭐ -// 理由: RandInt 的 int64 版本,适用于大范围随机数 func RandInt64(min, max int64) int64 { if min == max { return min diff --git a/utils/strings.go b/utils/strings.go index 42e25c0..46d2d51 100644 --- a/utils/strings.go +++ b/utils/strings.go @@ -6,8 +6,6 @@ import ( ) // IsBlank 检查字符串是否为空或仅包含空白字符 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 常用函数,正确处理 Unicode 空白字符 func IsBlank(s string) bool { if s == "" { return true @@ -21,15 +19,11 @@ func IsBlank(s string) bool { } // IsNotBlank 检查字符串是否非空且不全是空白字符 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: IsBlank 的反向,语义更清晰 func IsNotBlank(s string) bool { return !IsBlank(s) } // IsAnyBlank 检查多个字符串中是否有任意一个为空或空白 -// 评分: ⭐⭐⭐⭐ -// 理由: 参数校验场景常用,避免多次调用 IsBlank func IsAnyBlank(strs ...string) bool { for _, s := range strs { if IsBlank(s) { @@ -40,8 +34,6 @@ func IsAnyBlank(strs ...string) bool { } // IsAllBlank 检查所有字符串是否都为空或空白 -// 评分: ⭐⭐⭐⭐ -// 理由: IsAnyBlank 的反向,适用于批量校验 func IsAllBlank(strs ...string) bool { for _, s := range strs { if IsNotBlank(s) { @@ -52,8 +44,6 @@ func IsAllBlank(strs ...string) bool { } // DefaultIfBlank 如果字符串为空或空白,返回默认值 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 配置默认值场景常用,代码简洁 func DefaultIfBlank(s, def string) string { if IsBlank(s) { return def @@ -62,8 +52,6 @@ func DefaultIfBlank(s, def string) string { } // IsEmpty 检查任意类型是否为空值 -// 评分: ⭐⭐⭐ -// 理由: 功能实用,但使用反射有性能开销;建议仅在必要时使用 // 支持: string, []T, map[K]V, nil, 零值 func IsEmpty(v any) bool { if v == nil { @@ -80,8 +68,6 @@ func IsEmpty(v any) bool { } // Trim 去除字符串首尾的空白字符(包括空格、制表符、换行符) -// 评分: ⭐⭐⭐⭐ -// 理由: 比标准库 strings.Trim 更语义化,预设常用空白字符 func Trim(s string) string { return trimFunc(s, unicode.IsSpace) } @@ -113,8 +99,6 @@ func trimRightFunc(s string, f func(rune) bool) string { } // Substr 截取子字符串(按 Unicode 字符计数) -// 评分: ⭐⭐⭐⭐ -// 理由: 正确处理多字节字符(中文等),避免截断 // 参数: start 起始位置(支持负数从末尾计算),length 截取长度 func Substr(s string, start, length int) string { runes := []rune(s) @@ -147,15 +131,11 @@ func Substr(s string, start, length int) string { } // StrLen 获取字符串的 Unicode 字符数 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 正确计算多字节字符长度,比 len() 更准确 func StrLen(s string) int { return utf8.RuneCountInString(s) } // EqualsIgnoreCase 不区分大小写比较字符串 -// 评分: ⭐⭐⭐⭐ -// 理由: 常用比较函数,使用标准库实现保证正确性 func EqualsIgnoreCase(a, b string) bool { if a == b { return true diff --git a/utils/url.go b/utils/url.go index d0bba99..ef4ebc1 100644 --- a/utils/url.go +++ b/utils/url.go @@ -5,16 +5,12 @@ import ( ) // URLBuilder URL 构建器 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 链式调用设计优雅,便于构建复杂 URL type URLBuilder struct { u *url.URL query url.Values } // ParseURL 解析 URL 字符串 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 创建 URLBuilder 的入口函数 func ParseURL(rawURL string) (*URLBuilder, error) { u, err := url.Parse(rawURL) if err != nil { @@ -27,16 +23,12 @@ func ParseURL(rawURL string) (*URLBuilder, error) { } // AddQuery 添加单个查询参数 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 链式调用,支持多次添加 func (b *URLBuilder) AddQuery(key, value string) *URLBuilder { b.query.Add(key, value) return b } // AddQueries 批量添加查询参数 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 批量添加更高效 func (b *URLBuilder) AddQueries(params map[string]string) *URLBuilder { for k, v := range params { b.query.Set(k, v) @@ -45,62 +37,46 @@ func (b *URLBuilder) AddQueries(params map[string]string) *URLBuilder { } // SetQuery 设置查询参数(覆盖同名参数) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 区别于 Add,覆盖同名参数 func (b *URLBuilder) SetQuery(key, value string) *URLBuilder { b.query.Set(key, value) return b } // SetPath 设置路径 -// 评分: ⭐⭐⭐⭐ -// 理由: 动态修改路径 func (b *URLBuilder) SetPath(path string) *URLBuilder { b.u.Path = path return b } // SetScheme 设置协议(http/https) -// 评分: ⭐⭐⭐⭐ -// 理由: 协议切换 func (b *URLBuilder) SetScheme(scheme string) *URLBuilder { b.u.Scheme = scheme return b } // SetHost 设置主机 -// 评分: ⭐⭐⭐⭐ -// 理由: 域名切换 func (b *URLBuilder) SetHost(host string) *URLBuilder { b.u.Host = host return b } // Build 构建最终的 URL -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 返回标准库 url.URL func (b *URLBuilder) Build() *url.URL { b.u.RawQuery = b.query.Encode() return b.u } // String 返回 URL 字符串 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 最常用的输出方法 func (b *URLBuilder) String() string { return b.Build().String() } // URLEncode URL 编码 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 常用编码函数 func URLEncode(s string) string { return url.QueryEscape(s) } // URLDecode URL 解码 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 常用解码函数 func URLDecode(s string) (string, error) { return url.QueryUnescape(s) } diff --git a/utils/uuid.go b/utils/uuid.go index 575bfc3..018caae 100644 --- a/utils/uuid.go +++ b/utils/uuid.go @@ -5,29 +5,21 @@ import ( ) // UUID 生成 UUID v4 字符串 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 常用唯一标识生成,使用标准库 google/uuid func UUID() string { return uuid.New().String() } // UUIDShort 生成短 UUID(无横线) -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 数据库主键、订单号等场景常用 func UUIDShort() string { return uuid.New().String()[:32] } // UUIDParse 解析 UUID 字符串 -// 评分: ⭐⭐⭐⭐ -// 理由: UUID 解析验证 func UUIDParse(s string) (uuid.UUID, error) { return uuid.Parse(s) } // UUIDValid 检查 UUID 字符串是否有效 -// 评分: ⭐⭐⭐⭐ -// 理由: 快速验证 func UUIDValid(s string) bool { _, err := uuid.Parse(s) return err == nil diff --git a/utils/validator.go b/utils/validator.go index f8a1f4b..2d767dd 100644 --- a/utils/validator.go +++ b/utils/validator.go @@ -5,8 +5,6 @@ import ( ) // IsPhone 检查是否为有效的中国大陆手机号 -// 评分: ⭐⭐⭐⭐ -// 理由: 常用验证,但正则需要随运营商号段更新 // 注意: 正则基于当前号段,新号段开放时需更新 func IsPhone(phone string) bool { // 1开头,第二位为3-9,共11位 @@ -16,8 +14,6 @@ func IsPhone(phone string) bool { } // IsEmail 检查是否为有效的邮箱地址 -// 评分: ⭐⭐⭐⭐ -// 理由: 常用验证,简单正则覆盖大部分场景 func IsEmail(email string) bool { // 简单邮箱验证:xxx@xxx.xxx pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` @@ -26,8 +22,6 @@ func IsEmail(email string) bool { } // IsIPv4 检查是否为有效的 IPv4 地址 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 网络编程常用验证 func IsIPv4(ip string) bool { pattern := `^(\d{1,3}\.){3}\d{1,3}$` matched, _ := regexp.MatchString(pattern, ip) @@ -46,8 +40,6 @@ func IsIPv4(ip string) bool { } // IsIDCard 检查是否为有效的中国身份证号(18位) -// 评分: ⭐⭐⭐ -// 理由: 身份证验证需求常见,但校验位算法复杂,此为简化版 // 注意: 仅校验格式,不校验校验位 func IsIDCard(id string) bool { // 18位身份证:6位地区码 + 8位生日 + 3位顺序码 + 1位校验码 @@ -57,8 +49,6 @@ func IsIDCard(id string) bool { } // IsChinese 检查字符串是否全部为中文字符 -// 评分: ⭐⭐⭐⭐ -// 理由: 中文内容校验常用 func IsChinese(s string) bool { for _, r := range s { if r < 0x4E00 || r > 0x9FFF { @@ -69,8 +59,6 @@ func IsChinese(s string) bool { } // HasChinese 检查字符串是否包含中文字符 -// 评分: ⭐⭐⭐⭐ -// 理由: 混合内容检测 func HasChinese(s string) bool { for _, r := range s { if r >= 0x4E00 && r <= 0x9FFF { @@ -81,8 +69,6 @@ func HasChinese(s string) bool { } // IsNumeric 检查字符串是否全部为数字 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 简单高效的数字检测 func IsNumeric(s string) bool { if len(s) == 0 { return false @@ -96,8 +82,6 @@ func IsNumeric(s string) bool { } // IsAlpha 检查字符串是否全部为字母 -// 评分: ⭐⭐⭐⭐ -// 理由: 字母检测 func IsAlpha(s string) bool { if len(s) == 0 { return false @@ -111,8 +95,6 @@ func IsAlpha(s string) bool { } // IsAlphanumeric 检查字符串是否全部为字母或数字 -// 评分: ⭐⭐⭐⭐⭐ -// 理由: 用户名、密码等常用验证 func IsAlphanumeric(s string) bool { if len(s) == 0 { return false