- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
13 KiB
13 KiB
name, overview, todos
| name | overview | todos | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 虚拟支付退款推送字段修复与查单兜底 | 修复微信虚拟支付退款推送 xpay_refund_notify 报文字段解析错误(应用 MchOrderId/MchRefundId/RetCode,而非 OutTradeNo/RefundOrderId/RefundStatus)导致的订单永久卡在「6 申请退款中」问题;解析不到关键字段时不再静默应答成功,并新增退款结果查单兜底定时任务(微信侧已退款自动订正为已退款,仍未退款则保持状态并告警人工)。 |
|
产品概述
用户对虚拟支付订单(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 告警人工]
关键决策与权衡
- 官方字段为主、旧字段兼容:
MchOrderId优先、OutTradeNo兜底;MchRefundId优先、RefundOrderId/WxRefundId兜底;成功判定RetCode优先、RefundStatus(大小写不敏感 SUCCESS)兜底。既修正线上行为,又避免微信侧版式差异再次导致静默失败。 RetCode用指针类型区分「未携带」与「0」:直接使用*int,避免字段缺失被误判为「退款成功」而错误回收用户权益(资损方向)。- 不可判定一律应答失败:推送无法定位订单或无法判定结果时返回
ErrCode:1,让微信按 2/4/8/16 秒重试最多 15 次;叠加兜底任务,做到「推送失败也不会永久卡单」。 - 兜底不自动回退订单(已与用户确认):反查到微信侧仍未退款时保持「申请退款中」并 Error 告警,避免运营处理中的退款被误判回退造成重复退款风险。
- 复用既有补偿范式:新任务完全对齐
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: 输出完整的引用清单与受影响的文件/调用点,确保字段替换无遗漏、无编译期破坏,并避免与既有命名重复