2 Commits

Author SHA1 Message Date
杭州明婳科技 dcfd24b624 release: v1.0.3 bug fix release
v1.0.3 定位为 bug fix release,收口 v1.0.2 引入的破坏性清理并修复
4 个轻量 bug + 依赖复查 + 版本号治理 + 文档对齐。同时包含此前未提交
的 v1.0.2 全部工作。

v1.0.3 变更:

Fixed:
- generateJTI 忽略 rand.Read 错误(jwt)— 改为 (string, error) 并传播
- QueryBuilder.Page Count 受残留 Limit 截断(repository)— countDB 加 Limit(-1).Offset(-1)
- OSS/本地存储文件名冲突(storage)— 新增 uniqueFilename 加 8 字节随机后缀
- 数据库重试策略对不可恢复错误无效(database)— 新增 isTransientDBError

Dependencies:
- go mod tidy 补全 postgres 方言传递依赖;安全补丁升级
  x/crypto v0.49→v0.53、golang-jwt/jwt/v5 v5.2.1→v5.3.1、gorilla/websocket v1.5.1→v1.5.3

Removed:
- Breaking: 清理 v1.0.2 兼容别名(InitMySQL* / driverName)
- 死代码 database.DBResolver
- 代码中 292 行"评分/理由"自夸注释(#26,23 个文件)

Changed:
- Breaking: 错误码体系重构 CodeSuccess 1→0、CodeFail 0→1,删除 CodeInvalidParams,加编译期防撞码
- database/mysql.go → manager.go;Logger 拆分三独立 core 修复 Tee 重复写入
- 版本号常量化:app.go 新增 const Version 作为唯一来源,CLI/脚手架模板引用之,不再散落字面量

Security:
- CORS 中间件按 W3C 规范修复 Allow-Credentials / Vary: Origin / 非白名单不回显

Added:
- console 包显式 level 控制(SetLevel/WithLevel/LevelSilent,atomic.Int32 并发安全)
- 新增测试 jwt/repository/storage/database/router/middleware/app
- 文档:新增 CHANGELOG.md、Version_v1.0.2_report.md;更新 README/GUIDE 顶部更新日志
- 文档对齐:删除对不存在的 config.DefaultManager() getter 的描述(保持 API 与文档一致)

详见 CHANGELOG.md 与 README.md 更新日志。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:40:10 +08:00
Ethan xlp 2cc8c70960 chore: change license to MIT 2026-04-30 23:40:59 +08:00
62 changed files with 5625 additions and 1299 deletions
+348
View File
@@ -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)` 辅助函数,格式 `<unixNano>-<8字节crypto/rand hex>.<ext>`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
+185 -45
View File
@@ -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: <name>` 生效,DSN 构建器同步登记到 `config` 包:
```go
import (
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func init() {
database.RegisterDialect(database.DialectSpec{
Name: "sqlite",
Aliases: []string{"sqlite3"},
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
DSN: func(c *config.DatabaseConfig) string { return c.Name }, // Name 当作文件路径
})
}
```
之后只需把配置改成 `database.driver: sqlite` 即可。SQL Server / ClickHouse / TiDB 等同理。
诊断接口:
- `database.RegisteredDialects()` - 列出已注册驱动(含别名)
- `database.LookupDialect(name)` - 检查某驱动是否可用
- `config.RegisteredDrivers()` - 列出已登记的 DSN 构建器
未注册的驱动会回退到 MySQL 以保持向后兼容。
### 4.2 定义模型
```go
@@ -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_
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ethan xlp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+242 -53
View File
@@ -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: <name>` 生效,DSN 构建器会同步登记到 `config` 包,因此 `cfg.Database.DSN()` 也会识别新驱动:
```go
import (
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func init() {
database.RegisterDialect(database.DialectSpec{
Name: "sqlite",
Aliases: []string{"sqlite3"},
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
DSN: func(c *config.DatabaseConfig) string { return c.Name }, // Name 当作文件路径
})
}
```
诊断接口:`database.RegisteredDialects()` 返回当前已注册的驱动名(含别名),`database.LookupDialect(name)` 用于检查某个驱动是否已就绪。未注册的驱动会回退到 MySQL 以保持向后兼容。
### Repository 泛型 CRUD
```go
@@ -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)``<unixNano>-<8字节crypto/rand hex>.<ext>`),4 处统一改用。
#### 🐛 Fixed — 数据库重试策略对不可恢复错误无效
`InitDB` 对认证失败(`Access denied`)、未知数据库(`Unknown database`)、非法 DSN、未注册驱动等配置类错误也退避重试 5 次,白白延迟 31 秒。新增 `isTransientDBError`,这类错误首次出现即返回;网络类错误仍正常重试。
#### 📦 Dependencies — `go mod tidy` + 安全补丁升级
- `go mod tidy` 补全 postgres 方言传递依赖(`jackc/pgx` 家族、`golang.org/x/sync`),`gorm.io/driver/postgres` 提升为直接依赖
- 安全补丁升级(无 API 变更):`golang.org/x/crypto` v0.49→v0.53、`golang-jwt/jwt/v5` v5.2.1→v5.3.1、`gorilla/websocket` v1.5.1→v1.5.3
- 暂缓升级(留待 v1.0.4 / v1.1):`gin` / `validator` / `gorm` / `aliyun-oss-go-sdk` v2→v3(major 破坏性)等跨版本升级
#### ⚠️ Breaking — 清理 v1.0.2 兼容别名(database 包)
```go
// ❌ 移除
database.InitMySQL(cfg)
database.InitMySQLWithReplicas(cfg, replicas)
(*Manager).InitMySQL / InitMySQLWithReplicas
// ✅ 改用(v1.0.2 起就是正式 API,驱动由 cfg.Database.Driver 决定)
database.InitDB(cfg)
database.InitDBWithReplicas(cfg, replicas)
```
xlgo 仍是早期框架,趁此一次彻底清理 v1.0.2 临时保留的别名,避免长期累积技术债。
#### 🗑️ Removed — 删除死代码 `database.DBResolver`
`database.DBResolver.BeforeQuery` 从未被注册到 GORM callback chain,属于纯死代码。文档曾暗示的"自动读写分离"实际从未生效——读写分离一直依赖业务侧显式调用 `database.UseMaster(ctx)` / `database.UseReplica(ctx)`
需要 callback 级自动路由的用户请直接接入官方 [`gorm.io/plugin/dbresolver`](https://github.com/go-gorm/dbresolver)。
#### 🔄 Changed — 文件重命名 `database/mysql.go → database/manager.go`
文件内容已与 MySQL 解耦(v1.0.2 引入可插拔方言注册表后),继续叫 `mysql.go` 误导。**导入路径无变化**,公开 API 全部保留。
#### ✨ Added — console 包显式 level 控制
`console` 包新增显式级别屏蔽能力:
```go
console.SetLevel(console.LevelWarn) // 只看 Warn / Error
console.SetLevel(console.LevelSilent) // 完全静默
```
**定位明确**console 是**开发期彩色 stdout 工具**(跟 `fmt.Println` 同级),**不写文件、不感知环境**。业务可观测信息请使用 `logger`。框架不会自动根据 `app.env` 切换级别——选择权完全在调用方,避免"dev / prod 行为不一致"的隐式陷阱。
完整对比表见 [GUIDE.md §3.3](./GUIDE.md#33-彩色控制台输出)。
#### 🐛 Fixed — Logger 重复写入修复
修复通用 `Logger``apiCore``dbCore` 都 Tee 进来导致**每条日志写三份**的 bug。
- `Logger`(通用)→ `logs/app.log` + console
- `APILog()``logs/api.log` + console
- `DBLog()``logs/database.log` + console
- 互不串扰,磁盘体积砍掉 2/3
**新增**`logger.Close()` 显式关闭文件句柄,`App.Shutdown` 已自动调用;`Init(nil)` 改为返回 error 而非 panic;生产默认级别从 `Warn` 调整为 `Info`
**升级注意**:日志目录会新增 `logs/app.log` 文件(之前通用日志被串写进了 `api.log`/`database.log`),运维采集脚本如有需要请补上。
#### 🔒 Security — CORS 中间件修复
修复 CORS 中间件多个安全与规范遵守问题:
- **`Access-Control-Allow-Credentials` 永远是 `true`** — 旧实现 `if/else` 两个分支都设了 `"true"`,导致即使配置 `AllowCredentials=false` 也会发送凭证头
- **`*` + `credentials: true` 的规范违规** — 同时发送会被浏览器拒绝;修复后此场景回显具体 Origin
- **缺失 `Vary: Origin`** — 防止 CDN / 网关把 A 用户的 CORS 响应缓存给 B 用户
- 非白名单 Origin 不再被回显,防反射型 CORS 漏洞
**升级影响**:如果你之前依赖"默认允许凭证"的隐式行为,需要在配置里显式启用:
```yaml
cors:
allowed_origins: ["https://your-frontend.example"]
allow_credentials: true
```
#### ⚠️ Breaking — 错误码体系重构
修复 `CodeSuccess``CodeInvalidParams` 撞码的生产级 bug(两者都等于 `1`,导致业务错误响应被前端误判为成功)。
**数值变更**
| 常量 | 旧值 | 新值 |
|---|---|---|
| `response.CodeSuccess` | `1` | **`0`** |
| `response.CodeFail` | `0` | **`1`** |
**移除**
- `response.CodeInvalidParams`(与 `CodeSuccess` 撞码,且参数错误码应由业务自定义)
- `response.ErrInvalidParams`
**迁移指南**
```go
// ❌ 旧代码(编译失败)
response.FailWithError(c, response.ErrInvalidParams)
// ✅ 推荐:业务侧自定义错误码
var ErrInvalidParams = response.NewError(40001, "参数错误")
response.FailWithError(c, ErrInvalidParams)
// ✅ 或直接使用通用失败响应
response.Fail(c, "用户名格式错误")
```
**前端**`if (resp.code === 1)``if (resp.code === 0)`
新增 `_errorCodeUniquenessGuard` 编译期防撞码 map,任何后续 `Code*` 常量重复都会在 `go build` 阶段直接报错。
详细迁移说明见 [CHANGELOG.md](./CHANGELOG.md)。
### v1.0.2 (2026-06-20)
#### 数据库
- **可插拔方言注册表** - 新增 `database.RegisterDialect` / `LookupDialect` / `RegisteredDialects`,应用可一次注册即让 `database.driver: <name>` 生效,DSN 构建器同步登记到 `config` 包;内置 `mysql``postgres`(含 `postgresql``pg` 别名),未注册驱动回退 MySQL
- **多数据库驱动** - `database.driver` 支持 `mysql`(默认)与 `postgres`,新增 `database.InitDB` / `InitDBWithReplicas`v1.0.3 起原 `InitMySQL*` 别名已移除)
- **数据库管理器** - 引入实例化 `database.Manager` 持有主从连接,提供 `Master/Replica/FromContext/HealthCheck` 等方法
- **从库选择策略** - 新增 `ReplicaPicker` 接口与 `RoundRobinPicker` / `RandomPicker` 实现,可通过 `SetReplicaPicker` 自定义
- **私有上下文键** - `db_mode` 字符串键替换为私有 `dbModeContextKey{}` 类型,避免上下文键名冲突
- **DSN 构建器注册表** - `config` 包新增 `RegisterDSNBuilder` / `LookupDSNBuilder` / `RegisteredDrivers``DatabaseConfig.DSN()` 改为查注册表,自定义 `CustomDSN` 优先
#### App 启动流程
- **轻量默认** - `xlgo.New()` 默认不再初始化 MySQL / Redis / Storage,也不注册 `/health``/swagger/*`;通过 Option 显式启用
- **组件 Option** - 新增 `WithLogger / WithMySQL / WithRedis / WithStorage / 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)
File diff suppressed because it is too large Load Diff
+519
View File
@@ -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 1045access denied/ 1049unknown 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` 会带 LIMITCount 是错的。需要 `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` 类型 + 全局便捷 facadeApp 持有自己的 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.3Bug Fix Release12 周)
**修真实 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.4DX & Docs
-#25 模块路径修正
-#28 CLI 多模板
-#29 examples/
-#30 文档结构调整
### v1.1.0HA & 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。
+349 -81
View File
@@ -19,14 +19,46 @@ import (
"github.com/EthanCodeCraft/xlgo-core/wire"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Version 框架版本号。发版时只改这一处,避免版本字面量散落各处。
// CLIxlgo 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
}
return errors.Join(errs...)
}
+243
View File
@@ -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"))
}
}
-36
View File
@@ -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)
-12
View File
@@ -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)
+6 -1
View File
@@ -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)
+3 -1
View File
@@ -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()
+34 -127
View File
@@ -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/Storagexlgo.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: 禁用
+24
View File
@@ -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
}
-12
View File
@@ -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)
+279 -141
View File
@@ -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 ""
+88
View File
@@ -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{
+87 -11
View File
@@ -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 存储 Level0 = 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
// 时间
+124
View File
@@ -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),
)
// 默认 LevelDebugDebug 应输出
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)
}
}
}
+139
View File
@@ -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() },
})
}
+473
View File
@@ -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
}
+216
View File
@@ -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)
}
}
}
-315
View File
@@ -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
}
+13 -3
View File
@@ -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 客户端
+23
View File
@@ -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")
}
}
+31
View File
@@ -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)
}
})
}
}
+14 -8
View File
@@ -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
+30 -20
View File
@@ -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=
-6
View File
@@ -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)
}
+13 -13
View File
@@ -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 IDexpiry 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()
+2 -2
View File
@@ -30,6 +30,6 @@ var Field = struct {
}
},
Error: func(err error) zap.Field {
return zap.Error(err)
},
return zap.Error(err)
},
}
+137 -65
View File
@@ -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 之后调用;测试场景需要清理临时目录时也应调用。
//
// 调用后再次写日志不会 panicfall 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
}
+116 -2
View File
@@ -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 不 panicglobals 仍为 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)
}
}
+135 -88
View File
@@ -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)
+160
View File
@@ -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")
}
}
+30 -17
View File
@@ -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 {
-2
View File
@@ -43,8 +43,6 @@ func Logger() gin.HandlerFunc {
}
// LoggerWithConfig 使用自定义配置的日志中间件
// 评分: ⭐⭐⭐⭐⭐
// 理由: 可配置是否记录请求体/响应体,跳过静态资源路径,慢请求警告
func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
return func(c *gin.Context) {
// 检查是否跳过此路径
+125
View File
@@ -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) {
-6
View File
@@ -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)
-4
View File
@@ -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) {
-2
View File
@@ -6,8 +6,6 @@ import (
)
// RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪
// 评分: ⭐⭐⭐⭐⭐
// 理由: 日志追踪、问题排查必备,简洁高效
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
+72
View File
@@ -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)
}
}
+2 -5
View File
@@ -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
}
+51 -4
View File
@@ -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{
-2
View File
@@ -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)
+11 -11
View File
@@ -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 != "成功" {
+80
View File
@@ -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")
}
}
+52 -9
View File
@@ -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"})
})
}
+45 -4
View File
@@ -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 生成带随机后缀的唯一文件名,避免同一纳秒内并发上传导致文件名冲突。
// 格式: <unixNano>-<8字节随机hex>.<ext>
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)
}
+21
View File
@@ -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",
+34
View File
@@ -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")
}
}
+1 -9
View File
@@ -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)
-24
View File
@@ -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
-22
View File
@@ -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 将换行符替换为 <br> 标签
// 评分: ⭐⭐⭐
// 理由: 特定场景使用(富文本显示),但现代前端框架通常自行处理
func Nl2br(s string, isXhtml bool) string {
var br string
if isXhtml {
-32
View File
@@ -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
-18
View File
@@ -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) {
-8
View File
@@ -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()
}
-8
View File
@@ -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
-20
View File
@@ -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
-24
View File
@@ -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)
}
-8
View File
@@ -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
-18
View File
@@ -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