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
@@ -0,0 +1,93 @@
---
name: 会员额度不叠加
overview: 修复月度会员包与年度会员包赠送额度叠加的问题:会员包来源(source=1)的额度凭证不再求和,只取其中 amount 最大的一份计入有效额度;Daily 额度与 Cos 永久额度均适用,含扣减/回退/剩余计算全链路;其他来源(加量包/广告/体验)维持叠加;存量数据无需迁移。
todos:
- id: quota-select-core
content: 新增会员包生效份选择纯函数,改造 getEffectiveQuota 聚合(Cos 与每日额度)
status: completed
- id: cos-quota-chain
content: 改造 Cos 扣减、剩余与回退链路复用选择规则,未生效份不参与扣减与回退
status: completed
dependencies:
- quota-select-core
- id: refund-expire-consistency
content: 退款回收流水改为吊销前后重算差值记账,核对到期失效补记逻辑
status: completed
dependencies:
- quota-select-core
- id: quota-tests
content: 补充选择/聚合/扣减/回退/退款单元测试并跑通构建与全量回归
status: completed
dependencies:
- cos-quota-chain
- refund-expire-consistency
- id: docs-sync
content: 更新接口文档与 README 的额度叠加规则说明
status: completed
dependencies:
- quota-select-core
---
## 用户需求
修复"购买的月度会员与年度会员额度会叠加"的问题:额度规则调整为年度会员与月度会员额度相同、不叠加。
## 产品概述
用户同时持有多份会员包(如月度包 + 年度包)时,同类额度不再多份相加,只按"一份"生效;该规则即时生效,历史数据无需迁移。
## 核心功能
- 额度聚合调整:会员包来源(source=1)的同类额度只取 amount 最大的一份计入总额;月/年额度相同时即等价于只有一份
- 全链路一致:额度概览、额度校验、消费扣减、失败回退、退款回收、到期记账统一按单份口径计算
- 范围限定:仅会员包来源不叠加;加量包、广告激励、体验会员等其他来源仍按原逻辑叠加
- 存量兼容:历史凭证保留,查询/消费时按新聚合规则即时生效,无需数据修复
- 用完顺延:选中份额度用尽后自动启用下一份有效会员包凭证,已购额度不浪费,且任一时刻展示额度不翻倍(如 200 变 100)
## 技术栈
沿用现有 Go + Gin + GORM(MySQL)+ Redis 架构;不改表结构、不引入新依赖、不做数据迁移。
## 实现思路
在 service 层引入"会员包生效份选择"纯函数,替换现有对全部凭证直接 SUM 的聚合方式:
- **选择规则(仅 source=1 会员包)**:从有效凭证中排除已用尽(amount-used<=0)的份,按 `amount 降序 → used 降序(保持正在使用的一份,避免剩余展示抖动)→ id 降序(新购优先)` 取第一条;无候选则会员包贡献为 0。
- **其他来源(source=2/3/4)**:维持 SUM 叠加,行为不变。
- **改造点**:
1. `getEffectiveQuota`:总额 = 免费额度 + 选中份 amount + 其他来源 SUM;Cos 已用 = 免费已用 + 选中份 used + 其他来源 used;每日额度仅改 Total(已用为当日全局计数)。额度概览 `InfoGetBySelf` 自动继承。
2. `consumeCosQuota`:事务内仍按"先到期先用"扣减其他来源凭证,会员包组只扣选中份,再扣免费额度;未生效份不参与扣减。
3. `cosRemainTx`:免费剩余 + 其他来源剩余 + 选中份剩余,与展示口径一致。
4. `refundCosQuotaTx`:先退免费,再退凭证;其他来源按原顺序回退,会员包仅回退选中份。
5. `buildQuotaRevokeFlows`:改为"吊销前剩余 vs 排除该订单凭证后重算剩余"的差值记账(diff=0 不写流水),保证退款回收与实额一致;`quota_expire.go` 到期补记自动适配,核对即可。
- **关键决策与权衡**:选择为纯内存 O(n)(n 为用户凭证数,通常个位数),无新增 DB 往返;选择在每处自治重算,无需持久化状态;已用尽自动切换下一位,兼顾"不叠加展示"与"已购额度不浪费"。
- **安全与可靠性**:仍依赖凭证行的条件更新(used+n<=amount)防并发超扣;聚合失败保持既有降级告警策略,不新增泄密日志。
## 关键代码结构
```
// pickEffectiveMemberGrant 在有效凭证中选出会员包来源(source=1)的生效份:
// 排除已用尽,按 amount 降序 → used 降序 → id 降序取一份;无候选返回 nil。
// 纯函数:供 getEffectiveQuota / consumeCosQuota / cosRemainTx / refundCosQuotaTx 复用。
func pickEffectiveMemberGrant(grants []mysqlmodel.QuotaGrant) *mysqlmodel.QuotaGrant
```
## 目录结构
```
zt-closet-backend/
├── internal/service/
│ ├── service.go # [MODIFY] 新增 pickEffectiveMemberGrant 纯函数;改造 getEffectiveQuota、consumeCosQuota、cosRemainTx、refundCosQuotaTx,统一复用选择规则并更新注释
│ ├── order.go # [MODIFY] buildQuotaRevokeFlows 改为吊销前后重算差值记账(排除订单凭证后按同一规则聚合)
│ ├── quota_select_test.go # [NEW] 选择与聚合纯函数单测:月+年同额只取一份、取最大、used 稳定性、用尽顺延、其他来源仍叠加
│ └── cos_quota_test.go # [NEW] sqlmock 集成测试:Cos 扣减只落选中份、回退、剩余计算、退款回收流水差值
├── 接口文档.md # [MODIFY] 更新额度规则(629-653 行附近):会员包来源不叠加,其他来源仍叠加
└── README.md # [MODIFY] 同步额度叠加规则说明
```
## 测试与验证
- 覆盖:月度+年度同时持有、amount 不同取最大、选中份用尽顺延、加量包/体验/广告仍叠加、Cos 消费/回退/退款回收、每日额度 Total 不翻倍。
- 回归:`go build ./...`、`go vet ./...`、`go test ./...` 全量通过,确保现有发放幂等与补偿测试不受影响。