feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: 会员额度不叠加
|
||||
overview: 修复月度会员包与年度会员包赠送额度叠加的问题:会员包来源(source=1)的额度凭证不再求和,只取其中 amount 最大的一份计入有效额度;Daily 额度与 Cos 永久额度均适用,含扣减/回退/剩余计算全链路;其他来源(加量包/广告/体验)维持叠加;存量数据无需迁移。
|
||||
todos:
|
||||
- id: quota-select-core
|
||||
content: 新增会员包生效份选择纯函数,改造 getEffectiveQuota 聚合(Cos 与每日额度)
|
||||
status: completed
|
||||
- id: cos-quota-chain
|
||||
content: 改造 Cos 扣减、剩余与回退链路复用选择规则,未生效份不参与扣减与回退
|
||||
status: completed
|
||||
dependencies:
|
||||
- quota-select-core
|
||||
- id: refund-expire-consistency
|
||||
content: 退款回收流水改为吊销前后重算差值记账,核对到期失效补记逻辑
|
||||
status: completed
|
||||
dependencies:
|
||||
- quota-select-core
|
||||
- id: quota-tests
|
||||
content: 补充选择/聚合/扣减/回退/退款单元测试并跑通构建与全量回归
|
||||
status: completed
|
||||
dependencies:
|
||||
- cos-quota-chain
|
||||
- refund-expire-consistency
|
||||
- id: docs-sync
|
||||
content: 更新接口文档与 README 的额度叠加规则说明
|
||||
status: completed
|
||||
dependencies:
|
||||
- quota-select-core
|
||||
---
|
||||
|
||||
## 用户需求
|
||||
|
||||
修复"购买的月度会员与年度会员额度会叠加"的问题:额度规则调整为年度会员与月度会员额度相同、不叠加。
|
||||
|
||||
## 产品概述
|
||||
|
||||
用户同时持有多份会员包(如月度包 + 年度包)时,同类额度不再多份相加,只按"一份"生效;该规则即时生效,历史数据无需迁移。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 额度聚合调整:会员包来源(source=1)的同类额度只取 amount 最大的一份计入总额;月/年额度相同时即等价于只有一份
|
||||
- 全链路一致:额度概览、额度校验、消费扣减、失败回退、退款回收、到期记账统一按单份口径计算
|
||||
- 范围限定:仅会员包来源不叠加;加量包、广告激励、体验会员等其他来源仍按原逻辑叠加
|
||||
- 存量兼容:历史凭证保留,查询/消费时按新聚合规则即时生效,无需数据修复
|
||||
- 用完顺延:选中份额度用尽后自动启用下一份有效会员包凭证,已购额度不浪费,且任一时刻展示额度不翻倍(如 200 变 100)
|
||||
|
||||
## 技术栈
|
||||
|
||||
沿用现有 Go + Gin + GORM(MySQL)+ Redis 架构;不改表结构、不引入新依赖、不做数据迁移。
|
||||
|
||||
## 实现思路
|
||||
|
||||
在 service 层引入"会员包生效份选择"纯函数,替换现有对全部凭证直接 SUM 的聚合方式:
|
||||
|
||||
- **选择规则(仅 source=1 会员包)**:从有效凭证中排除已用尽(amount-used<=0)的份,按 `amount 降序 → used 降序(保持正在使用的一份,避免剩余展示抖动)→ id 降序(新购优先)` 取第一条;无候选则会员包贡献为 0。
|
||||
- **其他来源(source=2/3/4)**:维持 SUM 叠加,行为不变。
|
||||
- **改造点**:
|
||||
|
||||
1. `getEffectiveQuota`:总额 = 免费额度 + 选中份 amount + 其他来源 SUM;Cos 已用 = 免费已用 + 选中份 used + 其他来源 used;每日额度仅改 Total(已用为当日全局计数)。额度概览 `InfoGetBySelf` 自动继承。
|
||||
2. `consumeCosQuota`:事务内仍按"先到期先用"扣减其他来源凭证,会员包组只扣选中份,再扣免费额度;未生效份不参与扣减。
|
||||
3. `cosRemainTx`:免费剩余 + 其他来源剩余 + 选中份剩余,与展示口径一致。
|
||||
4. `refundCosQuotaTx`:先退免费,再退凭证;其他来源按原顺序回退,会员包仅回退选中份。
|
||||
5. `buildQuotaRevokeFlows`:改为"吊销前剩余 vs 排除该订单凭证后重算剩余"的差值记账(diff=0 不写流水),保证退款回收与实额一致;`quota_expire.go` 到期补记自动适配,核对即可。
|
||||
|
||||
- **关键决策与权衡**:选择为纯内存 O(n)(n 为用户凭证数,通常个位数),无新增 DB 往返;选择在每处自治重算,无需持久化状态;已用尽自动切换下一位,兼顾"不叠加展示"与"已购额度不浪费"。
|
||||
- **安全与可靠性**:仍依赖凭证行的条件更新(used+n<=amount)防并发超扣;聚合失败保持既有降级告警策略,不新增泄密日志。
|
||||
|
||||
## 关键代码结构
|
||||
|
||||
```
|
||||
// pickEffectiveMemberGrant 在有效凭证中选出会员包来源(source=1)的生效份:
|
||||
// 排除已用尽,按 amount 降序 → used 降序 → id 降序取一份;无候选返回 nil。
|
||||
// 纯函数:供 getEffectiveQuota / consumeCosQuota / cosRemainTx / refundCosQuotaTx 复用。
|
||||
func pickEffectiveMemberGrant(grants []mysqlmodel.QuotaGrant) *mysqlmodel.QuotaGrant
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
zt-closet-backend/
|
||||
├── internal/service/
|
||||
│ ├── service.go # [MODIFY] 新增 pickEffectiveMemberGrant 纯函数;改造 getEffectiveQuota、consumeCosQuota、cosRemainTx、refundCosQuotaTx,统一复用选择规则并更新注释
|
||||
│ ├── order.go # [MODIFY] buildQuotaRevokeFlows 改为吊销前后重算差值记账(排除订单凭证后按同一规则聚合)
|
||||
│ ├── quota_select_test.go # [NEW] 选择与聚合纯函数单测:月+年同额只取一份、取最大、used 稳定性、用尽顺延、其他来源仍叠加
|
||||
│ └── cos_quota_test.go # [NEW] sqlmock 集成测试:Cos 扣减只落选中份、回退、剩余计算、退款回收流水差值
|
||||
├── 接口文档.md # [MODIFY] 更新额度规则(629-653 行附近):会员包来源不叠加,其他来源仍叠加
|
||||
└── README.md # [MODIFY] 同步额度叠加规则说明
|
||||
```
|
||||
|
||||
## 测试与验证
|
||||
|
||||
- 覆盖:月度+年度同时持有、amount 不同取最大、选中份用尽顺延、加量包/体验/广告仍叠加、Cos 消费/回退/退款回收、每日额度 Total 不翻倍。
|
||||
- 回归:`go build ./...`、`go vet ./...`、`go test ./...` 全量通过,确保现有发放幂等与补偿测试不受影响。
|
||||
@@ -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: 输出完整的引用清单与受影响的文件/调用点,确保字段替换无遗漏、无编译期破坏,并避免与既有命名重复
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: 退款申请订单状态流转修复
|
||||
overview: 新增订单状态「6=申请退款中」,修复用户发起退款申请后订单状态仍停留在「已支付」的问题:退款受理与订单状态流转原子化,退款成功流转为「5 已退款」,退款失败回退为「2 已支付」以便重新申请,并同步接口文档与状态机。
|
||||
todos:
|
||||
- id: add-status-constant
|
||||
content: 新增订单状态 6「申请退款中」常量,并同步 mysqlmodel 与两份迁移 SQL 的列注释
|
||||
status: completed
|
||||
- id: atomic-refund-apply
|
||||
content: 实现退款受理原子流转 DAO 并改造 RefundApply,使订单 2→6 与流水落库同事务
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-status-constant
|
||||
- id: settle-and-failure
|
||||
content: 放行状态 6:退款成功结算 6→5 并建回收任务,退款失败回退 6→2(普通与虚拟支付回调)
|
||||
status: completed
|
||||
dependencies:
|
||||
- atomic-refund-apply
|
||||
- id: list-filter-dto
|
||||
content: 订单列表放开按状态 6 筛选并更新 DTO 注释
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-status-constant
|
||||
- id: refund-tests
|
||||
content: 用 sqlmock 补充退款受理、成功结算、失败回退的单测
|
||||
status: completed
|
||||
dependencies:
|
||||
- atomic-refund-apply
|
||||
- settle-and-failure
|
||||
- id: sync-api-docs
|
||||
content: 同步接口文档:枚举、状态机、退款流程说明与前端轮询 FAQ
|
||||
status: completed
|
||||
dependencies:
|
||||
- atomic-refund-apply
|
||||
- settle-and-failure
|
||||
---
|
||||
|
||||
## 产品概述
|
||||
|
||||
修复订单退款流程中的状态缺失问题:用户提交退款申请成功后,订单状态应实时切换为「申请退款中」,而当前订单仍停留在「已支付」,导致用户端无法感知退款进度,运营也无法按状态筛选待处理退款单。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 新增订单状态「6=申请退款中」:用户通过 `POST /order/refund` 申请退款成功后,订单立即由「2 已支付」流转为「6 申请退款中」
|
||||
- 申请受理保持幂等与并发安全:同一订单重复申请、并发申请均只受理一次;上次退款失败的订单可复用旧退款流水重新申请
|
||||
- 退款成功:微信退款回调(普通支付/虚拟支付)到达后订单流转为「5 已退款」,并回收已发放权益、发送退款通知
|
||||
- 退款失败:微信回调返回非成功结果时,订单回退为「2 已支付」、退款流水置为失败,用户可重新申请
|
||||
- 订单列表支持按新状态筛选:前端可按「申请退款中」过滤订单并轮询进度
|
||||
- 已取消但已付款的竞态订单保持「3 已取消」不变(其权益从未发放)
|
||||
- 同步更新接口文档:订单状态枚举、状态机、退款模块说明与前端轮询说明
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 语言与框架:Go + Gin + GORM(现有项目架构,不引入新框架)
|
||||
- 数据库:MySQL(`orders.order_status` 为 TINYINT,无枚举约束,无需改列类型);Redis 仅用于回调分布式锁(已有)
|
||||
- 测试:`go-sqlmock` + 现有 `global.MysqlEngine` 注入模式(参考 `internal/service/order_quota_test.go`、`internal/mysqldao/hardening_test.go`)
|
||||
- 迁移:golang-migrate(`pkg/migration`,go:embed,`cmd/migrate` 手动执行)
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 核心思路
|
||||
|
||||
在订单状态枚举中新增 `OrderStatusRefunding int8 = 6 // 申请退款中`;将「受理退款申请」改造为事务内的「锁定订单 → 校验已支付 → 订单流转 2→6 → 写入/复用退款流水」原子操作,使订单状态与退款流水严格一致;再把退款结果回调链路(成功置 5、失败回退 2)与权益回收判断同步到新状态 6。
|
||||
|
||||
### 关键决策与权衡
|
||||
|
||||
1. **用订单行锁 + 条件流转作为并发闸门**:现有 `RefundApply` 注释称「order_no 唯一索引兜底并发」,但 `refund_flow_logs` 实际只有 `uk_refund_flow_no`(refund_no 唯一)与普通索引 `idx_refund_flow_order(order_id)`,无 order_no 唯一约束,并发申请存在双写风险。改为在 DAO 中新增事务方法(`SELECT ... FOR UPDATE` 锁订单行,沿用 `RefundSettleWithRevokeTask` 的既有范式),只有拿到「2 已支付」的请求才能流转为 6 并落流水,天然互斥且幂等;顺手修正误导性注释。
|
||||
2. **失败回退为「已支付」(已与用户确认)**:与现有「失败可复用旧流水重新申请」逻辑闭环;注意运营在线下直接拒绝(未发起微信退款)不会产生回调,文档中明确提示该场景订单会停留在 6,需运营退款或人工订正。
|
||||
3. **已取消但已付款的自动退款单不改状态(已与用户确认)**:`autoRefundCancelledOrder` 生成待审核退款流水后保持订单 3 已取消,避免误创建权益回收任务(该单权益从未发放)。
|
||||
4. **`RefundSettleWithRevokeTask` 必须放行 6**:否则退款成功回调会静默 return,订单无法置 5;且状态 6 表示权益已发放过,必须创建 `benefit_revoke_tasks` 回收任务(现仅 `order_status == 2` 时创建,需扩展为 2 或 6)。
|
||||
5. **错误码不新增**:复用 `errcode.RefundNotAllowed(71001)`、`RefundApplyFailed(71003)`、`DatabaseError(19001)`,避免破坏前端约定。
|
||||
|
||||
### 性能与可靠性
|
||||
|
||||
- 受理路径新增一次行锁事务,单次申请成本为 1 次主键/唯一索引行锁 + 2 条写语句,量级可忽略;无 N+1、无热点表全扫。
|
||||
- 状态流转全部使用条件更新(`WHERE order_status = from`),并发/乱序回调天然幂等,不依赖额外分布式锁。
|
||||
- 回调链路保持原有 Redis 锁 + 条件流转双重防重,不改变现有时序。
|
||||
|
||||
## 实现要点
|
||||
|
||||
- `internal/service/order.go`:`RefundApply` 改用新的原子 DAO 方法,成功后返回 `dto.RefundResponse`(`status=0` 待处理,订单状态由前端 `GET /order` 轮询);`RefundNotify` 失败分支新增订单回退 6→2(条件更新,失败仅记日志不回滚已更新的流水状态,保证幂等)。
|
||||
- 日志沿用 `slog.InfoContext/WarnContext/ErrorContext` 与关键字段(`order_no`/`user_id`/`out_refund_no`/`from_status`/`to_status`),不打印敏感信息。
|
||||
- 兼容性:`executeGrant` 已按 `order_status != 2` 跳过发放,状态 6 自动阻止继续发权益,无需改动;`OrderExpireByTime`(1→4)、`OrderListVirtualUnconfirmed`(IN 1,4)、`OrderListPaidMissingBenefitTasks`(=2)、`Cancel`(仅待支付)均不受影响。
|
||||
- 新增迁移只改列注释(`ALTER TABLE orders MODIFY COLUMN order_status ...`),不涉及数据回填;存量已进入退款流程但订单仍为 2 的老数据,可后续人工订正,不在本次自动改写范围。
|
||||
|
||||
## 架构设计
|
||||
|
||||
沿用现有分层:`routers → service → mysqldao → mysqlmodel`,不新增架构模式。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[POST /order/refund] --> B[OrderService.RefundApply]
|
||||
B --> C[RefundFlowLogDao.RefundApplyWithOrderStatus 事务<br/>锁订单行 + 2→6 + 写/复用流水]
|
||||
C --> D[(orders.order_status=6)]
|
||||
D --> E{微信退款回调}
|
||||
E -->|SUCCESS| F[RefundSettleWithRevokeTask<br/>6→5 + 取消发放 + 建回收任务]
|
||||
E -->|非 SUCCESS| G[流水置失败 + 订单回退 6→2]
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
zt-closet-backend/
|
||||
├── internal/
|
||||
│ ├── service/
|
||||
│ │ ├── constants.go # [MODIFY] 新增 OrderStatusRefunding int8 = 6「申请退款中」;补充退款状态注释
|
||||
│ │ ├── order.go # [MODIFY] RefundApply 改为调用原子受理 DAO 方法并修正并发注释;RefundNotify 失败分支回退订单 6→2
|
||||
│ │ └── virtualpay.go # [MODIFY] handleRefundNotify:非 SUCCESS 时回退订单 6→2 并尽力置流水失败;SUCCESS 时尽力将已存在的流水置成功(保持与订单终态一致)
|
||||
│ ├── mysqldao/
|
||||
│ │ ├── order.go # [MODIFY] RefundFlowLogDao 新增 RefundApplyWithOrderStatus:事务内 SELECT ... FOR UPDATE 锁订单、校验 2 已支付、条件流转 2→6、新建或复用(上次失败)退款流水;返回受理结果区分「不可退款/并发冲突/DB 错误」
|
||||
│ │ └── benefit_revoke.go # [MODIFY] RefundSettleWithRevokeTask:前置状态放行 6;状态为 2 或 6 时创建 benefit_revoke_tasks 回收任务
|
||||
│ ├── dto/
|
||||
│ │ └── order.go # [MODIFY] OrderResponse.OrderStatus 注释补 6;OrderListRequest.Status 校验改为 oneof=1 2 3 4 5 6
|
||||
│ └── mysqlmodel/
|
||||
│ └── order.go # [MODIFY] order_status 字段注释补 6=申请退款中
|
||||
├── pkg/migration/
|
||||
│ ├── 000001_init.up.sql # [MODIFY] orders.order_status 列注释补 6=申请退款中(新库一致)
|
||||
│ ├── 000002_order_status_refunding.up.sql # [NEW] ALTER TABLE orders MODIFY COLUMN order_status 更新列注释(存量库)
|
||||
│ └── 000002_order_status_refunding.down.sql # [NEW] 回滚列注释为旧文案
|
||||
├── internal/mysqldao/
|
||||
│ └── order_refund_test.go # [NEW] sqlmock 单测:受理成功 2→6 且流水落库;订单非已支付时拒绝且无写入;退款成功 6→5 且创建回收任务;失败回退 6→2 条件流转(复用 hardening_test.go / order_quota_test.go 的 setup 范式)
|
||||
└── 接口文档.md # [MODIFY] order_status 枚举补 6(字段表、5.12 模块、错误码章节附近说明)、状态机图与退款流程说明、前端轮询表与 FAQ(申请后订单=6 申请退款中、失败回退 2)
|
||||
```
|
||||
|
||||
## 关键代码结构(示意,接口级)
|
||||
|
||||
```
|
||||
// internal/service/constants.go
|
||||
const (
|
||||
// ...现有 1~5 不变
|
||||
OrderStatusRefunding int8 = 6 // 申请退款中(已受理退款申请,待运营审核/退款)
|
||||
)
|
||||
|
||||
// internal/mysqldao/order.go
|
||||
// RefundApplyWithOrderStatus 原子受理退款申请:锁定订单行并校验「2 已支付」,
|
||||
// 流转为「6 申请退款中」,同时新建退款流水或复用上次失败的流水。
|
||||
// 返回 allowed=false 表示订单状态不满足或并发冲突(映射 errcode.RefundNotAllowed)。
|
||||
func (d *RefundFlowLogDao) RefundApplyWithOrderStatus(
|
||||
ctx context.Context, orderNo, refundNo, reason string, applyTime int64,
|
||||
) (flow *mysqlmodel.RefundFlowLog, allowed bool, err error)
|
||||
```
|
||||
|
||||
## Agent Extensions
|
||||
|
||||
### Skill
|
||||
|
||||
- **lsp-code-analysis**
|
||||
- Purpose: 在改动前对 `OrderStatusPaid`/`OrderStatusRefunded`/`order_status` 做引用与影响面分析,确认所有消费订单状态的分支(含 DAO 查询、补偿任务、DTO 校验)都已覆盖状态 6
|
||||
- Expected outcome: 输出完整的引用清单与需同步修改的位置,避免遗漏消费点导致状态机断裂
|
||||
Reference in New Issue
Block a user