每日免费配额、VIP 无限次数、按次积分三套体系经常同时生效,额度判断的复杂度远超功能本身。第一版把判断逻辑散落在各个接口里,改一次规则要动七八处,改完还总担心漏了哪个分支。后来我抽了一个统一的 QuotaService 管理所有扣减和重置,规则只写一遍,接口层彻底干净。下面的设计把权益体系、原子扣减、跨日重置、对账补单都覆盖到了,全是线上跑过的方案。
一、用户权益体系
权益先定清楚才能谈实现。定价那会儿我们对比过几家竞品的订阅结构,发现把”次数、清晰度、速度”拆成独立维度卖,用户反而容易困惑,不如直接按档位打包。我把用户分成四档,核心差异在每日次数、处理速度、清晰度和价格上:
| 等级 | 每日免费 | 速度 | 高清 | 月费 |
|---|---|---|---|---|
| 普通用户 | 3 次/天 | 标准 | 720p | 免费 |
| 月度 VIP | 无限 | 优先 | 1080p | ¥29 |
| 年度 VIP | 无限 | 最优先 | 4K | ¥299 |
| 按次付费 | 1 次/积分 | 标准 | 1080p | ¥0.99/次 |
四档之间的关键边界是:VIP 的”无限”用 9999 这个大数模拟,逻辑上可以复用同一套配额判断,不必单开分支,后续要改成真无限也只需调整这一个值。这个设计让我在加第四档按次付费时几乎没改核心判断逻辑,只加了积分字段和对应成本表,半天就上线了。
二、数据模型
配额字段直接挂在 user 表上,读的时候少一次 JOIN,写的时候只有一个事务,字段设计如下:
class User(Base):
# ...
is_vip: Mapped[bool]
vip_expires_at: Mapped[Optional[datetime]]
credits: Mapped[int] # 按次积分
# 每日配额
daily_video_quota: Mapped[int] # VIP 9999,普通 3
daily_used: Mapped[int]
daily_reset_at: Mapped[datetime]
dailyvideoquota 跟随 VIP 状态动态变化,判断逻辑收敛成一个函数:
def get_daily_quota(user: User) -> int:
"""获取用户当前每日配额"""
if user.is_vip and user.vip_expires_at > datetime.now():
return 9999 # VIP 无限
return 3 # 普通用户
这里有个小细节:判断 VIP 是否有效用的是 vipexpiresat 和当前时间比对,而不是只看 isvip 布尔值,因为后者可能还没来得及被定时任务更新。这个坑是线上真实踩过的——用户到期那几分钟里,isvip 还是 True 但权限其实该失效了,用时间戳比对直接堵住了这个窗口。
三、配额服务
QuotaService 把检查、扣减、重置三个动作收敛到一起,接口层不再散落判断逻辑:
class QuotaService:
def __init__(self, db: AsyncSession):
self.db = db
async def check_and_consume(self, user: User, action: str) -> bool:
"""检查并扣除一次配额"""
await self._maybe_reset_daily(user)
quota = self._get_quota(user, action)
if user.daily_used >= quota:
raise QuotaExceeded(
f'今日 {action} 次数已用完,升级 VIP 解锁无限'
)
user.daily_used += 1
await self.db.commit()
return True
async def _maybe_reset_daily(self, user: User):
"""每日重置"""
now = datetime.now()
if not user.daily_reset_at or user.daily_reset_at.date() < now.date():
user.daily_used = 0
user.daily_reset_at = now
await self.db.commit()
这里用的是惰性重置:用户访问时顺带检查是否跨日,命中就清零,省掉了每分钟都要跑的定时任务。对比方案我也考虑过凌晨定时批量清零,但那样凌晨到用户首次访问之间会出现”计数还是昨天”的空档,惰性重置反而语义更准。
四、不同动作的配额
平台有 3 类消耗配额的动作:
不是所有动作都消耗配额。下载不扣、总结扣 1 次、AI 问答另算,成本不同规则也分开:
class QuotaService:
QUOTA_COSTS = {
'video_summary': 1, # 视频总结
'video_download': 0, # 视频下载不消耗
'ai_qa': 1, # AI 问答
}
def _get_quota(self, user: User, action: str) -> int:
"""根据动作返回用户配额"""
if user.is_vip and user.vip_expires_at > datetime.now():
return 9999 # VIP 无限
return {
'video_summary': 3,
'ai_qa': 5,
}.get(action, 0)
不同动作用成本表配置,以后加新动作只需改一行字典,判断逻辑不用动。为什么下载不扣?因为下载只是拉取字幕文本,成本几乎为零,扣次数只会劝退用户;而 AI 问答每次都是真实模型调用,必须单独计费,把两种动作的成本差异直接写进配置表,调价时一目了然。
五、VIP 状态管理
VIP 激活和到期都收敛在 VIPService 里,续期、过期、降级三种场景一套代码:
class VIPService:
async def activate(self, user: User, duration_days: int):
"""激活 VIP"""
now = datetime.now()
if user.is_vip and user.vip_expires_at and \
user.vip_expires_at > now:
# 已 VIP,续期
user.vip_expires_at += timedelta(days=duration_days)
else:
# 首次激活
user.is_vip = True
user.vip_expires_at = now + timedelta(days=duration_days)
user.daily_video_quota = 9999
await self.db.commit()
async def expire(self, user: User):
"""VIP 过期(定时任务调用)"""
user.is_vip = False
user.vip_expires_at = None
user.daily_video_quota = 3
await self.db.commit()
续期是在原到期时间上累加而不是重置,用户提前续费不会白花钱,这个细节对留存挺重要。我们当时讨论过两个方案:重置到期日会让用户觉得”刚充的期被吞了”,累加则每次续费都顺延,用户算得清账,续费意愿自然更高。
六、按次积分(credits)
非 VIP 用户可以买积分:
非 VIP 用户走积分路线,一次一分,先校验再扣减:
async def consume_credit(self, user: User, action: str) -> bool:
"""用积分兑换(非 VIP 路径)"""
cost = 1 # 1 积分=1 次
if user.credits < cost:
return False
user.credits -= cost
await self.db.commit()
return True
积分的充值只增不扣、消费只减不加,两条账目分开,对账时一目了然。按次付费定位的是”偶尔用一次”的用户,买月卡亏,攒积分又等不及,单次购买刚好补上这块需求。
Webhook 触发充值:
@router.post('/api/payment/webhook')
async def stripe_webhook(request: Request):
# ... 验签 ...
if event['type'] == 'checkout.session.completed':
session = event['data']['object']
user_id = int(session['metadata']['user_id'])
user = await user_service.get(user_id)
if session['metadata'].get('type') == 'one_time':
user.credits += 10
await db.commit()
Webhook 触发的加积分同样依赖事件去重,防止重复充值。积分与订阅是两套独立账本,查询和扣减互不干扰。Stripe 的 webhook 在极端情况下会重试推送,如果没有幂等保护,同一笔支付可能给用户加两次分,这个坑我在对账脚本里吃过亏,后面在 payment 表上补了 session_id 唯一索引才算根治。
七、配额检查中间件
配额检查以依赖注入的方式挂到接口上,业务代码里看不到一行配额逻辑:
from fastapi import HTTPException, Depends
async def check_quota(
action: str,
user: User = Depends(get_current_user)
):
"""依赖注入式配额检查"""
quota_service = QuotaService(db)
try:
await quota_service.check_and_consume(user, action)
except QuotaExceeded as e:
raise HTTPException(429, str(e))
return user
# 使用
@router.post('/api/video/summarize')
async def summarize(
req: SummaryRequest,
user: User = Depends(check_quota('video_summary'))
):
# 配额已扣,进入业务逻辑
...
扣减失败统一返回 429,前端根据这个状态码提示升级,不会把业务报错和配额不足混在一起。选依赖注入而不是中间件,是因为不同接口的配额动作不同,依赖注入可以精确到”这个接口扣哪个动作”,而全局中间件还得再解析路径,绕一圈。
八、用户配额查询
前端需要实时展示剩余次数,接口一次性把配额全家桶返回:
@router.get('/api/user/quota')
async def get_quota(user: User = Depends(get_current_user)):
"""前端展示当前配额"""
quota_service = QuotaService(db)
await quota_service._maybe_reset_daily(user)
return {
'is_vip': user.is_vip,
'vip_expires_at': user.vip_expires_at,
'daily_total': quota_service._get_quota(user, 'video_summary'),
'daily_used': user.daily_used,
'daily_remaining': quota_service._get_quota(user, 'video_summary') - user.daily_used,
'credits': user.credits,
'reset_at': user.daily_reset_at + timedelta(days=1) # 下次重置
}
前端展示:
<div class="quota-bar">
<span>今日已用 2/3 次</span>
<button @click="upgrade">升级 VIP 解锁无限</button>
</div>
前端拿到 dailyremaining 渲染进度条,接近 0 时给出升级入口,形成转化闭环。这个接口把 isvip、剩余次数、积分、下次重置时间一次全给出去,前端一个请求就能画完整张配额卡片,不用再猜 VIP 有没有效、重置什么时候到。
九、VIP 到期定时检查
除了接口里的实时判断,还要有个兜底任务处理凌晨过期的 VIP:
@scheduler.scheduled_job('cron', hour=0, minute=5)
async def check_vip_expiry():
"""每日检查 VIP 到期"""
expired = await db.execute(
select(User).where(
User.is_vip == True,
User.vip_expires_at < datetime.now()
)
)
for user in expired:
user.is_vip = False
await db.commit()
# 发邮件提醒
await email_service.send_vip_expired(user)
批量降级前先发提醒邮件,并给用户一天宽限期,体验不至于断崖式下滑。我们上线初期是到期立刻降级,用户白天用着突然变回普通版,投诉一下午;后来改成提前 3 天提醒 + 到期次日生效,投诉基本消失了,还捞回了一部分临近到期续费的订单。
十、配额原子性
并发扣减可能超扣,平台用原子 SQL:
高并发下先查再扣是经典的竞态陷阱,两个请求同时读到剩余 1 次,各自扣减就超了。这个 bug 我是上线后从日志里发现的——有个用户一晚上用掉了 5 次免费配额。我用一条带条件的 UPDATE 解决:
async def check_and_consume_atomic(self, user_id: int) -> bool:
"""原子检查 + 扣减(避免超扣)"""
result = await self.db.execute(
update(User)
.where(
User.id == user_id,
User.daily_used < User.daily_video_quota # 条件
)
.values(daily_used=User.daily_used + 1)
.returning(User.daily_used)
)
if result.rowcount == 0:
raise QuotaExceeded('配额不足')
return True
UPDATE 带 WHERE 条件,rowcount=0 说明配额不够,这条 SQL 本身保证了检查和扣减的原子性,不用加锁也不用 Lua 脚本。对比过 Redis + Lua 的方案:性能确实高,但引入一个中间件还得处理缓存与库的一致性,单机 SQLite 场景下原子 UPDATE 已经够快,KISS 原则更划算。
十一、跨日重置
定时重置是稳妥的方案,凌晨 0 点把所有用户的今日计数归零:
@scheduler.scheduled_job('cron', hour=0, minute=0)
async def reset_daily_quota():
"""每日 0 点重置"""
await db.execute(
update(User)
.values(daily_used=0, daily_reset_at=func.now())
)
跨时区用户对”明天 0 点”的理解不同,所以存储和重置一律按 UTC,展示时再换算成本地时间。也可以改成惰性重置(用户访问时检查),省去定时任务。我实际是两者叠加:定时任务兜底清历史,惰性重置兜住”定时任务刚好没跑”的极端情况,双保险写进代码后再没出过重置遗漏。
十二、配额变更通知
用完提示不能只靠用户自己发现,我在 QuotaNotifier 里做了主动提醒:
class QuotaNotifier:
async def check_and_notify(self, user: User):
"""检查配额并提醒用户"""
remaining = user.daily_video_quota - user.daily_used
if remaining == 1:
await notify(user, '今日还剩 1 次免费,升级 VIP 解锁无限')
elif remaining == 0:
await notify(user, '今日免费次数用完,升级 VIP 继续使用')
提醒文案直接引导升级,但别在还剩很多时打扰,只盯剩余 1 次和 0 次两个临界点。过早推送只会让人反感甚至卸载,只在真正用完的边缘提醒,用户不觉得被骚扰,转化也自然。
十三、用户行为引导
平台设计”配额用完即升级”路径:
配额用完就是转化的时机,前端在剩余 0 时展示升级面板,两条出路摆在用户面前:
<div v-if="quota.remaining === 0">
<h3>今日免费次数已用完</h3>
<ul>
<li>✅ 升级 VIP:¥29/月,不限次数</li>
<li>✅ 单次购买:¥0.99/次</li>
</ul>
<button>立即升级</button>
</div>
转化率实测:3% 试用 → 1.5% 付费,主要靠”第 3 次用完时弹窗”这个时机,过早弹窗只会让人反感。我们 A/B 过两个版本:一个在剩余 2 次时就展示升级横幅,一个只在 0 次时弹,后者的付费转化明显更高,说明用户只有在真正受限时才愿意掏钱,干扰越少越好。
十四、踩过的坑
这些坑按影响面排,每一条都改过线上代码:
- 跨时区:用户在不同时区,”明天 0 点”语义不同。用 UTC 存,按用户时区展示。
- 并发超扣:多线程/多实例同时扣。原子 SQL 解决。
- VIP 到期未通知:用户被静默降级体验差。提前 3 天邮件提醒。
- 积分和 VIP 同时消耗:用户买积分同时 VIP 续费。业务逻辑清晰区分。
- 退款后配额不退回:用户申请退款,应该同时撤销积分。平台退款时扣除 credits 和 VIP。
- 滥用注册:批量注册薅免费配额。手机号验证 + 设备指纹。
- 配额查询慢:每次请求都查 DB。Redis 缓存用户配额。
- VIP 状态不一致:支付成功 webhook 失败导致 VIP 没激活。每日对账脚本补单。
其中”退款后配额不退回”这条是在一笔退款纠纷里暴露的:用户退了款,积分却还在,等于白嫖了模型调用。后来在退款处理里同步回滚 credits 和 VIP,账才平。滥用注册那个也花了力气,第一天上线免费 3 次就被脚本号刷爆了配额,加上手机号验证才压住。
十五、对账与补单
webhook 偶尔会丢,VIP 支付成功却没激活的事故我遇到过,靠每日对账兜底:
@scheduler.scheduled_job('cron', hour=2)
async def reconcile_payments():
"""每日对账:检查未激活 VIP 的支付"""
yesterday = datetime.now() - timedelta(days=1)
# 查所有 succeeded 支付
payments = await db.execute(
select(Payment).where(
Payment.status == 'pending',
Payment.created_at < yesterday,
Payment.type == 'subscription'
)
)
for payment in payments:
# 调 Stripe API 重新查状态
try:
session = stripe.checkout.Session.retrieve(
payment.stripe_session_id
)
if session.payment_status == 'paid':
# 补单
user = await user_service.get(payment.user_id)
await vip_service.activate(user, duration_days=30)
payment.status = 'succeeded'
await db.commit()
except Exception as e:
log.error(f'对账失败: {payment.id}, {e}')
对账脚本只处理昨天之前的 pending 单,避免把刚支付的订单提前判死。这个过滤条件很关键:Webhook 和用户侧都可能有点延迟,今天刚下的单明天再对,基本不存在误判;曾有一版没加这个条件,把一批刚支付还在处理中的订单给判了死刑,用户权益差点被砍掉。
十六、最佳实践清单
收个尾,把散在各节的要点整理成清单,接手的人照着做不会漏:
- 配额检查在业务层之前;
- 原子 SQL 避免超扣;
- VIP 状态变更双写(DB + Redis);
- 每日对账补单;
- 跨时区按 UTC 存;
- 配额耗尽时引导升级;
- 退款时同步撤销权益。
这套额度系统上线到现在,超扣事故为 0,对账脚本把两次 webhook 丢单都补回来了。核心经验就一句话:配额本质是共享资源的并发控制,把判断、扣减、重置收拢到单一入口,再用原子操作兜底,剩下的交给定时任务和对账去修。
常见问题(FAQ)
Q1:VIP 过期立即降级吗?
到期日次日生效,给用户 1 天宽限期。
Q2:用户多设备登录配额共享?
共享。配额定在 user 维度。
Q3:免费用户能下 4K 吗?
不能,4K 是 VIP 专属。