Files
junhong_cmp_fiber/cmd/api/main.go
huang 409a68d60b
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m45s
feat: OpenAPI 契约对齐与框架优化
主要变更:
1. OpenAPI 文档契约对齐
   - 统一错误响应字段名为 msg(非 message)
   - 规范 envelope 响应结构(code, msg, data, timestamp)
   - 个人客户路由纳入文档体系(使用 Register 机制)
   - 新增 BuildDocHandlers() 统一管理 handler 构造
   - 确保文档生成的幂等性

2. Service 层错误处理统一
   - 全面替换 fmt.Errorf 为 errors.New/Wrap
   - 统一错误码使用规范
   - Handler 层参数校验不泄露底层细节
   - 新增错误码验证集成测试

3. 代码质量提升
   - 删除未使用的 Task handler 和路由
   - 新增代码规范检查脚本(check-service-errors.sh)
   - 新增注释路径一致性检查(check-comment-paths.sh)
   - 更新 API 文档生成指南

4. OpenSpec 归档
   - 归档 openapi-contract-alignment 变更(63 tasks)
   - 归档 service-error-unify-core 变更
   - 归档 service-error-unify-support 变更
   - 归档 code-cleanup-docs-update 变更
   - 归档 handler-validation-security 变更
   - 同步 delta specs 到主规范文件

