--- 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) ```