6 Commits

Author SHA1 Message Date
杭州明婳科技 75756dd5fb release: v1.1.1 补丁发布
- ServerConfig.Host 绑定地址字段
- 面向用户文案统一中文
- 修正 README 过时/错误描述(含会致启动失败的配置示例)+ 重写首段介绍

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:19:26 +08:00
杭州明婳科技 8421e06fa8 docs: README 修正过时描述 + 重写首段介绍
修正 v1.1.0 后的过时/错误描述:
- 删除目录结构里已移除的 wire/ 段
- 快速开始配置示例:jwt.secret 补足 32 字节(否则被 Validate 拦截),
  expire 由 86400(秒,int) 改 "24h"(time.Duration),补 refresh_expire/issuer/algorithm
- server 段补 host/timeout/response_mode 字段
- v1.0.2 更新日志标注 WithWire 已于 v1.1.0 移除
- 目录结构补 v1.1.0 新文件:metrics/timeout/metrics route/validate/mode
- 框架特性段重写为三组(架构可注入/生产就绪/基础功能),补全 v1.1.0 能力
- 响应格式段补 Mode 开关(ModeBusiness/ModeREST)与 Custom API

重写首段介绍(原描述过于普通化、无辨识度):
- 加 tagline 点明核心差异:组件全部 Manager 化,简单调用与注入实例兼得
- 新增"为什么是 xlgo"段:对比一般 Gin 脚手架的包级单例痛点 + 对照代码
- 5 条差异化卖点(可注入/生产就绪内置/零 Fatal/默认轻量/可插拔方言)
- 30 秒上手极简可跑示例

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:13:11 +08:00
杭州明婳科技 18e4f7e891 fix: ServerConfig 加 Host 字段 + 面向用户文案统一中文
- ServerConfig 新增 Host 字段:空=监听所有接口(兼容),设值=绑定指定地址
  (127.0.0.1 仅本机 / 内网IP 绑定指定网卡),避免生产暴露 0.0.0.0
- app.go 启动地址改 host:port,日志区分"所有接口"/指定地址
- 面向用户/调用的文案统一中文:
  - middleware/recover.go: "Panic recovered"→"panic 已恢复","Panic: %v"→"服务器内部错误: %v"
  - middleware/logger.go: 5 处日志消息改中文
  - middleware/metrics.go: 3 个 Prometheus Help 改中文
  - app.go/database/manager.go/logger.go: 英文 error 改中文
