feat(order): 重构退款全流程并新增申请退款中状态

- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
This commit is contained in:
2026-09-16 03:44:09 +08:00
parent 89b59c7b91
commit b94dd6dffb
29 changed files with 2141 additions and 158 deletions
+49 -15
View File
@@ -228,7 +228,7 @@ Authorization: Bearer <access_token>
| 衣物 AI 处理 | `GET /clothes/:uuid/status` | `4` 成功 / `5` 失败(`1/2/3` 继续轮询) | 1~2 秒,建议上限 60 次 |
| AI 推荐生成 | `GET /ai-recommend/status` | `2` 成功 / `3` 失败(`1` 继续轮询) | 2 秒,建议上限 90 次 |
| 支付结果 | `GET /order`(按 `status` 或 `order_no` 过滤) | `2` 已支付 / `3` 已取消 / `4` 已过期 | 支付后 1 秒起,最多 15 分钟 |
| 退款进度 | `GET /order` | `5` 已退款 | 30 秒(人工审核,可能很久) |
| 退款进度 | `GET /order` | `5` 已退款(申请后为 `6` 申请退款中,继续轮询;回到 `2` 表示退款失败/被驳回) | 30 秒(人工审核,可能很久) |
| 今日运势未生成 | `GET /horoscope/today` | 返回数据(首次可能 `90300`) | 3~5 秒,最多 3 次 |
| 未读通知数 | `GET /notification/unread` | 无终态,按需刷新 | 进入页面时 / 30~60 秒 |
@@ -452,7 +452,7 @@ Authorization: Bearer <access_token>
| unit_price | int64 | 单价(**分**) |
| pay_amount | int64 | 实付金额(**分**) |
| pay_channel | int | 支付渠道 `1`=微信支付(JSAPI) `2`=虚拟支付 |
| order_status | int | `1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 |
| order_status | int | `1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 `6`=申请退款中 |
| member_days | int64 | 订单快照:会员天数 |
| expire_time | int64 | 支付截止时间(秒),前端倒计时用(15 分钟) |
| pay_time | int64 | 支付时间(秒),未支付为 0 |
@@ -639,7 +639,7 @@ Authorization: Bearer <access_token>
| 3 | 社区分享额度(搭配分享) | 每日 | 10 | 穿搭首次公开扣 1 |
| 4 | 衣柜分享额度 | 每日 | 5 | 创建一次衣柜分享扣 1 |
**② 额度凭证**(`quota_grants`)—— 有有效期的赠送额度,按来源分三类:
**② 额度凭证**(`quota_grants`)—— 有有效期的赠送额度,按来源分四类:
| source | 来源 | 有效期 |
| --- | --- | --- |
@@ -650,7 +650,8 @@ Authorization: Bearer <access_token>
> 新用户注册时,`NewUserGift` 段配置的体验会员额度以 **source=4** 凭证发放;`GiftVIPDay` 配 0(不赠送会员)时这部分额度一律不发放。
> ⚠️ 体验额度**不会**被后续充值「继承」:用户 3 天体验期后再购买会员,体验额度照常失效,只剩免费额度 + 新购会员包额度。
> Cos 消费先扣「即将过期凭证」再扣免费额度;每日额度总额 = 免费配置 + 有效凭证赠送额。
> ⚠️ **会员包来源(source=1)的额度不叠加**:月度包与年度包同时持有时,同类额度只取 **amount 最大的一份**计入(月/年额度相同时即等价于只按一份计算),避免额度翻倍;该份**用尽后自动顺延**到下一份有效会员包凭证,已购额度不浪费。加量包(source=2)、广告激励(source=3)、体验额度(source=4)仍按原逻辑**叠加求和**。
> Cos 消费先扣「非会员包凭证(先到期先用)」再扣「会员包生效份」,最后扣免费额度;未生效的会员包份不参与扣减与回退。每日额度总额 = 免费配置 + 会员包生效份 + 其他来源凭证赠送额。
#### GET `/quota/info` — 额度概览(鉴权)
@@ -1707,7 +1708,7 @@ Authorization: Bearer <access_token>
### 5.12 订单与退款模块
**`order_status` 枚举**:`1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款
**`order_status` 枚举**:`1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 `6`=申请退款中(已受理退款申请,待运营审核并手动发起退款)
**退款 `status` 枚举**:`0`=待处理 `1`=退款中 `2`=退款成功 `3`=退款失败
#### POST `/order` — 创建订单并发起微信支付(鉴权)
@@ -1798,7 +1799,7 @@ wx.requestVirtualPayment({
| 参数 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| page / page_size | int | 否 | — | 分页 |
| status | int | 否 | `oneof=1 2 3 4 5` | 订单状态筛选;不传 = 全部 |
| status | int | 否 | `oneof=1 2 3 4 5 6` | 订单状态筛选(`6`=申请退款中);不传 = 全部 |
**响应** `data.list[]` = [订单对象](#45-订单对象-orderitem)
@@ -1833,11 +1834,15 @@ wx.requestVirtualPayment({
**业务逻辑(重要:人工审核模式)**
- **iOS 虚拟支付订单不可主动退款** → `71004`,请引导用户走 App Store 申请退款(Apple 通过退款问询推送回收权益)。
- **仅 `2` 已支付**订单可申请,其它状态 → `71001`。
- ⚠️ **系统不会自动调微信退款**:接口只是登记退款申请(`status=0` 待处理),**需运营人工审核并手动发起退款**——普通支付订单在微信支付商户后台退款,**虚拟支付订单(`pay_channel=2`)请在虚拟支付后台(Midas)发起退款**,退款结果由推送回调同步。
- 前端应提示"**退款审核中**",并允许轮询订单状态直到 `5` 已退款;**不要承诺实时到账**。
- **仅 `2` 已支付**订单可申请,其它状态(含申请中的 `6`)→ `71001`。
- ✅ **受理成功后订单立即置 `6` 申请退款中**:订单状态流转与退款流水(`status=0` 待处理)在同一事务内完成;重复/并发申请只有一次受理成功,其余返回 `71001`。前端刷新订单列表即可看到"退款审核中"。
- ⚠️ **系统不会自动调微信退款**:接口只是登记退款申请(流水 `status=0` 待处理),**需运营人工审核并手动发起退款**——普通支付订单在微信支付商户后台退款,**虚拟支付订单(`pay_channel=2`)请在虚拟支付后台(Midas)发起退款**,退款结果由推送回调同步。
- 退款结果由回调驱动(`pay_channel=1` 走微信支付 V3 退款通知;`pay_channel=2` 走虚拟支付退款推送 `xpay_refund_notify`,以 `RetCode=0` 判定成功):**成功** → 订单 `5` 已退款(回收权益 + 发退款通知);**失败** → 退款流水置 `3` 失败、订单回退 `2` 已支付,用户可重新申请。
- 前端应提示"**退款审核中**"并轮询订单状态:`6` 继续轮询、`5` 为终态、回到 `2` 表示退款失败/被驳回(可再次申请);**不要承诺实时到账**。
- 同一订单已有退款记录且状态不是 `3`(失败)→ `71001`;上次失败可重新申请(复用记录、换新退款单号)。
- 退款成功后,订单置 `5`,**会员天数与已发放额度会被回收**(已消耗部分不回收),并发退款通知。
- 退款成功后,**会员天数与已发放额度会被回收**(已消耗部分不回收),并发退款通知。
- 🛟 **兜底**:虚拟支付退款结果除推送外还有**查单兜底任务**(每 60 秒扫描停留超 10 分钟的「申请退款中」订单反查 `query_order`)——微信侧已退款会自动订正为 `5` 并回收权益;微信侧仍未退款则保持 `6`,超过 24 小时输出告警交人工。**推送丢失也不会导致订单永久卡单**。
- ⚠️ **运营线下拒绝**(未发起微信退款)不会产生回调,订单会停留在 `6` 申请退款中:兜底任务会持续核对并对超期订单告警,需运营在后台发起退款,或由研发人工订正。
**请求**(json)
@@ -1865,7 +1870,28 @@ wx.requestVirtualPayment({
- 不走统一 `{code,msg,data}` 响应包装,按微信 V3 规范返回:成功 HTTP 200 `{"code":"SUCCESS","message":"成功"}`;失败 HTTP 500 `{"code":"FAIL","message":"<原因>"}`。
- 服务端处理:验签 + 解密 → 幂等锁(30 秒)→ **金额校验**(与订单金额不一致则拒绝流转、人工介入)→ 流转订单状态 → 发放/回收权益 → 发站内通知。
- 竞态兜底:已过期但已付款 → 补发货;已取消但已付款 → **自动发起微信退款**。
- 竞态兜底:已过期但已付款 → 补发货;已取消但已付款 → 生成**待审核退款单**(订单保持 `3` 已取消,其权益从未发放),由管理员手动退款。
---
#### POST `/xpay/notify` — 虚拟支付消息推送(公开)
🚫 **这是微信服务端推送地址,前端绝对不要调用。**
- 事件类型(`Event` 字段):`xpay_goods_deliver_notify` 道具发货、`xpay_refund_notify` 退款完成、`xpay_subscribe_ios_refund_query_notify` iOS 退款问询(需 3 秒内应答);代币支付/投诉/风控等其余事件仅记录不处理。
- 应答按虚拟支付规范:`{"ErrCode":0,"ErrMsg":"success"}` 表示处理完成;`{"ErrCode":1}` 表示处理失败,**微信会按 2/4/8/16… 秒重试,最多 15 次**。服务端对「关键字段缺失 / 订单查不到 / 退款结果无法判定」一律应答失败以触发重试,不再静默应答成功。
- **退款推送 `xpay_refund_notify` 的官方字段**(易错点,勿按普通支付回调的字段理解):
| 字段 | 说明 |
| --- | --- |
| MchOrderId | 原支付单**商户单号**(= 本地 `order_no`);该推送**没有** `OutTradeNo` |
| MchRefundId | 商户退款单号 |
| WxRefundId | 微信退款单号 |
| RefundFee | 退款金额(分) |
| RetCode | **退款结果:`0`=成功,非 0=失败**;该推送**没有** `RefundStatus` |
| RetMsg | 退款失败原因 |
- 处理链路:定位订单 → 幂等锁 → `RetCode=0` 时**反查 `query_order` 确认真实退款且金额一致**(推送无签名,防伪造回收权益)→ 订单 `6 → 5` + 回收权益 + 发通知 + 退款流水回写微信退款单号与原始报文;`RetCode≠0` 时订单回退 `2 已支付` + 退款流水置失败(原因取 `RetMsg`)。
---
## 6. 业务逻辑与状态机
@@ -1987,6 +2013,9 @@ stateDiagram-v2
pending --> cancelled : 用户主动取消(同步关闭微信订单)
expired --> paid : 过期后付款到账(补发货)
cancelled --> refunded : 已取消但已付款 → 生成待审核退款单,管理员手动退款
paid --> refunding : 用户申请退款(POST /order/refund,同事务写退款流水)
refunding --> refunded : 退款成功回调(回收权益 → 通知)
refunding --> paid : 退款失败/被驳回(流水置失败,可重新申请)
paid --> refunded : 退款成功回调(回收权益 → 通知)
state "1 待支付" as pending
@@ -1994,14 +2023,16 @@ stateDiagram-v2
state "3 已取消" as cancelled
state "4 已过期" as expired
state "5 已退款" as refunded
state "6 申请退款中" as refunding
```
**关键规则**
- **金额校验**:回调金额与订单 `pay_amount` 不一致 → 拒绝流转、不发货、转人工。
- **权益发放**:会员天数按快照实际发放,会员类型按**续费后最终到期时长**推导(剩余 ≥365 天为年度,否则月度,避免年度会员续月包被降级);上传 / AI / 衣柜分享 / 搭配分享额度改为写入**额度凭证** `quota_grants`(会员包 `expire_time=0` 跟随 VIP、加量包按 `goods.valid_days` 独立到期)。
- **权益发放**:会员天数按快照实际发放,会员类型按**续费后最终到期时长**推导(剩余 ≥365 天为年度,否则月度,避免年度会员续月包被降级);上传 / AI / 衣柜分享 / 搭配分享额度改为写入**额度凭证** `quota_grants`(会员包 `expire_time=0` 跟随 VIP、加量包按 `goods.valid_days` 独立到期)。**会员包来源额度不叠加**:月度包/年度包同时持有时同类额度只取 amount 最大的一份,该份用尽后自动顺延下一份。
- **幂等 + 补偿**:`benefit_grant_tasks` 唯一索引 + "发放中"状态防并发;失败自动落任务,**每分钟重试**,重试 3 次仍失败则标记失败并告警人工介入。
- **退款回收**:会员天数按快照回退(不回退到过去);赠送额度通过**失效凭证**回收未用部分,**已消耗部分不追回**。
- **退款状态流转**:`POST /order/refund` 受理后订单 `2 → 6`(申请退款中,同一事务写入退款流水,行锁防重复受理);退款成功回调 `6 → 5` 并回收权益;微信退款失败回调 `6 → 2` 且流水置失败,用户可重新申请。运营线下直接拒绝不会产生回调,订单会停留在 `6`,需人工介入。
---
@@ -2315,7 +2346,7 @@ sequenceDiagram
| 79002 | 更新订单失败 | 200 |
| 79003 | 创建虚拟支付订单失败 | 200 |
| 71000 | 退款记录不存在 | 200 |
| 71001 | 当前状态不允许退款 | 200 |
| 71001 | 当前状态不允许退款(非已支付或已有进行中的退款申请) | 200 |
| 71002 | 退款金额不合法 | 200 |
| 71003 | 退款申请失败 | 200 |
| 71004 | iOS 订单请通过 App Store 申请退款 | 200 |
@@ -2383,7 +2414,10 @@ A:不能。额度在**生成时**已消耗,只有生成失败才回退。前
A:以服务端为准。微信回调是异步的,**轮询 `GET /order` 直到 `order_status=2`**。
**Q:退款申请后一直没到账?**
A:退款是**人工审核模式**,接口只登记申请(`status=0`)。前端提示"退款审核中",并允许轮询订单状态到 `5`。
A:退款是**人工审核模式**,接口只登记申请(退款流水 `status=0`)。申请成功后订单为 `6` 申请退款中,前端提示"退款审核中"并轮询订单状态:`5` 已退款为终态;若回到 `2` 已支付,说明退款失败/被驳回,可引导用户重新申请。
**Q:虚拟支付退款推送丢了 / 解析失败,会不会一直卡在「申请退款中」?**
A:不会。退款推送处理对无法判定的报文会应答失败触发微信重试(间隔 2/4/8/16… 最多 15 次);同时后台**查单兜底任务**每 60 秒反查停留超 10 分钟的「申请退款中」订单——微信侧已退款会自动订正为 `5 已退款` 并回收权益;若微信侧确实未退款则保持 `6`,超过 24 小时输出告警交人工介入。
**Q:衣柜分享列表里显示"正常",但对方打不开?**
A:过期是**惰性**的。前端需同时判断 `status` 与 `expire_time`。
@@ -2402,7 +2436,7 @@ A:调用 `GET /outfit/community` 分页获取其他用户已公开(公开 +
- [ ] 金额展示 `÷100`,时间展示 `×1000`
- [ ] 上传后轮询状态(1~2 秒,最多 60 次),展示处理中/失败态
- [ ] 支付后轮询订单状态,不依赖支付组件回调
- [ ] 退款提示"人工审核中"
- [ ] 退款提示"人工审核中"(申请后订单状态 `6`,轮询到 `5` 为终态,回到 `2` 表示失败可重申请)
- [ ] 公开穿搭前提示将消耗 1 次搭配分享额度
- [ ] AI 推荐前提示将消耗 1 次 AI 额度,且**不保存不退**
- [ ] 点赞/收藏按钮做乐观更新 + 失败回滚(接口幂等,可重试)