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