feat(order): 重构退款全流程并新增申请退款中状态

- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
This commit is contained in:
2026-09-16 03:44:09 +08:00
parent 89b59c7b91
commit b94dd6dffb
29 changed files with 2141 additions and 158 deletions
+239
View File
@@ -0,0 +1,239 @@
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
}