package main import ( "context" "database/sql" "errors" "net/http" "os" "os/signal" "syscall" "time" "github.com/gin-gonic/gin" "github.com/joho/godotenv" _ "github.com/luxsin/app-api/docs" "github.com/luxsin/app-api/internal/cache" "github.com/luxsin/app-api/internal/config" "github.com/luxsin/app-api/internal/database" "github.com/luxsin/app-api/internal/model" "github.com/luxsin/app-api/internal/repository" "github.com/luxsin/app-api/internal/router" "github.com/luxsin/app-api/internal/search" "github.com/luxsin/app-api/internal/storage" "github.com/luxsin/app-api/internal/task" "github.com/luxsin/app-api/pkg/logger" "github.com/redis/go-redis/v9" "go.uber.org/zap" ) // @title App Audio API // @version 1.0 // @description 耳机音频参数 EQ 后端服务 // // @BasePath / // // @contact.name Luxsin func main() { _ = godotenv.Load() cfg, err := config.Load() if err != nil { panic(err) } if cfg.Env == "production" { gin.SetMode(gin.ReleaseMode) } log, err := logger.New(cfg.Env) if err != nil { panic(err) } defer log.Sync() //nolint:errcheck db, err := database.Open(cfg.Database) if err != nil { log.Fatal("database connect failed", zap.Error(err)) } defer db.Close() log.Info("database connected", zap.String("host", cfg.Database.Host), zap.Int("port", cfg.Database.Port), zap.String("database", cfg.Database.Name), ) searchClient := search.NewClient(cfg.Meilisearch) log.Info("meilisearch configured", zap.String("host", cfg.Meilisearch.Host), zap.String("index", cfg.Meilisearch.Index), ) rdb := cache.NewClient(cfg.Redis) defer rdb.Close() log.Info("redis connected", zap.String("host", cfg.Redis.Host), zap.Int("port", cfg.Redis.Port), zap.Int("db", cfg.Redis.Database), ) log.Info("share code ttl configured", zap.Duration("ttl", cfg.ShareCodeTTL)) // 启动时预热缓存 warmUpCache(log, db, rdb) // 启动定时刷入任务:每 5 分钟从 Redis 刷入数据库(仅 ENABLE_PERSIST_TASK=true 的容器运行) if cfg.EnablePersistTask { deviceRepo := repository.NewDeviceRepository(db) devicePersistTask := task.NewDevicePersistTask(rdb, deviceRepo, log) devicePersistTask.Start(5 * time.Minute) shareCodeCache := cache.NewShareCodeCache(rdb, cfg.ShareCodeTTL) shareCodeRepo := repository.NewShareCodeRepository(db) shareCodePersistTask := task.NewShareCodePersistTask(shareCodeCache, shareCodeRepo, log) shareCodePersistTask.Start(5 * time.Minute) } else { log.Info("device persist task disabled") } s3Storage, err := storage.NewS3Storage(context.Background(), cfg.S3) if err != nil { log.Fatal("s3 client init failed", zap.Error(err)) } log.Info("s3 configured", zap.String("bucket", cfg.S3.Bucket), zap.String("region", cfg.S3.Region), ) engine := router.New(log, db, searchClient, rdb, cfg.Equalize, s3Storage, cfg.ShareCodeMaxPerMac, cfg.ShareCodeTTL, cfg.Env) srv := &http.Server{ Addr: cfg.Addr(), Handler: engine, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } go func() { log.Info("server starting", zap.String("addr", cfg.Addr()), zap.String("env", cfg.Env)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal("server failed", zap.Error(err)) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit log.Info("server shutting down") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Fatal("server shutdown failed", zap.Error(err)) } log.Info("server stopped") } // warmUpCache 启动时从数据库加载数据到 Redis(缓存已存在则跳过,避免多实例重复预热) func warmUpCache(log *zap.Logger, db *sql.DB, rdb *redis.Client) { ctx := context.Background() brandCache := cache.NewBrandCache(rdb) modelCache := cache.NewModelCache(rdb) brandExists, err := brandCache.Exists(ctx) if err != nil { log.Warn("check brand cache failed, will warm up", zap.Error(err)) } modelExists, err := modelCache.Exists(ctx) if err != nil { log.Warn("check model cache failed, will warm up", zap.Error(err)) } if brandExists && modelExists { log.Info("cache already warmed, skip warm-up") return } brandRepo := repository.NewBrandRepository(db, brandCache) modelRepo := repository.NewModelRepository(db, modelCache) // 预热 brand:all if brands, err := brandRepo.ListFromDB(ctx, ""); err != nil { log.Warn("brand warm-up failed", zap.Error(err)) } else if err := brandCache.SetAll(ctx, brands); err != nil { log.Warn("brand cache set failed", zap.Error(err)) } else { log.Info("brand cache warmed up", zap.Int("count", len(brands))) } // 预热 model:all + model:brand:{brandName} if allModels, err := modelRepo.ListAllFromDB(ctx); err != nil { log.Warn("model warm-up failed", zap.Error(err)) } else if err := modelCache.SetAll(ctx, allModels); err != nil { log.Warn("model cache set failed", zap.Error(err)) } else { log.Info("model cache warmed up", zap.Int("count", len(allModels))) // 按品牌分组预热 model:brand:{brandName} grouped := groupModelsByBrand(allModels) for brandName, models := range grouped { if err := modelCache.SetByBrand(ctx, brandName, models); err != nil { log.Warn("model brand cache set failed", zap.String("brand", brandName), zap.Error(err)) } } log.Info("model brand cache warmed up", zap.Int("brands", len(grouped))) } } // groupModelsByBrand 按品牌分组型号列表 func groupModelsByBrand(models []model.Model) map[string][]model.Model { result := make(map[string][]model.Model) for _, m := range models { result[m.BrandName] = append(result[m.BrandName], m) } return result }