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

240 lines
10 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 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
}