Files
zt-closet-backend/.codebuddy/plans/额度体系改造_每日额度与VIP有效期_9b667d2b(未完成).md
T
2026-09-11 01:41:52 +08:00

184 lines
10 KiB
Markdown
Raw 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: 额度体系改造:每日额度与VIP有效期
overview: 重构额度体系:AI/Outfit/Closet 三类改为每日重置额度(每日总额度 = 免费配置 + 购买赠送,购买赠送随 VIP 有效期失效);Cos 保持永久累计,但购买赠送的 Cos 额度也随 VIP 过期失效。
todos:
- id: dao-layer
content: 改造数据访问层:新增订单赠送额度汇总查询与 Cos 动态总额度消费方法
status: pending
- id: redis-daily
content: 实现 Redis 每日额度原子限流:带限额 Lua 脚本及 consume/refund/value 封装
status: pending
- id: service-core
content: 实现 Service 层额度核心:动态总额度计算、每日额度消费/回退、Cos 消费改造
status: pending
dependencies:
- dao-layer
- redis-daily
- id: biz-switch
content: 切换 AI/Outfit/Closet 业务使用点为每日额度消费,Cos 适配动态总额度
status: pending
dependencies:
- service-core
- id: order-benefit
content: 改造订单权益发放/退款/补偿链路:额度类不再落库发放,仅保留 VIP 发放
status: pending
dependencies:
- service-core
- id: info-config-doc
content: 更新额度信息接口、配置语义注释与 README/接口文档
status: pending
dependencies:
- biz-switch
- order-benefit
---
## 产品概述
将当前「AI 推荐、社区分享(OutfitShare)、衣柜分享(ClosetShare)、上传(Cos)」四类统一永久累计的额度体系,改造为混合额度模型:
- **Cos(上传)**:永久额度,不每日重置;免费部分永久,购买赠送部分随 VIP 有效期生效
- **AI / Outfit / Closet**:每日额度,每天 0 点重置
## 核心功能
1. **Cos 永久额度**:总额度 = 免费永久额度(配置 20)+ 购买赠送额度(仅 VIP 有效期内计入);已用量永久累计,删除衣物/处理失败时回退。
2. **AI / Outfit / Closet 每日额度**:每日总额度 = 免费每日额度(配置 AI:3 / OutfitShare:3 / ClosetShare:0)+ 购买赠送额度(仅 VIP 有效期内计入);当日已用每天 0 点自动清零;ClosetShare:0 表示新用户无免费衣柜分享次数,只能靠购买赠送获得。
3. **购买赠送额度随 VIP 过期失效**:用户 VIP 过期后,购买赠送的 Cos/AI/Outfit/Closet 额度全部失效;重新成为会员后恢复。
4. **消耗顺序**:免费额度与购买赠送额度合并为单一额度池,无需区分消耗顺序(用户已确认「都可以」)。
5. **额度信息接口**:`GET /api/v1/quota/info` 对 Cos 返回永久总额度/累计已用/剩余;对 AI/Outfit/Closet 返回每日总额度/当日已用/当日剩余。
## 技术栈
- 后端语言/框架:Go + Gin(复用现有)
- 数据层:GORM + MySQL(复用现有)
- 缓存/原子计数:Redis + go-redis(复用现有,新增 Lua 原子限流脚本)
- 迁移:golang-migrate(复用现有迁移机制)
## 实现方案
### 核心策略:赠送额度动态计算、不落库
不再通过 `grantQuota` 把购买赠送额度累加进 `user_quotas.total`,而是每次查询/消费时**动态计算**有效总额度:
- `每日总额度(AI/Outfit/Closet) = 免费配置值 + (VIP有效 ? SUM(有效订单对应赠送额度) : 0)`
- `永久总额度(Cos) = 免费配置值 + (VIP有效 ? SUM(有效订单 cos_quota) : 0)`
「有效订单」= `order_status = paid` 且未退款(退款后状态变为 `refunded` 自动排除);VIP 有效性用 `user.vip_expire > now` 判断。免费底值直接读配置,不再依赖被历史数据污染过的 `user_quotas.total`。
### 分渠道消费机制
- **Cos(永久额度)**:继续走 MySQL 事务条件更新 `used`,但将 `total` 上限从表字段改为**动态计算后作为参数传入**,避免 total 被历史购买赠送污染。
- **AI / Outfit / Closet(每日额度)**:用 Redis Lua 脚本原子完成「当日已用 + 1 且不超过每日总额度」的校验与计数,key 按 `user_id:YYYYMMDD` 区分并带 48h TTL 跨天自然过期;MySQL `user_daily_usages` 保留作为统计与 Redis 故障兜底。
### 为什么这样设计
- **不落库赠送额度**:赠送额度与 VIP 有效期强耦合,落库后需要额外的过期回收任务且易产生账实不符;动态计算天然跟随 VIP 状态与订单退款状态,消除过期/退款回收的复杂度和资损窗口。
- **Cos 用 MySQL 事务、每日额度用 Redis 原子计数**:Cos 的 `used` 需要永久累计并写流水,适合事务;每日额度需要高频原子增减与每日清零,Redis INCR + TTL 天然适配,避免每日重置的定时任务。
- **复用现有半成品**:`user_daily_usages` 表、`recordDailyUsage`、`QuotaDaily*` Redis 函数均已存在,只需补上「带限额的原子校验」与业务接入。
### 关键并发与一致性
- 每日额度消费:Redis Lua 脚本内完成「读当日计数 → 判断 < 每日总额度 → INCR → 首次设置 TTL」,保证不超发、并发安全。
- 业务失败回退:AI 推荐失败、分享创建失败等沿用现有 `refundQuota` 思路,新增 `refundDailyQuota`(Redis DECR 并保证不小于 0)。
- Cos 消费:在事务内先读取动态总额度,再条件更新 `used + n <= limit`,避免并发超扣;沿用现有「流水与数值同事务」的强一致模式。
- 幂等:购买赠送额度不再创建额度发放流水,`BenefitGrantTask` 中 Cos/AI/Outfit/Closet 四类改为「记录支付完成但不实际发放」,只保留 VIP 实际发放。
### 性能考量
- 动态总额度需要 `SUM(orders.xxx_quota)`,已存在索引 `idx_orders_user_status(user_id, order_status)` 可支撑;商品 SKU 少、单用户订单量小,直接聚合开销可控。
- 为避免高频消费时反复 SUM + 读用户,可在 Redis 增加「当日总额度缓存」(key 带 YYYYMMDD,TTL 到当天结束),订单支付/退款、VIP 变更时失效;实现时作为可选项,先以直接计算为主、缓存优化为增量。
## 架构设计
```mermaid
flowchart TD
subgraph 额度查询/消费入口
Q[GET /quota/info] --> E[getEffectiveQuota]
C1[AI/Outfit/Closet 业务] --> D[consumeDailyQuota]
C2[Cos 上传] --> P[consumeCosQuota]
end
E --> V{vip_expire > now?}
V -->|是| S[SUM 有效订单赠送额度]
V -->|否| F[免费配置值]
S --> T[每日/永久总额度]
D --> L[Redis Lua 原子限流<br/>当日计数 +1 且 <= 总额度]
L --> M[user_daily_usages 统计]
P --> TX[MySQL 事务<br/>used + 1 且 <= 动态总额度]
TX --> FLOW[写 quota_flow_logs]
T --> Q
```
## 目录结构
```
internal/
├── service/
│ ├── service.go # [MODIFY] 新增 getEffectiveQuota、consumeDailyQuota、refundDailyQuota;改造 Cos consumeQuota 支持动态总额度
│ ├── constants.go # [MODIFY] 更新额度语义注释,补充每日额度/永久额度常量说明
│ ├── quota.go # [MODIFY] ensureDefaultQuota 仅初始化 Cos 底值;InfoGetBySelf 返回每日额度信息
│ ├── airecommend.go # [MODIFY] AI 消费改 consumeDailyQuota,失败回退 refundDailyQuota
│ ├── outfit.go # [MODIFY] OutfitShare 消费改 consumeDailyQuota
│ ├── closet.go # [MODIFY] ClosetShare 消费改 consumeDailyQuota
│ ├── clothes.go # [MODIFY] Cos 消费适配动态总额度(调用点基本不变,内部语义调整)
│ ├── order.go # [MODIFY] grantOrderBenefits/revokeOrderBenefits 不再发放/回退四类额度,仅保留 VIP;doGrantBenefit 对额度类直接置成功
│ └── benefit_compensation.go # [MODIFY] 额度类补偿任务兼容(不发放直接成功)
├── mysqldao/
│ ├── order.go # [MODIFY] 新增 OrderGrantedQuotaSumByUserID:SUM 有效订单四类赠送额度
│ └── quota.go # [MODIFY] 新增 Cos 动态总额度消费方法(传入 limit 条件更新)
├── mysqlmodel/
│ └── quota.go # [MODIFY] 模型注释更新(total 对 Cos 语义调整,AI/Outfit/Closet 不再使用)
├── redisdao/
│ └── quota.go # [MODIFY] 新增带限额 Lua 脚本及 DailyQuotaConsume/Refund/Value 封装
├── redismodel/
│ └── quota.go # [MODIFY] 复用/补充每日额度 key 定义
└── dto/
└── quota.go # [MODIFY] QuotaItemResponse 字段语义注释(Cos 永久 vs 每日),按 quota_type 区分含义
pkg/
└── setting/
└── section.go # [MODIFY] UserDefaultQuotaSettingS 注释更新(AI/Outfit/Closet 为每日免费额度)
configs/
└── config.example.yaml # [MODIFY] UserDefaultQuota 段注释更新(Cos 永久、AI/Outfit/Closet 每日)
README.md # [MODIFY] 额度语义说明同步
接口文档.md # [MODIFY] /quota/info 返回字段语义同步
```
## 关键代码结构
```
// internal/service/service.go
// getEffectiveQuota 返回某类额度的当日/永久有效总额度:
// Cos = 免费配置 + (VIP有效 ? 订单赠送cos : 0);AI/Outfit/Closet = 免费配置 + (VIP有效 ? 订单赠送对应值 : 0)
func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (int64, *errcode.Error)
// consumeDailyQuota 原子扣减 AI/Outfit/Closet 当日额度:Redis Lua 校验限额 + INCR
func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error
// refundDailyQuota 业务失败时回退当日已用计数(DECR 且不小于 0)
func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64)
```
```
-- internal/redisdao/quota.go 中的限流 Lua(伪代码示意)
-- KEYS[1]=daily count key, ARGV[1]=limit, ARGV[2]=ttl
local v = tonumber(redis.call('GET', KEYS[1]) or '0')
if v >= tonumber(ARGV[1]) then return -1 end
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
return n
```
```
// internal/mysqldao/order.go
// OrderGrantedQuotaSum 用户有效订单(order_status=paid)四类赠送额度汇总
type OrderGrantedQuotaSum struct {
CosQuota int64
AIRecommendQuota int64
OutfitShareQuota int64
ClosetShareQuota int64
}
func (d *OrderDao) OrderGrantedQuotaSumByUserID(ctx context.Context, userID int64) (*OrderGrantedQuotaSum, error)
```