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

13 KiB
Raw Permalink Blame History

name, overview, todos
name overview todos
虚拟支付退款推送字段修复与查单兜底 修复微信虚拟支付退款推送 xpay_refund_notify 报文字段解析错误(应用 MchOrderId/MchRefundId/RetCode,而非 OutTradeNo/RefundOrderId/RefundStatus)导致的订单永久卡在「6 申请退款中」问题;解析不到关键字段时不再静默应答成功,并新增退款结果查单兜底定时任务(微信侧已退款自动订正为已退款,仍未退款则保持状态并告警人工)。
id content status
fix-refund-push-parse 用 [skill:lsp-code-analysis] 核对引用点后,按官方字段重写退款推送解析与 RetCode 判定,补 pkg/wechat 单测 completed
id content status dependencies
harden-refund-notify 改造 handleRefundNotify 与 verifyXPayRefund:字段驱动流转、失败重试应答、流水与原始报文回写 completed
fix-refund-push-parse
id content status dependencies
add-refund-reconcile-job 用 [skill:lsp-code-analysis] 核对命名后,新增退款查单兜底任务与 DAO/Redis key 并注册到 main.go completed
harden-refund-notify
id content status dependencies
add-reconcile-tests 补 DAO 扫描与解析判定单测,跑通 go build、go vet 与全量 go test 回归 completed
add-refund-reconcile-job
id content status dependencies
sync-refund-docs 同步接口文档与 README:虚拟支付退款推送字段、失败重试语义与查单兜底说明 completed
harden-refund-notify
add-refund-reconcile-job

产品概述

用户对虚拟支付订单(pay_channel=2)申请退款后,订单进入「6 申请退款中」;微信侧退款完成后会推送 xpay_refund_notify。当前该推送的报文字段解析与官方不一致:官方用 MchOrderId(原支付单商户单号)、MchRefundId、RetCode(0=成功、非 0=失败)、RetMsg,而代码读的是 OutTradeNo、RefundOrderId、RefundStatus,导致取不到订单号,既没有把订单置为「5 已退款」,又向微信应答「处理成功」使其不再重试,订单永久停留在「申请退款中」。

核心功能

  • 按官方报文正确解析退款推送:MchOrderId、MchRefundId、WxRefundId、RefundFee、RetCode、RetMsg、RetryTimes;同时兼容历史版式字段(OutTradeNo/RefundOrderId/RefundStatus),避免微信侧版本差异
  • 关键字段缺失或退款结果无法判定时,向微信返回「处理失败」,触发其重试(间隔 2/4/8/16…,最多 15 次),不再静默应答成功
  • 退款成功:订单「申请退款中(6)」流转为「已退款(5)」,回收该单已发放权益(会员天数按快照回退、赠送额度失效未用部分,已消耗部分不追回),发送退款成功站内通知,退款流水置成功并记录微信退款单号与原始报文
  • 退款失败(RetCode 非 0):订单回退为「已支付(2)」,用户可重新申请,退款流水置失败并记录微信失败原因
  • 兜底查单:定时反查长期停留在「申请退款中」的虚拟支付订单;微信侧已退款的自动订正为「已退款」并回收权益;微信侧仍未退款的保持状态不变并输出告警日志交人工处理
  • 幂等保障:重复推送、推送与兜底任务并发时,权益只回收一次、状态只流转一次
  • 前端可见效果:订单列表与详情中该单状态由「申请退款中」变为「已退款」(失败则回到「已支付」),退款时间与退款金额同步更新,用户与运营不再看到长期卡单

技术栈

  • 语言与框架:Go + Gin + GORM(沿用现有分层 routers → service → mysqldao → mysqlmodel,不引入新框架)
  • 存储:MySQL(订单/退款流水,无需新增列);Redis(分布式锁与查询频率负缓存,复用现有 redisdao 封装)
  • 测试:go-sqlmock(沿用 newQuotaTestDAO 注入范式);虚拟支付日志沿用 slog.*Context
  • 依赖:无新增第三方依赖,复用 pkg/wechat 既有 ParseNotify/BuildNotifyResponse/QueryOrder/IsOrderRefundedStatus

实现方案

核心思路

分三层修复:解析层按官方字段重写退款推送结构并提供语义化访问器;处理层用解析结果驱动订单流转,并让「无法判定」场景 fail-closed 返回失败触发微信重试;兜底层新增查单对账定时任务,保证推送丢失/解析失败时订单仍能收敛到终态,与官方「推送 + 轮询双保险」建议一致。

