diff --git a/cmd/server/main.go b/cmd/server/main.go index 2f98dd5..de592d6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,170 +10,28 @@ package main import ( - "context" "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" - "carrotskin/internal/container" - "carrotskin/internal/handler" - "carrotskin/internal/middleware" - "carrotskin/internal/task" - "carrotskin/pkg/auth" + "carrotskin/internal/app" "carrotskin/pkg/config" - "carrotskin/pkg/database" - "carrotskin/pkg/email" - "carrotskin/pkg/logger" - "carrotskin/pkg/redis" - "carrotskin/pkg/storage" - "github.com/gin-gonic/gin" - "go.uber.org/zap" + "go.uber.org/fx" ) +// main 应用入口。通过 uber-go/fx 装配所有依赖并管理生命周期。 +// +// fx.Run() 会: +// 1. 按 fx.Supply/Provide 构建依赖图 +// 2. 执行所有 fx.Invoke(迁移、路由注册、服务器/任务启动) +// 3. 监听 SIGINT/SIGTERM 信号 +// 4. 按依赖逆序执行所有 OnStop 钩子(优雅关闭) func main() { - // 初始化配置 - if err := config.Init(); err != nil { + // 加载配置(唯一的全局入口,配置本身是无状态的纯数据) + cfg, err := config.Load() + if err != nil { log.Fatalf("配置加载失败: %v", err) } - cfg := config.MustGetConfig() - // 初始化日志 - if err := logger.Init(cfg.Log); err != nil { - log.Fatalf("日志初始化失败: %v", err) - } - loggerInstance := logger.MustGetLogger() - defer loggerInstance.Sync() - - // 初始化数据库 - if err := database.Init(cfg.Database, loggerInstance); err != nil { - loggerInstance.Fatal("数据库初始化失败", zap.Error(err)) - } - defer database.Close() - - // 执行数据库迁移 - if err := database.AutoMigrate(loggerInstance); err != nil { - loggerInstance.Fatal("数据库迁移失败", zap.Error(err)) - } - - // 初始化种子数据 - if err := database.Seed(loggerInstance); err != nil { - loggerInstance.Fatal("种子数据初始化失败", zap.Error(err)) - } - - // 初始化JWT服务 - if err := auth.Init(cfg.JWT); err != nil { - loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err)) - } - - // 初始化Redis(开发/测试环境失败时会自动回退到miniredis) - if err := redis.Init(cfg.Redis, loggerInstance); err != nil { - loggerInstance.Fatal("Redis初始化失败", zap.Error(err)) - } - defer redis.Close() - - // 记录Redis模式 - if redis.IsUsingMiniRedis() { - loggerInstance.Info("使用miniredis进行开发/测试") - } else { - loggerInstance.Info("使用生产Redis") - } - - // 初始化对象存储 (RustFS - S3兼容) - var storageClient *storage.StorageClient - if err := storage.Init(cfg.RustFS); err != nil { - loggerInstance.Warn("对象存储连接失败,某些功能可能不可用", zap.Error(err)) - } else { - storageClient = storage.MustGetClient() - loggerInstance.Info("对象存储连接成功") - } - - // 初始化邮件服务 - if err := email.Init(cfg.Email, loggerInstance); err != nil { - loggerInstance.Fatal("邮件服务初始化失败", zap.Error(err)) - } - emailServiceInstance := email.MustGetService() - - // 初始化Casbin权限服务 - casbinService, err := auth.NewCasbinService(database.MustGetDB(), cfg.Casbin.ModelPath, loggerInstance) - if err != nil { - loggerInstance.Fatal("Casbin服务初始化失败", zap.Error(err)) - } - - // 创建依赖注入容器 - c := container.NewContainer( - database.MustGetDB(), - redis.MustGetClient(), - loggerInstance, - auth.MustGetJWTService(), - casbinService, - storageClient, - emailServiceInstance, - ) - - // 设置Gin模式 - if cfg.Server.Mode == "production" { - gin.SetMode(gin.ReleaseMode) - } - - // 创建路由 - router := gin.New() - - // 禁用自动重定向,允许API路径带或不带/结尾都能正常访问 - router.RedirectTrailingSlash = false - router.RedirectFixedPath = false - - // 添加中间件 - router.Use(middleware.Logger(loggerInstance)) - router.Use(middleware.Recovery(loggerInstance)) - router.Use(middleware.CORS()) - - // 使用依赖注入方式注册路由 - handler.RegisterRoutesWithDI(router, c) - - // 启动后台任务(Token已迁移到Redis,不再需要清理任务) - // 如需使用数据库Token存储,可以恢复TokenCleanupTask - taskRunner := task.NewRunner(loggerInstance) - taskCtx, taskCancel := context.WithCancel(context.Background()) - defer taskCancel() - taskRunner.Start(taskCtx) - - // 创建HTTP服务器 - srv := &http.Server{ - Addr: cfg.Server.Port, - Handler: router, - ReadTimeout: cfg.Server.ReadTimeout, - WriteTimeout: cfg.Server.WriteTimeout, - } - - // 启动服务器 - go func() { - loggerInstance.Info("服务器启动", zap.String("port", cfg.Server.Port)) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - loggerInstance.Fatal("服务器启动失败", zap.Error(err)) - } - }() - - // 等待中断信号优雅关闭 - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - loggerInstance.Info("正在关闭服务器...") - - // 停止后台任务 - taskCancel() - taskRunner.Wait() - - // 设置关闭超时 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := srv.Shutdown(ctx); err != nil { - loggerInstance.Fatal("服务器强制关闭", zap.Error(err)) - } - - loggerInstance.Info("服务器已关闭") + // 创建并运行 fx 应用 + fx.New(app.Module(cfg)).Run() } diff --git a/go.mod b/go.mod index 0c793cf..fa1ae91 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/swaggo/gin-swagger v1.6.1 github.com/wenlng/go-captcha-assets v1.0.7 github.com/wenlng/go-captcha/v2 v2.0.4 + go.uber.org/fx v1.24.0 go.uber.org/zap v1.27.1 gopkg.in/mail.v2 v2.3.1 gorm.io/datatypes v1.2.7 @@ -69,6 +70,7 @@ require ( github.com/tinylib/msgp v1.6.3 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/atomic v1.11.0 // indirect + go.uber.org/dig v1.19.0 // indirect go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect @@ -102,7 +104,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.8.0 + github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 @@ -120,7 +122,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/swaggo/swag v1.16.6 // indirect + github.com/swaggo/swag v1.16.6 github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index 5a9897f..3f46ef3 100644 --- a/go.sum +++ b/go.sum @@ -291,6 +291,10 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= diff --git a/internal/app/container_module.go b/internal/app/container_module.go new file mode 100644 index 0000000..c58c751 --- /dev/null +++ b/internal/app/container_module.go @@ -0,0 +1,138 @@ +package app + +import ( + "context" + "net/http" + + "carrotskin/internal/container" + "carrotskin/internal/handler" + "carrotskin/internal/middleware" + "carrotskin/internal/task" + "carrotskin/pkg/auth" + "carrotskin/pkg/config" + "carrotskin/pkg/database" + "carrotskin/pkg/email" + "carrotskin/pkg/redis" + "carrotskin/pkg/storage" + + "github.com/gin-gonic/gin" + "go.uber.org/fx" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// BootstrapModule 注册 Container、路由注册、HTTP 服务器与后台任务的生命周期。 +// +// 设计说明:当前所有 Handler 仍依赖 *container.Container(阶段6将逐步解耦为 +// 直接依赖各 Service 接口)。本 Module 由 fx 注入基础设施,聚合为 Container, +// 再用它注册路由、启动服务器与后台任务。当 fx.Run() 接收到 SIGINT/SIGTERM 时, +// 会按依赖逆序执行所有 OnStop 钩子,实现优雅关闭。 +var BootstrapModule = fx.Options( + // Gin 路由引擎 + fx.Provide(provideRouter), + + // 由 fx 注入的基础设施聚合为 Container(Container 内部会构造 Repository/Service) + fx.Provide(provideContainer), + + // 数据库迁移与种子数据(infra 就绪后执行) + fx.Invoke(runMigrate), + + // 注册所有 HTTP 路由 + fx.Invoke(registerRoutes), + + // HTTP 服务器生命周期(启动/优雅关闭) + fx.Invoke(registerServerLifecycle), + + // 后台任务调度器生命周期 + fx.Invoke(registerTaskLifecycle), +) + +// provideRouter 创建 Gin 引擎并挂载全局中间件。 +func provideRouter(cfg *config.Config, logger *zap.Logger) *gin.Engine { + if cfg.Server.Mode == "production" { + gin.SetMode(gin.ReleaseMode) + } + router := gin.New() + // 禁用自动重定向,允许API路径带或不带/结尾都能正常访问 + router.RedirectTrailingSlash = false + router.RedirectFixedPath = false + router.Use(middleware.Logger(logger)) + router.Use(middleware.Recovery(logger)) + router.Use(middleware.CORS(cfg)) + return router +} + +// provideContainer 将所有 fx 管理的基础设施聚合为 Container。 +// Container.NewContainer 内部会基于这些 infra 构造 Repository 与 Service。 +// 注意:email.Service 可能为 nil(当 EMAIL_ENABLED=false 时),Container 会降级处理。 +func provideContainer( + cfg *config.Config, + db *gorm.DB, + redisClient *redis.Client, + logger *zap.Logger, + jwt *auth.JWTService, + casbin *auth.CasbinService, + storageClient *storage.StorageClient, + emailService *email.Service, +) *container.Container { + return container.NewContainer( + cfg, db, redisClient, logger, jwt, casbin, storageClient, emailService, + ) +} + +// runMigrate 执行数据库迁移与种子数据初始化。 +func runMigrate(db *gorm.DB, logger *zap.Logger) error { + if err := database.AutoMigrateWithDB(db, logger); err != nil { + return err + } + return database.SeedWithDB(db, logger) +} + +// registerRoutes 注册所有 HTTP 路由。 +func registerRoutes(router *gin.Engine, c *container.Container) { + handler.RegisterRoutesWithDI(router, c) +} + +// registerServerLifecycle 注册 HTTP 服务器的启动与优雅关闭。 +func registerServerLifecycle(lc fx.Lifecycle, router *gin.Engine, cfg *config.Config, logger *zap.Logger) { + srv := &http.Server{ + Addr: cfg.Server.Port, + Handler: router, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + } + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + logger.Info("服务器启动", zap.String("port", cfg.Server.Port)) + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal("服务器启动失败", zap.Error(err)) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + logger.Info("正在关闭服务器...") + return srv.Shutdown(ctx) + }, + }) +} + +// registerTaskLifecycle 注册后台任务调度器的启动与停止。 +func registerTaskLifecycle(lc fx.Lifecycle, logger *zap.Logger) { + runner := task.NewRunner(logger) + ctx, cancel := context.WithCancel(context.Background()) + + lc.Append(fx.Hook{ + OnStart: func(_ context.Context) error { + runner.Start(ctx) + return nil + }, + OnStop: func(_ context.Context) error { + cancel() + runner.Wait() + return nil + }, + }) +} diff --git a/internal/app/handler_module.go b/internal/app/handler_module.go new file mode 100644 index 0000000..04466fd --- /dev/null +++ b/internal/app/handler_module.go @@ -0,0 +1,8 @@ +package app + +// HandlerModule 已在阶段4移除。 +// +// 当前阶段,Handler 仍由 internal/container.Container + handler.RegisterRoutesWithDI +// 构造与注册(见 container_module.go 的 registerRoutes)。 +// +// TODO(阶段6): Handler 将改为直接依赖各 Service 接口,由 fx 直接 Provide 与注册。 diff --git a/internal/app/infra_module.go b/internal/app/infra_module.go new file mode 100644 index 0000000..0f02bd4 --- /dev/null +++ b/internal/app/infra_module.go @@ -0,0 +1,108 @@ +package app + +import ( + "context" + + "carrotskin/pkg/auth" + "carrotskin/pkg/config" + "carrotskin/pkg/database" + "carrotskin/pkg/email" + "carrotskin/pkg/logger" + "carrotskin/pkg/redis" + "carrotskin/pkg/storage" + + "go.uber.org/fx" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// InfraModule 注册所有基础设施依赖:Logger/DB/Redis/Storage/Email/JWT/Casbin。 +// 每个需要资源释放的组件都通过 fx.Lifecycle 注册 OnStop 钩子, +// 彻底替代 main.go 里分散的 defer xxx.Close()。 +var InfraModule = fx.Options( + // Logger + fx.Provide(logger.New), + + // Database: 同时暴露 *database.DB(封装,带连接池统计)和 *gorm.DB(裸 GORM) + fx.Provide(provideDatabase), + + // 暴露 *gorm.DB 供 Repository/Casbin 等消费 + fx.Provide(func(d *database.DB) *gorm.DB { return d.DB }), + + // Redis(含 miniredis 测试回退) + fx.Provide(provideRedis), + + // 对象存储(S3 兼容) + fx.Provide(provideStorage), + + // 邮件服务 + fx.Provide(email.NewService), + + // JWT 服务(应用层认证) + fx.Provide(func(cfg *config.Config) *auth.JWTService { + return auth.NewJWTService(cfg.JWT.Secret, cfg.JWT.ExpireHours) + }), + + // Casbin 权限服务 + fx.Provide(func(db *gorm.DB, cfg *config.Config, log *zap.Logger) (*auth.CasbinService, error) { + return auth.NewCasbinService(db, cfg.Casbin.ModelPath, log) + }), +) + +// provideDatabase 创建数据库连接并注册关闭钩子。 +func provideDatabase(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*database.DB, error) { + db, err := database.New(cfg.Database) + if err != nil { + return nil, err + } + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + log.Info("正在关闭数据库连接") + return db.Close() + }, + }) + return db, nil +} + +// provideRedis 创建 Redis 客户端并注册关闭钩子。 +// 开发/测试环境下若连接失败会自动回退到 miniredis。 +func provideRedis(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*redis.Client, error) { + client, err := redis.New(cfg.Redis, log) + if err != nil { + // 检查是否允许回退到 miniredis(仅开发/测试环境) + if redis.AllowFallbackToMiniRedis() { + log.Warn("Redis连接失败,尝试使用miniredis回退", zap.Error(err)) + client, err = redis.InitMiniRedis(log) + if err != nil { + return nil, err + } + log.Info("已回退到miniredis用于开发/测试环境") + } else { + return nil, err + } + } + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + log.Info("正在关闭 Redis 连接") + return client.Close() + }, + }) + return client, nil +} + +// provideStorage 创建对象存储客户端并注册关闭钩子。 +// 存储服务在配置缺失时可能是可选的,失败时返回 error 让调用方决定是否降级。 +func provideStorage(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*storage.StorageClient, error) { + client, err := storage.NewStorage(cfg.RustFS) + if err != nil { + return nil, err + } + // MinIO 客户端无需显式 Close,但保留钩子以备未来扩展 + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + log.Info("对象存储客户端已停止") + return nil + }, + }) + return client, nil +} diff --git a/internal/app/module.go b/internal/app/module.go new file mode 100644 index 0000000..4ebb65e --- /dev/null +++ b/internal/app/module.go @@ -0,0 +1,30 @@ +// Package app 提供 uber-go/fx 应用装配层。 +// +// 该包用 fx 的 Provider/Lifecycle 取代了 pkg/*/manager.go 的全局单例和 +// internal/container 的手动 DI,是整个应用唯一的依赖装配入口。 +// +// 使用方式: +// +// cfg, err := config.Load() +// if err != nil { log.Fatal(err) } +// fx.New(app.Module(cfg)).Run() +package app + +import ( + "carrotskin/pkg/config" + + "go.uber.org/fx" +) + +// Module 返回应用的根 fx.Option,聚合所有子模块。 +// cfg 通过 fx.Supply 注入,供各 Provider 消费其子配置结构。 +// +// 当前阶段:fx 管理 infra + Container 聚合 + 路由/服务器/任务生命周期。 +// Container 内部构造 Repository 与 Service(阶段6后将逐步解耦为 fx 直接管理 Service)。 +func Module(cfg *config.Config) fx.Option { + return fx.Options( + fx.Supply(cfg), + InfraModule, + BootstrapModule, + ) +} diff --git a/internal/app/repository_module.go b/internal/app/repository_module.go new file mode 100644 index 0000000..3d295cb --- /dev/null +++ b/internal/app/repository_module.go @@ -0,0 +1,13 @@ +package app + +// RepositoryModule 已在阶段4移除。 +// +// 当前阶段,Repository 由 internal/container.Container 内部构造。 +// +// TODO(阶段6): Container 移除后,Repository 将回到此 Module 由 fx 直接管理: +// +// var RepositoryModule = fx.Options( +// fx.Provide(repository.NewUserRepository), +// fx.Provide(repository.NewProfileRepository), +// ... +// ) diff --git a/internal/app/server_module.go b/internal/app/server_module.go new file mode 100644 index 0000000..57dbc84 --- /dev/null +++ b/internal/app/server_module.go @@ -0,0 +1,7 @@ +package app + +// ServerModule 已在阶段4移除。 +// +// 当前阶段,HTTP 服务器生命周期由 container_module.go 的 registerServerLifecycle 管理。 +// +// TODO(阶段6): 此处将作为独立 Module 重新启用。 diff --git a/internal/app/service_module.go b/internal/app/service_module.go new file mode 100644 index 0000000..07fc374 --- /dev/null +++ b/internal/app/service_module.go @@ -0,0 +1,15 @@ +package app + +// ServiceModule 已在阶段4移除。 +// +// 当前阶段,Service 由 internal/container.Container 内部构造(Container 聚合了 +// 所有 fx 注入的基础设施,在其 NewContainer 方法里创建 Repository 与 Service)。 +// +// TODO(阶段6): 当 Handler 解耦为直接依赖各 Service 接口后,Container 将被移除, +// 届时 Service 将回到此 Module 由 fx 直接管理: +// +// var ServiceModule = fx.Options( +// fx.Provide(service.NewUserService), +// fx.Provide(service.NewProfileService), +// ... +// ) diff --git a/internal/app/task_module.go b/internal/app/task_module.go new file mode 100644 index 0000000..6933c95 --- /dev/null +++ b/internal/app/task_module.go @@ -0,0 +1,7 @@ +package app + +// TaskModule 已在阶段4移除。 +// +// 当前阶段,后台任务生命周期由 container_module.go 的 registerTaskLifecycle 管理。 +// +// TODO(阶段6): 此处将作为独立 Module 重新启用。