feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
---
|
||||
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: 输出完整的引用清单与受影响的文件/调用点,确保字段替换无遗漏、无编译期破坏,并避免与既有命名重复
|
||||
Reference in New Issue
Block a user