5 Commits

Author SHA1 Message Date
杭州明婳科技 0c9f256dfc feat(v1.1.0): HA & Manager 化
对应体检报告 #10-#24,一次性完成 v1.1.0 全部 13 项:

- #10 storage/cache/redis/jwt/logger 五组件 Manager 化(XxxManager + DefaultXxx + SetDefaultXxxManager,包级 facade 保留兼容)
- #11 删除 wire 包 + 清理 StartServerWithPort/GracefulShutdown 双轨
- #12 Lifecycle Hooks(OnInit/OnStart/OnReady/OnStop)
- #13 Server 参数配置化(timeout/TLS/unix_socket/response_mode)
- #14 JWTConfig.Expire 改 time.Duration + 删 AppConfig.TokenExpire
- #15 response Mode 开关(ModeBusiness/ModeREST)
- #16 Config.Validate 启动期校验
- #17 livez/readyz 探针路由
- #18 Prometheus metrics 中间件 + /metrics
- #19 请求级 Timeout 中间件
- #21 主库探活 + replica 健康剔除
- #22 App.Go + in-flight goroutine 管理
- #24 RequestID 默认装入 + #23 核对

Breaking:删 wire、删 TokenExpire、JWTConfig.Expire 类型变更、删双轨函数。
详见 CHANGELOG 升级说明。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:41:05 +08:00
杭州明婳科技 cd3d629282 release: v1.0.4 DX & Docs release
v1.0.4 定位为 DX & Docs release,开发体验与文档改进,无破坏性 API 变更。
对应体检报告 #25/#27/#28/#29/#30(代码改动已在 deb3cc7 提交)。

本 release commit:
- app.go const Version bump 1.0.3 → 1.0.4
- CHANGELOG 新增 [1.0.4] 章节(Added/Changed + 升级说明)
- CHANGELOG 底部链接补 [1.0.4],修正 [1.0.2] 指向(无 v1.0.2 tag,改 compare 链接)
- README 更新日志补 v1.0.4 (2026-06-22) 摘要

v1.0.4 内容:
- #28 CLI 多模板(minimal/api/fullstack)
- #29 examples/ 目录(minimal + full 可运行示例)
- #25 模块路径文档改进(保留 -core,明确 module path vs package name)
- #27 Without* Option 定位文档化
- #30 文档结构(规划文档归档 docs/plans/)

build/vet/test 全绿,xlgo version → v1.0.4。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:28:46 +08:00
杭州明婳科技 deb3cc71bf feat(v1.0.4): DX & Docs 改进
按报告 v1.0.4 路线图完成 5 项开发体验与文档改进:

#25 模块路径文档改进:
- 保留 -core 后缀(xlgo 是多产品系列:xlgo-core/xlgo-orm/xlgo-ai)
- README 快速开始补完整 import 示例 + 新增「模块路径与包名」小节
- CLAUDE.md Import Path Note 措辞明确化

#27 Without* Option 定位文档化:
- 调研确认 Without* 有真实用例(测试覆盖 + NewFullStack 后排除单项)
- 不删不标 Deprecated,在 app.go 注释 + README 说明其定位

#28 CLI 多模板:
- xlgo new --template minimal/api/fullstack
- minimal 轻量HTTP无依赖,api 标准业务分层(默认),fullstack 全组件
- 三模板实测生成正确

#29 examples/ 目录:
- examples/minimal(50行可跑,无外部依赖)
- examples/full(mysql+redis+jwt+user CRUD)
- examples/README.md

#30 文档结构:
- Version_*.md + report.md 移到 docs/plans/(v2.0-review.md)
- 新增 docs/README.md 索引
- CHANGELOG/GUIDE 留根目录(按惯例)
- .gitignore 整理

