Files
zt-closet-backend/.codebuddy/plans/会员额度不叠加_76777dd5.md
zhengchiliu b94dd6dffb feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
2026-09-16 03:44:09 +08:00

93 lines
6.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 ./...` 全量通过,确保现有发放幂等与补偿测试不受影响。