flowchart LR
    A[微信退款推送 xpay_refund_notify] --> B[解析 MchOrderId/MchRefundId/RetCode]
    B -->|字段缺失或不可判定| C[应答 ErrCode:1 触发重试]
    B -->|退款成功| D[反查 query_order 校验真实退款]
    D --> E[订单 6 转 5 + 回收权益 + 通知 + 流水回写]
    B -->|退款失败| F[订单 6 转 2 + 流水置失败]
    G[兜底任务 每分钟扫描 order_status=6] --> H[query_order 反查]
    H -->|微信侧已退款| E
    H -->|仍未退款| I[保持状态 + 24h 告警人工]

关键决策与权衡

  1. 官方字段为主、旧字段兼容:MchOrderId 优先、OutTradeNo 兜底;MchRefundId 优先、RefundOrderId/WxRefundId 兜底;成功判定 RetCode 优先、RefundStatus(大小写不敏感 SUCCESS)兜底。既修正线上行为,又避免微信侧版式差异再次导致静默失败。
  2. RetCode 用指针类型区分「未携带」与「0」:直接使用 *int,避免字段缺失被误判为「退款成功」而错误回收用户权益(资损方向)。
  3. 不可判定一律应答失败:推送无法定位订单或无法判定结果时返回 ErrCode:1,让微信按 2/4/8/16 秒重试最多 15 次;叠加兜底任务,做到「推送失败也不会永久卡单」。
  4. 兜底不自动回退订单(已与用户确认):反查到微信侧仍未退款时保持「申请退款中」并 Error 告警,避免运营处理中的退款被误判回退造成重复退款风险。
  5. 复用既有补偿范式:新任务完全对齐 virtualpay_compensate.go 的 Redis 锁、批量上限、负缓存、并发 worker 与调度注册方式,避免新增架构模式。

性能与可靠性

  • 推送路径无新增外部调用(成功路径已有一次 query_order 反查),内存与耗时与原实现同级;兜底任务每轮限制批量(100 单),并用负缓存把单个订单的微信接口查询频率压到每 10 分钟最多一次,24 小时告警去重避免刷日志
  • 状态流转全部使用条件更新与行锁(RefundSettleWithRevokeTask 仅允许 2/3/6 流转到 5),推送与兜底并发天然幂等,不依赖额外分布式锁;兜底任务自身以 Redis 锁保证多实例单跑
  • 失败路径:查询失败不写负缓存,下一轮立即重试;写库失败仅记录告警,不影响微信应答语义,由重试或兜底收敛

目录结构

zt-closet-backend/
├── pkg/wechat/
│   ├── virtualpay.go                    # [MODIFY] 重写 XPayRefundNotify 为官方字段 + 兼容字段,新增 OrderNo/RefundNo/Succeeded 访问器与 XPayRefundRetCodeSuccess 常量;XPayIOSRefundQueryNotify 增加 MchOrderId 兼容字段与 OrderNo(不改变 OutTradeNo 优先级)
│   └── virtualpay_test.go               # [MODIFY] 补退款推送解析与判定单测:官方报文(RetCode=0)、失败报文(RetCode 非 0 + RetMsg)、旧版式(OutTradeNo/RefundStatus)、关键字段缺失四类
├── internal/service/
│   ├── virtualpay.go                    # [MODIFY] handleRefundNotify 改为字段驱动:订单号为空/结果不可判定返回 errcode.OrderRealityCheckFailed 触发微信重试;成功分支用订单号查询并 settleRefundSuccess;失败分支回退 6→2、fail_reason 取 RetMsg;verifyXPayRefund 反查改用 order.OrderNo、openid 缺失时回退用户表 WxOpenID;syncRefundFlowSuccess 扩展回写 wechat_refund_no 与 callback_raw;handleIOSRefundQuery 改用兼容 OrderNo
│   ├── virtual_refund_confirm.go        # [NEW] 退款查单兜底定时任务:常量(批量 100、锁 TTL、扫描宽限 10 分钟、24h 告警、查询负缓存 10 分钟)、StartVirtualRefundConfirmScheduler、runVirtualRefundConfirm、单笔 reconcile(反查已退款则结算并同步流水,未退款则按阈值告警)
│   └── refund_apply_test.go             # [MODIFY] 视需要补充服务层用例(沿用 setupOrderQuotaTest + sqlmock)
├── internal/mysqldao/
│   ├── order.go                         # [MODIFY] 新增 OrderListVirtualRefunding(pay_channel=2 且 order_status=6 且 updated_at 小于扫描水位,按 id 升序限量),写法对齐 OrderListVirtualUnconfirmed
│   └── order_refund_test.go             # [MODIFY] 补 OrderListVirtualRefunding 的 sqlmock 条件与返回断言
├── internal/redismodel/
│   ├── lock.go                          # [MODIFY] 新增 KeyLockVirtualRefundConfirm(兜底任务全局单实例锁)
│   └── virtualpay.go                    # [MODIFY] 新增 KeyVirtualRefundChecked(查询负缓存)与 KeyVirtualRefundStuckAlert(24h 告警去重)
├── internal/redisdao/
│   └── virtualpay.go                    # [MODIFY] 新增 VirtualRefundCheckedSetNX、VirtualRefundStuckAlertSetNX(SET NX EX 语义,写法对齐 TryLock/VirtualPayUnpaidSet)
├── main.go                              # [MODIFY] 注册 service.StartVirtualRefundConfirmScheduler()
├── 接口文档.md                           # [MODIFY] 5.12 与 /xpay/notify 相关章节、退款业务逻辑、FAQ:补虚拟支付退款推送字段(MchOrderId/MchRefundId/RetCode/RetMsg)、失败会重试、查单兜底与人工告警口径
└── README.md                            # [MODIFY] 「5. 交易与权益」退款条目补虚拟支付推送字段与查单兜底说明

