---
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 原子限流
当日计数 +1 且 <= 总额度]
L --> M[user_daily_usages 统计]
P --> TX[MySQL 事务
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)
```