package main import ( "context" "errors" "fmt" "io" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" "time" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/routers" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/cos" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tracing" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat" "github.com/gin-gonic/gin" "go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/otel" "gopkg.in/natefinch/lumberjack.v2" ) func init() { if err := setupSetting(); err != nil { panic(err) } // OTel 需在 logger 之前初始化,使 LoggerProvider 就绪后 slog 可桥接到 OTel Logs。 setupOTel() if err := setupSlogLogger(); err != nil { panic(err) } if err := setupMysqlEngine(); err != nil { panic(err) } if err := setupRedisEngine(); err != nil { panic(err) } setupCosClient() setupWechatPayClient() setupVirtualPayClient() } func main() { gin.SetMode(global.ServerSetting.RunMode) // 启动运势后台任务:每日 0 点生成 + 启动兜底。 tokenhub.EnsureTodayFortunes() tokenhub.StartFortuneScheduler() // 启动权益发放补偿、订单超时过期与虚拟支付查单补偿后台任务。 service.StartBenefitCompensationScheduler() service.StartClothesProcessingRecoveryScheduler() service.StartOrderExpireScheduler() service.StartVirtualPayConfirmScheduler() // 清理超期未保存的 AI 推荐穿搭(status=3),避免长期堆积。 service.StartAIOutfitCleanupScheduler() // 为已过期的固定有效期额度凭证补记「到期失效」流水,让额度明细闭环。 service.StartQuotaExpireScheduler() router := routers.NewRouter() s := &http.Server{ Addr: ":" + global.ServerSetting.HttpPort, Handler: router, ReadTimeout: time.Duration(global.ServerSetting.ReadTimeout) * time.Second, WriteTimeout: time.Duration(global.ServerSetting.WriteTimeout) * time.Second, // 限制http请求头的大小为1MB MaxHeaderBytes: 1 << 20, } // 后台启动 HTTP 服务;启动失败(如端口占用)通过 channel 反馈主流程,避免进程「假存活」。 serverErr := make(chan error, 1) go func() { if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { serverErr <- err } }() slog.Info("server started", "port", global.ServerSetting.HttpPort) // 等待退出信号或服务启动错误。 quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) select { case <-quit: slog.Info("shutting down server...") case err := <-serverErr: slog.Error("http server start failed", "error", err) } // 优雅关闭:HTTP 与 OTel 使用独立 context,避免 HTTP 等待耗尽 OTel flush 的超时。 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() if err := s.Shutdown(shutdownCtx); err != nil { slog.Error("http server shutdown error", "error", err) } if global.OTelShutdown != nil { otelCtx, otelCancel := context.WithTimeout(context.Background(), 5*time.Second) defer otelCancel() if err := global.OTelShutdown(otelCtx); err != nil { slog.Error("otel shutdown error", "error", err) } } slog.Info("server exited") } func setupSetting() error { setting, err := setting.NewSetting() if err != nil { return err } err = setting.ReadSection("Server", &global.ServerSetting) if err != nil { return err } err = setting.ReadSection("Log", &global.LogSetting) if err != nil { return err } err = setting.ReadSection("Mysql", &global.MysqlSetting) if err != nil { return err } err = setting.ReadSection("Redis", &global.RedisSetting) if err != nil { return err } err = setting.ReadSection("Otel", &global.OtelSetting) if err != nil { return err } err = setting.ReadSection("JWT", &global.JWTSetting) if err != nil { return err } // 校验 JWT 关键配置,防止密钥缺失导致可用空密钥伪造 token。 if global.JWTSetting == nil || global.JWTSetting.SecretKey == "" { return fmt.Errorf("JWT secret key must not be empty") } // 过期时间必须为正数,这里 fail-fast 不静默兜底: // 配 0 会让签出的 token 立即过期(登录即失败),且 TTL=0 传给 Redis 表示「不设过期」, // 会话 key 会永久驻留、无法自然回收。 if global.JWTSetting.AccessTokenExpire <= 0 { return fmt.Errorf("JWT AccessTokenExpire must be > 0, got %d", global.JWTSetting.AccessTokenExpire) } if global.JWTSetting.RefreshTokenExpire <= 0 { return fmt.Errorf("JWT RefreshTokenExpire must be > 0, got %d", global.JWTSetting.RefreshTokenExpire) } err = setting.ReadSection("Cos", &global.CosSetting) if err != nil { return err } err = setting.ReadSection("Upload", &global.UploadSetting) if err != nil { return err } if global.UploadSetting.MaxSizeMB <= 0 { global.UploadSetting.MaxSizeMB = 20 } err = setting.ReadSection("Tokenhub", &global.TokenhubSetting) if err != nil { return err } if global.TokenhubSetting.Model == "" { global.TokenhubSetting.Model = "hy4" } err = setting.ReadSection("MattingModel", &global.MattingModelSetting) if err != nil { return err } if global.MattingModelSetting.MaxRetry <= 0 { global.MattingModelSetting.MaxRetry = 3 } err = setting.ReadSection("ClassificationModel", &global.ClassificationModelSetting) if err != nil { return err } if global.ClassificationModelSetting.MaxRetry <= 0 { global.ClassificationModelSetting.MaxRetry = 3 } err = setting.ReadSection("WechatMini", &global.WechatMiniSetting) if err != nil { return err } err = setting.ReadSection("WechatPay", &global.WechatPaySetting) if err != nil { return err } err = setting.ReadSection("UserDefaultQuota", &global.UserDefaultQuotaSetting) if err != nil { return err } // 新用户注册赠送的体验会员及其额度(与上面的免费额度相互独立、叠加生效)。 err = setting.ReadSection("NewUserGift", &global.NewUserGiftSetting) if err != nil { return err } err = setting.ReadSection("AdReward", &global.AdRewardSetting) if err != nil { return err } err = setting.ReadSection("WechatVirtualPay", &global.VirtualPaySetting) if err != nil { return err } // 校验 Server 配置(时间字段已按秒解析,此处仅做存在性校验)。 if global.ServerSetting == nil { return fmt.Errorf("Server config section is missing") } return nil } func setupSlogLogger() error { // 确保日志目录存在(lumberjack 不会自动创建父目录,目录缺失时文件日志会静默失效)。 if err := os.MkdirAll(global.LogSetting.LogSavePath, 0o755); err != nil { return fmt.Errorf("create log dir: %w", err) } writer := &lumberjack.Logger{ Filename: global.LogSetting.LogSavePath + "/" + global.LogSetting.LogFileName + global.LogSetting.LogFileExt, MaxSize: global.LogSetting.MaxSize, MaxBackups: global.LogSetting.MaxBackups, MaxAge: global.LogSetting.MaxAge, Compress: true, LocalTime: true, // 轮转文件名时间戳使用本地时区(默认 UTC,与业务展示时区不一致) } multiWriter := io.MultiWriter(writer, os.Stdout) // 级别匹配忽略大小写与首尾空白,避免 "debug"/"Info " 等误配静默回落 Info。 level := slog.LevelInfo unknownLevel := "" switch strings.ToLower(strings.TrimSpace(global.LogSetting.LogLevel)) { case "debug": level = slog.LevelDebug case "", "info": level = slog.LevelInfo case "warn", "warning": level = slog.LevelWarn case "error": level = slog.LevelError default: unknownLevel = global.LogSetting.LogLevel } // 本地 JSON 日志(文件 + stdout)。 // AddSource 保留全量开启:按级别裁剪需自定义 Handler(slog.HandlerOptions 不支持), // 复杂度与收益不成比例;后续如需降本可在此处自定义 Handler 实现。 localHandler := slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{ AddSource: true, Level: level, }) // OTel 日志桥接:将 slog 记录导出到 OTel Logs(LoggerProvider 已在 setupOTel 就绪; // 未启用 OTel 时为 no-op,不产生开销)。 otelHandler := otelslog.NewHandler("zt-closet-backend") // 扇出:本地日志 + OTel 日志同时输出。 // trace_id/span_id 注入放在扇出外层:FanoutHandler 对子 handler 使用 record 副本分发(r.Clone()), // 在外层 AddAttrs 后两个分支都能拿到,本地输出等价、OTel 桥接分支也能携带链路标识。 slog.SetDefault(slog.New(tracing.OtelContextHandler{ Handler: tracing.NewFanoutHandler(localHandler, otelHandler), })) if unknownLevel != "" { slog.Warn("unknown log level in config, fallback to info", "configured", unknownLevel) } return nil } // setupMysqlEngine 初始化 MySQL 数据库连接。 func setupMysqlEngine() error { var err error global.MysqlEngine, err = mysqlmodel.NewMysqlEngine(global.MysqlSetting) if err != nil { return err } return nil } // setupRedisEngine 初始化 Redis 连接。 func setupRedisEngine() error { var err error global.RedisEngine, err = redismodel.NewRedisEngine(global.RedisSetting) if err != nil { return err } return nil } func setupOTel() { tracer, shutdown, err := tracing.NewOTelProvider(global.OtelSetting) // NewOTelProvider 在任何错误路径都会返回可用的 no-op tracer 与安全 shutdown。 global.Tracer = tracer global.OTelShutdown = shutdown if err != nil { // 此时 logger 尚未就绪,用标准输出告警;已降级为 no-op。 fmt.Printf("[WARN] init otel failed, downgraded to no-op: %v\n", err) } name := "zt-closet-backend" if global.OtelSetting != nil && global.OtelSetting.ServiceName != "" { name = global.OtelSetting.ServiceName } global.Meter = otel.Meter(name) } // setupCosClient 初始化腾讯云 COS 客户端(衣物图片上传依赖)。 // 初始化失败仅告警,不阻断服务启动。 func setupCosClient() { client, err := cos.NewCosClient() if err != nil { slog.Warn("init cos client failed, upload features may fail", "error", err) return } global.CosClient = client } // setupWechatPayClient 初始化微信支付客户端。 // 未启用(Enable=false)或未配置时仅告警,不阻断服务启动(便于本地/未开通支付的开发环境)。 func setupWechatPayClient() { if global.WechatPaySetting == nil || !global.WechatPaySetting.Enable { slog.Warn("wechat pay not enabled, pay features disabled") return } if err := wechat.InitPayClient(global.WechatPaySetting); err != nil { slog.Warn("init wechat pay client failed, pay features disabled", "error", err) return } slog.Info("wechat pay client initialized") } // setupVirtualPayClient 初始化微信小程序虚拟支付(XPay)客户端。 // 未开通(Enable=false)或配置不完整时仅告警,不阻断服务启动;虚拟商品下单时会返回「虚拟支付未开通」。 func setupVirtualPayClient() { if global.VirtualPaySetting == nil || !global.VirtualPaySetting.Enable { slog.Warn("virtual pay not enabled, virtual goods purchase disabled") return } if err := wechat.InitVirtualPayClient(global.VirtualPaySetting); err != nil { slog.Warn("init virtual pay client failed, virtual goods purchase disabled", "error", err) return } env, _ := wechat.GetVirtualPayEnv() slog.Info("virtual pay client initialized", "env", env, "env_name", map[int]string{0: "prod", 1: "sandbox"}[env], "msg_encrypt", wechat.HasMsgEncryptKey()) }