1 Commits

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:41:05 +08:00
31 changed files with 1787 additions and 257 deletions
+104 -1
View File
@@ -14,6 +14,108 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
---
## [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。
@@ -414,7 +516,8 @@ v1.0.2 引入可插拔方言注册表后,`gorm.io/driver/postgres` 成为直
- 基础框架功能
- 完整示例代码
[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.0.4...HEAD
[Unreleased]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.1.0...HEAD
[1.1.0]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.1.0
[1.0.4]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.4
[1.0.3]: https://github.com/EthanCodeCraft/xlgo-core/releases/tag/v1.0.3
[1.0.2]: https://github.com/EthanCodeCraft/xlgo-core/compare/v1.0.1...v1.0.3
+19
View File
@@ -734,6 +734,25 @@ docker run -d -p 8080:8080 xlgo-app:latest
> 完整变更历史见 [CHANGELOG.md](./CHANGELOG.md)。
### 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 变更。
+226 -69
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.4"
const Version = "1.1.0"
// 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,6 +148,29 @@ func WithDefaultRoutes() Option {
}
}
// 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*
@@ -147,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 }
@@ -209,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 {
@@ -248,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"})
}
}
@@ -261,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)
@@ -304,7 +374,11 @@ 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 master unhealthy (probe)")
}
status := database.HealthCheck()
if !status["master"] {
return errors.New("mysql master unavailable")
@@ -326,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 {
@@ -345,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)
@@ -358,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
}
@@ -403,26 +523,59 @@ 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 {
a.server.Addr = fmt.Sprintf(":%d", srvCfg.Port)
}
// OnStart hooks:监听端口前
for _, h := range a.hooks {
if h.OnStart != nil {
if err := h.OnStart(a); err != nil {
return fmt.Errorf("OnStart hook %q 失败: %w", h.Name, err)
}
}
}
serverErr := make(chan error, 1)
go func() {
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 {
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)
@@ -441,10 +594,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 {
@@ -456,6 +628,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()
@@ -476,6 +660,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
@@ -490,48 +692,3 @@ func (a *App) GetRouter() *gin.Engine {
func (a *App) GetServer() *http.Server {
return a.server
}
// StartServerWithPort 使用指定端口启动服务器(简化版本)
// 注意: 此函数会阻塞,需要自行处理信号
func StartServerWithPort(r *gin.Engine, port int) error {
server := &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
}
logger.Infof("服务器启动,监听端口 %d", port)
return server.ListenAndServe()
}
// GracefulShutdown 优雅关闭辅助函数
func GracefulShutdown(server *http.Server, timeout time.Duration, cleanupFuncs ...func()) error {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
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()
}
+21 -7
View File
@@ -75,11 +75,15 @@ func registerRoutes(r *gin.RouterGroup) {
env: "dev" # dev/test/prod
debug: true
base_url: "http://localhost:8080"
token_expire: 86400 # Token过期时间(秒)
server:
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
@@ -99,8 +103,11 @@ redis:
db: 0
jwt:
secret: your_jwt_secret_key_here
expire: 86400
secret: your_jwt_secret_key_change_me_to_32_bytes_at_least
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
refresh_expire: "168h" # 刷新 token 过期时间
issuer: xlgo
algorithm: HS256 # HS256(默认)/HS384/HS512
storage:
driver: local
@@ -238,11 +245,15 @@ func registerRoutes(r *gin.RouterGroup) {
env: "dev"
debug: true
base_url: "http://localhost:8080"
token_expire: 86400
server:
port: 8080
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business
database:
driver: mysql # mysql(默认)或 postgres
@@ -261,8 +272,11 @@ redis:
db: 0
jwt:
secret: your_jwt_secret_key_here
expire: 86400
secret: your_jwt_secret_key_change_me_to_32_bytes_at_least
expire: "24h" # time.Duration,支持 "24h"/"30m" 等
refresh_expire: "168h" # 刷新 token 过期时间
issuer: xlgo
algorithm: HS256 # HS256(默认)/HS384/HS512
storage:
driver: local
+97 -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,74 @@ func (c *AppConfig) IsProd() bool {
return c.Env == "prod" || c.Env == "production"
}
// TLSConfig HTTPS/TLS 配置
type TLSConfig struct {
Enabled bool `mapstructure:"enabled"`
CertFile string `mapstructure:"cert_file"`
KeyFile string `mapstructure:"key_file"`
}
// ServerConfig 服务配置
type ServerConfig struct {
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // development 或 production
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // development 或 production
ReadTimeout time.Duration `mapstructure:"read_timeout"` // 读超时,如 "15s"
WriteTimeout time.Duration `mapstructure:"write_timeout"` // 写超时,如 "30s"
IdleTimeout time.Duration `mapstructure:"idle_timeout"` // 空闲超时,如 "60s"
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` // 优雅关闭超时,如 "30s"
MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 最大请求头字节数
TLS TLSConfig `mapstructure:"tls"`
UnixSocket string `mapstructure:"unix_socket"` // 非空时优先于 Port,监听 unix socket
ResponseMode string `mapstructure:"response_mode"` // business(默认) 或 rest,见 response.SetMode
}
// 默认值常量(ServerConfig 字段为零值时回退使用)
const (
defaultReadTimeout = 15 * time.Second
defaultWriteTimeout = 30 * time.Second
defaultIdleTimeout = 60 * time.Second
defaultShutdownTimeout = 30 * time.Second
defaultMaxHeaderBytes = 1 << 20 // 1MB
)
// EffectiveReadTimeout 返回生效的读超时(零值回退默认)
func (c ServerConfig) EffectiveReadTimeout() time.Duration {
if c.ReadTimeout > 0 {
return c.ReadTimeout
}
return defaultReadTimeout
}
// EffectiveWriteTimeout 返回生效的写超时(零值回退默认)
func (c ServerConfig) EffectiveWriteTimeout() time.Duration {
if c.WriteTimeout > 0 {
return c.WriteTimeout
}
return defaultWriteTimeout
}
// EffectiveIdleTimeout 返回生效的空闲超时(零值回退默认)
func (c ServerConfig) EffectiveIdleTimeout() time.Duration {
if c.IdleTimeout > 0 {
return c.IdleTimeout
}
return defaultIdleTimeout
}
// EffectiveShutdownTimeout 返回生效的关闭超时(零值回退默认)
func (c ServerConfig) EffectiveShutdownTimeout() time.Duration {
if c.ShutdownTimeout > 0 {
return c.ShutdownTimeout
}
return defaultShutdownTimeout
}
// EffectiveMaxHeaderBytes 返回生效的最大请求头字节数(零值回退默认)
func (c ServerConfig) EffectiveMaxHeaderBytes() int {
if c.MaxHeaderBytes > 0 {
return c.MaxHeaderBytes
}
return defaultMaxHeaderBytes
}
// 数据库驱动常量
@@ -165,6 +230,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 +279,11 @@ func (c *RedisConfig) Addr() string {
// JWTConfig JWT 配置
type JWTConfig struct {
Secret string `mapstructure:"secret"`
Expire int `mapstructure:"expire"` // 过期时间(秒
Secret string `mapstructure:"secret"`
Expire time.Duration `mapstructure:"expire"` // 过期时间,如 "24h"time.Duration
RefreshExpire time.Duration `mapstructure:"refresh_expire"` // 刷新 token 过期时间,如 "168h"
Issuer string `mapstructure:"issuer"` // 签发者
Algorithm string `mapstructure:"algorithm"` // 签名算法:HS256(默认)/RS256
}
// SMSConfig 短信配置
@@ -336,6 +410,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 +428,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 +484,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 +556,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)
}
}
+150 -3
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 根据上下文选择数据库
@@ -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()
}
+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 忽略),用户网页创建。
+9 -1
View File
@@ -6,6 +6,11 @@ app:
server:
port: 8082
mode: development
read_timeout: 15s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
response_mode: business # business(默认) 或 rest
database:
driver: mysql
@@ -25,7 +30,10 @@ redis:
jwt:
secret: change-me-to-a-long-random-secret-at-least-32-chars
expire: 86400
expire: "24h"
refresh_expire: "168h"
issuer: xlgo
algorithm: HS256
log:
dir: ./logs
+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)
+46 -4
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,7 +59,7 @@ 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")
}
@@ -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 {
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// #18 Prometheus 指标。三个标配:
// - http_requests_total: 请求计数(按 method/route/status 维度)
// - http_request_duration_seconds: 请求耗时直方图(按 method/route 维度)
// - http_requests_in_flight: 当前在飞请求数
var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests.",
},
[]string{"method", "route", "status"},
)
httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency in seconds.",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "route"},
)
httpRequestsInFlight = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "http_requests_in_flight",
Help: "Current number of in-flight HTTP requests.",
},
)
)
// Metrics Prometheus 指标中间件(#18)。记录请求计数、耗时、在飞数。
// route 标签用 c.FullPath()(路由模板,如 /api/v1/users/:id),避免高基数。
func Metrics() gin.HandlerFunc {
return func(c *gin.Context) {
httpRequestsInFlight.Inc()
start := time.Now()
c.Next()
httpRequestsInFlight.Dec()
elapsed := time.Since(start).Seconds()
status := strconv.Itoa(c.Writer.Status())
route := c.FullPath()
if route == "" {
route = "not_found"
}
method := c.Request.Method
httpRequestsTotal.WithLabelValues(method, route, status).Inc()
httpRequestDuration.WithLabelValues(method, route).Observe(elapsed)
}
}
+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
}