- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
391 lines
12 KiB
Go
391 lines
12 KiB
Go
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()
|
||
// 虚拟支付退款结果查单兜底:退款推送丢失/未识别时避免订单永久停留在「申请退款中」。
|
||
service.StartVirtualRefundConfirmScheduler()
|
||
|
||
// 清理超期未保存的 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())
|
||
}
|