build/vet/test + examples 编译全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:21:59 +08:00
杭州明婳科技 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
84 changed files with 8475 additions and 1519 deletions
+8 -1
View File
@@ -1,2 +1,9 @@
report.md
# Claude Code 协作指引(本地,不入库)
CLAUDE.md
# 临时发版辅助文件
gitHub_release_*.md
# 构建产物
*.exe
bin/
+525
View File
@@ -0,0 +1,525 @@
# 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.1.0] - 2026-06-23
> 本版本定位为 **HA & Manager 化 release**:高可用与生产就绪改进 + 组件 Manager 化。对应体检报告 #10-#24。
> 含少量破坏性变更,升级前请阅读下方「升级说明」。
### Breaking ⚠️
详见下方「升级说明」。
- 删除 `wire` 包及其 `WithWire` Option(其事 App Option 已覆盖)。`WithoutWire` 保留为空 stub 以兼容调用。
- 删除 `AppConfig.TokenExpire` 字段(与 `JWTConfig.Expire` 重复),过期统一由 `jwt.expire` 配置。
- `JWTConfig.Expire` 类型由 `int`(秒)改为 `time.Duration`(如 `"24h"`)。
- 删除 `StartServerWithPort``GracefulShutdown` 双轨函数(与 `App.StartServer`/`App.Shutdown` 重复)。
### Added ✨
#### 组件 Manager 化(#10
storage / cache / redis / jwt / logger 五个组件照 `database.Manager` 模式新增 `XxxManager` + `DefaultXxx` + `SetDefaultXxxManager`,包级 facade 保留兼容存量。支持多实例与测试注入 mock:
```go
// 注入自定义实现 / 多实例
database.SetDefaultRedisManager(myRedisMgr)
cache.SetDefaultCacheManager(mockCacheMgr)
jwtMgr := jwt.NewJWTManagerWithRedis(refreshRedisClient) // 独立黑名单
```
#### Lifecycle Hooks#12
```go
xlgo.New(
xlgo.WithHook(xlgo.Hook{
Name: "register-service",
OnStart: func(a *xlgo.App) error { return registerToDiscovery() },
OnStop: func(a *xlgo.App) error { return deregisterFromDiscovery() },
}),
)
```
各阶段:`OnInit`Init 内组件就绪后)/ `OnStart`(监听前)/ `OnReady`(端口就绪后)/ `OnStop`Shutdown 开头)。
#### App.Go + in-flight goroutine#22
`App.Go(func(ctx context.Context))` 启动受 App 生命周期管理的后台 goroutineShutdown 时 cancel ctx 并 `wg.Wait`(带 `shutdown_timeout` 超时),避免业务异步任务被进程退出强制砍掉。
#### Server 参数配置化(#13
`server` 新增 `read_timeout`/`write_timeout`/`idle_timeout`/`shutdown_timeout`/`max_header_bytes`/`tls`/`unix_socket`/`response_mode`,缺省回退原硬编码值。支持 TLS 与 unix socket 监听。
#### JWTConfig time.Duration#14
`jwt.expire`/`refresh_expire``time.Duration``"24h"`/`"168h"`),新增 `issuer`/`algorithm`HS256/HS384/HS512)。删除冗余的 `AppConfig.TokenExpire`
#### Config Validate#16
`Config.Validate()``Manager.Load` 解析后自动调用,校验端口范围、JWT 密钥长度(≥32 字节)、启用数据库时关键字段、TLS 证书、Duration 非负等。把配置错误从"运行时第一次请求"提前到"进程启动"。
#### response REST 模式(#15
`response.SetMode(ModeBusiness|ModeREST)`,默认 `ModeBusiness`(全 200 + 业务码,兼容存量)。`ModeREST` 下失败响应按错误码映射 HTTP status401/404/429/500...),body 仍带业务码,便于 APM/Prometheus/网关按 status 区分异常。可在 `server.response_mode` 配置。新增 `response.Custom(c, httpStatus, code, msg, data)`
#### livez / readyz#17
```go
xlgo.New(xlgo.WithLivenessRoute(), xlgo.WithReadinessRoute())
// GET /livez 永不依赖外部,始终 200K8s livenessProbe
// GET /readyz 复用 healthChecks,失败 503K8s readinessProbe
```
`/health` 保留兼容。`WithFullStack`/`NewFullStack` 默认启用。
#### Prometheus metrics#18
```go
xlgo.New(xlgo.WithMetricsRoute()) // 默认 /metrics
```
`middleware.Metrics()` 采集 `http_requests_total` / `http_request_duration_seconds` / `http_requests_in_flight`。新增 `prometheus/client_golang` 依赖。
#### 请求级 Timeout 中间件(#19
`middleware.Timeout(d)` 为每个请求的 context 设 deadline,下游 GORM/Redis 走 `c.Request.Context()` 级联取消。可通过 `WithRequestTimeout(d)` 装入全局。
#### 依赖健康自愈(#21
`database.Manager` 后台探活(`App.Go` 启动,每 `health_check_interval` ping 一次):主库连续失败达阈值标记不健康,`/readyz`/`/health` 返回 503;从库失败临时剔除读流量,恢复自动重新纳入。新增 `database.ConnMaxIdleTime`/`health_check_interval`/`health_check_failure_threshold` 配置。
#### RequestID 默认装入(#24
`App.Init` 无条件装入 `middleware.RequestID()`(在 Recovery 之前),让每个响应/panic 日志都带 `request_id`。移除 `gin.Recovery()` 双重保险,统一用 `middleware.Recover()`#23 已带 request_id)。
### 升级说明 🛠️
1. **wire 包删除**:移除 `import "github.com/EthanCodeCraft/xlgo-core/wire"``wire.InitServices()`/`WithWire()` 调用。原由 wire 触发的 `cache.Init()` 现由 `WithRedis` 自动触发。
2. **AppConfig.TokenExpire 删除**:改用 `jwt.expire` 配置 token 过期。grep `token_expire` 清理旧配置。
3. **JWTConfig.Expire 类型变更**YAML 由 `expire: 86400`(秒)改为 `expire: "24h"`Duration 字符串)。代码中 `time.Duration(cfg.JWT.Expire) * time.Second` 改为直接 `cfg.JWT.Expire`
4. **StartServerWithPort / GracefulShutdown 删除**:改用 `App.Run()` / `App.Shutdown()`
5. **JWT 密钥长度**`Config.Validate` 要求启用 JWT 时 secret ≥32 字节,原短密钥会在启动期被拦截,请生成足够长的随机密钥。
6. **配置文件**:建议补 `server.read_timeout` 等字段(缺省自动回退,不强制),`jwt.expire` 必须改为 Duration 字符串。
---
## [1.0.4] - 2026-06-22
> 本版本定位为 **DX & Docs release**:开发体验与文档改进,无破坏性 API 变更。对应体检报告 #25/#27/#28/#29/#30。
### Added ✨
#### CLI 多模板(#28
`xlgo new` 新增 `--template` 参数,支持三种脚手架模板:
```bash
xlgo new myapp --template minimal # 轻量 HTTP,无 MySQL/Redis 依赖
xlgo new myapp --template api # 标准业务 API,含分层目录(默认)
xlgo new myapp --template fullstack # 全组件,NewFullStack 一键启用
```
- `minimal`:仅 logger + health + 示例路由,目录结构最小化,第一次接触 xlgo 从这里开始
- `api`:含 handler/model/repository/service 分层 + MySQL/Redis/JWT 配置(默认模板)
- `fullstack``NewFullStack` 全组件 + Swagger + Storage
#### examples/ 目录(#29
新增两个可运行示例,帮助快速上手:
- `examples/minimal/` — 50 行可跑,不依赖外部服务
- `examples/full/` — MySQL + Redis + JWT + user CRUD 完整示例(登录发 token、认证路由、创建/查询用户)
- `examples/README.md` — 运行说明与接口文档
#### docs/ 文档结构(#30
- 新增 `docs/` 目录,`docs/plans/` 归档历史规划与体检报告
- 新增 `docs/README.md` 文档索引
- `Version_Update_Plan_v1.0.2.md``docs/plans/`
- `Version_v1.0.2_report.md``docs/plans/`
- 早期 `report.md``docs/plans/v2.0-review.md`
- `CHANGELOG.md` / `GUIDE.md` 按惯例保留在仓库根目录
### Changed 🔄
#### 模块路径文档改进(#25
经评估**保留** `xlgo-core` 模块路径——`-core` 后缀反映这是 xlgo 多产品系列(xlgo-core / xlgo-orm / xlgo-ai ...)的核心产品,不去掉。模块路径(`github.com/EthanCodeCraft/xlgo-core`)与包名(`xlgo`)不同是 Go 惯例(cf. `github.com/gin-gonic/gin` → 包名 `gin`)。
改进文档说明,消除新用户 `go mod tidy` 撞墙的困惑:
- README 快速开始新增「模块路径与包名」小节,给出完整 import 示例:
```go
import xlgo "github.com/EthanCodeCraft/xlgo-core"
```
- CLAUDE.md `Import Path Note` 措辞明确化,说明 module path / package name / `-core` 语义
#### Without* Option 定位文档化(#27
经调研 `Without*` 系列 Option 有真实用例(测试覆盖「先开再关」语义 + `NewFullStack` 后排除单项),**不删除、不标 Deprecated**,改为文档化其定位:
- `app.go` `WithoutLogger` 注释说明:`Without*` 主要用于 `NewFullStack` / `RunFullStack` 启用全部组件后排除个别项
- README 快速开始补充用法说明:`xlgo.NewFullStack(xlgo.WithoutSwaggerRoutes())`
### 依赖与构建
- `.gitignore` 整理:忽略 `CLAUDE.md`、构建产物(`*.exe` / `bin/`)、临时发版文件(`gitHub_release_*.md`
### 升级说明
v1.0.4 **无破坏性变更**,从 v1.0.3 升级只需:
```bash
go get github.com/EthanCodeCraft/xlgo-core@v1.0.4
go mod tidy
```
---
## [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.1.0...HEAD
[1.1.0]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.1.0
[1.0.4]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.4
[1.0.3]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.3
[1.0.2]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.0.1...v1.0.3
[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.
+308 -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,43 @@ go run main.go
访问 http://localhost:8080/health 检查服务状态。
> v1.0.2 起,`xlgo.New()` 默认是轻量应用,不会自动初始化 MySQL、Redis、Storage 或 Swagger。需要完整基础设施时请显式使用 `WithMySQL()`、`WithRedis()`、`WithStorage()`、`WithSwaggerRoutes()`,或直接使用 `xlgo.NewFullStack()`。
>
> `Without*` 系列 Option(如 `WithoutSwaggerRoutes`)主要用于 `NewFullStack` / `RunFullStack` 启用全部组件后排除个别项,例如 `xlgo.NewFullStack(xlgo.WithoutSwaggerRoutes())` 全组件但关闭 Swagger。`xlgo.New()` 本身已全关,无需再 `Without`。
### 4. 模块路径与包名
xlgo 的 **模块路径**`github.com/EthanCodeCraft/xlgo-core`**包名** 是 `xlgo`(二者不同是 Go 惯例,如 `github.com/gin-gonic/gin` → 包名 `gin`)。import 时用别名 `xlgo` 即可:
```go
package main
import (
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
)
func main() {
app := xlgo.New(
xlgo.WithConfigPath("./config.yaml"),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
)
// 注册一个示例路由
app.GetRouter().GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Hello xlgo!"})
})
// 启用 JWT 认证示例路由(需配合 WithMySQL/WithRedis 生成 token
_ = middleware.RequireUserTypes("admin")
_ = app.Run()
}
```
> 子包(`config` / `database` / `logger` / `middleware` ...)的包名与目录名一致,无需别名。
---
## 核心功能
@@ -119,13 +160,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 +189,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 +502,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 +644,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 +732,216 @@ 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.1.0 (2026-06-23)
### v2.0.0 (2026-04-30)
> 本版本定位为 **HA & Manager 化 release**:高可用与生产就绪改进 + 组件 Manager 化。含少量破坏性变更,升级前请阅读 [CHANGELOG 升级说明](./CHANGELOG.md#升级说明)。
- **新增工具函数库** - 111 个实用函数(随机数、字符串、时间、转换、文件、URL、验证、加密、HTTP 客户端、UUID)
- **新增彩色控制台输出** - console 包支持 Debug/Info/Success/Warn/Error 五级彩色输出
- **新增压缩解压** - compress 包支持 Gzip/Zip 压缩解压
- **新增键名前缀管理** - cache.K() 自动添加站点前缀,解决多项目共用 Redis 冲突
- **新增分布式锁** - cache.Lock/TryLock/WithLock 完整实现
- **新增计数器** - cache.Incr/Decr/IncrBy 支持
- **新增 RequestID 中间件** - 请求追踪支持
- **新增 Recover 中间件** - Panic 恢复
- **新增类型安全参数获取** - handler.QueryInt/PathInt/FormInt 等
- **新增路由架构** - router 包支持模块化、版本化 API、中间件分组、RESTful CRUD
- **新增 AppConfig** - 站点别名、环境判断
- **CLI 工具重构** - 模块化结构,模板分离
- **单元测试覆盖** - 17 个包有测试(68%覆盖)
- **组件 Manager 化** - storage/cache/redis/jwt/logger 五组件新增 `XxxManager` + `SetDefaultXxxManager`,包级 facade 保留兼容,支持多实例与测试注入
- **Lifecycle Hooks** - `WithHook(Hook{OnInit/OnStart/OnReady/OnStop})` 生命周期钩子
- **App.Go** - 受管理的后台 goroutineShutdown 时 cancel + 等待退出
- **Server 配置化** - `server` 新增 timeout/TLS/unix_socket/response_mode 等字段
- **JWTConfig time.Duration** - `jwt.expire``"24h"`,新增 issuer/algorithm
- **Config Validate** - 启动期校验配置(端口/密钥/必填字段),错误前置
- **response REST 模式** - `SetMode(ModeREST)` 按错误码映射 HTTP status
- **livez/readyz** - K8s 友好的存活性/就绪性探针
- **Prometheus metrics** - `/metrics` + `middleware.Metrics()` 采集请求指标
- **请求级 Timeout** - `middleware.Timeout(d)` 级联取消下游
- **依赖健康自愈** - 主库探活 + replica 健康剔除,`/readyz` 联动 503
- **RequestID 默认装入** - 每个响应/panic 日志都带 request_id
### v1.1.0 (2026-04-29)
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.1.0`(⚠️ 含破坏性变更,见升级说明)
- 新增配置热更新支持
- 新增数据库读写分离
- 新增 CSRF 防护中间件
- 新增 SSE 流式响应支持
- 新增 WebSocket 支持
- 新增定时任务调度器
- 新增 CLI 脚手架工具
- 新增测试工具包
- 新增统一错误码体系
- 新增密码加密工具
- 新增请求验证器(支持自定义错误消息
- 实现 OSS 存储上传
- 优化缓存 DeleteByPattern 使用 SCAN
- 修复限流器 goroutine 泄漏问题
### v1.0.4 (2026-06-22)
> 本版本定位为 **DX & Docs release**:开发体验与文档改进,无破坏性 API 变更。
- **CLI 多模板** - `xlgo new --template minimal/api/fullstack`,支持轻量 HTTP / 标准业务 / 全组件三种脚手架
- **examples/ 目录** - 新增 `examples/minimal`(无依赖可跑)与 `examples/full`mysql+redis+jwt+user CRUD)可运行示例
- **模块路径文档** - 明确说明 module path `xlgo-core` 与 package name `xlgo` 的关系,补完整 import 示例(保留 `-core`xlgo 多产品系列)
- **Without* 定位** - 文档化 `Without*` Option 主要用于 `NewFullStack` 后排除单项
- **文档结构** - 规划文档归档到 `docs/plans/`,新增 `docs/README.md` 索引
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.0.4`(无破坏性变更
### v1.0.3 (2026-06-22)
> 本版本定位为 **bug fix release**:收口 v1.0.2 引入的破坏性清理,并修复 4 个轻量 bug + 依赖复查。完整说明见 [CHANGELOG.md#unreleased](./CHANGELOG.md#unreleased)。
#### 🐛 Fixed — JWT JTI 生成忽略 `rand.Read` 错误
`generateJTI()` 丢弃 `crypto/rand.Read` 的 error,失败时会基于全零字节生成 JTI,导致所有 token 的 JTI 相同、黑名单机制失效。改为 `(string, error)` 并在 `GenerateToken` / `GenerateTokenWithCustomExpiry` 传播错误。
#### 🐛 Fixed — `QueryBuilder.Page` 统计行数被残留 Limit 截断
`Page()` 复制查询做 Count 时未清除残留 `Limit`/`Offset`,调用方先 `.Limit(n)``.Page(...)` 会让 Count 被包成子查询,返回 `total` 被截断为 ≤ n。countDB 改为 `.Limit(-1).Offset(-1)` 清除残留条件。
#### 🐛 Fixed — OSS / 本地存储文件名冲突
4 处上传路径仅用 `time.Now().UnixNano()` 命名,同纳秒并发上传会撞名覆盖。新增 `uniqueFilename(now, ext)``<unixNano>-<8字节crypto/rand hex>.<ext>`),4 处统一改用。
#### 🐛 Fixed — 数据库重试策略对不可恢复错误无效
`InitDB` 对认证失败(`Access denied`)、未知数据库(`Unknown database`)、非法 DSN、未注册驱动等配置类错误也退避重试 5 次,白白延迟 31 秒。新增 `isTransientDBError`,这类错误首次出现即返回;网络类错误仍正常重试。
#### 📦 Dependencies — `go mod tidy` + 安全补丁升级
- `go mod tidy` 补全 postgres 方言传递依赖(`jackc/pgx` 家族、`golang.org/x/sync`),`gorm.io/driver/postgres` 提升为直接依赖
- 安全补丁升级(无 API 变更):`golang.org/x/crypto` v0.49→v0.53、`golang-jwt/jwt/v5` v5.2.1→v5.3.1、`gorilla/websocket` v1.5.1→v1.5.3
- 暂缓升级(留待 v1.0.4 / v1.1):`gin` / `validator` / `gorm` / `aliyun-oss-go-sdk` v2→v3(major 破坏性)等跨版本升级
#### ⚠️ Breaking — 清理 v1.0.2 兼容别名(database 包)
```go
// ❌ 移除
database.InitMySQL(cfg)
database.InitMySQLWithReplicas(cfg, replicas)
(*Manager).InitMySQL / InitMySQLWithReplicas
// ✅ 改用(v1.0.2 起就是正式 API,驱动由 cfg.Database.Driver 决定)
database.InitDB(cfg)
database.InitDBWithReplicas(cfg, replicas)
```
xlgo 仍是早期框架,趁此一次彻底清理 v1.0.2 临时保留的别名,避免长期累积技术债。
#### 🗑️ Removed — 删除死代码 `database.DBResolver`
`database.DBResolver.BeforeQuery` 从未被注册到 GORM callback chain,属于纯死代码。文档曾暗示的"自动读写分离"实际从未生效——读写分离一直依赖业务侧显式调用 `database.UseMaster(ctx)` / `database.UseReplica(ctx)`
需要 callback 级自动路由的用户请直接接入官方 [`gorm.io/plugin/dbresolver`](https://github.com/go-gorm/dbresolver)。
#### 🔄 Changed — 文件重命名 `database/mysql.go → database/manager.go`
文件内容已与 MySQL 解耦(v1.0.2 引入可插拔方言注册表后),继续叫 `mysql.go` 误导。**导入路径无变化**,公开 API 全部保留。
#### ✨ Added — console 包显式 level 控制
`console` 包新增显式级别屏蔽能力:
```go
console.SetLevel(console.LevelWarn) // 只看 Warn / Error
console.SetLevel(console.LevelSilent) // 完全静默
```
**定位明确**console 是**开发期彩色 stdout 工具**(跟 `fmt.Println` 同级),**不写文件、不感知环境**。业务可观测信息请使用 `logger`。框架不会自动根据 `app.env` 切换级别——选择权完全在调用方,避免"dev / prod 行为不一致"的隐式陷阱。
完整对比表见 [GUIDE.md §3.3](./GUIDE.md#33-彩色控制台输出)。
#### 🐛 Fixed — Logger 重复写入修复
修复通用 `Logger``apiCore``dbCore` 都 Tee 进来导致**每条日志写三份**的 bug。
- `Logger`(通用)→ `logs/app.log` + console
- `APILog()``logs/api.log` + console
- `DBLog()``logs/database.log` + console
- 互不串扰,磁盘体积砍掉 2/3
**新增**`logger.Close()` 显式关闭文件句柄,`App.Shutdown` 已自动调用;`Init(nil)` 改为返回 error 而非 panic;生产默认级别从 `Warn` 调整为 `Info`
**升级注意**:日志目录会新增 `logs/app.log` 文件(之前通用日志被串写进了 `api.log`/`database.log`),运维采集脚本如有需要请补上。
#### 🔒 Security — CORS 中间件修复
修复 CORS 中间件多个安全与规范遵守问题:
- **`Access-Control-Allow-Credentials` 永远是 `true`** — 旧实现 `if/else` 两个分支都设了 `"true"`,导致即使配置 `AllowCredentials=false` 也会发送凭证头
- **`*` + `credentials: true` 的规范违规** — 同时发送会被浏览器拒绝;修复后此场景回显具体 Origin
- **缺失 `Vary: Origin`** — 防止 CDN / 网关把 A 用户的 CORS 响应缓存给 B 用户
- 非白名单 Origin 不再被回显,防反射型 CORS 漏洞
**升级影响**:如果你之前依赖"默认允许凭证"的隐式行为,需要在配置里显式启用:
```yaml
cors:
allowed_origins: ["https://your-frontend.example"]
allow_credentials: true
```
#### ⚠️ Breaking — 错误码体系重构
修复 `CodeSuccess``CodeInvalidParams` 撞码的生产级 bug(两者都等于 `1`,导致业务错误响应被前端误判为成功)。
**数值变更**
| 常量 | 旧值 | 新值 |
|---|---|---|
| `response.CodeSuccess` | `1` | **`0`** |
| `response.CodeFail` | `0` | **`1`** |
**移除**
- `response.CodeInvalidParams`(与 `CodeSuccess` 撞码,且参数错误码应由业务自定义)
- `response.ErrInvalidParams`
**迁移指南**
```go
// ❌ 旧代码(编译失败)
response.FailWithError(c, response.ErrInvalidParams)
// ✅ 推荐:业务侧自定义错误码
var ErrInvalidParams = response.NewError(40001, "参数错误")
response.FailWithError(c, ErrInvalidParams)
// ✅ 或直接使用通用失败响应
response.Fail(c, "用户名格式错误")
```
**前端**`if (resp.code === 1)``if (resp.code === 0)`
新增 `_errorCodeUniquenessGuard` 编译期防撞码 map,任何后续 `Code*` 常量重复都会在 `go build` 阶段直接报错。
详细迁移说明见 [CHANGELOG.md](./CHANGELOG.md)。
### v1.0.2 (2026-06-20)
#### 数据库
- **可插拔方言注册表** - 新增 `database.RegisterDialect` / `LookupDialect` / `RegisteredDialects`,应用可一次注册即让 `database.driver: <name>` 生效,DSN 构建器同步登记到 `config` 包;内置 `mysql``postgres`(含 `postgresql``pg` 别名),未注册驱动回退 MySQL
- **多数据库驱动** - `database.driver` 支持 `mysql`(默认)与 `postgres`,新增 `database.InitDB` / `InitDBWithReplicas`v1.0.3 起原 `InitMySQL*` 别名已移除)
- **数据库管理器** - 引入实例化 `database.Manager` 持有主从连接,提供 `Master/Replica/FromContext/HealthCheck` 等方法
- **从库选择策略** - 新增 `ReplicaPicker` 接口与 `RoundRobinPicker` / `RandomPicker` 实现,可通过 `SetReplicaPicker` 自定义
- **私有上下文键** - `db_mode` 字符串键替换为私有 `dbModeContextKey{}` 类型,避免上下文键名冲突
- **DSN 构建器注册表** - `config` 包新增 `RegisterDSNBuilder` / `LookupDSNBuilder` / `RegisteredDrivers``DatabaseConfig.DSN()` 改为查注册表,自定义 `CustomDSN` 优先
#### App 启动流程
- **轻量默认** - `xlgo.New()` 默认不再初始化 MySQL / Redis / Storage,也不注册 `/health``/swagger/*`;通过 Option 显式启用
- **组件 Option** - 新增 `WithLogger / WithMySQL / WithRedis / WithStorage / 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)
+558 -129
View File
@@ -7,26 +7,88 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/EthanCodeCraft/xlgo-core/cache"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/logger"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/EthanCodeCraft/xlgo-core/storage"
"github.com/EthanCodeCraft/xlgo-core/wire"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Version 框架版本号。发版时只改这一处,避免版本字面量散落各处。
// CLIxlgo version)、脚手架生成的 go.mod 等均引用此常量。
const Version = "1.1.0"
// HealthCheckFunc 健康检查函数
type HealthCheckFunc func(context.Context) error
// Migrator 数据库迁移函数
type Migrator func(*gorm.DB) error
// Hook 生命周期钩子。各回调在 App 生命周期的对应阶段被调用:
// - OnInit: Init() 内组件初始化完成后、路由注册前
// - OnStart: StartServer() 监听端口前
// - OnReady: 端口就绪后(已开始接受连接)
// - OnStop: Shutdown() 开头,关 HTTP 之前
//
// OnInit/OnStart/OnStop 返回 error 会中断流程并向上返回。
type Hook struct {
Name string
OnInit func(*App) error
OnStart func(*App) error
OnReady func(*App)
OnStop func(*App) 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
enableHealth bool
enableSwagger bool
enableAutoMigrate bool
enableLiveness bool
enableReadiness bool
enableMetrics bool
metricsPath string
staticRoutes []staticRoute
migrators []Migrator
healthChecks []router.HealthCheck
hooks []Hook
initialized bool
// 请求级超时(#19),<=0 表示不启用
requestTimeout time.Duration
// in-flight goroutine 管理(#22
rootCtx context.Context // 根 ctxApp.Go 启动的 goroutine 共享
cancel context.CancelFunc // Shutdown 时 cancel,通知后台任务退出
wg sync.WaitGroup // 跟踪 App.Go 启动的 goroutine
}
// Option 应用选项
@@ -35,10 +97,190 @@ 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 }
}
// 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
}
}
// WithLivenessRoute 启用存活性探针路由 GET /livez(#17)。
// 永不依赖外部,始终 200,供 K8s livenessProbe。
func WithLivenessRoute() Option {
return func(a *App) { a.enableLiveness = true }
}
// WithReadinessRoute 启用就绪性探针路由 GET /readyz#17)。
// 复用 healthChecks 检查依赖,失败返回 503,供 K8s readinessProbe。
func WithReadinessRoute() Option {
return func(a *App) { a.enableReadiness = true }
}
// WithMetricsRoute 启用 Prometheus 指标端点与采集中间件(#18)。
// path 默认 /metrics,传入可自定义。
func WithMetricsRoute(path ...string) Option {
return func(a *App) {
a.enableMetrics = true
if len(path) > 0 && path[0] != "" {
a.metricsPath = path[0]
}
}
}
// WithoutLogger 关闭日志。
//
// Without* 系列的定位:xlgo.New() 默认是轻量应用(组件全关),故 Without*
// 主要用于 NewFullStack / RunFullStack 启用全部组件后,排除个别不需要的项。
// 例如:xlgo.NewFullStack(xlgo.WithoutSwaggerRoutes()) 全组件但关 Swagger。
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 已移除(wire 包在 v1.1.0 删除)。保留空函数仅为编译兼容,
// 调用无副作用。后续版本将删除。
func WithoutWire() Option {
return func(a *App) {}
}
// 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
}
}
}
// WithHook 注册生命周期钩子(#12)。可多次调用注册多个,按注册顺序触发。
// 详见 Hook 类型注释。
func WithHook(h Hook) Option {
return func(a *App) {
a.hooks = append(a.hooks, h)
}
}
// WithRequestTimeout 设置请求级超时(#19)。下游 GORM/Redis 走
// c.Request.Context() 即可级联取消。d <= 0 不启用。
func WithRequestTimeout(d time.Duration) Option {
return func(a *App) { a.requestTimeout = d }
}
// 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,11 +306,31 @@ 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.enableHealth = true
a.enableSwagger = true
a.enableAutoMigrate = true
// 生产就绪路由(#17/#18
a.enableLiveness = true
a.enableReadiness = true
a.enableMetrics = true
a.staticRoutes = append(a.staticRoutes, staticRoute{relativePath: "/public", root: "./public"})
}
}
// New 创建新应用
func New(opts ...Option) *App {
app := &App{}
app.router = gin.New()
app.registry = router.NewRegistry(app.router)
// rootCtx 生命周期与 App 一致,不依赖 Init,使 App.Go 在 Init 前也可用(#22
app.rootCtx, app.cancel = context.WithCancel(context.Background())
for _, opt := range opts {
opt(app)
@@ -77,132 +339,343 @@ 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)
}
// 设置基础中间件
a.router.Use(gin.Recovery())
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(ctx context.Context) error {
// 优先读探活缓存标记(#21),避免每次探针都同步 ping
if !database.IsDBHealthy() {
return errors.New("mysql master unhealthy (probe)")
}
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.enableRedis {
// Redis 就绪后初始化缓存(cache 依赖 Redis 客户端)
cache.Init()
}
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)
}
}
}
// 全局中间件链:RequestID 必须最先装入,保证后续 Recovery/日志/响应都能拿到 request_id#24
a.router.Use(middleware.RequestID())
a.router.Use(middleware.Recover())
// 请求级超时(#19),配置后装入,下游走 c.Request.Context() 级联取消
if a.requestTimeout > 0 {
a.router.Use(middleware.Timeout(a.requestTimeout))
}
// 静态文件服务
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...)
}
if a.enableLiveness {
router.RegisterLivenessRoute(a.router)
}
if a.enableReadiness {
router.RegisterReadinessRoute(a.router, a.healthChecks...)
}
if a.enableMetrics {
if a.metricsPath != "" {
router.RegisterMetricsRoute(a.router, a.metricsPath)
} else {
router.RegisterMetricsRoute(a.router)
}
}
// 应用用户注册的路由
a.registry.Apply()
// 启动服务器(优雅关闭
// 响应模式:默认 business(全 200 + 业务码),可配置 rest(按错误码映射 HTTP status)(#15
if mode := strings.TrimSpace(strings.ToLower(a.config.Server.ResponseMode)); mode != "" {
switch mode {
case "rest":
response.SetMode(response.ModeREST)
case "business":
response.SetMode(response.ModeBusiness)
}
}
// in-flight goroutine 根 ctx 在 New() 时已初始化(#22
// 启动主库/从库探活后台循环(#21),ctx 在 Shutdown 时取消
if a.enableMySQL {
a.Go(database.StartDBProbing)
}
a.initialized = true
// OnInit hooks:组件初始化完成后触发(#12)
for _, h := range a.hooks {
if h.OnInit != nil {
if err := h.OnInit(a); err != nil {
return fmt.Errorf("OnInit hook %q 失败: %w", h.Name, err)
}
}
}
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 {
port := a.config.Server.Port
if a.config == nil {
return config.ErrConfigNotLoaded
}
srvCfg := a.config.Server
// 创建 HTTP 服务器
a.server = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: a.router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
Handler: a.router,
ReadTimeout: srvCfg.EffectiveReadTimeout(),
WriteTimeout: srvCfg.EffectiveWriteTimeout(),
IdleTimeout: srvCfg.EffectiveIdleTimeout(),
MaxHeaderBytes: srvCfg.EffectiveMaxHeaderBytes(),
}
// 启动服务器(非阻塞)
go func() {
logger.Infof("服务器启动,监听端口 %d", port)
if err := a.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Fatalf("服务器启动失败: %v", err)
useUnix := strings.TrimSpace(srvCfg.UnixSocket) != ""
if useUnix {
a.server.Addr = srvCfg.UnixSocket
} else {
a.server.Addr = fmt.Sprintf(":%d", srvCfg.Port)
}
// OnStart hooks:监听端口前
for _, h := range a.hooks {
if h.OnStart != nil {
if err := h.OnStart(a); err != nil {
return fmt.Errorf("OnStart hook %q 失败: %w", h.Name, err)
}
}
}
serverErr := make(chan error, 1)
go func() {
if useUnix {
logger.Infof("服务器启动,监听 unix socket %s", srvCfg.UnixSocket)
} else {
logger.Infof("服务器启动,监听端口 %d", srvCfg.Port)
}
var err error
if srvCfg.TLS.Enabled {
err = a.server.ListenAndServeTLS(srvCfg.TLS.CertFile, srvCfg.TLS.KeyFile)
} else {
err = a.server.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
return
}
serverErr <- nil
}()
// 等待中断信号
// OnReady hooks:端口就绪后
for _, h := range a.hooks {
if h.OnReady != nil {
h.OnReady(a)
}
}
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)
shutdownTimeout := 30 * time.Second
if a.config != nil {
shutdownTimeout = a.config.Server.EffectiveShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
// 1. 停止 HTTP 服务器
var errs []error
// OnStop hooks:关 HTTP 之前触发(#12
for _, h := range a.hooks {
if h.OnStop != nil {
if err := h.OnStop(a); err != nil {
errs = append(errs, fmt.Errorf("OnStop hook %q 失败: %w", h.Name, err))
}
}
}
// 取消根 ctx,通知 App.Go 启动的后台 goroutine 退出(#22
if a.cancel != nil {
a.cancel()
}
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. 停止限流器
// 等待业务 in-flight goroutine 退出(#22),受 shutdownTimeout 约束
waitDone := make(chan struct{})
go func() {
a.wg.Wait()
close(waitDone)
}()
select {
case <-waitDone:
case <-ctx.Done():
logger.Warnf("等待后台 goroutine 退出超时")
}
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...)
}
// Go 启动一个受 App 生命周期管理的后台 goroutine(#22)。
// fn 收到的 ctx 在 Shutdown 时被 cancelfn 应在 ctx.Done() 时及时退出。
// Shutdown 会等待所有 App.Go 启动的 goroutine 退出(带 ShutdownTimeout 超时)。
func (a *App) Go(fn func(ctx context.Context)) {
if fn == nil {
return
}
ctx := a.rootCtx
if ctx == nil {
ctx = context.Background()
}
a.wg.Add(1)
go func() {
defer a.wg.Done()
fn(ctx)
}()
}
// GetRegistry 获取路由注册中心(用于动态注册)
@@ -219,47 +692,3 @@ func (a *App) GetRouter() *gin.Engine {
func (a *App) GetServer() *http.Server {
return a.server
}
// StartServerWithPort 使用指定端口启动服务器(简化版本)
// 注意: 此函数会阻塞,需要自行处理信号
func StartServerWithPort(r *gin.Engine, port int) error {
server := &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
}
logger.Infof("服务器启动,监听端口 %d", port)
return server.ListenAndServe()
}
// 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)
<-quit
logger.Info("收到关闭信号,开始优雅关闭...")
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// 关闭 HTTP 服务器
if err := server.Shutdown(ctx); err != nil {
logger.Warnf("服务器关闭超时: %v", err)
server.Close()
}
// 执行清理函数
for _, cleanup := range cleanupFuncs {
if cleanup != nil {
cleanup()
}
}
logger.Info("应用已优雅关闭")
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package xlgo_test
import (
"context"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
)
func TestAppGoWaitsOnShutdown(t *testing.T) {
app := xlgo.New()
started := make(chan struct{})
exited := make(chan struct{})
app.Go(func(ctx context.Context) {
close(started)
<-ctx.Done()
// 模拟收尾
time.Sleep(10 * time.Millisecond)
close(exited)
})
<-started
// Shutdown 应 cancel ctx 并等待 goroutine 退出
done := make(chan error, 1)
go func() { done <- app.Shutdown() }()
select {
case <-exited:
// goroutine 在 cancel 后退出,符合预期
case <-time.After(2 * time.Second):
t.Fatal("App.Go goroutine did not exit after Shutdown cancel")
}
select {
case err := <-done:
if err != nil {
t.Errorf("Shutdown returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Shutdown did not return")
}
}
+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"))
}
}
+49 -8
View File
@@ -3,6 +3,7 @@ package cache
import (
"context"
"encoding/json"
"sync"
"time"
"github.com/EthanCodeCraft/xlgo-core/logger"
@@ -35,7 +36,7 @@ type redisCache struct {
// NewRedisCache 创建 Redis 缓存实例
func NewRedisCache() CacheService {
return &redisCache{
client: database.RedisClient,
client: database.GetRedis(),
}
}
@@ -146,18 +147,58 @@ func (c *redisCache) Exists(ctx context.Context, key string) bool {
return c.client.Exists(ctx, key).Val() > 0
}
// 全局缓存实例
var globalCache CacheService
// CacheManager 缓存管理器(#10)。照 database.Manager 模式:
// 实例化 + DefaultCache 全局默认 + 包级 facade 代理,支持测试注入 mock 实现。
type CacheManager struct {
mu sync.Mutex
svc CacheService
}
// DefaultCache 默认缓存管理器,包级 facade 代理到它。
var DefaultCache = NewCacheManager()
// NewCacheManager 创建缓存管理器实例。
func NewCacheManager() *CacheManager { return &CacheManager{} }
// SetDefaultCacheManager 提升指定 CacheManager 为全局默认。
func SetDefaultCacheManager(m *CacheManager) {
if m != nil {
DefaultCache = m
}
}
// Init 初始化缓存服务(基于 DefaultRedis 的客户端)。
func (m *CacheManager) Init() {
m.mu.Lock()
defer m.mu.Unlock()
m.svc = NewRedisCache()
}
// Set 设置缓存服务实现(用于注入 mock 或自定义实现)。
func (m *CacheManager) Set(svc CacheService) {
m.mu.Lock()
defer m.mu.Unlock()
m.svc = svc
}
// Get 返回缓存服务(未初始化时延迟初始化)。
func (m *CacheManager) Get() CacheService {
m.mu.Lock()
defer m.mu.Unlock()
if m.svc == nil {
m.svc = NewRedisCache()
}
return m.svc
}
// --- 包级 facade(代理到 DefaultCache,兼容存量) ---
// Init 初始化全局缓存实例
func Init() {
globalCache = NewRedisCache()
DefaultCache.Init()
}
// GetCache 获取全局缓存实例
func GetCache() CacheService {
if globalCache == nil {
Init()
}
return globalCache
return DefaultCache.Get()
}
-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)
+66 -24
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) {
@@ -17,17 +19,46 @@ func createProject(name string) {
return
}
// 创建目录结构
dirs := []string{
name,
name + "/config",
name + "/handler",
name + "/model",
name + "/repository",
name + "/service",
name + "/middleware",
name + "/public",
name + "/logs",
// 解析 --template 与 --module 参数(默认 template=api
tmplName := "api"
module := name
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--template", "-t":
if i+1 < len(args) {
tmplName = args[i+1]
i++
}
case "--module", "-m":
if i+1 < len(args) {
module = args[i+1]
i++
}
}
}
// 校验模板名
switch tmplName {
case "minimal", "api", "fullstack":
// ok
default:
fmt.Printf("未知模板: %s(可选: minimal / api / fullstack\n", tmplName)
return
}
// minimal 模板目录结构最小化;api/fullstack 含完整分层目录
var dirs []string
dirs = append(dirs, name, name+"/public", name+"/logs")
if tmplName != "minimal" {
dirs = append(dirs,
name+"/config",
name+"/handler",
name+"/model",
name+"/repository",
name+"/service",
name+"/middleware",
)
}
for _, dir := range dirs {
@@ -37,12 +68,6 @@ func createProject(name string) {
}
}
// 获取模块路径
module := name
if len(os.Args) > 3 && os.Args[3] == "--module" && len(os.Args) > 4 {
module = os.Args[4]
}
caser := cases.Title(language.English)
data := TemplateData{
Package: caser.String(name),
@@ -52,14 +77,28 @@ func createProject(name string) {
Year: time.Now().Year(),
}
// 按模板选择 main.go 与 config.yaml
var mainTmpl, configTmpl string
switch tmplName {
case "minimal":
mainTmpl, configTmpl = templates.MainMinimal, templates.ConfigMinimal
case "fullstack":
mainTmpl, configTmpl = templates.MainFull, templates.ConfigFull
default: // api
mainTmpl, configTmpl = templates.Main, templates.Config
}
// 创建文件
files := map[string]string{
name + "/main.go": templates.Main,
name + "/config.yaml": templates.Config,
name + "/go.mod": fmt.Sprintf(templates.GoMod, module),
name + "/Makefile": templates.Makefile,
name + "/.gitignore": templates.Gitignore,
name + "/handler/home.go": templates.Handler,
name + "/main.go": mainTmpl,
name + "/config.yaml": configTmpl,
name + "/go.mod": fmt.Sprintf(templates.GoMod, module, xlgo.Version),
name + "/Makefile": templates.Makefile,
name + "/.gitignore": templates.Gitignore,
}
// api/fullstack 模板带示例 handler
if tmplName != "minimal" {
files[name+"/handler/home.go"] = templates.Handler
}
for path, content := range files {
@@ -82,7 +121,7 @@ func createProject(name string) {
}
}
fmt.Printf("✓ 项目 %s 创建成功\n", name)
fmt.Printf("✓ 项目 %s 创建成功(模板: %s\n", name, tmplName)
fmt.Println("\n下一步:")
fmt.Printf(" cd %s\n", name)
fmt.Println(" go mod tidy")
@@ -125,6 +164,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 +182,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 +218,7 @@ func createService(name, nameTitle string) {
nameTitle, nameTitle,
nameTitle, nameTitle,
)
content = replaceModuleImports(content)
writeFile(path, content)
fmt.Printf("✓ 创建服务: %s\n", path)
+13 -4
View File
@@ -3,21 +3,30 @@ package main
import (
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
)
func printUsage() {
fmt.Println(`xlgo - Go Web 框架脚手架工具
用法:
xlgo new <项目名> 创建新项目
xlgo make handler <名称> 创建处理器
xlgo new <项目名> [--template <模板>] [--module <模块路径>] 创建新项目
xlgo make handler <名称> 创建处理器
xlgo make repository <名称> 创建仓库
xlgo make model <名称> 创建模型
xlgo make service <名称> 创建服务
xlgo version 显示版本号
xlgo version 显示版本号
模板 (xlgo new --template <名称>):
minimal 轻量 HTTP 服务,不依赖 MySQL/Redis(默认入门)
api 标准业务 API,含 MySQL/Redis/JWT 与 handler/model/repository/service 分层(默认)
fullstack 全组件,一键启用 MySQL/Redis/Storage/Swagger/AutoMigrate
示例:
xlgo new myapp
xlgo new myapp --template minimal
xlgo new myapp --template fullstack --module github.com/me/myapp
xlgo make handler user
xlgo make repository user`)
}
@@ -47,7 +56,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()
+219 -132
View File
@@ -2,8 +2,12 @@ package main
// Templates 存放所有代码生成模板
var templates = struct {
Main string
Config string
Main string // api 模板:标准业务 APImysql+redis+jwt+分层)
MainMinimal string // minimal 模板:轻量 HTTP,无外部依赖
MainFull string // fullstack 模板:全组件
Config string // api 模板配置
ConfigMinimal string
ConfigFull string
GoMod string
Makefile string
Gitignore string
@@ -18,26 +22,15 @@ var templates = struct {
Main: `package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"{{.Module}}/config"
"{{.Module}}/database"
"{{.Module}}/handler"
"{{.Module}}/logger"
"{{.Module}}/middleware"
"{{.Module}}/storage"
"{{.Module}}/validation"
)
var configPath string
@@ -49,104 +42,28 @@ func init() {
func main() {
flag.Parse()
// 加载配置
cfg, err := config.Load(configPath)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
app := xlgo.New(
xlgo.WithConfigPath(configPath),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
// 如需 Swagger 文档:xlgo.WithSwaggerRoutes() 或 xlgo.WithDefaultRoutes()
// 如需 MySQL/Redis/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}}!"})
})
}
`,
@@ -158,13 +75,188 @@ func setupRoutes(r *gin.Engine) {
env: "dev" # dev/test/prod
debug: true
base_url: "http://localhost:8080"
token_expire: 86400 # Token过期时间(秒)
server:
port: 8080
mode: development # development 或 production
read_timeout: 15s # 读超时
write_timeout: 30s # 写超时
idle_timeout: 60s # 空闲超时
shutdown_timeout: 30s # 优雅关闭超时
response_mode: business # business(默认,全200+业务码) 或 rest(按错误码映射HTTP status)
database:
driver: mysql # mysql(默认)或 postgres
host: localhost
port: 3306
user: root
password: your_password
name: {{.NameLower}}
max_idle_conns: 10
max_open_conns: 100
# dsn: "自定义连接字符串,设置后优先于上面的字段"
redis:
host: localhost
port: 6379
password: ""
db: 0
jwt:
secret: your_jwt_secret_key_change_me_to_32_bytes_at_least
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
refresh_expire: "168h" # 刷新 token 过期时间
issuer: xlgo
algorithm: HS256 # HS256(默认)/HS384/HS512
storage:
driver: local
local:
path: ./public
base_url: http://localhost:8080/public
log:
dir: ./logs
max_size: 100
max_backups: 30
max_age: 30
compress: true
`,
// MainMinimal minimal 模板:轻量 HTTP 服务,不依赖 MySQL/Redis/Storage
MainMinimal: `package main
import (
"flag"
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
)
var configPath string
func init() {
flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径")
}
func main() {
flag.Parse()
app := xlgo.New(
xlgo.WithConfigPath(configPath),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
)
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
}
func registerRoutes(r *gin.RouterGroup) {
api := r.Group("/api/v1")
api.GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Hello {{.Name}}!"})
})
}
`,
// ConfigMinimal minimal 模板配置:仅 app + server + log,无数据库/Redis
ConfigMinimal: `app:
name: "{{.Name}}"
site_name: "{{.NameLower}}"
version: "1.0.0"
env: "dev"
debug: true
base_url: "http://localhost:8080"
server:
port: 8080
mode: development
log:
dir: ./logs
max_size: 100
max_backups: 30
max_age: 30
compress: true
`,
// MainFull fullstack 模板:全组件(FullStack),含 Swagger + Storage
MainFull: `package main
import (
"flag"
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
)
var configPath string
func init() {
flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径")
}
func main() {
flag.Parse()
// NewFullStack 一键启用全部组件:Logger/MySQL/Redis/Storage/Wire/Health/Swagger/AutoMigrate
// 如需排除个别组件,追加对应 Without* Option,例如 xlgo.WithoutSwaggerRoutes()
app := xlgo.NewFullStack(
xlgo.WithConfigPath(configPath),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
// xlgo.WithModels(&User{}, &Order{}), // 注册模型以启用自动迁移
)
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
}
func registerRoutes(r *gin.RouterGroup) {
api := r.Group("/api/v1")
api.GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Welcome to {{.Name}} (fullstack)!"})
})
}
`,
// ConfigFull fullstack 模板配置:全组件配置
ConfigFull: `app:
name: "{{.Name}}"
site_name: "{{.NameLower}}"
version: "1.0.0"
env: "dev"
debug: true
base_url: "http://localhost:8080"
server:
port: 8080
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business
database:
driver: mysql # mysql(默认)或 postgres
host: localhost
port: 3306
user: root
@@ -180,8 +272,11 @@ redis:
db: 0
jwt:
secret: your_jwt_secret_key_here
expire: 86400
secret: your_jwt_secret_key_change_me_to_32_bytes_at_least
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
refresh_expire: "168h" # 刷新 token 过期时间
issuer: xlgo
algorithm: HS256 # HS256(默认)/HS384/HS512
storage:
driver: local
@@ -198,24 +293,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 +366,8 @@ config.local.yaml
Handler: `package handler
import (
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"{{.Module}}/response"
)
// HealthCheck 健康检查
@@ -305,8 +389,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 +472,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 +503,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)
+381 -160
View File
@@ -4,8 +4,10 @@ import (
"fmt"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
)
@@ -29,26 +31,21 @@ type Config struct {
}
// AppConfig 应用配置
// 评分: ⭐⭐⭐⭐⭐
// 理由: 多站点共用 Redis/日志时,通过 SiteName 区分来源
// 使用场景:
// - 缓存键名前缀: cache:{site_name}:user:1
// - 日志标识: [site_a] 2024-01-01 10:00:00 ...
// - 站点追踪: Request-ID 带站点标识
// - 分布式锁: lock:{site_name}:order:123
type AppConfig struct {
Name string `mapstructure:"name"` // 应用名称,如 "用户管理系统"
SiteName string `mapstructure:"site_name"` // 站点别名,如 "site_a"、"user_api"
Version string `mapstructure:"version"` // 应用版本
Env string `mapstructure:"env"` // 运行环境: dev/test/prod
Debug bool `mapstructure:"debug"` // 是否开启调试模式
BaseURL string `mapstructure:"base_url"` // 应用基础URL
TokenExpire int `mapstructure:"token_expire"` // Token过期时间(秒)
Name string `mapstructure:"name"` // 应用名称,如 "用户管理系统"
SiteName string `mapstructure:"site_name"` // 站点别名,如 "site_a"、"user_api"
Version string `mapstructure:"version"` // 应用版本
Env string `mapstructure:"env"` // 运行环境: dev/test/prod
Debug bool `mapstructure:"debug"` // 是否开启调试模式
BaseURL string `mapstructure:"base_url"` // 应用基础URL
}
// GetSiteName 获取站点别名,如果未设置则返回空字符串
// 评分: ⭐⭐⭐⭐⭐
// 理由: 安全获取,避免空指针
func (c *AppConfig) GetSiteName() string {
if c == nil {
return ""
@@ -57,8 +54,6 @@ func (c *AppConfig) GetSiteName() string {
}
// GetCachePrefix 获取缓存键名前缀
// 评分: ⭐⭐⭐⭐⭐
// 理由: 统一的缓存前缀生成方法
func (c *AppConfig) GetCachePrefix() string {
return c.GetSiteName()
}
@@ -87,29 +82,188 @@ func (c *AppConfig) IsProd() bool {
return c.Env == "prod" || c.Env == "production"
}
// TLSConfig HTTPS/TLS 配置
type TLSConfig struct {
Enabled bool `mapstructure:"enabled"`
CertFile string `mapstructure:"cert_file"`
KeyFile string `mapstructure:"key_file"`
}
// ServerConfig 服务配置
type ServerConfig struct {
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // development 或 production
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // development 或 production
ReadTimeout time.Duration `mapstructure:"read_timeout"` // 读超时,如 "15s"
WriteTimeout time.Duration `mapstructure:"write_timeout"` // 写超时,如 "30s"
IdleTimeout time.Duration `mapstructure:"idle_timeout"` // 空闲超时,如 "60s"
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` // 优雅关闭超时,如 "30s"
MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 最大请求头字节数
TLS TLSConfig `mapstructure:"tls"`
UnixSocket string `mapstructure:"unix_socket"` // 非空时优先于 Port,监听 unix socket
ResponseMode string `mapstructure:"response_mode"` // business(默认) 或 rest,见 response.SetMode
}
// 默认值常量(ServerConfig 字段为零值时回退使用)
const (
defaultReadTimeout = 15 * time.Second
defaultWriteTimeout = 30 * time.Second
defaultIdleTimeout = 60 * time.Second
defaultShutdownTimeout = 30 * time.Second
defaultMaxHeaderBytes = 1 << 20 // 1MB
)
// EffectiveReadTimeout 返回生效的读超时(零值回退默认)
func (c ServerConfig) EffectiveReadTimeout() time.Duration {
if c.ReadTimeout > 0 {
return c.ReadTimeout
}
return defaultReadTimeout
}
// EffectiveWriteTimeout 返回生效的写超时(零值回退默认)
func (c ServerConfig) EffectiveWriteTimeout() time.Duration {
if c.WriteTimeout > 0 {
return c.WriteTimeout
}
return defaultWriteTimeout
}
// EffectiveIdleTimeout 返回生效的空闲超时(零值回退默认)
func (c ServerConfig) EffectiveIdleTimeout() time.Duration {
if c.IdleTimeout > 0 {
return c.IdleTimeout
}
return defaultIdleTimeout
}
// EffectiveShutdownTimeout 返回生效的关闭超时(零值回退默认)
func (c ServerConfig) EffectiveShutdownTimeout() time.Duration {
if c.ShutdownTimeout > 0 {
return c.ShutdownTimeout
}
return defaultShutdownTimeout
}
// EffectiveMaxHeaderBytes 返回生效的最大请求头字节数(零值回退默认)
func (c ServerConfig) EffectiveMaxHeaderBytes() int {
if c.MaxHeaderBytes > 0 {
return c.MaxHeaderBytes
}
return defaultMaxHeaderBytes
}
// 数据库驱动常量
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
}
}
}
// 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 {
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"`
// 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"`
// ConnMaxIdleTime 连接最大空闲时间,如 "5m"(#21)。0 表示用驱动默认
ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"`
// HealthCheckInterval 主库探活间隔,如 "30s"#21)。0 表示用默认 30s
HealthCheckInterval time.Duration `mapstructure:"health_check_interval"`
// HealthCheckFailureThreshold 连续探活失败多少次标记不健康(#21)。0 表示用默认 3
HealthCheckFailureThreshold int `mapstructure:"health_check_failure_threshold"`
}
// DSN 返回 MySQL 连接字符串
// 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"`
@@ -125,8 +279,11 @@ func (c *RedisConfig) Addr() string {
// JWTConfig JWT 配置
type JWTConfig struct {
Secret string `mapstructure:"secret"`
Expire int `mapstructure:"expire"` // 过期时间(秒
Secret string `mapstructure:"secret"`
Expire time.Duration `mapstructure:"expire"` // 过期时间,如 "24h"time.Duration
RefreshExpire time.Duration `mapstructure:"refresh_expire"` // 刷新 token 过期时间,如 "168h"
Issuer string `mapstructure:"issuer"` // 签发者
Algorithm string `mapstructure:"algorithm"` // 签名算法:HS256(默认)/RS256
}
// SMSConfig 短信配置
@@ -229,104 +386,113 @@ 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
)
// Load 加载配置文件(使用 sync.Once 确保只加载一次)
func Load(configPath string) (*Config, error) {
configOnce.Do(func() {
v := viper.New()
v.SetConfigFile(configPath)
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
loadErr = fmt.Errorf("读取配置文件失败: %w", err)
return
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
loadErr = fmt.Errorf("解析配置文件失败: %w", err)
return
}
configMutex.Lock()
globalConfig = &cfg
viperInstance = v
configMutex.Unlock()
})
if loadErr != nil {
return nil, loadErr
}
return Get(), nil
// Manager 配置管理器
type Manager struct {
mu sync.RWMutex
path string
v *viper.Viper
cfg *Config
callbacks []func(*Config)
}
// LoadWithWatch 加载配置文件并启用热更新
func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) {
cfg, err := Load(configPath)
if err != nil {
var defaultManager = NewManager("")
// NewManager 创建配置管理器
func NewManager(configPath string) *Manager {
return &Manager{path: configPath}
}
func newViper(configPath string) *viper.Viper {
v := viper.New()
v.SetConfigFile(configPath)
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
return v
}
// unmarshalConfig 将 viper 解析到 Config,启用 string→time.Duration decode hook
// 使 ServerConfig/JWTConfig 的 Duration 字段可写 "24h"/"15s" 等字符串。
func unmarshalConfig(v *viper.Viper, cfg *Config) error {
return v.Unmarshal(cfg, viper.DecodeHook(mapstructure.StringToTimeDurationHookFunc()))
}
// Load 加载配置文件
func (m *Manager) Load() (*Config, error) {
if m == nil || m.path == "" {
return nil, ErrConfigNotLoaded
}
v := newViper(m.path)
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("读取配置文件失败: %w", err)
}
var cfg Config
if err := unmarshalConfig(v, &cfg); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, err
}
// 注册回调
if onChange != nil {
RegisterCallback(onChange)
}
m.mu.Lock()
m.v = v
m.cfg = &cfg
m.mu.Unlock()
// 启动文件监听
if err := StartWatcher(); err != nil {
return &cfg, nil
}
// LoadWithWatch 加载配置文件并启用热更新
func (m *Manager) LoadWithWatch(onChange func(*Config)) (*Config, error) {
cfg, err := m.Load()
if err != nil {
return nil, err
}
if onChange != nil {
m.RegisterCallback(onChange)
}
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 {
if err := unmarshalConfig(v, &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 +503,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
}
@@ -384,19 +556,15 @@ func Reload() error {
}
var newCfg Config
if err := v.Unmarshal(&newCfg); err != nil {
if err := unmarshalConfig(v, &newCfg); err != nil {
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 +573,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 +701,6 @@ func (c *Config) GetAppName() string {
}
// GetSiteName 获取站点别名
// 评分: ⭐⭐⭐⭐⭐
// 理由: 全局统一获取站点别名,用于缓存前缀、日志标识等
func (c *Config) GetSiteName() string {
if c == nil {
return ""
+90 -2
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",
@@ -130,8 +162,8 @@ redis:
db: 0
jwt:
secret: "test-secret"
expire: 3600
secret: "test-secret-12345678901234567890123456789012"
expire: "1h"
`
tmpFile, err := setupTempFile("test_config.yaml", content)
if err != nil {
@@ -216,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{
+69
View File
@@ -0,0 +1,69 @@
package config
import (
"fmt"
"strings"
"time"
)
// Validate 校验配置完整性与取值合法性(#16)。
// 在 Manager.Load 解析后自动调用,把"运行时第一次请求才暴露"的配置错误
// 提前到进程启动期。返回的 error 描述具体字段,便于定位。
func (c *Config) Validate() error {
if c == nil {
return fmt.Errorf("配置为空")
}
var problems []string
// Server
if c.Server.Port < 0 || c.Server.Port > 65535 {
problems = append(problems, fmt.Sprintf("server.port 超出范围(0-65535): %d", c.Server.Port))
}
if c.Server.TLS.Enabled {
if strings.TrimSpace(c.Server.TLS.CertFile) == "" || strings.TrimSpace(c.Server.TLS.KeyFile) == "" {
problems = append(problems, "server.tls 启用后必须同时配置 cert_file 与 key_file")
}
}
if !validDuration(c.Server.ReadTimeout) || !validDuration(c.Server.WriteTimeout) ||
!validDuration(c.Server.IdleTimeout) || !validDuration(c.Server.ShutdownTimeout) {
problems = append(problems, "server 的 timeout 配置不能为负值")
}
// JWT:仅当配置了 secret 时校验(未启用 jwt 的项目可留空)
if c.JWT.Secret != "" {
if len(c.JWT.Secret) < 32 {
problems = append(problems, fmt.Sprintf("jwt.secret 长度不足 32 字节(当前 %d),HMAC 密钥过短不安全", len(c.JWT.Secret)))
}
if c.JWT.Expire < 0 || c.JWT.RefreshExpire < 0 {
problems = append(problems, "jwt.expire / jwt.refresh_expire 不能为负值")
}
}
// Database:仅当配置了 driver 时校验(未启用 mysql 的项目可留空)
if strings.TrimSpace(c.Database.Driver) != "" {
if c.Database.Host == "" {
problems = append(problems, "database.host 启用数据库后必填")
}
if c.Database.Name == "" {
problems = append(problems, "database.name 启用数据库后必填")
}
if c.Database.Port <= 0 || c.Database.Port > 65535 {
problems = append(problems, fmt.Sprintf("database.port 超出范围(1-65535): %d", c.Database.Port))
}
}
// Redis:仅当配置了 host 时校验
if strings.TrimSpace(c.Redis.Host) != "" {
if c.Redis.Port <= 0 || c.Redis.Port > 65535 {
problems = append(problems, fmt.Sprintf("redis.port 超出范围(1-65535): %d", c.Redis.Port))
}
}
if len(problems) > 0 {
return fmt.Errorf("配置校验失败: %s", strings.Join(problems, "; "))
}
return nil
}
// validDuration 校验 Duration 非负(0 表示未配置/用默认,合法)。
func validDuration(d time.Duration) bool { return d >= 0 }
+63
View File
@@ -0,0 +1,63 @@
package config_test
import (
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/config"
)
func validBase() *config.Config {
return &config.Config{
Server: config.ServerConfig{Port: 8080},
}
}
func TestValidateOK(t *testing.T) {
if err := validBase().Validate(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateServerPort(t *testing.T) {
c := validBase()
c.Server.Port = 99999
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "server.port") {
t.Fatalf("expected server.port error, got %v", err)
}
}
func TestValidateJWTSecretTooShort(t *testing.T) {
c := validBase()
c.JWT.Secret = "short"
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "jwt.secret") {
t.Fatalf("expected jwt.secret error, got %v", err)
}
}
func TestValidateJWTSecretAbsentSkipped(t *testing.T) {
c := validBase()
// secret 为空时不校验(未启用 jwt)
if err := c.Validate(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateDatabaseMissingHost(t *testing.T) {
c := validBase()
c.Database.Driver = "mysql"
c.Database.Port = 3306
c.Database.Name = "db"
// Host 留空
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "database.host") {
t.Fatalf("expected database.host error, got %v", err)
}
}
func TestValidateTLSMissingCert(t *testing.T) {
c := validBase()
c.Server.TLS.Enabled = true
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "cert_file") {
t.Fatalf("expected tls cert_file error, got %v", err)
}
}
+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() },
})
}
+620
View File
@@ -0,0 +1,620 @@
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
// #21 健康自愈
healthy atomic.Bool // 主库是否健康
replicaHealthy []atomic.Bool // 每个从库的健康标记,索引与 replicas 对齐
probeFailures int // 主库连续探活失败次数
probeMu sync.Mutex // 保护 probeFailures
replicaHealthSet bool // replicaHealthy 是否已按 replicas 长度初始化
}
// NewManager 创建数据库管理器
func NewManager(cfg *config.Config) *Manager {
return &Manager{cfg: cfg, picker: &RandomPicker{}}
}
// 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 按策略选择一个从库;无从库时返回主库。
// #21:启用探活后,自动过滤不健康的从库;全不健康时回退到全部从库(仍可服务)。
func (m *Manager) Replica() *gorm.DB {
if len(m.replicas) == 0 {
return m.master
}
m.mu.Lock()
defer m.mu.Unlock()
pool := m.replicas
// 启用探活且至少有一个健康标记时,仅从健康从库中选取
if m.replicaHealthSet {
var healthy []*gorm.DB
for i, r := range m.replicas {
if i < len(m.replicaHealthy) && m.replicaHealthy[i].Load() {
healthy = append(healthy, r)
}
}
if len(healthy) > 0 {
pool = healthy
}
// healthy 为空时回退到全部 replicas,避免读流量完全中断
}
if m.picker != nil {
if db := m.picker.Pick(pool); db != nil {
return db
}
}
return pool[0]
}
// IsHealthy 返回主库当前健康状态(#21)。供 readiness/health 探针联动。
func (m *Manager) IsHealthy() bool {
return m.healthy.Load()
}
// initReplicaHealth 按 replicas 数量初始化健康标记(全部为健康)。
func (m *Manager) initReplicaHealth() {
m.mu.Lock()
defer m.mu.Unlock()
if m.replicaHealthSet {
return
}
m.replicaHealthy = make([]atomic.Bool, len(m.replicas))
for i := range m.replicaHealthy {
m.replicaHealthy[i].Store(true)
}
m.replicaHealthSet = true
}
// StartProbing 启动主库与从库的健康探活后台循环(#21)。
// 阻塞调用方,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
// 周期 ping 主库,连续失败达阈值后标记不健康(IsHealthy=false);
// 同时 ping 各从库,失败则从读流量剔除,恢复后自动重新纳入。
func (m *Manager) StartProbing(ctx context.Context) {
m.initReplicaHealth()
interval := 30 * time.Second
if m.cfg != nil && m.cfg.Database.HealthCheckInterval > 0 {
interval = m.cfg.Database.HealthCheckInterval
}
threshold := 3
if m.cfg != nil && m.cfg.Database.HealthCheckFailureThreshold > 0 {
threshold = m.cfg.Database.HealthCheckFailureThreshold
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.probeOnce(ctx, threshold)
}
}
}
// probeOnce 执行一轮主库+从库探活并更新健康标记。
func (m *Manager) probeOnce(ctx context.Context, threshold int) {
// 主库
if err := m.HealthCheck(ctx); err != nil {
m.probeMu.Lock()
m.probeFailures++
if m.probeFailures >= threshold {
if m.healthy.Load() {
logger.Warnf("数据库主库连续探活失败 %d 次,标记为不健康: %v", m.probeFailures, err)
}
m.healthy.Store(false)
}
m.probeMu.Unlock()
} else {
m.probeMu.Lock()
if m.probeFailures >= threshold && !m.healthy.Load() {
logger.Info("数据库主库探活恢复,重新标记为健康")
}
m.probeFailures = 0
m.probeMu.Unlock()
m.healthy.Store(true)
}
// 从库
m.mu.Lock()
replicas := make([]*gorm.DB, len(m.replicas))
copy(replicas, m.replicas)
healthSet := m.replicaHealthSet
m.mu.Unlock()
if !healthSet {
return
}
for i, r := range replicas {
if r == nil {
continue
}
sqlDB, err := r.DB()
if err != nil {
if i < len(m.replicaHealthy) {
m.replicaHealthy[i].Store(false)
}
continue
}
if err := sqlDB.PingContext(ctx); err != nil {
if i < len(m.replicaHealthy) && m.replicaHealthy[i].Load() {
logger.Warnf("数据库从库 #%d 探活失败,暂时剔除读流量: %v", i, err)
}
if i < len(m.replicaHealthy) {
m.replicaHealthy[i].Store(false)
}
} else {
if i < len(m.replicaHealthy) {
m.replicaHealthy[i].Store(true)
}
}
}
}
// 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 cfg.Database.ConnMaxIdleTime > 0 {
sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime)
}
m.healthy.Store(true) // 主库连通即标记健康(#21
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
}
// IsDBHealthy 返回主库探活健康状态(#21)。
// 与 HealthCheck()(实时 ping)不同,这是后台探活维护的缓存标记,
// 供 readiness 探针快速判断是否接流量,避免每次探针都同步 ping。
func IsDBHealthy() bool {
return DefaultManager.IsHealthy()
}
// StartDBProbing 启动主库/从库探活后台循环(#21)。
// 阻塞,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
func StartDBProbing(ctx context.Context) {
DefaultManager.StartProbing(ctx)
}
+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
}
+83 -14
View File
@@ -3,6 +3,7 @@ package database
import (
"context"
"fmt"
"sync"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
@@ -11,38 +12,106 @@ import (
"go.uber.org/zap"
)
var (
// RedisClient 全局 Redis 客户端
RedisClient *redis.Client
)
// RedisClient 全局 Redis 客户端(兼容 facade,由 RedisManager.Init 同步维护)。
// 保留供存量代码直接访问;新代码建议用 GetRedis() 或持有 *RedisManager 实例。
var RedisClient *redis.Client
// InitRedis 初始化 Redis 连接
func InitRedis(cfg *config.Config) error {
RedisClient = redis.NewClient(&redis.Options{
// RedisManager Redis 连接管理器(#10)。照 database.Manager 模式:
// 实例化 + DefaultRedis 全局默认 + 包级 facade 代理,支持多实例与测试注入。
type RedisManager struct {
mu sync.Mutex
cfg *config.Config
client *redis.Client
}
// DefaultRedis 默认 Redis 管理器,包级 facade 代理到它。
var DefaultRedis = NewRedisManager()
// NewRedisManager 创建 Redis 管理器实例。
func NewRedisManager() *RedisManager { return &RedisManager{} }
// SetDefaultRedisManager 提升指定 RedisManager 为全局默认,后续包级 facade 走它。
// 用于多实例场景或测试注入 mock。
func SetDefaultRedisManager(m *RedisManager) {
if m != nil {
DefaultRedis = m
}
}
// Init 初始化 Redis 连接并 ping 验证。
func (m *RedisManager) Init(cfg *config.Config) error {
m.mu.Lock()
defer m.mu.Unlock()
client := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr(),
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
})
// 测试连接
ctx := context.Background()
if err := RedisClient.Ping(ctx).Err(); err != nil {
if err := client.Ping(ctx).Err(); err != nil {
client.Close()
return fmt.Errorf("Redis 连接失败: %w", err)
}
m.cfg = cfg
m.client = client
RedisClient = client // 同步兼容 facade
logger.Info("Redis 连接成功", zap.String("addr", cfg.Redis.Addr()))
return nil
}
// Close 关闭 Redis 连接。
func (m *RedisManager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.client == nil {
return nil
}
err := m.client.Close()
m.client = nil
RedisClient = nil
return err
}
// Client 返回当前 Redis 客户端(未初始化返回 nil)。
func (m *RedisManager) Client() *redis.Client {
m.mu.Lock()
defer m.mu.Unlock()
return m.client
}
// HealthCheck Redis 健康检查。
func (m *RedisManager) HealthCheck(ctx context.Context) error {
m.mu.Lock()
client := m.client
m.mu.Unlock()
if client == nil {
return fmt.Errorf("Redis 未初始化")
}
return client.Ping(ctx).Err()
}
// --- 包级 facade(代理到 DefaultRedis,兼容存量) ---
// InitRedis 初始化 Redis 连接
func InitRedis(cfg *config.Config) error {
return DefaultRedis.Init(cfg)
}
// CloseRedis 关闭 Redis 连接
func CloseRedis() error {
if RedisClient != nil {
return RedisClient.Close()
}
return nil
return DefaultRedis.Close()
}
// HealthCheckRedis Redis 健康检查
func HealthCheckRedis(ctx context.Context) error {
return DefaultRedis.HealthCheck(ctx)
}
// GetRedis 获取 Redis 客户端
func GetRedis() *redis.Client {
return RedisClient
return DefaultRedis.Client()
}
+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)
}
})
}
}
+21
View File
@@ -0,0 +1,21 @@
# xlgo 文档
| 文档 | 位置 | 说明 |
|---|---|---|
| README.md | 仓库根 | 快速开始、功能概览、更新日志 |
| GUIDE.md | 仓库根 | 完整使用指南 |
| CHANGELOG.md | 仓库根 | 变更日志(遵循 Keep a Changelog |
| CLAUDE.md | 仓库根 | Claude Code 协作指引 |
| docs/plans/ | 本目录 | 历史版本规划与体检报告归档 |
## docs/plans/ 归档文档
这些是各版本开发时的规划/评审文档,**反映当时状态**,代码可能已演进,阅读时请对照 CHANGELOG 确认现状。
| 文件 | 说明 |
|---|---|
| Version_Update_Plan_v1.0.2.md | v1.0.2 更新计划(已完成) |
| Version_v1.0.2_report.md | v1.0.2 体检报告,含 #1-#30 改进建议与版本路线图 |
| v2.0-review.md | 更早期的 v2.0 评估报告 |
> v1.0.2 体检报告中的 #1-#30 改进项,完成情况见根目录 [CHANGELOG.md](../CHANGELOG.md) 各版本章节。
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
# xlgo v1.1.0 实施计划 — HA & Manager 化
> 本文件为 v1.1.0 版本的逐项实施计划,对应体检报告(`docs/plans/Version_v1.0.2_report.md`)第三~四章 #10-#24 架构/HA 改进。
> 范围:13 项(#20 RedisRateLimiter、#23 Recover 带 request_id 已实现,本次核对配合)。
## Context
v1.0.3bug fix)与 v1.0.4DX & Docs)已发布推送,GitHub Release 已创建,工作区干净。v1.0.2 体检报告规划的 v1.1.0 阶段共 13 项架构/HA 改进,目标是把 xlgo 从"一半组件可注入、一半是单例"的撕裂状态,贯彻到"通用 / 高可用 / 易上手"。
本次一次性推完 13 项,发 v1.1.0。已确认的边界决策:
- **版本策略**v1.1.0 接受少量 breaking#11 删 wire 包、#14`AppConfig.TokenExpire`、删 `StartServerWithPort`/`GracefulShutdown``JWTConfig.Expire` 类型变更),在 CHANGELOG「升级说明」章节列出;其余(#15 response)用全局开关默认兼容存量。
- **#10 Manager 化**storage/cache/redis/jwt/logger **5 个全做**(含 logger),照 `database.Manager` + 包级 facade 蓝本。
- **#15 response**:全局 `SetMode(ModeBusiness|ModeREST)`,默认 `ModeBusiness`(现状全 200 + 业务码)兼容;`ModeREST` 下 401/404/500 返回对应 HTTP statusbody 仍带业务码。可在 `ServerConfig.ResponseMode` 配置。
- **#11 wire**:删掉整个 `wire` 包(其事 App Option 已覆盖)。
- **#18**:接受 `prometheus/client_golang` 新依赖。
- **#12**:只提供 `WithHook(Hook{...})` 机制,不引入 etcd 依赖、不提供内置示例。
## 实现方案(按依赖顺序分 6 组)
### 组 1:配置层(其余项的基础)
**#13 Server 参数配置化** — `config/config.go:84-88`
`ServerConfig` 增字段:`ReadTimeout/WriteTimeout/IdleTimeout/ShutdownTimeout/MaxHeaderBytes``TLS{Enabled,CertFile,KeyFile}``UnixSocket string``ResponseMode string``app.go:408-414``http.Server` 改为读这些字段(缺省回退到当前硬编码值)。TLS 开启时用 `ListenAndServeTLS``UnixSocket` 非空时优先 unix socket。Shutdown 超时读 `ShutdownTimeout`
**#14 JWTConfig.Expire time.Duration + 删 TokenExpire** — `config/config.go:44, 209-213`
`JWTConfig.Expire``time.Duration`mapstructure + viper string 解析 `"24h"`),新增 `RefreshExpire time.Duration``Issuer``Algorithm`。**删 `AppConfig.TokenExpire`**breaking)。`jwt/jwt.go` 内过期取值改读 `JWTConfig.Expire`。Duration 解析依赖 mapstructure decode hook。
**#16 Config Validate** — 新增 `config/config.go` `(*Config).Validate() error`
校验:`Server.Port` 范围、`JWT.Secret` 非空且 ≥32 字符(启用 jwt 时)、启用 mysql 时关键字段、Duration 非负等。`config.Manager.Load``config/manager.go:340`)解析后自动调用,错误包裹返回,把"运行时第一次请求才暴露"提前到启动。
**#15 response Mode 开关** — `response/response.go`
新增 `type Mode int``ModeBusiness/ModeREST`、包级 `currentMode` + `SetMode(m)``Mode()``Fail/Unauthorized/NotFound/ServerError/RateLimit/FailWithCode` 内:`ModeREST` 时按错误码/错误类型映射 HTTP status`ErrUnauthorized→401``ErrNotFound→404``ErrServer→500``ErrRateLimit→429`、参数类→400),`c.JSON(status, Response{...})``ModeBusiness` 维持 200。`App.Init` 末尾按 `ServerConfig.ResponseMode``response.SetMode`。新增 `response.Custom(c, httpStatus, code, data)` 显式 API。
### 组 2:组件 Manager 化(#105 组件,照 database.Manager 蓝本)
蓝本参考 `database/manager.go:180-192``DefaultManager` 包级实例 + `SetDefaultManager` + 包级 facade 代理 + 实例方法)。每个组件统一模式:
- 新增 `type Manager struct{ ...; mu sync.Mutex }` + `var DefaultXxx = &Manager{}`
- `SetDefaultManager(m)` 提升用户实例到全局
- 包级 `Init/Get/操作` 函数代理到 `DefaultXxx`**保留,兼容存量**
- `App` 持有各 Manager 实例(`App` 加字段),`WithXxx` 时初始化 App 自己的实例并 `SetDefaultXxx`
顺序(按依赖):**redis → cache → jwt → storage → logger**
1. **redis**`database/redis.go`:新增 `type RedisManager struct{ cfg; client *redis.Client; mu }` + `DefaultRedis``InitRedis/CloseRedis/GetRedis/HealthCheckRedis` 代理。下游 5 处直接读 `database.RedisClient``jwt/jwt.go``middleware/ratelimit.go``cache/cache.go``cache/lock.go` 20+ 处、`app.go`)全部改为 `database.GetRedis()``cache/lock.go` 改为接受 `*redis.Client` 参数或内部 `GetRedis()`
2. **cache**`cache/cache.go``type CacheManager struct{ client *redis.Client; svc CacheService; mu }` + `DefaultCache``redisCache.client` 从硬编码 `database.RedisClient` 改为构造时注入。`Init/GetCache` 代理。`cache/lock.go` 的分布式锁函数改走 `DefaultCache` 或显式传 client。
3. **jwt**`jwt/jwt.go``type Manager struct{ blacklist *TokenBlacklist; cfg *config.JWTConfig; mu }` + `DefaultJWT``TokenBlacklist.Add/IsBlacklisted` 内部 `database.RedisClient``database.GetRedis()``GenerateToken/ParseToken/InvalidateToken/RefreshToken/...` 代理到 `DefaultJWT`
4. **storage**`storage/storage.go``type Manager struct{ cfg; current Storage; mu }` + `DefaultStorage``Init/GetStorage/SetStorage/Upload/...` 代理到 `DefaultStorage.current`。最干净,无外部下游。
5. **logger**`logger/logger.go``type Manager struct{ cfg; logger/apiLog/dbLog *zap.Logger; fileWriters; mu }` + `DefaultLogger`。包级 `Init/Sync/Close/Info/.../APILog/DBLog` 代理。**特殊**logger 是 `App.Init` 最先初始化的组件,`DefaultLogger` 初始化前包级函数须安全降级到 Nop(现状 `Close()` 已重置为 Nop,沿用)。下游 8 包的 `logger.Info(...)` 调用点无需改(仍走包级 facade)。
`App` struct`app.go:41-62`)加字段:`redisMgr *database.RedisManager``cacheMgr *cache.CacheManager``jwtMgr *jwt.Manager``storageMgr *storage.Manager``loggerMgr *logger.Manager`
### 组 3App 生命周期
**#12 Lifecycle Hooks** — `app.go`
新增 `type Hook struct{ Name string; OnInit func(*App) error; OnStart func(*App) error; OnReady func(*App); OnStop func(*App) error }` + `WithHook(Hook) Option``App``hooks []Hook``Init()` 内组件初始化完成后按序调 `OnInit``StartServer` 监听前调 `OnStart`,端口就绪后调 `OnReady``Shutdown` 开头调 `OnStop`。各 hook 错误中断流程并返回。
**#22 App.Go + in-flight goroutine** — `app.go`
`App``wg sync.WaitGroup` + `ctx context.Context`(根 ctx+ `cancel`。新增 `App.Go(fn func(ctx context.Context))``wg.Add(1); go func(){ defer wg.Done(); fn(ctx) }()``Shutdown``OnStop` 后、关 HTTP 前调 `cancel()``wg.Wait()`(带 `ShutdownTimeout` 超时)。
**#11 删 wire 包** — 删 `wire/wire.go`(整包)。清理 `app.go``WithWire/WithoutWire/enableWire``Init()``wire.InitServices()` 调用。`cache.Init()` 原由 wire 触发,改由 `WithRedis`/`WithCache` 触发(或 `App.Init` 显式调)。
**清理双轨**`app.go:494-537`:删 `StartServerWithPort``GracefulShutdown`(与 `App.StartServer`/`App.Shutdown` 重复)。breaking,升级说明列出。
### 组 4:中间件与路由
**#24 RequestID 默认装入** — `app.go:~348`
`App.Init` 中间件链改为无条件 `a.router.Use(middleware.RequestID())`(在 Recovery 之前),让每个响应/panic 日志都带 request_id。核对 #23`middleware/recover.go:20``GetRequestID(c)`,配合 #24 后 panic 日志 request_id 非空。移除 `gin.Recovery()` 双重保险(保留 `middleware.Recover()` 一个即可)。
**#19 请求级 Timeout 中间件** — 新增 `middleware/timeout.go`
`func Timeout(d time.Duration) gin.HandlerFunc``ctx, cancel := context.WithTimeout(c.Request.Context(), d); defer cancel(); c.Request = c.Request.WithContext(ctx); c.Next()`。可通过 `WithRequestTimeout(d)` Option 装入全局,下游 GORM/Redis 走 `c.Request.Context()` 级联取消。
**#18 Prometheus metrics** — 新依赖 `prometheus/client_golang`
新增 `middleware/metrics.go``middleware.Metrics()` 记录 HTTP latency / status code / in-flighthistogram + counter + gauge)。新增 `router/metrics.go``RegisterMetricsRoute(r, path...)` 默认 `/metrics``promhttp.Handler()`。新增 `WithMetricsRoute(path...)` Option。`App.Init` 装入 `Metrics()` 中间件 + 路由。
**#17 livez/readyz** — `router/router.go`
新增 `RegisterLivenessRoute(r)``GET /livez` 永不依赖外部(进程存活即 200)。新增 `RegisterReadinessRoute(r, checks...)``GET /readyz` 复用 `HealthCheck`,失败 503。新增 `WithLivenessRoute()`/`WithReadinessRoute()` Option。`/health` 保留兼容。`livez` 不查依赖、`readyz` 查依赖,对 K8s probe 友好。
### 组 5:依赖健康自愈(#21)
**#21 主库探活 + replica 健康剔除** — `database/manager.go`
- `database.Manager``healthy bool` + `consecutiveFailures int` + `healthMu``Pool.SetConnMaxIdleTime` 配置化(`DatabaseConfig``ConnMaxIdleTime`)。
- 探活:`App.Init` 末尾用 `App.Go` 起后台 goroutine,每 30s `HealthCheck(ctx)`,连续 N 次失败标记 `healthy=false``readyz`/`/health` 读此标记返回 503。
- `ReplicaPicker` 接口加健康度:replica ping 失败剔除轮询,恢复后重新纳入。`RoundRobinPicker`/`RandomPicker` 实现 health-aware 选取。
### 组 6:收尾与发版
- `app.go:27` `const Version = "1.1.0"`
- `CHANGELOG.md``[1.1.0]` 章节(Added/Changed/Fixed + **升级说明** breaking 列表)。README 更新日志 + 底部链接。
- `examples/`full/minimal)同步:full 例子的配置文件加 `server.read_timeout` 等新字段、`jwt.expire: 24h`,验证 5 组件 Manager 化后仍可跑。
- 测试:`go test ./...` 全绿;为 Manager 化、Validate、response Mode、livez/readyz、metrics、timeout、App.Go 补单测。
- `go mod tidy`
## 关键文件清单
| 文件 | 涉及 issue |
|---|---|
| `app.go` | #10 App 字段、#12 hooks、#13 server、#19/#18/#17/#24 路由中间件、#21 探活、#22 App.Go、#11 删 wire、删双轨、Version |
| `config/config.go` | #13 ServerConfig、#14 JWTConfig+删TokenExpire、#16 Validate |
| `config/manager.go` | #16 Load 调 Validate、Duration decode hook |
| `response/response.go` | #15 Mode 开关 + status 映射 |
| `database/redis.go` | #10 redis Manager |
| `database/manager.go` | #21 探活 + replica 健康剔除 |
| `cache/cache.go` `cache/lock.go` | #10 cache Manager + 解耦 RedisClient |
| `jwt/jwt.go` | #10 jwt Manager + #14 Expire |
| `storage/storage.go` | #10 storage Manager |
| `logger/logger.go` | #10 logger Manager |
| `middleware/{requestid,recover}.go` | #24 装入 + #23 核对 |
| `middleware/timeout.go`(新) | #19 |
| `middleware/metrics.go`(新) | #18 |
| `router/router.go` `router/metrics.go`(新) | #17 livez/readyz + #18 /metrics |
| `wire/wire.go` | #11 删除 |
| `go.mod` | #18 prometheus 依赖 |
| `CHANGELOG.md` `README.md` | 发版 |
## 验证
1. `go mod tidy && go build ./...` — 编译通过。
2. `go test ./...` — 全绿,含新增单测。
3. `go run ./example`full 栈)— 启动无错,`/livez`→200、`/readyz`→200(依赖在时)、`/metrics`→prometheus 文本、`/health` 兼容;配置错误时 `Validate` 在启动期拦截。
4. 手动验证 breaking:删 wire 后 `example` 不再 import wire`AppConfig.TokenExpire` 删除后 grep 无残留;`response.SetMode(ModeREST)``Unauthorized` 返回 401。
5. `App.Go` 起一个长任务 goroutine,发 SIGTERM,确认 `Shutdown` 等其退出(日志可见)。
6. 主库探活:手动关 mysql,30s 后 `/readyz`→503、`/health`→503;恢复后自动转 200。
7. 发版:commit + annotated tag `v1.1.0``git push xlgo-core main && git push xlgo-core v1.1.0`release 内容写本地 `gitHub_release_v1.1.0.md`.gitignore 忽略),用户网页创建。
+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。
+485
View File
@@ -0,0 +1,485 @@
# xlgo Web 框架评估报告(v2.0 优化后版本)
## 一、项目概述
xlgo 是一个基于 Go + Gin 构建的轻量级 Web 开发框架,旨在提供后端开发的基础设施。本报告基于 v2.0 版本(经过全面优化和 zl 工具库移植后)进行评估。
---
## 二、本轮优化内容汇总
### 2.1 新增核心包
| 包名 | 来源 | 函数数 | 核心功能 |
|------|------|--------|----------|
| `utils/` | zl/utils | 111 | 随机数、字符串、时间、转换、文件、URL、验证、加密、HTTP客户端、UUID |
| `console/` | zl/utils/print_*.go | 22 | 彩色控制台输出(Debug/Info/Success/Warn/Error |
| `compress/` | zl/service/compress | 7 | Gzip/Zip 压缩解压 |
| `cache/keybuilder.go` | zl/service/cache | 新增 | 键名前缀管理,多站点共用 Redis |
| `cache/lock.go` | zl/service/cache | 新增 | 分布式锁、计数器、TTL管理 |
| `middleware/requestid.go` | zl/middleware | 2 | 请求ID追踪 |
| `middleware/recover.go` | zl/middleware | 2 | Panic恢复中间件 |
| `handler/handler.go` | zl/response | 增强 | 类型安全参数获取(QueryInt/PathInt/FormInt等) |
| `response/response.go` | zl/response | 增强 | RequestID字段、文件下载、HTML响应、跳转 |
| `config/config.go` | 新增 | AppConfig | 应用配置(SiteName/Env/Version等) |
### 2.2 配置增强
新增 `AppConfig` 配置块,支持站点别名:
```yaml
app:
name: "用户管理系统"
site_name: "user_api" # 站点别名,用于缓存键前缀、日志标识
version: "1.0.0"
env: "prod"
debug: false
base_url: "https://user.example.com"
token_expire: 86400
```
### 2.3 CLI 工具重构
将原来单个 `main.go`(约700行)拆分为模块化结构:
```
cmd/xlgo/
├── main.go # 入口和命令路由(~50行)
├── types.go # 类型定义
├── utils.go # 工具函数
├── commands.go # 命令实现
└── templates.go # 所有模板定义
```
---
## 三、项目规模对比
| 指标 | v1.1.0 | v2.0 | 提升 |
|------|--------|------|------|
| Go 源文件数 | 28 | 54 | +93% |
| 代码行数 | ~2,800 | ~8,400 | +200% |
| 包数量 | 16 | 23 | +44% |
| 函数数量 | ~150 | ~450 | +200% |
| 测试覆盖 | 0% | 8% (2/24包) | +8% | utils/console已有测试 |
---
## 四、模块完整性评估(v2.0
### 4.1 核心模块评分对比
| 模块 | v1.1.0 | v2.0 | 提升 | 说明 |
|------|--------|------|------|------|
| 配置管理 | 8/10 | 9/10 | +1 | 新增 AppConfig、SiteName |
| 数据库 | 8/10 | 8/10 | - | 保持稳定 |
| Redis/缓存 | 9/10 | 10/10 | +1 | 分布式锁、键名前缀、计数器 |
| JWT 认证 | 8/10 | 8/10 | - | 保持稳定 |
| 日志系统 | 9/10 | 9/10 | - | 保持稳定 |
| 中间件 | 8/10 | 9/10 | +1 | RequestID、Recover、彩色输出 |
| 文件存储 | 9/10 | 9/10 | - | 保持稳定 |
| 工具函数 | 3/10 | 9/10 | +6 | 111个实用函数 |
| 验证器 | 9/10 | 9/10 | - | 保持稳定 |
| 错误处理 | 8/10 | 8/10 | - | 保持稳定 |
| 密码加密 | 9/10 | 9/10 | - | 保持稳定 |
| SSE 支持 | 8/10 | 8/10 | - | 保持稳定 |
| WebSocket | 8/10 | 8/10 | - | 保持稳定 |
| 定时任务 | 7/10 | 7/10 | - | 保持稳定 |
| 测试支持 | 8/10 | 8/10 | - | 保持稳定 |
| CLI 工具 | 7/10 | 8/10 | +1 | 模块化重构、模板分离 |
| 压缩解压 | 0/10 | 8/10 | +8 | Gzip/Zip完整实现 |
| 控制台输出 | 0/10 | 9/10 | +9 | 跨平台彩色输出 |
### 4.2 新增 utils 包功能清单
#### 4.2.1 随机数生成 (random.go)
```go
RandString(16) // 随机字符串(字母+数字)
RandDigit(6) // 随机数字字符串
RandInt(1, 100) // 随机整数
RandInt64(0, 1000) // 随机int64
```
#### 4.2.2 字符串处理 (strings.go)
```go
IsBlank(s) // 检查空白
IsAnyBlank(strs...) // 批量检查
DefaultIfBlank(s, def) // 默认值
Substr(s, 0, 10) // 子字符串(支持中文)
StrLen(s) // Unicode长度
EqualsIgnoreCase(a, b) // 不区分大小写
Trim(s) // 去除空白
```
#### 4.2.3 时间日期 (datetime.go)
```go
NowUnix() // 秒时间戳
NowTimestamp() // 毫秒时间戳
FromUnix(unix) // 时间戳转时间
FormatDateTime(t) // 格式化 "2006-01-02 15:04:05"
StartOfDay(t) // 当天开始
EndOfDay(t) // 当天结束
StartOfMonth(t) // 当月开始
EndOfMonth(t) // 当月结束
```
#### 4.2.4 类型转换 (convert.go)
```go
ToInt(s) // 字符串转int
ToIntDefault(s, 0) // 带默认值
ToInt64(s) // 转int64
ToFloat64(s) // float64
CalcPageCount(100, 10) // 计算总页数
CalcOffset(2, 20) // 分页偏移
```
#### 4.2.5 文件操作 (file.go)
```go
FileExists(path) // 检查文件存在
DirExists(path) // 检查目录存在
EnsureDir(path) // 确保目录存在
ReadFile(path) // 读取文件
WriteFile(path, data) // 写入文件
CopyFile(dst, src) // 复制文件
```
#### 4.2.6 URL处理 (url.go)
```go
ParseURL(rawURL) // 解析URL
builder.AddQuery("key", "value") // 链式添加参数
URLEncode(s) // URL编码
URLDecode(s) // URL解码
```
#### 4.2.7 格式验证 (validator.go)
```go
IsPhone(phone) // 手机号验证
IsEmail(email) // 邮箱验证
IsIPv4(ip) // IPv4验证
IsIDCard(id) // 身份证验证
IsChinese(s) // 中文验证
IsNumeric(s) // 数字验证
IsAlphanumeric(s) // 字母数字验证
```
#### 4.2.8 加密编码 (crypto.go)
```go
MD5(s) // MD5哈希
SHA256(s) // SHA256哈希
Base64Encode(data) // Base64编码
Base64URLEncode(data) // URL安全编码
```
#### 4.2.9 HTTP客户端 (http.go)
```go
client := NewHTTPClient()
client.SetTimeout(30*time.Second)
client.SetHeader("Authorization", "Bearer xxx")
client.Get(url, params) // GET请求
client.PostJSON(url, data) // POST JSON
client.Upload(url, files, params) // 文件上传
```
#### 4.2.10 UUID生成 (uuid.go)
```go
UUID() // UUID v4字符串
UUIDShort() // 短UUID(无横线)
UUIDValid(s) // 验证UUID
```
### 4.3 新增 console 包功能
```go
console.Debug("调试信息") // 青色
console.Info("普通信息") // 白色
console.Success("成功信息") // 绿色
console.Warn("警告信息") // 黄色
console.Error("错误信息") // 红色
// 自定义配置
c := console.New(
console.WithColor(true),
console.WithTime(true),
console.WithCaller(true, 2),
)
c.Debug("自定义控制台输出")
```
### 4.4 新增 compress 包功能
```go
// Gzip 数据压缩
GzipCompress(data)
GzipDecompress(data)
// Gzip 文件压缩
GzipCompressFile(src, dst)
GzipDecompressFile(src, dst)
// Zip 打包
Zip(zipPath, paths) // 打包文件/目录
Unzip(zipPath, dstDir) // 解压到目录
```
### 4.5 缓存键名前缀管理
```go
// 自动从配置读取 site_name
cache.K("user:1") // -> "cache:user_api:user:1"
cache.KTemp("token") // -> "temp:user_api:token"
cache.KPerm("config") // -> "perm:user_api:config"
cache.KLock("order:123") // -> "lock:user_api:order:123"
cache.KCounter("visit") // -> "counter:user_api:visit"
cache.KSession("sid") // -> "session:user_api:sid"
// 分布式锁
cache.Lock(ctx, key, ttl)
cache.Unlock(ctx, key)
cache.TryLock(ctx, key, ttl, retry, maxRetry)
cache.WithLock(ctx, key, ttl, fn) // 自动管理锁
// 计数器
cache.Incr(ctx, key)
cache.Decr(ctx, key)
```
### 4.6 中间件增强
```go
// RequestID - 请求追踪
r.Use(middleware.RequestID())
// 响应头自动添加 X-Request-ID
// Recover - Panic恢复
r.Use(middleware.Recover()) // 生产环境
r.Use(middleware.RecoverWithDetail()) // 开发环境(返回详细信息)
```
### 4.7 Handler 参数获取增强
```go
// 类型安全的参数获取
page := handler.QueryInt(c, "page", 1)
id := handler.PathInt64(c, "id", 0)
price := handler.QueryFloat64(c, "price", 0.0)
enabled := handler.QueryBool(c, "enabled", false)
count := handler.FormInt(c, "count", 0)
```
---
## 五、多站点共用 Redis 方案
### 5.1 问题背景
多个小项目共用一台 Redis 服务器时,缓存键名可能冲突:
- 项目A: `user:1`
- 项目B: `user:1`
- 项目C: `user:1`
### 5.2 解决方案
通过 `site_name` 配置自动添加前缀:
| 项目 | config.yaml | 实际键名 |
|------|-------------|----------|
| 用户API | `site_name: user_api` | `cache:user_api:user:1` |
| 订单API | `site_name: order_api` | `cache:order_api:user:1` |
| 支付API | `site_name: pay_api` | `cache:pay_api:user:1` |
### 5.3 使用方式
```yaml
# config.yaml
app:
site_name: "my_project"
```
```go
// 无需额外代码,自动生效
cache.K("user:1") // -> "cache:my_project:user:1"
```
---
## 六、性能评估(v2.0
### 6.1 性能优化点
| 优化项 | 说明 |
|--------|------|
| RandString/RandDigit | 使用 sync.Pool 复用 rand.Source |
| StringToBytes/BytesToString | 零拷贝转换 |
| HTTPClient | 链式配置,连接复用 |
| 缓存键名 | 预构建,避免重复拼接 |
### 6.2 性能基准估算
| 场景 | 预估 QPS | 说明 |
|------|----------|------|
| 简单查询 API | 12,000-18,000 | 无 DB 查询 |
| 带 DB 查询 | 3,000-5,000 | 单表主键查询 |
| 缓存查询 | 10,000-15,000 | Redis 缓存命中 |
| SSE 流式响应 | 5,000-8,000 | 单连接 |
| WebSocket 连接 | 10,000+ | 连接数 |
| 文件压缩 | 50-100 MB/s | Gzip |
| HTTP请求 | 1,000-5,000 | 外部API调用 |
---
## 七、扩展性评估(v2.0
| 方面 | v1.1.0 | v2.0 | 说明 |
|------|--------|------|------|
| 存储扩展 | 本地 + OSS | 本地 + OSS | 保持 |
| 缓存扩展 | 基础操作 | 分布式锁、计数器、前缀 | +3功能 |
| 验证扩展 | 自定义规则 | 手机/邮箱/IP等预定义 | +7规则 |
| 通信扩展 | HTTP + SSE + WS | 保持 | - |
| 工具扩展 | 无 | 111个函数 | +111 |
| 压缩扩展 | 无 | Gzip + Zip | +2 |
| 控制台扩展 | 无 | 彩色输出 | +1 |
---
## 八、易用性评估(v2.0
### 8.1 开发效率提升
| 任务 | v1.1.0 | v2.0 | 节省 |
|------|--------|------|------|
| 创建新项目 | 5 分钟 | 5 分钟 | - |
| 添加 CRUD 模块 | 10 分钟 | 10 分钟 | - |
| 请求验证 | 5 分钟 | 5 分钟 | - |
| 参数类型转换 | 手动编写 | 调用 handler.QueryInt | 90% |
| 文件压缩 | 手动实现 | 调用 compress.Zip | 100% |
| 随机字符串 | 手动实现 | 调用 utils.RandString | 100% |
| 时间格式化 | 记忆布局 | 调用 utils.FormatDateTime | 80% |
| 分布式锁 | 手动实现 | 调用 cache.Lock | 100% |
| HTTP请求 | 手动封装 | 调用 NewHTTPClient().Get | 100% |
| 控制台调试 | fmt.Println | console.Debug(彩色) | 50% |
### 8.2 代码简化示例
```go
// v1.1.0 - 手动实现
func GetUser(c *gin.Context) {
pageStr := c.Query("page")
page := 1
if pageStr != "" {
p, err := strconv.Atoi(pageStr)
if err == nil {
page = p
}
}
// ...
}
// v2.0 - 一行搞定
func GetUser(c *gin.Context) {
page := handler.QueryInt(c, "page", 1)
// ...
}
```
---
## 九、安全性评估(v2.0
| 检查项 | v1.1.0 | v2.0 | 说明 |
|--------|--------|------|------|
| SQL 注入 | ✅ GORM | ✅ GORM | 保持 |
| XSS | ⚠️ 应用层 | ⚠️ 应用层 | 保持 |
| CSRF | ❌ 无 | ✅ 完善 | +1 | 多种模式:Cookie/API/DoubleSubmit |
| 密码加密 | ✅ bcrypt | ✅ bcrypt | 保持 |
| 请求验证 | ✅ validator | ✅ validator | 保持 |
| 限流防护 | ✅ 完善 | ✅ 完善 | 保持 |
| JWT 安全 | ✅ 黑名单 | ✅ 黑名单 | 保持 |
| Panic恢复 | ❌ 无 | ✅ Recover中间件 | +1 |
| 请求追踪 | ❌ 无 | ✅ RequestID | +1 |
| 类型安全 | ⚠️ 手动 | ✅ 强类型函数 | +1 |
---
## 十、综合评分(v2.0
| 维度 | v1.1.0 | v2.0 | 提升 |
|------|--------|------|------|
| 完整性 | 8.5/10 | 9.5/10 | +1.0 |
| 性能 | 8.5/10 | 9.0/10 | +0.5 |
| 扩展性 | 7.5/10 | 9.0/10 | +1.5 |
| 易用性 | 9.0/10 | 9.5/10 | +0.5 |
| 安全性 | 8.0/10 | 9.0/10 | +1.0 |
| **总分** | **8.35/10** | **9.2/10** | **+0.85** |
---
## 十一、剩余改进建议
### 11.1 P3 - 下一步优化
| 功能 | 优先级 | 说明 |
|------|--------|------|
| Kafka 生产者 | 低 | 需重新设计连接池 |
| WebSocket 客户端 | 低 | 框架已有服务端实现 |
| 链路追踪 | 中 | OpenTelemetry集成 |
| 单元测试 | 高 | 进行中,覆盖所有包 |
| 性能基准 | 中 | 建立benchmark |
### 11.2 测试建议
```bash
# 运行现有测试
go test ./utils/... ./console/... -v
# 建议添加
go test ./cache/... ./handler/... ./middleware/...
```
---
## 十二、结论
经过本轮全面优化,xlgo 框架已从 **生产就绪** 提升至 **功能丰富** 水平。
### 12.1 核心优势
1. **工具库完善** - 111个实用函数,覆盖日常开发80%场景
2. **多站点支持** - SiteName配置解决共用Redis冲突
3. **类型安全** - QueryInt/PathInt等避免手动转换
4. **开发调试** - 彩色控制台输出,一眼识别日志级别
5. **分布式支持** - Redis分布式锁开箱即用
6. **文件处理** - Gzip/Zip压缩解压
### 12.2 适用场景
- ✅ 中大型 API 服务
- ✅ 用户管理系统
- ✅ AI 应用后端
- ✅ 实时通信应用
- ✅ 多项目共用资源
- ✅ 创业项目 MVP
- ✅ 学习 Go Web 开发
### 12.3 开发效率总结
| 场景 | 使用 xlgo v2.0 | 不使用框架 | 节省 |
|------|----------------|------------|------|
| 用户系统 | 4-5 人天 | 10-16 人天 | 60% |
| AI应用 | 4-5 人天 | 9-14 人天 | 55% |
| 通用API | 2-3 人天 | 5-8 人天 | 60% |
---
## 十三、版本历史
| 版本 | 日期 | 主要变化 |
|------|------|----------|
| v1.0.0 | 2026-04-29 | 基础框架 |
| v1.1.0 | 2026-04-29 | P0/P1/P2修复(12项) |
| v2.0.0 | 2026-04-29 | zl工具库移植、模块重构 |
---
*评估日期:2026-04-29*
*评估版本:v2.0.0*
*优化内容:新增5个包 + 增强5个包 + 配置扩展 + CLI重构 = 约120个函数*
+42
View File
@@ -0,0 +1,42 @@
# xlgo 示例
两个可运行示例,帮助快速上手 xlgo。
## minimal — 最小 HTTP 服务
不依赖 MySQL / Redis / Storage,纯 HTTP + 健康检查。第一次接触 xlgo 从这里开始。
```bash
go run ./examples/minimal
```
访问 http://localhost:8081/health(健康检查)与 http://localhost:8081/api/v1/(示例路由)。
## full — 完整业务 API
包含 MySQL + Redis + JWT + 一个 user CRUD(登录发 token、认证路由、创建/查询用户)。
**运行前需准备**
- MySQL(修改 `examples/full/config.yaml``database` 配置)
- Redis(修改 `examples/full/config.yaml``redis` 配置)
```bash
go run ./examples/full
```
启动后自动迁移 user 表。接口:
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
| POST | /api/v1/login | 登录,返回 token | 否 |
| GET | /api/v1/users/:id | 查询用户 | 是(Bearer token |
| POST | /api/v1/users | 创建用户 | 是(Bearer token |
登录示例:
```bash
curl -X POST http://localhost:8082/api/v1/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret"}'
```
> 示例代码为演示用途,密码明文存储、未做参数校验,生产环境请使用 bcrypt 哈希密码并配合 `validation` 包校验入参。
+43
View File
@@ -0,0 +1,43 @@
app:
name: "xlgo-full-example"
env: "dev"
debug: true
server:
port: 8082
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business # business(默认) 或 rest
database:
driver: mysql
host: localhost
port: 3306
user: root
password: your_password
name: xlgo_example
max_idle_conns: 10
max_open_conns: 100
redis:
host: localhost
port: 6379
password: ""
db: 0
jwt:
secret: change-me-to-a-long-random-secret-at-least-32-chars
expire: "24h"
refresh_expire: "168h"
issuer: xlgo
algorithm: HS256
log:
dir: ./logs
max_size: 100
max_backups: 30
max_age: 30
compress: true
+125
View File
@@ -0,0 +1,125 @@
// Package main 是 xlgo 的完整示例:MySQL + Redis + JWT + 一个 user CRUD。
//
// 运行前需准备:
// - MySQLconfig.yaml 中 database 配置)
// - Redisconfig.yaml 中 redis 配置)
//
// 启动后会自动迁移 user 表。访问:
//
// POST /api/v1/login {"username":"alice","password":"secret"} → 返回 token
// GET /api/v1/users/:id (需 Authorization: Bearer <token>
// POST /api/v1/users (创建用户)
//
// 运行:
//
// go run ./examples/full
package main
import (
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/jwt"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/repository"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// User 示例模型
type User struct {
gorm.Model
Username string `gorm:"uniqueIndex;size:64" json:"username"`
Password string `gorm:"size:128" json:"-"` // 实际项目应存 bcrypt 哈希
UserType string `gorm:"size:32" json:"user_type"`
}
var userRepo *repository.BaseRepo[User]
func main() {
app := xlgo.NewFullStack(
xlgo.WithConfigPath("./examples/full/config.yaml"),
xlgo.WithModels(&User{}),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
)
// 初始化 user repositoryApp.Init 之后 master DB 才可用,这里在 registerRoutes 里延迟拿)
_ = app
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
}
func registerRoutes(r *gin.RouterGroup) {
// 延迟初始化 repo:此时 App.Init 已完成,database.GetDB() 可用
userRepo = repository.NewBaseRepo[User](database.GetDB())
api := r.Group("/api/v1")
// 公开路由:登录
api.POST("/login", login)
// 认证路由
auth := api.Group("/", middleware.AuthRequired())
auth.GET("/users/:id", getUser)
auth.POST("/users", createUser)
}
func login(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, "参数错误")
return
}
// 示例简化:查询用户,不校验密码哈希
u, err := userRepo.FindOne(c.Request.Context(), "username = ?", req.Username)
if err != nil {
response.Fail(c, "用户不存在")
return
}
token, err := jwt.GenerateToken(u.ID, u.Username, "user", u.UserType)
if err != nil {
response.ServerError(c, "生成 token 失败")
return
}
response.Success(c, gin.H{"token": token})
}
func getUser(c *gin.Context) {
id := c.Param("id")
// 示例简化:直接用 repo 查询,实际应转 uint
var uid uint
fmt.Sscanf(id, "%d", &uid)
u, err := userRepo.FindByID(c.Request.Context(), uid)
if err != nil {
response.NotFound(c, "用户不存在")
return
}
response.Success(c, u)
}
func createUser(c *gin.Context) {
var u User
if err := c.ShouldBindJSON(&u); err != nil {
response.Fail(c, "参数错误")
return
}
u.UserType = "user"
if err := userRepo.Create(c.Request.Context(), &u); err != nil {
response.Fail(c, "创建失败: "+err.Error())
return
}
response.Success(c, u)
}
+8
View File
@@ -0,0 +1,8 @@
app:
name: "xlgo-minimal-example"
env: "dev"
debug: true
server:
port: 8081
mode: development
+42
View File
@@ -0,0 +1,42 @@
// Package main 是 xlgo 的最小可运行示例。
//
// 仅依赖一个 config.yaml,不初始化 MySQL / Redis / Storage
// 适合第一次接触 xlgo、或纯 HTTP 场景。
//
// 运行:
//
// go run ./examples/minimal
package main
import (
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
)
func main() {
app := xlgo.New(
xlgo.WithConfigPath("./examples/minimal/config.yaml"),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
)
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
}
func registerRoutes(r *gin.RouterGroup) {
api := r.Group("/api/v1")
api.GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Hello xlgo!"})
})
}
+22 -9
View File
@@ -7,9 +7,11 @@ 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/mitchellh/mapstructure v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.5.1
github.com/spf13/viper v1.18.2
github.com/swaggo/files v1.0.1
@@ -21,17 +23,19 @@ 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 (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -51,6 +55,10 @@ require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
@@ -60,10 +68,13 @@ require (
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
@@ -78,12 +89,14 @@ require (
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
+48 -20
View File
@@ -6,6 +6,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible h1:Sg/2xHwDrioHpxTN6WMiwbXTpUEinBpHsN7mG21Rc2k=
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -66,8 +68,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 +77,20 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -89,6 +99,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
@@ -99,6 +111,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
@@ -116,12 +130,22 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -191,29 +215,31 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -223,8 +249,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 +259,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 +297,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)
}
+103 -26
View File
@@ -6,11 +6,14 @@ import (
"encoding/base64"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
)
// Claims JWT 声明
@@ -37,21 +40,37 @@ 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{}
// TokenBlacklist Token 黑名单管理(使用 JTI 优化)
// client 为 nil 时回退到 database.GetRedis(),兼容存量未注入场景。
type TokenBlacklist struct {
client *redis.Client
}
// NewTokenBlacklist 创建黑名单实例,client 可为 nil(懒取全局 Redis)。
func NewTokenBlacklist(client *redis.Client) *TokenBlacklist {
return &TokenBlacklist{client: client}
}
func (tb *TokenBlacklist) redisClient() *redis.Client {
if tb != nil && tb.client != nil {
return tb.client
}
return database.GetRedis()
}
// 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 {
client := tb.redisClient()
if client == nil {
// Redis 未启用,跳过黑名单
return nil
}
@@ -65,32 +84,69 @@ func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
// 使用 JTI 作为键名(约24字节),而非完整 Token(数百字节)
key := fmt.Sprintf("jwt_bl:%s", jti)
return database.RedisClient.Set(ctx, key, "1", ttl).Err()
return client.Set(ctx, key, "1", ttl).Err()
}
// IsBlacklisted 检查 JTI 是否在黑名单中
func (tb *TokenBlacklist) IsBlacklisted(jti string) bool {
if database.RedisClient == nil {
client := tb.redisClient()
if client == nil {
// Redis 未启用,不检查黑名单
return false
}
ctx := context.Background()
key := fmt.Sprintf("jwt_bl:%s", jti)
return database.RedisClient.Exists(ctx, key).Val() > 0
return client.Exists(ctx, key).Val() > 0
}
// 全局黑名单实例
var tokenBlacklist = &TokenBlacklist{}
// Manager JWT 管理器(#10)。持有独立的 TokenBlacklist
// 支持多实例(如区分 user-token 与 refresh-token 黑名单)。
type Manager struct {
mu sync.Mutex
blacklist *TokenBlacklist
}
// DefaultJWT 默认 JWT 管理器,包级 facade 代理到它的 blacklist。
var DefaultJWT = NewJWTManager()
// NewJWTManager 创建 JWT 管理器实例(blacklist 懒取全局 Redis)。
func NewJWTManager() *Manager {
return &Manager{blacklist: NewTokenBlacklist(nil)}
}
// NewJWTManagerWithRedis 创建 JWT 管理器并注入指定 Redis 客户端(用于多 Redis/测试隔离)。
func NewJWTManagerWithRedis(client *redis.Client) *Manager {
return &Manager{blacklist: NewTokenBlacklist(client)}
}
// SetDefaultJWTManager 提升指定 Manager 为全局默认。
func SetDefaultJWTManager(m *Manager) {
if m != nil {
DefaultJWT = m
tokenBlacklist = m.blacklist
}
}
// Blacklist 返回 Manager 持有的黑名单实例。
func (m *Manager) Blacklist() *TokenBlacklist {
m.mu.Lock()
defer m.mu.Unlock()
return m.blacklist
}
// 全局黑名单实例(指向 DefaultJWT 的 blacklist,兼容存量包级函数)
var tokenBlacklist = DefaultJWT.blacklist
// 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,
@@ -99,15 +155,15 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
UserType: userType,
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(cfg.JWT.Expire) * time.Second)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.JWT.Expire)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
Issuer: issuerOrDefault(cfg.JWT.Issuer),
ID: jti, // 同时设置到 RegisteredClaims.ID
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token := jwt.NewWithClaims(signingMethod(cfg.JWT.Algorithm), claims)
return token.SignedString([]byte(cfg.JWT.Secret))
}
@@ -115,7 +171,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,
@@ -127,18 +186,38 @@ func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireSeconds) * time.Second)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
Issuer: issuerOrDefault(cfg.JWT.Issuer),
ID: jti,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token := jwt.NewWithClaims(signingMethod(cfg.JWT.Algorithm), claims)
return token.SignedString([]byte(cfg.JWT.Secret))
}
// issuerOrDefault 返回配置的 issuer,未配置时回退 "xlgo"。
func issuerOrDefault(issuer string) string {
if issuer == "" {
return "xlgo"
}
return issuer
}
// signingMethod 根据 algorithm 配置返回 HMAC 签名方法。
// 目前支持 HS256(默认)/HS384/HS512;其它值回退 HS256。
// RS256 等非对称算法需扩展密钥类型,暂不支持。
func signingMethod(algorithm string) jwt.SigningMethod {
switch strings.ToUpper(algorithm) {
case "HS384":
return jwt.SigningMethodHS384
case "HS512":
return jwt.SigningMethodHS512
default:
return jwt.SigningMethodHS256
}
}
// ParseToken 解析 JWT Token
// 评分: ⭐⭐⭐⭐⭐
// 理由: 使用 JTI 检查黑名单,效率更高
func ParseToken(tokenString string) (*Claims, error) {
cfg := config.Get()
@@ -171,8 +250,6 @@ func ParseToken(tokenString string) (*Claims, error) {
}
// InvalidateToken 使 Token 失效(加入黑名单)
// 评分: ⭐⭐⭐⭐⭐
// 理由: 使用 JTI 而非完整 Token,节省 Redis 内存
func InvalidateToken(tokenString string) error {
cfg := config.Get()
+2 -2
View File
@@ -12,8 +12,8 @@ func setupTestConfig() {
// 设置测试配置
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "test-secret-key-12345",
Expire: 3600, // 1小时
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
Expire: time.Hour, // 1小时
},
}
config.Set(cfg)
+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)
},
}
+180 -66
View File
@@ -1,8 +1,11 @@
package logger
import (
"errors"
"os"
"path/filepath"
"strings"
"sync"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -12,17 +15,57 @@ 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 初始化日志
func Init(cfg *config.Config) error {
// LogManager 日志管理器(#10)。照 database.Manager 模式:
// 实例化 + DefaultLogger 全局默认 + 包级 facade 代理。
// 包级 Logger/sugar/apiLog/dbLog 由 Init 同步维护,下游 8 个包零改动。
type LogManager struct {
mu sync.Mutex
cfg *config.Config
}
// DefaultLogger 默认日志管理器,包级 facade 代理到它。
var DefaultLogger = NewLogManager()
// NewLogManager 创建日志管理器实例。
func NewLogManager() *LogManager { return &LogManager{} }
// SetDefaultLogManager 提升指定 LogManager 为全局默认。
func SetDefaultLogManager(m *LogManager) {
if m != nil {
DefaultLogger = m
}
}
// Init 初始化日志。
//
// 三个 logger 的分流策略:
// - Logger(通用):写 console + logs/app.log
// - APILog() :写 console + logs/api.log
// - DBLog() :写 console + logs/database.log
//
// 关键修复(v1.0.3):旧实现把 apiCore 和 dbCore 都 Tee 进通用 Logger
// 导致每条 logger.Info(...) 都会同时落到 api.log + database.log + console
// 三份,磁盘占用翻倍且分流形同虚设。新实现通用 Logger 只走独立的 app.log。
func (m *LogManager) Init(cfg *config.Config) error {
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 +86,146 @@ 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))
m.mu.Lock()
defer m.mu.Unlock()
// 全部构造成功后再原子替换全局变量,避免半初始化状态。
// 同时关闭旧 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}
m.cfg = cfg
return nil
}
// Sync 同步日志
func Sync() {
if Logger != nil {
_ = Logger.Sync()
// Init 包级 facade:初始化日志(代理到 DefaultLogger)。
func Init(cfg *config.Config) error {
return DefaultLogger.Init(cfg)
}
// 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 (m *LogManager) 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...)
}
// Sync 包级 facade:同步全部 logger 缓冲(代理到 DefaultLogger)。
func Sync() error {
return DefaultLogger.Sync()
}
// Close 关闭日志文件句柄,重置全局 logger 为 Nop。
// 通常由 App.Shutdown 在 Sync 之后调用;测试场景需要清理临时目录时也应调用。
//
// 调用后再次写日志不会 panicfall back to nop logger),但不会写入文件。
// 如需重新启用,请再次调用 Init。
func (m *LogManager) Close() error {
syncErr := m.Sync()
m.mu.Lock()
closeFileWriters()
Logger = zap.NewNop()
sugar = Logger.Sugar()
apiLog = zap.NewNop()
dbLog = zap.NewNop()
m.mu.Unlock()
return syncErr
}
// Close 包级 facade:关闭日志文件句柄,重置为 Nop(代理到 DefaultLogger)。
func Close() error {
return DefaultLogger.Close()
}
// isHarmlessSyncError 识别 stdout/stderr Sync 在不同平台返回的预期错误。
// 这些错误来自 console core,对真实 writer 无影响,可安全忽略。
func isHarmlessSyncError(err error) bool {
if err == nil {
return true
}
msg := err.Error()
for _, sub := range []string{
"invalid argument", // Linux stdout
"inappropriate ioctl for device", // macOS stdout
"bad file descriptor", // Windows stdout
} {
if strings.Contains(msg, sub) {
return true
}
}
return false
}
// Debug 调试日志
func Debug(msg string, fields ...zap.Field) {
Logger.Debug(msg, fields...)
@@ -132,7 +246,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 +271,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) {
// 检查是否跳过此路径
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// #18 Prometheus 指标。三个标配:
// - http_requests_total: 请求计数(按 method/route/status 维度)
// - http_request_duration_seconds: 请求耗时直方图(按 method/route 维度)
// - http_requests_in_flight: 当前在飞请求数
var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests.",
},
[]string{"method", "route", "status"},
)
httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency in seconds.",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "route"},
)
httpRequestsInFlight = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "http_requests_in_flight",
Help: "Current number of in-flight HTTP requests.",
},
)
)
// Metrics Prometheus 指标中间件(#18)。记录请求计数、耗时、在飞数。
// route 标签用 c.FullPath()(路由模板,如 /api/v1/users/:id),避免高基数。
func Metrics() gin.HandlerFunc {
return func(c *gin.Context) {
httpRequestsInFlight.Inc()
start := time.Now()
c.Next()
httpRequestsInFlight.Dec()
elapsed := time.Since(start).Seconds()
status := strconv.Itoa(c.Writer.Status())
route := c.FullPath()
if route == "" {
route = "not_found"
}
method := c.Request.Method
httpRequestsTotal.WithLabelValues(method, route, status).Inc()
httpRequestDuration.WithLabelValues(method, route).Observe(elapsed)
}
}
+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")
+32
View File
@@ -0,0 +1,32 @@
package middleware
import (
"context"
"time"
"github.com/gin-gonic/gin"
)
// Timeout 请求级超时中间件(#19)。
//
// 与 http.Server.ReadTimeout(连接级)不同,这是业务级超时:
// 为每个请求的 context 设置 deadline,下游 GORM / Redis / HTTP 调用
// 走 c.Request.Context() 即可级联取消,避免单个慢请求拖垮协程。
//
// 用法:
//
// r.Use(middleware.Timeout(5 * time.Second))
//
// d <= 0 时不启用超时(直接 Next)。
func Timeout(d time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
if d <= 0 {
c.Next()
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
+72
View File
@@ -0,0 +1,72 @@
package middleware_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/gin-gonic/gin"
)
func TestTimeoutAllowsFastHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.Timeout(time.Second))
r.GET("/ok", func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/ok", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestTimeoutCancelsSlowHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.Timeout(50 * time.Millisecond))
r.GET("/slow", func(c *gin.Context) {
select {
case <-c.Request.Context().Done():
// 超时取消生效
return
case <-time.After(time.Second):
t.Error("handler should have been cancelled by timeout")
}
})
w := httptest.NewRecorder()
done := make(chan struct{})
go func() {
r.ServeHTTP(w, httptest.NewRequest("GET", "/slow", nil))
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("request did not return in time")
}
}
func TestTimeoutZeroDisables(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.Timeout(0)) // 不启用
ctxCancelled := false
r.GET("/x", func(c *gin.Context) {
ctxCancelled = c.Request.Context() == context.Background()
_ = ctxCancelled
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/x", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
+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
}
+53 -15
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,21 +140,55 @@ 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{
Code: err.Code,
Msg: err.Message,
RequestID: getRequestID(c),
})
writeResp(c, err.Code, err.Message, nil)
}
// FailWithDetail 使用预定义错误并添加详细信息
func FailWithDetail(c *gin.Context, err *Error, detail string) {
c.JSON(200, Response{
Code: err.Code,
Msg: err.Message,
Data: gin.H{"detail": detail},
RequestID: getRequestID(c),
})
writeResp(c, err.Code, err.Message, gin.H{"detail": detail})
}
+86
View File
@@ -0,0 +1,86 @@
package response
import (
"net/http"
"sync/atomic"
"github.com/gin-gonic/gin"
)
// Mode 响应模式
type Mode int32
const (
// ModeBusiness 业务码模式(默认):所有响应 HTTP 200,错误信息通过 body 中的 code 表达。
// 兼容存量"业务码 in body"玩法。
ModeBusiness Mode = iota
// ModeREST REST 模式:失败响应按业务码映射对应的 HTTP status401/404/500...),
// body 仍带业务码。便于 APM / Prometheus / 网关 / Sentry 按 status 区分异常。
ModeREST
)
// currentMode 当前响应模式,默认 ModeBusiness。原子读写,可在运行时切换。
var currentMode atomic.Int32
// SetMode 设置全局响应模式。
func SetMode(m Mode) { currentMode.Store(int32(m)) }
// GetMode 返回当前响应模式。
func GetMode() Mode { return Mode(currentMode.Load()) }
// statusForCode 按业务码推断 HTTP status(用于 ModeREST)。
// 已知框架错误码显式映射;用户/文件/数据模块的业务错误码(1xxxx~3xxxx
// 属业务语义而非 HTTP 错误,保持 200;操作失败类(4xxxx)映射 400。
func statusForCode(code int) int {
switch code {
case CodeSuccess:
return http.StatusOK
case CodeUnauthorized, CodeTokenExpired, CodeTokenInvalid:
return http.StatusUnauthorized
case CodeForbidden:
return http.StatusForbidden
case CodeNotFound, CodeUserNotFound, CodeFileNotFound, CodeDataNotFound:
return http.StatusNotFound
case CodeDataConflict:
return http.StatusConflict
case CodeRateLimit:
return http.StatusTooManyRequests
case CodeServerError:
return http.StatusInternalServerError
case CodeServiceUnavailable:
return http.StatusServiceUnavailable
}
if code >= 40000 && code < 50000 {
return http.StatusBadRequest
}
return http.StatusOK
}
// httpStatusFor 返回当前模式下失败响应对应的 HTTP status。
func httpStatusFor(code int) int {
if GetMode() == ModeREST {
return statusForCode(code)
}
return http.StatusOK
}
// writeResp 统一写入响应,按当前 Mode 决定 HTTP status。
func writeResp(c *gin.Context, code int, msg string, data any) {
c.JSON(httpStatusFor(code), Response{
Code: code,
Msg: msg,
Data: data,
RequestID: getRequestID(c),
})
}
// Custom 显式指定 HTTP status 与业务码的响应,不受 Mode 影响。
// 适用于需要精确控制 HTTP status 的场景(如 REST 模式下的特殊端点)。
func Custom(c *gin.Context, httpStatus, code int, msg string, data any) {
c.JSON(httpStatus, Response{
Code: code,
Msg: msg,
Data: data,
RequestID: getRequestID(c),
})
}
+93
View File
@@ -0,0 +1,93 @@
package response_test
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
)
// withMode 切换响应模式并在测试结束后恢复为默认 ModeBusiness。
func withMode(t *testing.T, m response.Mode) {
t.Helper()
response.SetMode(m)
t.Cleanup(func() { response.SetMode(response.ModeBusiness) })
}
func TestSetGetMode(t *testing.T) {
withMode(t, response.ModeREST)
if response.GetMode() != response.ModeREST {
t.Errorf("GetMode = %v, want ModeREST", response.GetMode())
}
}
func TestModeBusinessReturns200(t *testing.T) {
withMode(t, response.ModeBusiness)
r := setupTestRouter()
r.GET("/u", func(c *gin.Context) { response.Unauthorized(c, "no") })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/u", nil))
if w.Code != 200 {
t.Errorf("ModeBusiness Unauthorized status = %d, want 200", w.Code)
}
}
func TestModeRESTMapsStatus(t *testing.T) {
withMode(t, response.ModeREST)
cases := []struct {
path string
fn func(c *gin.Context)
wantCode int
}{
{"/unauth", func(c *gin.Context) { response.Unauthorized(c, "no") }, 401},
{"/notfound", func(c *gin.Context) { response.NotFound(c, "no") }, 404},
{"/server", func(c *gin.Context) { response.ServerError(c, "err") }, 500},
{"/ratelimit", func(c *gin.Context) { response.RateLimit(c) }, 429},
{"/fail", func(c *gin.Context) { response.Fail(c, "bad") }, 200}, // CodeFail 不映射
}
for _, tc := range cases {
r := setupTestRouter()
r.GET(tc.path, tc.fn)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
if w.Code != tc.wantCode {
t.Errorf("%s status = %d, want %d", tc.path, w.Code, tc.wantCode)
}
// body 仍带业务码
var body response.Response
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Errorf("%s body unmarshal: %v", tc.path, err)
}
if body.Code == 0 && tc.path != "/fail" {
// 非 Fail 路径 code 应非 0Fail 的 CodeFail=1 也非 0,跳过)
}
}
}
func TestCustomIgnoresMode(t *testing.T) {
withMode(t, response.ModeBusiness) // 即使 business 模式,Custom 也用指定 status
r := setupTestRouter()
r.GET("/c", func(c *gin.Context) { response.Custom(c, 418, 999, "teapot", nil) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/c", nil))
if w.Code != 418 {
t.Errorf("Custom status = %d, want 418", w.Code)
}
}
func TestFailWithErrorREST(t *testing.T) {
withMode(t, response.ModeREST)
r := setupTestRouter()
r.GET("/e", func(c *gin.Context) { response.FailWithError(c, response.ErrUnauthorized) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/e", nil))
if w.Code != 401 {
t.Errorf("FailWithError(ErrUnauthorized) status = %d, want 401", w.Code)
}
}
+6 -32
View File
@@ -50,56 +50,32 @@ func SuccessWithMsg(c *gin.Context, msg string, data any) {
// Fail 失败响应
func Fail(c *gin.Context, msg string) {
c.JSON(http.StatusOK, Response{
Code: CodeFail,
Msg: msg,
RequestID: getRequestID(c),
})
writeResp(c, CodeFail, msg, nil)
}
// FailWithCode 失败响应(自定义错误码)
func FailWithCode(c *gin.Context, code int, msg string) {
c.JSON(http.StatusOK, Response{
Code: code,
Msg: msg,
RequestID: getRequestID(c),
})
writeResp(c, code, msg, nil)
}
// Unauthorized 未授权响应
func Unauthorized(c *gin.Context, msg string) {
c.JSON(http.StatusOK, Response{
Code: CodeUnauthorized,
Msg: msg,
RequestID: getRequestID(c),
})
writeResp(c, CodeUnauthorized, msg, nil)
}
// NotFound 资源不存在响应
func NotFound(c *gin.Context, msg string) {
c.JSON(http.StatusOK, Response{
Code: CodeNotFound,
Msg: msg,
RequestID: getRequestID(c),
})
writeResp(c, CodeNotFound, msg, nil)
}
// ServerError 服务器错误响应
func ServerError(c *gin.Context, msg string) {
c.JSON(http.StatusOK, Response{
Code: CodeServerError,
Msg: msg,
RequestID: getRequestID(c),
})
writeResp(c, CodeServerError, msg, nil)
}
// RateLimit 请求过于频繁响应
func RateLimit(c *gin.Context) {
c.JSON(http.StatusOK, Response{
Code: CodeRateLimit,
Msg: "请求过于频繁,请稍后再试",
RequestID: getRequestID(c),
})
writeResp(c, CodeRateLimit, "请求过于频繁,请稍后再试", nil)
}
// Page 分页响应
@@ -113,8 +89,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")
}
}
+55
View File
@@ -0,0 +1,55 @@
package router_test
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/EthanCodeCraft/xlgo-core/router"
)
func TestRegisterLivenessRoute(t *testing.T) {
r := setupTestRouter()
router.RegisterLivenessRoute(r)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/livez", nil))
if w.Code != http.StatusOK {
t.Fatalf("livez status = %d, want 200", w.Code)
}
if !strings.Contains(w.Body.String(), `"status":"ok"`) {
t.Fatalf("livez body = %s", w.Body.String())
}
}
func TestRegisterReadinessRouteHealthy(t *testing.T) {
r := setupTestRouter()
router.RegisterReadinessRoute(r,
router.HealthCheck{Name: "mysql", Check: func(context.Context) error { return nil }},
)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if w.Code != http.StatusOK {
t.Fatalf("readyz status = %d, want 200", w.Code)
}
}
func TestRegisterReadinessRouteUnhealthy(t *testing.T) {
r := setupTestRouter()
router.RegisterReadinessRoute(r,
router.HealthCheck{Name: "redis", Check: func(context.Context) error { return errors.New("down") }},
)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("readyz status = %d, want 503", w.Code)
}
if !strings.Contains(w.Body.String(), `"redis":"error"`) {
t.Fatalf("readyz body = %s", w.Body.String())
}
}
+27
View File
@@ -0,0 +1,27 @@
package router
import (
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// RegisterMetricsRoute 注册 Prometheus 指标暴露端点与采集中间件(#18)。
//
// 默认路径 /metrics。传入 path 可自定义。同时把 Metrics() 中间件挂到该组,
// 这样只有业务路由被统计,/metrics 自身与 /health 等基础路由不计入。
//
// 用法:
//
// router.RegisterMetricsRoute(r) // /metrics
// router.RegisterMetricsRoute(r, "/metrics") // 等价
func RegisterMetricsRoute(r *gin.Engine, path ...string) {
p := "/metrics"
if len(path) > 0 && path[0] != "" {
p = path[0]
}
// 指标采集中间件挂在根引擎,统计所有业务请求
r.Use(middleware.Metrics())
r.GET(p, gin.WrapH(promhttp.Handler()))
}
+82 -9
View File
@@ -1,21 +1,94 @@
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
}
// runHealthChecks 执行所有检查项,返回总体状态、HTTP code 与逐项结果。
// 无检查项时视为健康(用于 /livez 与无依赖场景)。
func runHealthChecks(ctx context.Context, checks []HealthCheck) (string, int, map[string]string) {
if len(checks) == 0 {
return "ok", http.StatusOK, nil
}
status := "ok"
code := http.StatusOK
result := make(map[string]string, len(checks))
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"
}
return status, code, result
}
// RegisterHealthRoute 注册健康检查路由(兼容端点,等价于 readiness)。
func RegisterHealthRoute(r *gin.Engine, checks ...HealthCheck) {
r.GET("/health", func(c *gin.Context) {
status, code, result := runHealthChecks(c.Request.Context(), checks)
if result == nil {
c.JSON(http.StatusOK, gin.H{"status": status})
return
}
c.JSON(code, gin.H{"status": status, "checks": result})
})
}
// RegisterLivenessRoute 注册存活性探针(#17)。
// GET /livez 永不依赖外部,仅表示进程存活,始终返回 200。
// 供 K8s livenessProbe 使用:失败由进程崩溃体现,而非端点返回 503。
func RegisterLivenessRoute(r *gin.Engine) {
r.GET("/livez", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
}
// RegisterReadinessRoute 注册就绪性探针(#17)。
// GET /readyz 复用 HealthCheck 检查依赖(mysql/redis...),任一失败返回 503。
// 供 K8s readinessProbe 使用:未就绪时不接流量。
func RegisterReadinessRoute(r *gin.Engine, checks ...HealthCheck) {
r.GET("/readyz", func(c *gin.Context) {
status, code, result := runHealthChecks(c.Request.Context(), checks)
if result == nil {
c.JSON(http.StatusOK, gin.H{"status": status})
return
}
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 +101,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"})
})
}
+113 -14
View File
@@ -2,12 +2,15 @@ package storage
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -27,6 +30,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 +69,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 +119,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 +213,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 +240,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,54 +297,141 @@ func (s *OSSStorage) Exists(path string) bool {
return err == nil
}
// 全局存储实例
var ErrStorageNotInitialized = errors.New("storage not initialized")
// 全局存储实例(兼容 facade,由 Manager.Init 同步维护)
var storage Storage
// StorageManager 存储管理器(#10)。照 database.Manager 模式:
// 实例化 + DefaultStorage 全局默认 + 包级 facade 代理,支持多实例与测试注入。
type StorageManager struct {
mu sync.Mutex
cfg *config.StorageConfig
current Storage
}
// DefaultStorage 默认存储管理器,包级 facade 代理到它。
var DefaultStorage = NewStorageManager()
// NewStorageManager 创建存储管理器实例。
func NewStorageManager() *StorageManager { return &StorageManager{} }
// SetDefaultStorageManager 提升指定 StorageManager 为全局默认。
func SetDefaultStorageManager(m *StorageManager) {
if m != nil {
DefaultStorage = m
}
}
// Init 初始化存储
func Init(cfg *config.StorageConfig) error {
func (m *StorageManager) Init(cfg *config.StorageConfig) error {
m.mu.Lock()
defer m.mu.Unlock()
var s Storage
switch cfg.Driver {
case "local":
storage = NewLocalStorage(&cfg.Local)
s = NewLocalStorage(&cfg.Local)
logger.Info("使用本地存储", zap.String("path", cfg.Local.Path))
case "oss":
ossStorage, err := NewOSSStorage(&cfg.OSS)
if err != nil {
return err
}
storage = ossStorage
s = ossStorage
logger.Info("使用 OSS 存储", zap.String("bucket", cfg.OSS.Bucket))
default:
return fmt.Errorf("不支持的存储驱动: %s", cfg.Driver)
}
m.cfg = cfg
m.current = s
storage = s // 同步兼容 facade
return nil
}
// Get 返回当前存储实例。
func (m *StorageManager) Get() Storage {
m.mu.Lock()
defer m.mu.Unlock()
return m.current
}
// Set 设置存储实例(用于注入 mock 或自定义实现)。
func (m *StorageManager) Set(s Storage) {
m.mu.Lock()
defer m.mu.Unlock()
m.current = s
storage = s
}
// --- 包级 facade(代理到 DefaultStorage,兼容存量) ---
// Init 初始化存储
func Init(cfg *config.StorageConfig) error {
return DefaultStorage.Init(cfg)
}
// GetStorage 获取全局存储实例
func GetStorage() Storage {
return DefaultStorage.Get()
}
// SetStorage 设置全局存储实例
func SetStorage(s Storage) {
DefaultStorage.Set(s)
}
// Upload 上传文件
func Upload(file *multipart.FileHeader, subdir string) (string, error) {
return storage.Upload(file, subdir)
s := GetStorage()
if s == nil {
return "", ErrStorageNotInitialized
}
return s.Upload(file, subdir)
}
// UploadFromBytes 从字节数组上传文件
func UploadFromBytes(data []byte, filename, subdir string) (string, error) {
return storage.UploadFromBytes(data, filename, subdir)
s := GetStorage()
if s == nil {
return "", ErrStorageNotInitialized
}
return s.UploadFromBytes(data, filename, subdir)
}
// GetURL 获取文件访问 URL
func GetURL(path string) string {
return storage.GetURL(path)
s := GetStorage()
if s == nil {
return ""
}
return s.GetURL(path)
}
// Delete 删除文件
func Delete(path string) error {
return storage.Delete(path)
s := GetStorage()
if s == nil {
return ErrStorageNotInitialized
}
return s.Delete(path)
}
// Get 获取文件内容
func Get(path string) ([]byte, error) {
return storage.Get(path)
s := GetStorage()
if s == nil {
return nil, ErrStorageNotInitialized
}
return s.Get(path)
}
// Exists 检查文件是否存在
func Exists(path string) bool {
return storage.Exists(path)
s := GetStorage()
if s == nil {
return false
}
return s.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
-32
View File
@@ -1,32 +0,0 @@
package wire
import (
"github.com/EthanCodeCraft/xlgo-core/cache"
)
// ServiceContainer 服务容器
type ServiceContainer struct {
// 应用可以在这里添加自己的服务
}
// 全局服务容器
var container *ServiceContainer
// InitServices 初始化所有服务
func InitServices() *ServiceContainer {
// 初始化缓存
cache.Init()
// 创建服务容器
container = &ServiceContainer{}
return container
}
// GetContainer 获取服务容器
func GetContainer() *ServiceContainer {
if container == nil {
InitServices()
}
return container
}