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

156 lines
13 KiB
Markdown
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.
---
name: 虚拟支付退款推送字段修复与查单兜底
overview: 修复微信虚拟支付退款推送 xpay_refund_notify 报文字段解析错误(应用 MchOrderId/MchRefundId/RetCode,而非 OutTradeNo/RefundOrderId/RefundStatus)导致的订单永久卡在「6 申请退款中」问题;解析不到关键字段时不再静默应答成功,并新增退款结果查单兜底定时任务(微信侧已退款自动订正为已退款,仍未退款则保持状态并告警人工)。
todos:
- id: fix-refund-push-parse
content: 用 [skill:lsp-code-analysis] 核对引用点后,按官方字段重写退款推送解析与 RetCode 判定,补 pkg/wechat 单测
status: completed
- id: harden-refund-notify
content: 改造 handleRefundNotify 与 verifyXPayRefund:字段驱动流转、失败重试应答、流水与原始报文回写
status: completed
dependencies:
- fix-refund-push-parse
- id: add-refund-reconcile-job
content: 用 [skill:lsp-code-analysis] 核对命名后,新增退款查单兜底任务与 DAO/Redis key 并注册到 main.go
status: completed
dependencies:
- harden-refund-notify
- id: add-reconcile-tests
content: 补 DAO 扫描与解析判定单测,跑通 go build、go vet 与全量 go test 回归
status: completed
dependencies:
- add-refund-reconcile-job
- id: sync-refund-docs
content: 同步接口文档与 README:虚拟支付退款推送字段、失败重试语义与查单兜底说明
status: completed
dependencies:
- 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 返回失败触发微信重试;兜底层新增查单对账定时任务,保证推送丢失/解析失败时订单仍能收敛到终态,与官方「推送 + 轮询双保险」建议一致。
```mermaid
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: 输出完整的引用清单与受影响的文件/调用点,确保字段替换无遗漏、无编译期破坏,并避免与既有命名重复