184 lines
10 KiB
Markdown
184 lines
10 KiB
Markdown
---
|
||
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)
|
||
``` |