Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c9f256dfc |
+104
-1
@@ -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
|
||||
|
||||
@@ -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 变更。
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+49
-8
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 忽略),用户网页创建。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
+2
-2
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-30
@@ -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 分页响应
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user