- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
148 lines
10 KiB
Markdown
148 lines
10 KiB
Markdown
---
|
||
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: 输出完整的引用清单与需同步修改的位置,避免遗漏消费点导致状态机断裂 |