--- 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: 输出完整的引用清单与受影响的文件/调用点,确保字段替换无遗漏、无编译期破坏,并避免与既有命名重复