Files
zhengchiliu b94dd6dffb feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
2026-09-16 03:44:09 +08:00

391 lines
12 KiB
Go
Raw Permalink 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 (
"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())
}