- 保留英文:JSON 协议字段名、Prometheus metric Name、MySQL 错误串匹配、技术专有名词
- CLI 模板与 example 配置加 host 注释

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:12:40 +08:00
杭州明婳科技 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
44 changed files with 3111 additions and 319 deletions
+8 -1
View File
@@ -1,2 +1,9 @@
report.md
# Claude Code 协作指引(本地,不入库)
CLAUDE.md
# 临时发版辅助文件
gitHub_release_*.md
# 构建产物
*.exe
bin/
+238 -2
View File
@@ -14,6 +14,239 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
---
## [1.1.1] - 2026-06-23
> 本版本为 v1.1.0 的补丁发布:补 ServerConfig.Host 字段、统一面向用户文案为中文、修正 README 过时/错误描述。
### Added ✨
#### ServerConfig.Host(绑定地址)
`server` 新增 `host` 字段,控制监听地址:
- `host: ""`(默认)→ `:8080`,监听所有接口(0.0.0.0),向后兼容
- `host: "127.0.0.1"``127.0.0.1:8080`,仅本机(前面有 nginx 时常用)
- `host: "10.0.0.5"` → 绑定内网网卡
避免生产环境无意暴露在 0.0.0.0。启动日志相应区分"所有接口"/指定地址。
### Changed 🔄
#### 面向用户文案统一中文
v1.1.0 前部分面向用户/调用的文案为英文,与其余中文文案不一致。本次统一为中文:
- `middleware/recover.go``"Panic recovered"``"panic 已恢复"``"Panic: %v"``"服务器内部错误: %v"`(消除同文件内中英矛盾)
- `middleware/logger.go`:5 处日志消息(慢请求/请求错误/客户端请求错误/API 请求/请求)改中文
- `middleware/metrics.go`3 个 Prometheus `Help` 文本改中文
- `app.go` / `database/manager.go` / `logger/logger.go`:英文 error 改中文
**保留英文**(非文案,属协议/约定/技术必需):JSON 字段名(`code`/`msg`/`data`)、health 探针状态枚举(`ok`/`error`/`disabled`)、Prometheus metric `Name`(命名规则限制)、`database/manager.go` 中匹配 MySQL 驱动错误串的英文(`"Access denied"` 等,改了会失效)、Redis/CSRF Token/JWT/OSS 等技术专有名词。
### Fixed 🐛
#### README 错误描述修正
v1.1.0 后 README 存在过时/错误描述,照抄会导致新用户启动失败,本次修正:
- **删除目录结构里已移除的 `wire/` 段**wire 包 v1.1.0 已删)
- **快速开始配置示例**`jwt.secret` 补足 ≥32 字节(否则被 v1.1.0 `Validate` 拦截启动失败);`expire: 86400`int 秒)改为 `expire: "24h"``time.Duration`),补 `refresh_expire`/`issuer`/`algorithm`
- `server` 段补 `host`/`read_timeout`/`write_timeout`/`idle_timeout`/`shutdown_timeout`/`response_mode` 字段
- v1.0.2 更新日志标注 `WithWire` 已于 v1.1.0 移除
- 目录结构补 v1.1.0 新文件:`middleware/metrics.go``middleware/timeout.go``router/metrics.go``config/validate.go``response/mode.go`
- 框架特性段重写为三组(架构可注入 / 生产就绪 / 基础功能),补全 v1.1.0 能力
- 响应格式段补 `Mode` 开关(`ModeBusiness`/`ModeREST`)与 `Custom` API
#### README 首段重写
原首段描述("轻量级 Web 开发框架,提供完整后端基础设施")过于普通化,适用于多数 Gin 脚手架,无辨识度。重写为:
- tagline 点明核心差异:组件全部 Manager 化,简单调用与注入实例兼得
- "为什么是 xlgo"段:对比一般 Gin 脚手架的包级单例痛点 + 对照代码
- 5 条差异化卖点(可注入 / 生产就绪内置 / 零 Fatal / 默认轻量 / 可插拔方言)
- 30 秒上手极简可跑示例
### 升级说明 🛠️
从 v1.1.0 升级无破坏性变更,`go get github.com/EthanCodeCraft/xlgo-core@v1.1.1` 即可。`host` 字段默认空,行为与 v1.1.0 一致。
---
## [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 + 依赖复查。
@@ -341,8 +574,11 @@ v1.0.2 引入可插拔方言注册表后,`gorm.io/driver/postgres` 成为直
- 基础框架功能
- 完整示例代码
[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.0.3...HEAD
[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.1.1...HEAD
[1.1.1]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.1.1
[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/releases/tag/v1.0.2
[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
+184 -23
View File
@@ -6,22 +6,85 @@
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
</p>
xlgo 是一个基于 Go + Gin 的轻量级 Web 开发框架,提供了完整的后端开发基础设施,包括配置管理、数据库访问、缓存、认证、日志、文件存储等常用功能
> 基于 Go + Gin 的后端开发框架。**组件全部 Manager 化**——既能像脚本一样用包级函数简单调用,又能像正经框架一样注入实例、跑多套、塞 mock 做单测
## 为什么是 xlgo
多数 Gin 脚手架把数据库、Redis、缓存、JWT、日志做成**包级全局单例**:写小项目顺手,但一旦要做单元测试、同进程跑多套实例、或替换实现,就动弹不得。xlgo 把这些组件全部抽象成 `XxxManager`,同时保留包级便捷函数做 facade——**简单用法零成本,复杂场景不封顶**:
```go
// 简单用法:包级函数,跟单例脚手架一样直接
database.InitDB(cfg)
db := database.GetDB()
// 同一份代码,也能注入实例 / 跑多套 / 塞 mock
myDB := database.NewManager(cfg)
myDB.Open(ctx) // 独立实例,不受全局影响
database.SetDefaultManager(myDB) // 或提升为全局默认
mockCache := &fakeCacheSvc{}
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入
```
### 区别于一般 Gin 脚手架的几点
- **组件可注入** — database / redis / cache / jwt / storage / logger 均为 `Manager` + 全局 facade 双轨,支持多实例与 mock 注入,而非写死的包级单例
- **生产就绪内置** — `/livez` `/readyz`、Prometheus `/metrics`、主库探活自愈、请求级超时、优雅关闭(等待 in-flight goroutine)开箱即用,不用自己搭
- **框架内零 Fatal** — 错误一律返回由调用方处理,框架代码不 `panic` 杀进程
- **默认轻量、按需启用** — `New()` 什么都不开,`NewFullStack()` 一键全开,中间任意组合;`Without*` 精细排除
- **可插拔数据库方言** — GORM 方言注册表,内置 MySQL / PostgreSQL,可扩展 SQLite / ClickHouse 等
### 30 秒上手
```go
package main
import (
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
)
func main() {
app := xlgo.New(
xlgo.WithConfigPath("./config.yaml"),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
)
app.GetRouter().GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"hello": "xlgo"})
})
_ = app.Run()
}
```
## 框架特性
- **配置管理** - 支持 YAML 配置文件,环境变量覆盖,配置热更新
- **数据库** - 基于 GORM 的可插拔方言注册表,内置 MySQL / PostgreSQL,可注册任意 GORM 驱动;支持自动迁移、重试、连接池、读写分离
- **缓存** - Redis 缓存,支持分布式缓存、键前缀、TTL,SCAN 优化
- **认证** - JWT 认证,支持 Token 黑名单、刷新机制
- **日志** - 分级日志(API、数据库),日志轮转
- **中间件** - CORS、限流、日志、认证、CSRF 防护
- **文件存储** - 本地存储 + 阿里云 OSS 支持
- **实时通信** - SSE 流式响应 + WebSocket 支持
- **定时任务** - 内置任务调度器
- **验证器** - 请求参数验证,支持自定义错误消息
- **错误处理** - 统一错误码体系
- **CLI 工具** - 脚手架工具,快速创建项目和代码
**架构与可注入性**
- **组件 Manager 化** - database / redis / cache / jwt / storage / logger 均为 `XxxManager` + 全局 facade 双轨,支持多实例与测试注入 mock
- **可插拔数据库方言** - GORM 方言注册表,内置 MySQL / PostgreSQL,可注册 SQLite / ClickHouse 等任意驱动
- **读写分离** - 主库 + 多副本,`ReplicaPicker` 策略可插拔(轮询 / 随机 / 自定义),context 路由强制主库
- **Lifecycle Hooks** - `OnInit / OnStart / OnReady / OnStop` 生命周期钩子,可插拔启动/关闭逻辑
**生产就绪**
- **健康探针** - `/livez`(存活)+ `/readyz`(就绪)+ `/health`(兼容),K8s probe 友好
- **Prometheus 指标** - `/metrics` 端点 + 请求计数 / 耗时 / 在飞数采集中间件
- **依赖健康自愈** - 主库后台探活,连续失败标记不健康联动 503;从库失败临时剔除、恢复自动纳入
- **请求级超时** - `middleware.Timeout` 级联取消下游 GORM/Redis
- **优雅关闭** - `App.Go` 管理后台 goroutineShutdown 时 cancel + 等待退出(带超时)
- **配置校验** - 启动期 `Validate` 拦截非法配置(端口/密钥/必填字段),错误前置
**基础功能**
- **配置管理** - YAML + 环境变量覆盖 + 热更新,`time.Duration` 字符串解析(`"24h"`
- **缓存** - Redis 缓存,键前缀、TTL、SCAN 优化、分布式锁
- **认证** - JWT 认证,Token 黑名单、刷新机制,HMAC 算法可选
- **日志** - 分级日志(通用 / API / DB 三分流),lumberjack 轮转
- **中间件** - CORS、限流(内存 + Redis 版)、请求日志、认证、CSRF、RequestID、Recover
- **文件存储** - 本地 + 阿里云 OSS
- **实时通信** - SSE 流式响应 + WebSocket
- **定时任务** - 内置调度器(interval / daily / weekly / cron
- **验证器** - 请求参数验证,自定义错误消息
- **响应模式** - `ModeBusiness`(业务码,默认)/ `ModeREST`(按错误码映射 HTTP status)一键切换
- **CLI 工具** - 脚手架 + 代码生成(handler/repository/model/service
## 快速开始
@@ -47,8 +110,14 @@ go mod tidy
```yaml
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
port: 8080
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business # business(默认,全200+业务码) 或 rest(按错误码映射HTTP status)
database:
driver: mysql # mysql(默认)或 postgres
@@ -68,8 +137,11 @@ redis:
db: 0
jwt:
secret: your_jwt_secret_key
expire: 86400
secret: your_jwt_secret_key_at_least_32_bytes # ≥32 字节,否则启动期被 Validate 拦截
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
refresh_expire: "168h" # 刷新 token 过期时间
issuer: xlgo
algorithm: HS256 # HS256(默认)/HS384/HS512
storage:
driver: local
@@ -94,6 +166,41 @@ 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` ...)的包名与目录名一致,无需别名。
---
@@ -539,6 +646,16 @@ response.Unauthorized(c, "请先登录")
response.NotFound(c, "资源不存在")
response.ServerError(c, "服务器错误")
response.RateLimit(c)
// 显式指定 HTTP status(不受 Mode 影响)
response.Custom(c, http.StatusBadRequest, response.CodeFail, "参数错误", nil)
```
**响应模式**v1.1.0):默认 `ModeBusiness`,所有响应 HTTP 200 + 业务码(兼容存量)。切换 `ModeREST` 后,失败响应按错误码映射对应 HTTP status401/404/429/500...),body 仍带业务码,便于 APM / Prometheus / 网关按 status 区分异常:
```go
response.SetMode(response.ModeREST) // 代码切换
// 或在 config.yaml 配置:server.response_mode: rest
```
---
@@ -603,15 +720,16 @@ xlgo/
├── compress/
│ └── compress.go # Gzip/Zip 压缩解压
├── config/
── config.go # 配置管理(支持热更新)
── config.go # 配置管理(支持热更新、time.Duration 解析
│ └── validate.go # 配置校验(启动期拦截非法配置)
├── console/
│ └── console.go # 彩色控制台输出
├── cron/
│ └── cron.go # 定时任务调度
├── database/
│ ├── manager.go # 数据库管理器(主从、Picker、Init/Close/HealthCheck
│ ├── manager.go # 数据库管理器(主从、Picker、探活自愈、Init/Close/HealthCheck
│ ├── dialect.go # GORM 方言注册表(mysql / postgres,可扩展)
│ └── redis.go # Redis 连接
│ └── redis.go # Redis 连接管理器(RedisManager
├── handler/
│ └── handler.go # 基础处理器(类型安全参数获取)
├── jwt/
@@ -624,18 +742,22 @@ xlgo/
│ ├── cors.go # CORS 跨域中间件
│ ├── csrf.go # CSRF 防护中间件
│ ├── logger.go # 请求日志中间件
│ ├── metrics.go # Prometheus 指标中间件
│ ├── ratelimit.go # 限流中间件
│ ├── requestid.go # 请求ID中间件
── recover.go # Panic恢复中间件
── recover.go # Panic恢复中间件
│ └── timeout.go # 请求级超时中间件
├── model/
│ └── base.go # 基础模型
├── repository/
│ └── repository.go # 基础仓库(泛型CRUD
├── response/
│ ├── response.go # 统一响应格式
│ ├── mode.go # 响应模式开关(business / REST
│ └── error.go # 统一错误码
├── router/
── router.go # 路由注册中心(模块化/版本化)
── router.go # 路由注册中心(模块化/版本化、livez/readyz/health
│ └── metrics.go # Prometheus /metrics 端点
├── sse/
│ └── sse.go # SSE 流式响应
├── storage/
@@ -659,8 +781,6 @@ xlgo/
│ ├── validator.go # 请求验证器
│ ├── password.go # 密码强度验证
│ └── hash.go # 密码加密
├── wire/
│ └── wire.go # 依赖注入
└── ws/
└── ws.go # WebSocket 支持
```
@@ -699,6 +819,47 @@ docker run -d -p 8080:8080 xlgo-app:latest
> 完整变更历史见 [CHANGELOG.md](./CHANGELOG.md)。
### v1.1.1 (2026-06-23)
> v1.1.0 的补丁发布:补 `ServerConfig.Host` 绑定地址、面向用户文案统一中文、修正 README 过时/错误描述(含会致启动失败的配置示例)。
- **ServerConfig.Host** - `server.host` 控制监听地址,空=所有接口(兼容),`127.0.0.1`=仅本机,内网 IP=绑定指定网卡
- **文案统一中文** - recover/logger/metrics 等面向用户文案统一中文(保留协议字段名、Prometheus metric Name、MySQL 错误串匹配等英文)
- **README 修正** - 删 wire 段、修配置示例(secret≥32 字节 + expire 改 Duration)、补 v1.1.0 新文件、重写首段介绍
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.1.1`(无破坏性变更)
### v1.1.0 (2026-06-23)
> 本版本定位为 **HA & Manager 化 release**:高可用与生产就绪改进 + 组件 Manager 化。含少量破坏性变更,升级前请阅读 [CHANGELOG 升级说明](./CHANGELOG.md#升级说明)。
- **组件 Manager 化** - storage/cache/redis/jwt/logger 五组件新增 `XxxManager` + `SetDefaultXxxManager`,包级 facade 保留兼容,支持多实例与测试注入
- **Lifecycle Hooks** - `WithHook(Hook{OnInit/OnStart/OnReady/OnStop})` 生命周期钩子
- **App.Go** - 受管理的后台 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
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.1.0`(⚠️ 含破坏性变更,见升级说明)
### v1.0.4 (2026-06-22)
> 本版本定位为 **DX & Docs release**:开发体验与文档改进,无破坏性 API 变更。
- **CLI 多模板** - `xlgo new --template minimal/api/fullstack`,支持轻量 HTTP / 标准业务 / 全组件三种脚手架
- **examples/ 目录** - 新增 `examples/minimal`(无依赖可跑)与 `examples/full`mysql+redis+jwt+user CRUD)可运行示例
- **模块路径文档** - 明确说明 module path `xlgo-core` 与 package name `xlgo` 的关系,补完整 import 示例(保留 `-core`xlgo 多产品系列)
- **Without* 定位** - 文档化 `Without*` Option 主要用于 `NewFullStack` 后排除单项
- **文档结构** - 规划文档归档到 `docs/plans/`,新增 `docs/README.md` 索引
升级:`go get github.com/EthanCodeCraft/xlgo-core@v1.0.4`(无破坏性变更)
### v1.0.3 (2026-06-22)
> 本版本定位为 **bug fix release**:收口 v1.0.2 引入的破坏性清理,并修复 4 个轻量 bug + 依赖复查。完整说明见 [CHANGELOG.md#unreleased](./CHANGELOG.md#unreleased)。
@@ -841,7 +1002,7 @@ response.Fail(c, "用户名格式错误")
#### App 启动流程
- **轻量默认** - `xlgo.New()` 默认不再初始化 MySQL / Redis / Storage,也不注册 `/health``/swagger/*`;通过 Option 显式启用
- **组件 Option** - 新增 `WithLogger / WithMySQL / WithRedis / WithStorage / WithWire / WithHealthRoutes / WithSwaggerRoutes / WithDefaultRoutes / WithAutoMigrate` 及对应 `WithoutXxx` 关闭项
- **组件 Option** - 新增 `WithLogger / WithMySQL / WithRedis / WithStorage / WithHealthRoutes / WithSwaggerRoutes / WithDefaultRoutes / WithAutoMigrate` 及对应 `WithoutXxx` 关闭项(注:`WithWire` 曾在此版本新增,已于 v1.1.0 随 wire 包删除而移除)
- **迁移控制** - 新增 `Migrator` 类型与 `WithMigrator / WithModels`,注册时自动开启 `WithAutoMigrate``WithoutAutoMigrate` 可显式关闭;不再强制调用空的 `database.AutoMigrate()`
- **batteries-included** - 新增 `WithFullStack` / `NewFullStack` / `RunFullStack`,一键启用全部默认组件
- **错误传播** - 框架初始化失败一律 `return error`,不再在框架内部直接 `Fatalf` 退出进程
+236 -71
View File
@@ -7,16 +7,19 @@ 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"
@@ -24,7 +27,7 @@ import (
// Version 框架版本号。发版时只改这一处,避免版本字面量散落各处。
// CLIxlgo version)、脚手架生成的 go.mod 等均引用此常量。
const Version = "1.0.3"
const Version = "1.1.1"
// HealthCheckFunc 健康检查函数
type HealthCheckFunc func(context.Context) error
@@ -32,6 +35,21 @@ 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
@@ -50,15 +68,27 @@ type App struct {
enableMySQL bool
enableRedis bool
enableStorage bool
enableWire 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 应用选项
@@ -100,11 +130,6 @@ func WithStorage() Option {
return func(a *App) { a.enableStorage = true }
}
// WithWire 启用服务容器
func WithWire() Option {
return func(a *App) { a.enableWire = true }
}
// WithHealthRoutes 启用健康检查路由
func WithHealthRoutes() Option {
return func(a *App) { a.enableHealth = true }
@@ -123,7 +148,34 @@ func WithDefaultRoutes() Option {
}
}
// WithoutLogger 关闭日志
// 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 }
}
@@ -143,11 +195,14 @@ func WithoutStorage() Option {
return func(a *App) { a.enableStorage = false }
}
// WithoutWire 关闭服务容器
// WithoutWire 已移除(wire 包在 v1.1.0 删除)。保留空函数仅为编译兼容,
// 调用无副作用。后续版本将删除。
func WithoutWire() Option {
return func(a *App) { a.enableWire = false }
return func(a *App) {}
}
// WithAutoMigrate 启用数据库迁移(需配合 WithMigrator/WithModels 注册迁移逻辑)
func WithAutoMigrate() Option {
return func(a *App) { a.enableAutoMigrate = true }
@@ -205,6 +260,20 @@ func WithMigrator(m Migrator) Option {
}
}
// 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 {
@@ -244,10 +313,13 @@ func WithFullStack() Option {
a.enableMySQL = true
a.enableRedis = true
a.enableStorage = true
a.enableWire = 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"})
}
}
@@ -257,6 +329,8 @@ 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)
@@ -300,10 +374,14 @@ func (a *App) Init() error {
if err := database.InitDB(cfg); err != nil {
return fmt.Errorf("初始化数据库失败: %w", err)
}
a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "mysql", Check: func(context.Context) error {
a.healthChecks = append(a.healthChecks, router.HealthCheck{Name: "mysql", Check: func(ctx context.Context) error {
// 优先读探活缓存标记(#21),避免每次探针都同步 ping
if !database.IsDBHealthy() {
return errors.New("mysql 主库探活不健康")
}
status := database.HealthCheck()
if !status["master"] {
return errors.New("mysql master unavailable")
return errors.New("mysql 主库不可用")
}
return nil
}})
@@ -322,8 +400,9 @@ func (a *App) Init() error {
}
}
if a.enableWire {
wire.InitServices()
if a.enableRedis {
// Redis 就绪后初始化缓存(cache 依赖 Redis 客户端)
cache.Init()
}
if a.enableAutoMigrate && len(a.migrators) > 0 {
@@ -341,8 +420,13 @@ func (a *App) Init() error {
}
}
a.router.Use(gin.Recovery())
// 全局中间件链: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))
}
for _, staticRoute := range a.staticRoutes {
a.router.Static(staticRoute.relativePath, staticRoute.root)
@@ -354,9 +438,49 @@ func (a *App) Init() error {
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
}
@@ -399,26 +523,63 @@ func (a *App) StartServer() error {
if a.config == nil {
return config.ErrConfigNotLoaded
}
port := a.config.Server.Port
srvCfg := a.config.Server
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(),
}
useUnix := strings.TrimSpace(srvCfg.UnixSocket) != ""
if useUnix {
a.server.Addr = srvCfg.UnixSocket
} else {
// Host 为空时监听所有接口(":port");设值时绑定指定地址("host:port"),
// 用于仅本机(127.0.0.1)或绑定内网网卡的场景
a.server.Addr = fmt.Sprintf("%s:%d", srvCfg.Host, 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() {
logger.Infof("服务器启动,监听端口 %d", port)
if err := a.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
if useUnix {
logger.Infof("服务器启动,监听 unix socket %s", srvCfg.UnixSocket)
} else if srvCfg.Host != "" {
logger.Infof("服务器启动,监听 %s:%d", srvCfg.Host, srvCfg.Port)
} 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)
@@ -437,10 +598,29 @@ func (a *App) StartServer() error {
// Shutdown 优雅关闭应用
func (a *App) Shutdown() error {
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()
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 {
@@ -452,6 +632,18 @@ func (a *App) Shutdown() error {
}
}
// 等待业务 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()
@@ -472,6 +664,24 @@ func (a *App) Shutdown() error {
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 获取路由注册中心(用于动态注册)
func (a *App) GetRegistry() *router.Registry {
return a.registry
@@ -486,48 +696,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)
defer signal.Stop(quit)
<-quit
logger.Info("收到关闭信号,开始优雅关闭...")
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var errs []error
if err := server.Shutdown(ctx); err != nil {
logger.Warnf("服务器关闭超时: %v", err)
errs = append(errs, err)
if closeErr := server.Close(); closeErr != nil {
errs = append(errs, closeErr)
}
}
for _, cleanup := range cleanupFuncs {
if cleanup != nil {
cleanup()
}
}
logger.Info("应用已优雅关闭")
return errors.Join(errs...)
}
+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")
}
}
+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()
}
+61 -24
View File
@@ -19,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 {
@@ -39,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),
@@ -54,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, xlgo.Version),
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 {
@@ -84,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")
+10 -3
View File
@@ -11,15 +11,22 @@ 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`)
}
+188 -6
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
@@ -71,11 +75,16 @@ func registerRoutes(r *gin.RouterGroup) {
env: "dev" # dev/test/prod
debug: true
base_url: "http://localhost:8080"
token_expire: 86400 # Token过期时间(秒)
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机;内网IP=绑定指定网卡
port: 8080
mode: development
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
@@ -95,8 +104,181 @@ 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
local:
path: ./public
base_url: http://localhost:8080/public
log:
dir: ./logs
max_size: 100
max_backups: 30
max_age: 30
compress: true
`,
// MainMinimal minimal 模板:轻量 HTTP 服务,不依赖 MySQL/Redis/Storage
MainMinimal: `package main
import (
"flag"
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
)
var configPath string
func init() {
flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径")
}
func main() {
flag.Parse()
app := xlgo.New(
xlgo.WithConfigPath(configPath),
xlgo.WithLogger(),
xlgo.WithHealthRoutes(),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
)
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
}
func registerRoutes(r *gin.RouterGroup) {
api := r.Group("/api/v1")
api.GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Hello {{.Name}}!"})
})
}
`,
// ConfigMinimal minimal 模板配置:仅 app + server + log,无数据库/Redis
ConfigMinimal: `app:
name: "{{.Name}}"
site_name: "{{.NameLower}}"
version: "1.0.0"
env: "dev"
debug: true
base_url: "http://localhost:8080"
server:
port: 8080
mode: development
log:
dir: ./logs
max_size: 100
max_backups: 30
max_age: 30
compress: true
`,
// MainFull fullstack 模板:全组件(FullStack),含 Swagger + Storage
MainFull: `package main
import (
"flag"
"fmt"
"os"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/middleware"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
)
var configPath string
func init() {
flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径")
}
func main() {
flag.Parse()
// NewFullStack 一键启用全部组件:Logger/MySQL/Redis/Storage/Wire/Health/Swagger/AutoMigrate
// 如需排除个别组件,追加对应 Without* Option,例如 xlgo.WithoutSwaggerRoutes()
app := xlgo.NewFullStack(
xlgo.WithConfigPath(configPath),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
// xlgo.WithModels(&User{}, &Order{}), // 注册模型以启用自动迁移
)
if err := app.Run(); err != nil {
fmt.Printf("启动失败: %v\n", err)
os.Exit(1)
}
}
func registerRoutes(r *gin.RouterGroup) {
api := r.Group("/api/v1")
api.GET("/", func(c *gin.Context) {
response.Success(c, gin.H{"message": "Welcome to {{.Name}} (fullstack)!"})
})
}
`,
// ConfigFull fullstack 模板配置:全组件配置
ConfigFull: `app:
name: "{{.Name}}"
site_name: "{{.NameLower}}"
version: "1.0.0"
env: "dev"
debug: true
base_url: "http://localhost:8080"
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
port: 8080
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business
database:
driver: mysql # mysql(默认)或 postgres
host: localhost
port: 3306
user: root
password: your_password
name: {{.NameLower}}
max_idle_conns: 10
max_open_conns: 100
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
+98 -14
View File
@@ -4,8 +4,10 @@ import (
"fmt"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
)
@@ -35,13 +37,12 @@ type Config struct {
// - 站点追踪: 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 获取站点别名,如果未设置则返回空字符串
@@ -81,10 +82,75 @@ 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
Host string `mapstructure:"host"` // 绑定地址,空=监听所有接口(0.0.0.0)"127.0.0.1"=仅本机;内网IP=绑定指定网卡
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
}
// 数据库驱动常量
@@ -165,6 +231,12 @@ type DatabaseConfig struct {
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 根据驱动返回连接字符串。设置了 CustomDSN 时优先返回 CustomDSN
@@ -208,8 +280,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 短信配置
@@ -336,6 +411,12 @@ func newViper(configPath string) *viper.Viper {
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 == "" {
@@ -348,9 +429,12 @@ func (m *Manager) Load() (*Config, error) {
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
if err := unmarshalConfig(v, &cfg); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, err
}
m.mu.Lock()
m.v = v
@@ -401,7 +485,7 @@ func (m *Manager) StartWatcher() error {
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
}
@@ -473,7 +557,7 @@ func (m *Manager) Reload() error {
}
var newCfg Config
if err := v.Unmarshal(&newCfg); err != nil {
if err := unmarshalConfig(v, &newCfg); err != nil {
return fmt.Errorf("解析配置文件失败: %w", err)
}
+2 -2
View File
@@ -162,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 {
+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)
}
}
+151 -4
View File
@@ -62,6 +62,13 @@ type Manager struct {
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 创建数据库管理器
@@ -96,19 +103,142 @@ func (m *Manager) Replicas() []*gorm.DB {
return m.replicas
}
// Replica 按策略选择一个从库;无从库时返回主库
// 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(m.replicas); db != nil {
if db := m.picker.Pick(pool); db != nil {
return db
}
}
return m.replicas[0]
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 根据上下文选择数据库
@@ -179,7 +309,7 @@ func (m *Manager) Close() error {
// HealthCheck 健康检查,主库不可达时返回错误
func (m *Manager) HealthCheck(ctx context.Context) error {
if m.master == nil {
return errors.New("database master not initialized")
return errors.New("数据库主库未初始化")
}
sqlDB, err := m.master.DB()
if err != nil {
@@ -222,6 +352,10 @@ func (m *Manager) InitDB(cfg *config.Config) error {
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("数据库主库连接成功",
@@ -471,3 +605,16 @@ func HealthCheck() map[string]bool {
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)
}
+77 -18
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,48 +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
}
// CloseRedis 关闭 Redis 连接
func CloseRedis() error {
if RedisClient == nil {
// Close 关闭 Redis 连接
func (m *RedisManager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.client == nil {
return nil
}
err := RedisClient.Close()
err := m.client.Close()
m.client = nil
RedisClient = nil
return err
}
// HealthCheckRedis Redis 健康检查
func HealthCheckRedis(ctx context.Context) error {
if RedisClient == nil {
// 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 RedisClient.Ping(ctx).Err()
return client.Ping(ctx).Err()
}
// --- 包级 facade(代理到 DefaultRedis,兼容存量) ---
// InitRedis 初始化 Redis 连接
func InitRedis(cfg *config.Config) error {
return DefaultRedis.Init(cfg)
}
// CloseRedis 关闭 Redis 连接
func CloseRedis() error {
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()
}
+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) 各版本章节。
+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 忽略),用户网页创建。
+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` 包校验入参。
+44
View File
@@ -0,0 +1,44 @@
app:
name: "xlgo-full-example"
env: "dev"
debug: true
server:
host: "" # 绑定地址,空=监听所有接口;127.0.0.1=仅本机
port: 8082
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business # business(默认) 或 rest
database:
driver: mysql
host: localhost
port: 3306
user: root
password: your_password
name: xlgo_example
max_idle_conns: 10
max_open_conns: 100
redis:
host: localhost
port: 6379
password: ""
db: 0
jwt:
secret: change-me-to-a-long-random-secret-at-least-32-chars
expire: "24h"
refresh_expire: "168h"
issuer: xlgo
algorithm: HS256
log:
dir: ./logs
max_size: 100
max_backups: 30
max_age: 30
compress: true
+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!"})
})
}
+8 -1
View File
@@ -10,6 +10,8 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
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
@@ -33,6 +35,7 @@ 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
@@ -65,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
@@ -83,6 +89,7 @@ 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.55.0 // indirect
+18
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=
@@ -97,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=
@@ -107,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=
@@ -124,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=
@@ -199,6 +215,8 @@ 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=
+90 -13
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 声明
@@ -45,13 +48,29 @@ func generateJTI() (string, error) {
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 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,23 +84,59 @@ 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
func GenerateToken(userID uint, username, role, userType string) (string, error) {
@@ -100,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))
}
@@ -131,15 +186,37 @@ 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
func ParseToken(tokenString string) (*Claims, 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)
+47 -5
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -27,6 +28,27 @@ var (
fileWriters []*lumberjack.Logger
)
// 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 的分流策略:
@@ -37,9 +59,9 @@ var (
// 关键修复(v1.0.3):旧实现把 apiCore 和 dbCore 都 Tee 进通用 Logger
// 导致每条 logger.Info(...) 都会同时落到 api.log + database.log + console
// 三份,磁盘占用翻倍且分流形同虚设。新实现通用 Logger 只走独立的 app.log。
func Init(cfg *config.Config) error {
func (m *LogManager) Init(cfg *config.Config) error {
if cfg == nil {
return errors.New("logger: config is nil")
return errors.New("logger: 配置为空")
}
// 确保日志目录存在
@@ -96,6 +118,8 @@ func Init(cfg *config.Config) error {
zap.AddCaller(), zap.AddCallerSkip(1),
)
m.mu.Lock()
defer m.mu.Unlock()
// 全部构造成功后再原子替换全局变量,避免半初始化状态。
// 同时关闭旧 writer 释放句柄(重复 Init 场景,主要服务于测试)。
closeFileWriters()
@@ -104,10 +128,16 @@ func Init(cfg *config.Config) error {
apiLog = newAPILog
dbLog = newDBLog
fileWriters = []*lumberjack.Logger{appWriter, apiWriter, dbWriter}
m.cfg = cfg
return nil
}
// 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{
@@ -134,7 +164,7 @@ func closeFileWriters() {
// 注意:在 Windows / 部分 *nix 平台上对 stdout/stderr 调用 Sync 会返回
// "invalid argument" / "inappropriate ioctl for device",属于 zap 已知行为,
// 这里把这类错误识别并忽略,只返回真实的写入失败。
func Sync() error {
func (m *LogManager) Sync() error {
var errs []error
for _, l := range []*zap.Logger{Logger, apiLog, dbLog} {
if l == nil {
@@ -147,24 +177,36 @@ func Sync() error {
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 Close() error {
syncErr := Sync()
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 {
+5 -5
View File
@@ -124,13 +124,13 @@ func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
if latency > cfg.SlowRequestThreshold {
// 慢请求警告
fields = append(fields, zap.Bool("slow_request", true))
logger.APILog().Warn("Slow API Request", fields...)
logger.APILog().Warn("慢请求", fields...)
} else if status >= 500 {
logger.APILog().Error("API Request Error", fields...)
logger.APILog().Error("请求错误", fields...)
} else if status >= 400 {
logger.APILog().Warn("API Request Client Error", fields...)
logger.APILog().Warn("客户端请求错误", fields...)
} else {
logger.APILog().Info("API Request", fields...)
logger.APILog().Info("API 请求", fields...)
}
}
}
@@ -232,7 +232,7 @@ func LoggerMinimal() gin.HandlerFunc {
start := time.Now()
c.Next()
logger.APILog().Info("Request",
logger.APILog().Info("请求",
zap.Int("status", c.Writer.Status()),
zap.String("method", c.Request.Method),
zap.String("path", path),
+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: "HTTP 请求总数。",
},
[]string{"method", "route", "status"},
)
httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP 请求耗时(秒)。",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "route"},
)
httpRequestsInFlight = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "http_requests_in_flight",
Help: "正在处理的 HTTP 请求数。",
},
)
)
// Metrics Prometheus 指标中间件(#18)。记录请求计数、耗时、在飞数。
// route 标签用 c.FullPath()(路由模板,如 /api/v1/users/:id),避免高基数。
func Metrics() gin.HandlerFunc {
return func(c *gin.Context) {
httpRequestsInFlight.Inc()
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)
}
}
+3 -3
View File
@@ -20,7 +20,7 @@ func Recover() gin.HandlerFunc {
requestID := GetRequestID(c)
// 记录错误日志
logger.Error("Panic recovered",
logger.Error("panic 已恢复",
zap.String("request_id", requestID),
zap.String("error", fmt.Sprintf("%v", err)),
zap.String("path", c.Request.URL.Path),
@@ -45,7 +45,7 @@ func RecoverWithDetail() gin.HandlerFunc {
if err := recover(); err != nil {
requestID := GetRequestID(c)
logger.Error("Panic recovered",
logger.Error("panic 已恢复",
zap.String("request_id", requestID),
zap.String("error", fmt.Sprintf("%v", err)),
zap.String("path", c.Request.URL.Path),
@@ -54,7 +54,7 @@ func RecoverWithDetail() gin.HandlerFunc {
)
// 开发环境返回详细错误
response.FailWithCode(c, response.CodeServerError, fmt.Sprintf("Panic: %v", err))
response.FailWithCode(c, response.CodeServerError, fmt.Sprintf("服务器内部错误: %v", err))
c.AbortWithStatus(http.StatusInternalServerError)
}
}()
+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)
}
}
+2 -11
View File
@@ -185,19 +185,10 @@ var _ = map[int]string{
// 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 -30
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 分页响应
+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()))
}
+53 -23
View File
@@ -16,35 +16,65 @@ type HealthCheck struct {
Disabled bool
}
// RegisterHealthRoute 注册健康检查路由
// 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) {
if len(checks) == 0 {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
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})
})
}
status := "ok"
code := http.StatusOK
result := make(map[string]string, len(checks))
ctx := c.Request.Context()
for _, check := range checks {
if check.Name == "" {
continue
}
if check.Disabled || check.Check == nil {
result[check.Name] = "disabled"
continue
}
if err := check.Check(ctx); err != nil {
result[check.Name] = "error"
status = "error"
code = http.StatusServiceUnavailable
continue
}
result[check.Name] = "ok"
// 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})
})
}
+76 -18
View File
@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
@@ -298,82 +299,139 @@ func (s *OSSStorage) Exists(path string) bool {
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 storage
return DefaultStorage.Get()
}
// SetStorage 设置全局存储实例
func SetStorage(s Storage) {
storage = s
DefaultStorage.Set(s)
}
// Upload 上传文件
func Upload(file *multipart.FileHeader, subdir string) (string, error) {
if storage == nil {
s := GetStorage()
if s == nil {
return "", ErrStorageNotInitialized
}
return storage.Upload(file, subdir)
return s.Upload(file, subdir)
}
// UploadFromBytes 从字节数组上传文件
func UploadFromBytes(data []byte, filename, subdir string) (string, error) {
if storage == nil {
s := GetStorage()
if s == nil {
return "", ErrStorageNotInitialized
}
return storage.UploadFromBytes(data, filename, subdir)
return s.UploadFromBytes(data, filename, subdir)
}
// GetURL 获取文件访问 URL
func GetURL(path string) string {
if storage == nil {
s := GetStorage()
if s == nil {
return ""
}
return storage.GetURL(path)
return s.GetURL(path)
}
// Delete 删除文件
func Delete(path string) error {
if storage == nil {
s := GetStorage()
if s == nil {
return ErrStorageNotInitialized
}
return storage.Delete(path)
return s.Delete(path)
}
// Get 获取文件内容
func Get(path string) ([]byte, error) {
if storage == nil {
s := GetStorage()
if s == nil {
return nil, ErrStorageNotInitialized
}
return storage.Get(path)
return s.Get(path)
}
// Exists 检查文件是否存在
func Exists(path string) bool {
if storage == nil {
s := GetStorage()
if s == nil {
return false
}
return storage.Exists(path)
return s.Exists(path)
}
-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
}