影响范围:
- pkg/openapi: 新增 handlers.go,优化 generator.go
- internal/service/*: 48 个 service 文件错误处理统一
- internal/handler/admin: 优化参数校验错误提示
- internal/routes: 个人客户路由改造,删除 task 路由
- scripts: 新增 3 个代码检查脚本
- docs: 更新 OpenAPI 文档(15750+ 行)
- openspec/specs: 同步 3 个主规范文件

破坏性变更:无
向后兼容:是
2026-01-30 11:40:36 +08:00

330 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/requestid"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/pkg/auth"
pkgbootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/database"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
func main() {
// 1. 初始化配置
cfg := initConfig()
// 2. 初始化目录
if _, err := pkgbootstrap.EnsureDirectories(cfg, nil); err != nil {
panic("初始化目录失败: " + err.Error())
}
// 3. 初始化日志
appLogger := initLogger(cfg)
defer func() {
_ = logger.Sync()
}()
// 4. 初始化数据库
db := initDatabase(cfg, appLogger)
defer closeDatabase(db, appLogger)
// 5. 初始化 Redis
redisClient := initRedis(cfg, appLogger)
defer closeRedis(redisClient, appLogger)
// 6. 初始化队列客户端
queueClient := initQueue(redisClient, appLogger)
defer closeQueue(queueClient, appLogger)
// 7. 初始化认证管理器
jwtManager, tokenManager, verificationSvc := initAuthComponents(cfg, redisClient, appLogger)
// 8. 初始化对象存储服务(可选)
storageSvc := initStorage(cfg, appLogger)
// 9. 初始化所有业务组件(通过 Bootstrap
result, err := bootstrap.Bootstrap(&bootstrap.Dependencies{
DB: db,
Redis: redisClient,
Logger: appLogger,
JWTManager: jwtManager,
TokenManager: tokenManager,
VerificationService: verificationSvc,
QueueClient: queueClient,
StorageService: storageSvc,
})
if err != nil {
appLogger.Fatal("初始化业务组件失败", zap.Error(err))
}
// 10. 创建 Fiber 应用
app := createFiberApp(cfg, appLogger)
// 11. 注册中间件
initMiddleware(app, cfg, appLogger)
// 12. 注册路由
initRoutes(app, cfg, result, queueClient, db, redisClient, appLogger)
// 13. 生成 OpenAPI 文档
generateOpenAPIDocs("logs/openapi.yaml", appLogger)
// 14. 启动服务器
startServer(app, cfg, appLogger)
}
// initConfig 加载配置
func initConfig() *config.Config {
cfg, err := config.Load()
if err != nil {
panic("加载配置失败: " + err.Error())
}
return cfg
}
// initLogger 初始化日志
func initLogger(cfg *config.Config) *zap.Logger {
if err := logger.InitLoggers(
cfg.Logging.Level,
cfg.Logging.Development,
logger.LogRotationConfig{
Filename: cfg.Logging.AppLog.Filename,
MaxSize: cfg.Logging.AppLog.MaxSize,
MaxBackups: cfg.Logging.AppLog.MaxBackups,
MaxAge: cfg.Logging.AppLog.MaxAge,
Compress: cfg.Logging.AppLog.Compress,
},
logger.LogRotationConfig{
Filename: cfg.Logging.AccessLog.Filename,
MaxSize: cfg.Logging.AccessLog.MaxSize,
MaxBackups: cfg.Logging.AccessLog.MaxBackups,
MaxAge: cfg.Logging.AccessLog.MaxAge,
Compress: cfg.Logging.AccessLog.Compress,
},
); err != nil {
panic("初始化日志失败: " + err.Error())
}
appLogger := logger.GetAppLogger()
appLogger.Info("应用程序启动中...", zap.String("address", cfg.Server.Address))
return appLogger
}
// initDatabase 初始化数据库连接
func initDatabase(cfg *config.Config, appLogger *zap.Logger) *gorm.DB {
db, err := database.InitPostgreSQL(&cfg.Database, appLogger)
if err != nil {
appLogger.Fatal("初始化 PostgreSQL 失败", zap.Error(err))
}
return db
}
// closeDatabase 关闭数据库连接
func closeDatabase(db *gorm.DB, appLogger *zap.Logger) {
sqlDB, _ := db.DB()
if sqlDB != nil {
if err := sqlDB.Close(); err != nil {
appLogger.Error("关闭 PostgreSQL 连接失败", zap.Error(err))
}
}
}
// initRedis 初始化 Redis 连接
func initRedis(cfg *config.Config, appLogger *zap.Logger) *redis.Client {
redisAddr := cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port)
redisClient, err := database.NewRedisClient(database.RedisConfig{
Address: redisAddr,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize,
MinIdleConns: cfg.Redis.MinIdleConns,
DialTimeout: cfg.Redis.DialTimeout,
ReadTimeout: cfg.Redis.ReadTimeout,
WriteTimeout: cfg.Redis.WriteTimeout,
}, appLogger)
if err != nil {
appLogger.Fatal("连接 Redis 失败", zap.Error(err))
}
return redisClient
}
// closeRedis 关闭 Redis 连接
func closeRedis(redisClient *redis.Client, appLogger *zap.Logger) {
if err := redisClient.Close(); err != nil {
appLogger.Error("关闭 Redis 客户端失败", zap.Error(err))
}
}
// initQueue 初始化队列客户端
func initQueue(redisClient *redis.Client, appLogger *zap.Logger) *queue.Client {
return queue.NewClient(redisClient, appLogger)
}
// closeQueue 关闭队列客户端
func closeQueue(queueClient *queue.Client, appLogger *zap.Logger) {
if err := queueClient.Close(); err != nil {
appLogger.Error("关闭 Asynq 客户端失败", zap.Error(err))
}
}
// createFiberApp 创建 Fiber 应用
func createFiberApp(cfg *config.Config, appLogger *zap.Logger) *fiber.App {
return fiber.New(fiber.Config{
AppName: "君鸿卡管系统 v1.0.0",
StrictRouting: true,
CaseSensitive: true,
JSONEncoder: sonic.Marshal,
JSONDecoder: sonic.Unmarshal,
Prefork: cfg.Server.Prefork,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
ErrorHandler: internalMiddleware.ErrorHandler(appLogger),
})
}
// initMiddleware 注册中间件
func initMiddleware(app *fiber.App, cfg *config.Config, appLogger *zap.Logger) {
// 1. Recover - 必须第一个,捕获所有 panic
app.Use(internalMiddleware.Recover(appLogger))
// 2. RequestID - 为每个请求生成唯一 ID
app.Use(requestid.New(requestid.Config{
Generator: func() string {
return uuid.NewString()
},
}))
// 3. Logger - 记录所有请求
app.Use(logger.Middleware())
// 4. Compress - 响应压缩
app.Use(compress.New(compress.Config{
Level: compress.LevelDefault,
}))
}
// initRoutes 注册路由
func initRoutes(app *fiber.App, cfg *config.Config, result *bootstrap.BootstrapResult, queueClient *queue.Client, db *gorm.DB, redisClient *redis.Client, appLogger *zap.Logger) {
if cfg.Middleware.EnableRateLimiter {
rateLimitMiddleware := createRateLimiter(cfg, appLogger)
applyRateLimiterToBusinessRoutes(app, rateLimitMiddleware, appLogger)
}
routes.RegisterRoutes(app, result.Handlers, result.Middlewares)
}
// applyRateLimiterToBusinessRoutes 将限流器应用到真实业务路由组
func applyRateLimiterToBusinessRoutes(app *fiber.App, rateLimitMiddleware fiber.Handler, appLogger *zap.Logger) {
adminGroup := app.Group("/api/admin")
adminGroup.Use(rateLimitMiddleware)
h5Group := app.Group("/api/h5")
h5Group.Use(rateLimitMiddleware)
personalGroup := app.Group("/api/c/v1")
personalGroup.Use(rateLimitMiddleware)
appLogger.Info("限流器已应用到业务路由组",
zap.Strings("paths", []string{"/api/admin", "/api/h5", "/api/c/v1"}),
)
}
// createRateLimiter 创建限流器中间件
func createRateLimiter(cfg *config.Config, appLogger *zap.Logger) fiber.Handler {
var rateLimitStorage fiber.Storage
if cfg.Middleware.RateLimiter.Storage == "redis" {
rateLimitStorage = internalMiddleware.NewRedisStorage(
cfg.Redis.Address,
cfg.Redis.Password,
cfg.Redis.DB,
cfg.Redis.Port,
)
appLogger.Info("限流器使用 Redis 存储", zap.String("redis_address", cfg.Redis.Address))
} else {
rateLimitStorage = nil
appLogger.Info("限流器使用内存存储")
}
return internalMiddleware.RateLimiter(
cfg.Middleware.RateLimiter.Max,
cfg.Middleware.RateLimiter.Expiration,
rateLimitStorage,
)
}
func startServer(app *fiber.App, cfg *config.Config, appLogger *zap.Logger) {
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
go func() {
if err := app.Listen(cfg.Server.Address); err != nil {
appLogger.Fatal("服务器启动失败", zap.Error(err))
}
}()
appLogger.Info("服务器已启动", zap.String("address", cfg.Server.Address))
<-quit
appLogger.Info("正在关闭服务器...")
if err := app.ShutdownWithTimeout(cfg.Server.ShutdownTimeout); err != nil {
appLogger.Error("强制关闭服务器", zap.Error(err))
}
appLogger.Info("服务器已停止")
}
func initAuthComponents(cfg *config.Config, redisClient *redis.Client, appLogger *zap.Logger) (*auth.JWTManager, *auth.TokenManager, *verification.Service) {
jwtManager := auth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
accessTTL := time.Duration(cfg.JWT.AccessTokenTTL) * time.Second
refreshTTL := time.Duration(cfg.JWT.RefreshTokenTTL) * time.Second
tokenManager := auth.NewTokenManager(redisClient, accessTTL, refreshTTL)
verificationSvc := verification.NewService(redisClient, nil, appLogger)
return jwtManager, tokenManager, verificationSvc
}
func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
if cfg.Storage.Provider == "" || cfg.Storage.S3.Endpoint == "" {
appLogger.Info("对象存储未配置,跳过初始化")
return nil
}
provider, err := storage.NewS3Provider(&cfg.Storage)
if err != nil {
appLogger.Warn("初始化对象存储失败,功能将不可用", zap.Error(err))
return nil
}
appLogger.Info("对象存储已初始化",
zap.String("provider", cfg.Storage.Provider),
zap.String("bucket", cfg.Storage.S3.Bucket),
)
return storage.NewService(provider, &cfg.Storage)
}