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

10 KiB
Raw Permalink Blame History

name, overview, todos
name overview todos
额度体系改造:每日额度与VIP有效期 重构额度体系:AI/Outfit/Closet 三类改为每日重置额度(每日总额度 = 免费配置 + 购买赠送,购买赠送随 VIP 有效期失效);Cos 保持永久累计,但购买赠送的 Cos 额度也随 VIP 过期失效。
id content status
dao-layer 改造数据访问层:新增订单赠送额度汇总查询与 Cos 动态总额度消费方法 pending
id content status
redis-daily 实现 Redis 每日额度原子限流:带限额 Lua 脚本及 consume/refund/value 封装 pending
id content status dependencies
service-core 实现 Service 层额度核心:动态总额度计算、每日额度消费/回退、Cos 消费改造 pending
dao-layer
redis-daily
id content status dependencies
biz-switch 切换 AI/Outfit/Closet 业务使用点为每日额度消费,Cos 适配动态总额度 pending
service-core
id content status dependencies
order-benefit 改造订单权益发放/退款/补偿链路:额度类不再落库发放,仅保留 VIP 发放 pending
service-core
id content status dependencies
info-config-doc 更新额度信息接口、配置语义注释与 README/接口文档 pending
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 变更时失效;实现时作为可选项,先以直接计算为主、缓存优化为增量。

架构设计

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)