---
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 事务
锁订单行 + 2→6 + 写/复用流水]
C --> D[(orders.order_status=6)]
D --> E{微信退款回调}
E -->|SUCCESS| F[RefundSettleWithRevokeTask
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: 输出完整的引用清单与需同步修改的位置,避免遗漏消费点导致状态机断裂