package service import ( "context" "log/slog" "sync" "time" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat" ) const ( // virtualRefundConfirmGraceSeconds 退款申请的宽限期:只处理停留超过该时长的「申请退款中」订单, // 给退款推送留出处理时间,避免与推送路径抢跑。 virtualRefundConfirmGraceSeconds int64 = 10 * 60 // virtualRefundConfirmBatchSize 单轮最多扫描的候选订单数。 virtualRefundConfirmBatchSize = 100 // virtualRefundConfirmWorkers 单轮并发反查的 worker 数(query_order 为 HTTP 调用,串行会在积压时撞上超时)。 virtualRefundConfirmWorkers = 5 // virtualRefundConfirmRecheckTTL 单个订单的查单频率下限:命中「已核对」标记则跳过本轮, // 把单个订单的微信接口调用频率压到每 TTL 一次(退款为人工审核,长时间停留在 6 属正常)。 virtualRefundConfirmRecheckTTL = 10 * time.Minute // virtualRefundConfirmStuckSeconds 停留超过该时长仍未见微信侧退款 -> Error 告警交人工介入。 virtualRefundConfirmStuckSeconds int64 = 24 * 60 * 60 // virtualRefundConfirmStuckAlertTTL 「长期未完成」告警去重 TTL,避免每分钟刷屏。 virtualRefundConfirmStuckAlertTTL = 6 * time.Hour // virtualRefundConfirmLockTTL 单轮任务的分布式锁 TTL,必须大于单轮最大耗时,否则锁提前过期会让多实例并发执行。 virtualRefundConfirmLockTTL = 5 * time.Minute // virtualRefundConfirmTimeout 单轮任务的上下文超时,需给「批量查询 + 并发反查」留足余量。 virtualRefundConfirmTimeout = 5 * time.Minute ) // StartVirtualRefundConfirmScheduler 启动虚拟支付退款结果查单兜底任务。 // // 与发货链路的「推送 + 轮询」双保险同理:退款推送可能丢失、字段版式变化或应答失败重试耗尽, // 一旦发生订单会永久停留在「6 申请退款中」(用户看不到退款结果、运营无法按状态收口)。 // 本任务对停留超过宽限期的「申请退款中」订单主动反查 query_order: // - 微信侧已退款 -> 复用推送同一结算链路置「5 已退款」、回收权益并同步退款流水; // - 微信侧仍未退款 -> 保持订单状态不变(避免运营处理中的退款被误判回退),超过阈值输出 Error 告警交人工。 func StartVirtualRefundConfirmScheduler() { go func() { slog.Info("virtual refund confirm scheduler started") for { func() { defer func() { if r := recover(); r != nil { slog.Error("virtual refund confirm scheduler panic", "recover", r) } }() runVirtualRefundConfirm() }() time.Sleep(time.Minute) } }() } // runVirtualRefundConfirm 扫描「申请退款中」的虚拟支付订单并反查微信侧退款结果,单实例通过 Redis 分布式锁防重。 func runVirtualRefundConfirm() { // 虚拟支付未开通时直接返回,避免无谓的数据库扫描。 if !wechat.IsVirtualPayEnabled() { return } ctx, cancel := context.WithTimeout(context.Background(), virtualRefundConfirmTimeout) defer cancel() ctx, span := global.Tracer.Start(ctx, "scheduler.VirtualRefundConfirm") defer span.End() lockKey := redismodel.KeyLockVirtualRefundConfirm lockVal := genUUID() ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, virtualRefundConfirmLockTTL) if lerr != nil { slog.ErrorContext(ctx, "virtual refund confirm: acquire lock failed", "error", lerr) return } if !ok { slog.DebugContext(ctx, "virtual refund confirm: another instance running, skip") return } defer func() { if uerr := redisdao.Unlock(context.Background(), lockKey, lockVal); uerr != nil { slog.WarnContext(ctx, "virtual refund confirm: release lock failed", "error", uerr) } }() now := nowUnix() orders, err := mysqldao.NewOrderDao(). OrderListVirtualRefunding(ctx, now-virtualRefundConfirmGraceSeconds, virtualRefundConfirmBatchSize) if err != nil { slog.ErrorContext(ctx, "virtual refund confirm: list refunding orders failed", "error", err) return } if len(orders) == 0 { slog.DebugContext(ctx, "virtual refund confirm: no refunding orders in window") return } if len(orders) >= virtualRefundConfirmBatchSize { slog.WarnContext(ctx, "virtual refund confirm backlog detected, candidates reached limit", "candidates", len(orders), "limit", virtualRefundConfirmBatchSize) } s := NewOrderService() var ( mu sync.Mutex wg sync.WaitGroup queried int reconciled int stuck int ) sem := make(chan struct{}, virtualRefundConfirmWorkers) for i := range orders { order := &orders[i] // 频率限制:TTL 内已核对过的订单让出名额;标记不可用时不阻断本轮(退化为重复查询)。 first, serr := redisdao.VirtualRefundCheckedSetNX(ctx, order.OrderNo, virtualRefundConfirmRecheckTTL) if serr != nil { slog.WarnContext(ctx, "virtual refund confirm: set recheck mark failed", "order_no", order.OrderNo, "error", serr) } else if !first { continue } queried++ wg.Add(1) sem <- struct{}{} go func(o *mysqlmodel.Order) { defer wg.Done() defer func() { <-sem }() defer func() { if r := recover(); r != nil { slog.ErrorContext(ctx, "virtual refund confirm worker panic", "order_no", o.OrderNo, "recover", r) } }() done, alerted, qok := s.reconcileVirtualRefund(ctx, o, now) if !qok { // 查单失败:撤销「已核对」标记,让下一轮立即重试(撤销失败不阻断,退化为 TTL 节奏)。 if derr := redisdao.VirtualRefundCheckedDel(ctx, o.OrderNo); derr != nil { slog.WarnContext(ctx, "virtual refund confirm: clear recheck mark failed", "order_no", o.OrderNo, "error", derr) } } if done { mu.Lock() reconciled++ mu.Unlock() } if alerted { mu.Lock() stuck++ mu.Unlock() } }(order) } wg.Wait() if reconciled > 0 || stuck > 0 { slog.InfoContext(ctx, "virtual refund confirm finished", "scanned", len(orders), "queried", queried, "reconciled", reconciled, "stuck_alerted", stuck) return } slog.DebugContext(ctx, "virtual refund confirm finished without change", "scanned", len(orders), "queried", queried) } // reconcileVirtualRefund 反查单笔「申请退款中」订单在微信侧的退款结果。 // - 微信侧已退款:置「已退款」、回收权益并同步退款流水(与推送路径同一结算链路,条件流转天然幂等); // - 微信侧仍未退款:保持订单状态不变,停留超过阈值时输出 Error 告警交人工(不自动回退,避免运营处理中的退款被误判); // - 查询失败/用户信息缺失:仅记日志并返回 queried=false,交由下一轮立即重试。 // // 返回值:reconciled=已按查单结果订正为已退款;alerted=本次输出了人工介入告警;queried=查单是否成功执行。 func (s *OrderService) reconcileVirtualRefund(ctx context.Context, order *mysqlmodel.Order, now int64) (reconciled bool, alerted bool, queried bool) { // query_order 需要 openid,订单表不冗余该字段,按 user_id 取。 user, err := s.userDao.UserGetByID(ctx, order.UserID) if err != nil { slog.ErrorContext(ctx, "virtual refund confirm: get user failed", "order_no", order.OrderNo, "user_id", order.UserID, "error", err) return false, false, false } if user.WxOpenID == "" { slog.WarnContext(ctx, "virtual refund confirm: user openid unavailable", "order_no", order.OrderNo, "user_id", order.UserID) return false, false, false } info, qerr := wechat.QueryOrder(ctx, user.WxOpenID, order.OrderNo, "") if qerr != nil { slog.ErrorContext(ctx, "virtual refund confirm: query order failed", "order_no", order.OrderNo, "error", qerr) return false, false, false } if wechat.IsOrderRefundedStatus(info.Order.Status) { // 退款金额优先取退款单金额,其次按支付单「实付 - 剩余」推算,最后回退订单实付金额。 refundFee := info.Order.RefundFee if refundFee <= 0 { refundFee = info.Order.PaidFee - info.Order.LeftFee } if refundFee <= 0 { refundFee = order.PayAmount } if serr := s.settleRefundSuccess(ctx, order, refundFee); serr != nil { slog.ErrorContext(ctx, "virtual refund confirm: settle refund success failed", "order_no", order.OrderNo, "refund_fee", refundFee, "error", serr) return false, false, true // 查单成功但结算失败:保持 TTL 节奏重试。 } // 查单分支没有推送报文与微信退款单号,仅同步流水终态(有本地申请流水时)。 s.markRefundFlowSuccess(ctx, order, "", "") slog.InfoContext(ctx, "virtual refund confirm: refund reconciled by query_order", "order_no", order.OrderNo, "user_id", order.UserID, "wx_status", info.Order.Status, "refund_fee", refundFee) return true, false, true } // 微信侧仍未退款:订单保持「申请退款中」(运营可能正在处理),仅在长期无进展时告警人工介入。 stuckSeconds := now - order.UpdatedAt if stuckSeconds < virtualRefundConfirmStuckSeconds { slog.DebugContext(ctx, "virtual refund confirm: refund still pending in wechat side", "order_no", order.OrderNo, "wx_status", info.Order.Status, "stuck_seconds", stuckSeconds) return false, false, true } first, serr := redisdao.VirtualRefundStuckAlertSetNX(ctx, order.OrderNo, virtualRefundConfirmStuckAlertTTL) if serr != nil { slog.WarnContext(ctx, "virtual refund confirm: set stuck alert mark failed", "order_no", order.OrderNo, "error", serr) first = true // 标记不可用时仍然告警,避免静默。 } if !first { return false, false, true } slog.ErrorContext(ctx, "virtual refund confirm: refund stuck, manual intervention required", "order_no", order.OrderNo, "user_id", order.UserID, "stuck_seconds", stuckSeconds, "wx_status", info.Order.Status) return false, true, true }