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