From 0c9f256dfc92ae97f4ad7834959741da93703f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=AD=E5=B7=9E=E6=98=8E=E5=A9=B3=E7=A7=91=E6=8A=80?= Date: Tue, 23 Jun 2026 10:41:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(v1.1.0):=20HA=20&=20Manager=20=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对应体检报告 #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) --- CHANGELOG.md | 105 +++++++- README.md | 19 ++ app.go | 295 +++++++++++++++++------ app_go_test.go | 43 ++++ cache/cache.go | 57 ++++- cmd/xlgo/templates.go | 28 ++- config/config.go | 111 +++++++-- config/config_test.go | 4 +- config/validate.go | 69 ++++++ config/validate_test.go | 63 +++++ database/manager.go | 153 +++++++++++- database/redis.go | 95 ++++++-- docs/plans/Version_Update_Plan_v1.1.0.md | 128 ++++++++++ examples/full/config.yaml | 10 +- go.mod | 9 +- go.sum | 18 ++ jwt/jwt.go | 103 +++++++- jwt/jwt_test.go | 4 +- logger/logger.go | 50 +++- middleware/metrics.go | 64 +++++ middleware/timeout.go | 32 +++ middleware/timeout_test.go | 72 ++++++ response/error.go | 13 +- response/mode.go | 86 +++++++ response/mode_test.go | 93 +++++++ response/response.go | 36 +-- router/livez_readyz_test.go | 55 +++++ router/metrics.go | 27 +++ router/router.go | 76 ++++-- storage/storage.go | 94 ++++++-- wire/wire.go | 32 --- 31 files changed, 1787 insertions(+), 257 deletions(-) create mode 100644 app_go_test.go create mode 100644 config/validate.go create mode 100644 config/validate_test.go create mode 100644 docs/plans/Version_Update_Plan_v1.1.0.md create mode 100644 middleware/metrics.go create mode 100644 middleware/timeout.go create mode 100644 middleware/timeout_test.go create mode 100644 response/mode.go create mode 100644 response/mode_test.go create mode 100644 router/livez_readyz_test.go create mode 100644 router/metrics.go delete mode 100644 wire/wire.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c78bfe..f924ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 生命周期管理的后台 goroutine,Shutdown 时 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 status(401/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 永不依赖外部,始终 200(K8s livenessProbe) +// GET /readyz 复用 healthChecks,失败 503(K8s 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 diff --git a/README.md b/README.md index e6198d8..6b15821 100644 --- a/README.md +++ b/README.md @@ -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** - 受管理的后台 goroutine,Shutdown 时 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 变更。 diff --git a/app.go b/app.go index 03296b7..e23e0e1 100644 --- a/app.go +++ b/app.go @@ -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 框架版本号。发版时只改这一处,避免版本字面量散落各处。 // CLI(xlgo 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 // 根 ctx,App.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 时被 cancel,fn 应在 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...) -} diff --git a/app_go_test.go b/app_go_test.go new file mode 100644 index 0000000..aff75bc --- /dev/null +++ b/app_go_test.go @@ -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") + } +} diff --git a/cache/cache.go b/cache/cache.go index f289f27..41dd440 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -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() } diff --git a/cmd/xlgo/templates.go b/cmd/xlgo/templates.go index 9448610..ee9226f 100644 --- a/cmd/xlgo/templates.go +++ b/cmd/xlgo/templates.go @@ -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 diff --git a/config/config.go b/config/config.go index c32c60a..8fd0e44 100644 --- a/config/config.go +++ b/config/config.go @@ -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) } diff --git a/config/config_test.go b/config/config_test.go index 676c018..f61dc47 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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 { diff --git a/config/validate.go b/config/validate.go new file mode 100644 index 0000000..55796c3 --- /dev/null +++ b/config/validate.go @@ -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 } diff --git a/config/validate_test.go b/config/validate_test.go new file mode 100644 index 0000000..f62596e --- /dev/null +++ b/config/validate_test.go @@ -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) + } +} diff --git a/database/manager.go b/database/manager.go index 9a52b10..977ea10 100644 --- a/database/manager.go +++ b/database/manager.go @@ -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) +} diff --git a/database/redis.go b/database/redis.go index 0b2df2b..3517465 100644 --- a/database/redis.go +++ b/database/redis.go @@ -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() } diff --git a/docs/plans/Version_Update_Plan_v1.1.0.md b/docs/plans/Version_Update_Plan_v1.1.0.md new file mode 100644 index 0000000..66382ea --- /dev/null +++ b/docs/plans/Version_Update_Plan_v1.1.0.md @@ -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.3(bug fix)与 v1.0.4(DX & 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 status,body 仍带业务码。可在 `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 化(#10,5 组件,照 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`。 + +### 组 3:App 生命周期 + +**#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-flight(histogram + 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 忽略),用户网页创建。 diff --git a/examples/full/config.yaml b/examples/full/config.yaml index 148ab3c..463b705 100644 --- a/examples/full/config.yaml +++ b/examples/full/config.yaml @@ -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 diff --git a/go.mod b/go.mod index c6e4525..1e38fe2 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 1c56b20..f8d55a3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/jwt/jwt.go b/jwt/jwt.go index 03c4b45..01e772e 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -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 ID,expiry 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() diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go index 3f5062c..f02a93b 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -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) diff --git a/logger/logger.go b/logger/logger.go index 13b31c9..4393b36 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -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 之后调用;测试场景需要清理临时目录时也应调用。 // // 调用后再次写日志不会 panic(fall 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 { diff --git a/middleware/metrics.go b/middleware/metrics.go new file mode 100644 index 0000000..7e8ab42 --- /dev/null +++ b/middleware/metrics.go @@ -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) + } +} diff --git a/middleware/timeout.go b/middleware/timeout.go new file mode 100644 index 0000000..ef0b018 --- /dev/null +++ b/middleware/timeout.go @@ -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() + } +} diff --git a/middleware/timeout_test.go b/middleware/timeout_test.go new file mode 100644 index 0000000..a309c4c --- /dev/null +++ b/middleware/timeout_test.go @@ -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) + } +} diff --git a/response/error.go b/response/error.go index eaa1d68..5cfd472 100644 --- a/response/error.go +++ b/response/error.go @@ -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}) } diff --git a/response/mode.go b/response/mode.go new file mode 100644 index 0000000..8347b28 --- /dev/null +++ b/response/mode.go @@ -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 status(401/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), + }) +} diff --git a/response/mode_test.go b/response/mode_test.go new file mode 100644 index 0000000..c32cd87 --- /dev/null +++ b/response/mode_test.go @@ -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 应非 0(Fail 的 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) + } +} diff --git a/response/response.go b/response/response.go index 3e48320..ebfaa80 100644 --- a/response/response.go +++ b/response/response.go @@ -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 分页响应 diff --git a/router/livez_readyz_test.go b/router/livez_readyz_test.go new file mode 100644 index 0000000..c8c4c45 --- /dev/null +++ b/router/livez_readyz_test.go @@ -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()) + } +} diff --git a/router/metrics.go b/router/metrics.go new file mode 100644 index 0000000..cc9a690 --- /dev/null +++ b/router/metrics.go @@ -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())) +} diff --git a/router/router.go b/router/router.go index 36656a5..e8f4661 100644 --- a/router/router.go +++ b/router/router.go @@ -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}) }) } diff --git a/storage/storage.go b/storage/storage.go index 5b99614..b0eeedd 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -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) } diff --git a/wire/wire.go b/wire/wire.go deleted file mode 100644 index 448fcd1..0000000 --- a/wire/wire.go +++ /dev/null @@ -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 -}