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

148 lines
10 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: 新增订单状态「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: 输出完整的引用清单与需同步修改的位置,避免遗漏消费点导致状态机断裂