关键代码结构

// pkg/wechat/virtualpay.go
type XPayRefundNotify struct {
    XPayNotifyBase
    WxRefundID   string `json:"WxRefundId"`   // 微信退款单号
    MchRefundID  string `json:"MchRefundId"`  // 商户退款单号
    WxOrderID    string `json:"WxOrderId"`    // 退款单对应支付单的微信单号
    MchOrderID   string `json:"MchOrderId"`   // 退款单对应支付单的商户单号(= 本地 order_no)
    RefundFee    int64  `json:"RefundFee"`    // 退款金额(分)
    RetCode      *int   `json:"RetCode"`      // 0=退款成功,非 0=失败;nil 表示报文未携带
    RetMsg       string `json:"RetMsg"`       // 失败原因
    // 其余官方字段(时间戳/交易单号/RetryTimes/Attach 等)与旧版式兼容字段从略
}

// OrderNo 取商户订单号:MchOrderId 优先,旧版式回退 OutTradeNo。
func (n *XPayRefundNotify) OrderNo() string
// RefundNo 取退款单号:MchRefundId 优先,依次回退 RefundOrderId、WxRefundId。
func (n *XPayRefundNotify) RefundNo() string
// Succeeded 判定退款结果;determinable=false 表示报文未携带任何可判定字段(调用方应应答失败触发重试)。
func (n *XPayRefundNotify) Succeeded() (ok bool, determinable bool)
// internal/mysqldao/order.go
// OrderListVirtualRefunding 查询长期停留在「申请退款中」的虚拟支付订单(订单已申请退款但结果未回流)。
func (d *OrderDao) OrderListVirtualRefunding(ctx context.Context, beforeUpdatedAt int64, limit int) ([]mysqlmodel.Order, error)

// internal/service/virtual_refund_confirm.go
func StartVirtualRefundConfirmScheduler() // 每分钟一轮,Redis 锁防重,recover 兜底
func runVirtualRefundConfirm()            // 扫描 + 负缓存过滤 + 反查 + 结算/告警

实现要点

  • 应答语义:仅在确认处理完成后返回成功应答;字段缺失、订单不存在、查询失败、金额不一致一律返回失败,交由微信重试与兜底任务收敛;Redis 锁不可用的既有语义(不重复处理)保持不变
  • 日志:沿用 slog.*Context,推送分支记录 order_no/mch_refund_id/wx_refund_id/ret_code/ret_msg/retry_times,兜底任务记录 order_no/user_id/stuck_seconds;原始报文仅写入退款流水 callback_raw 字段(截断),不打印敏感信息
  • 安全性:退款成功仍强制反查 query_order(HMAC 服务端接口)确认微信侧真实退款与金额一致,防伪造推送回收权益;判定不确定时不回收、不流转
  • 兼容性:仅新增字段与方法,不改变 ParseNotify/BuildNotifyResponse/IsOrderRefundedStatus 等既有契约;handleGoodsDeliver 链路零改动
  • 边界(不做):不改为后端主动调用 refund_order(仍为运营在虚拟支付后台手动退款);普通微信支付(pay_channel=1)退款推送字段正确,不在本次范围;存量单笔订单不做人工订正

Agent Extensions

Skill

  • lsp-code-analysis
  • Purpose: 在改动 XPayRefundNotify/XPayIOSRefundQueryNotify 前,用 LSP 语义分析确认这两个结构的全部引用点(字段读取、方法调用、测试用例)与 handleRefundNotify/verifyXPayRefund/syncRefundFlowSuccess 的调用链,并核对新增 DAO 方法、Redis key 常量、调度函数命名无冲突
  • Expected outcome: 输出完整的引用清单与受影响的文件/调用点,确保字段替换无遗漏、无编译期破坏,并避免与既有命名重复