VIP 权限与每日配额管理方案(AI 视频下载总结器的额度系统)

每日免费配额、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 和用户侧都可能有点延迟,今天刚下的单明天再对,基本不存在误判;曾有一版没加这个条件,把一批刚支付还在处理中的订单给判了死刑,用户权益差点被砍掉。

十六、最佳实践清单

收个尾,把散在各节的要点整理成清单,接手的人照着做不会漏:

  1. 配额检查在业务层之前;
  2. 原子 SQL 避免超扣;
  3. VIP 状态变更双写(DB + Redis);
  4. 每日对账补单;
  5. 跨时区按 UTC 存;
  6. 配额耗尽时引导升级;
  7. 退款时同步撤销权益。

这套额度系统上线到现在,超扣事故为 0,对账脚本把两次 webhook 丢单都补回来了。核心经验就一句话:配额本质是共享资源的并发控制,把判断、扣减、重置收拢到单一入口,再用原子操作兜底,剩下的交给定时任务和对账去修。

常见问题(FAQ)

Q1:VIP 过期立即降级吗?

到期日次日生效,给用户 1 天宽限期。

Q2:用户多设备登录配额共享?

共享。配额定在 user 维度。

Q3:免费用户能下 4K 吗?

不能,4K 是 VIP 专属。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部