Files
xiaoxia-saas/docs/membership-points-design.md
T
xiaoxia 9feac537ad
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m44s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m27s
AI Code Review / AI Code Review (pull_request) Successful in 6m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m46s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 10m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 11m44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m47s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m10s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 31m9s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 42h10m1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 42h41m9s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 42h41m15s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 42h41m19s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 42h40m50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 42h40m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 42h40m44s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 42h40m45s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 42h40m46s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 42h40m50s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 42h40m50s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 42h40m57s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 42h8m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 42h9m0s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 42h9m28s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 42h9m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 42h40m40s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 42h40m40s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 42h40m50s
docs: #1895 会员+积分方案v2(两档简化版+基于真实成本定价)
2026-09-14 20:41:47 +08:00

33 KiB
Raw Blame History

会员制 + 积分方案设计文档(v2)

Issue: #1895 版本: v2.0 状态: 方案设计(待确认) 创建时间: 2026-09-14 更新: 2026-09-14 — 基于用户确认方案重写,简化为两档会员 + 基于真实 AI 调用成本定价


一、方案概述

1.1 核心思路

两档会员 + 积分消耗制:

  • 免费会员:每天可免费混剪 2 条视频;其他 AI 功能需购买积分包
  • 付费会员:解锁全部功能 + 每月赠送积分 + 积分购买折扣

1.2 设计原则

原则 说明
极简 只分免费/付费两档,不搞多梯度会员
成本驱动 积分定价严格基于真实 AI 调用成本,毛利率 60-75%
可扩展 新增消耗场景只需注册,不改动核心逻辑
平滑迁移 老用户统一转为年卡会员,无感知过渡

二、真实成本基准

数据来源:《小虾 SaaS AI 能力成本核算报告》(生产实测 + API 官方定价)

能力 调用方式 真实成本
AI 配音(TTS) 阿里云 cosyvoice-v3-flash ¥0.024/分钟
AI 数字人(口型同步) 火山 MediaKit lip-sync ¥1.028/分钟(含 TTS)
智能混剪渲染 本地 FFmpeg(CPU) ¥0.001/分钟
声音克隆训练 阿里云 voice-enrollment 免费(合成才收费)
声音克隆合成 同 cosyvoice TTS ¥0.024/分钟
AI 文案改写 / 标题生成 火山方舟 doubao-seed-1-6 <¥0.001/条
抖音链接提取文案 yt-dlp + 火山 ASR ¥0.03/分钟
AI 封面生成 本地 ffmpeg 抽帧 + PIL <¥0.001/张
OSS 存储 阿里云杭州标准 ¥0.001/条/月

典型场景成本:

  • 1 分钟基础混剪(TTS + FFmpeg + 封面 + 存储 1 月):≈ ¥0.03/条
  • 1 分钟数字人口播(TTS + lip-sync + 封装):≈ ¥1.03/条(是混剪的 35 倍)
  • 免费用户每天 2 条混剪:单活跃用户月成本 ≈ ¥2.3(完全可承受)

三、会员等级设计

3.1 两档会员对照

权益项 免费会员 付费会员
每日免费混剪 2 条/天(含 TTS + 渲染) 不限次数
AI 数字人 ✗ 不可用 ✓
声音克隆 ✗ 不可用 ✓
AI 文案改写 ✗ 不可用 ✓
AI 标题生成 ✗ 不可用 ✓
抖音链接提取 ✗ 不可用 ✓
AI 封面生成 ✗ 不可用 ✓
导出分辨率 720p 最高 4K
每月赠送积分 0 见下表
积分购买折扣 无 见下表
批量导出 ✗ ✓
多平台发布 ✗ ✓
去重检测报告 ✗ ✓

3.2 付费会员定价

套餐 价格 折合月价 积分购买折扣 每月赠送积分
月卡 ¥19.9/月 ¥19.9 9 折 200
季卡 ¥39.9/季 ≈ ¥13.3 8.7 折 600(200×3)
年卡 ¥159/年 ≈ ¥13.25 8 折 2,400(200×12)

年卡和季卡的核心优惠体现在积分折扣更低,而不是赠送更多积分。

3.3 与现有代码的映射

废弃 subscription.py 中的 4 档 PLAN_QUOTAS(free/standard/pro/enterprise),替换为:

MEMBER_TIERS = {
    "free": {
        "name": "免费会员",
        "daily_free_clips": 2,           # 每日免费混剪数
        "max_resolution": "720p",
        "ai_features_enabled": False,    # AI 高级功能锁定
        "monthly_credits": 0,
        "points_discount": 1.0,          # 无折扣
        "free_multiplier": 1.15,         # 积分消耗上浮 15%(非免费混剪场景不适用,因 AI 功能被锁定)
    },
    "paid": {
        "name": "付费会员",
        "daily_free_clips": -1,          # 不限
        "max_resolution": "4K",
        "ai_features_enabled": True,
        "monthly_credits": 200,
        "points_discount": 1.0,          # 默认月卡 9 折通过购买折扣体现
        "free_multiplier": 1.0,
    },
}

付费会员的积分购买折扣通过订单系统实现,不在 MEMBER_TIERS 中硬编码:

MEMBER_DISCOUNT = {
    "monthly": 0.9,      # 月卡 9 折
    "quarterly": 0.87,   # 季卡 8.7 折
    "yearly": 0.8,       # 年卡 8 折
}

四、积分体系设计

4.1 积分获取

获取方式 说明 频率
会员每月赠送 开通/续费时到账 200 积分 每月 1 次
积分包充值 用户按需购买,付费会员享受折扣 随时
任务奖励 注册送 50、首次生成视频送 20、邀请好友送 30/人(月上限 10 人) 一次性/周期性

4.2 积分包定价

积分定价核心:1 积分 ≈ ¥0.10(会员价),免费用户同操作消耗 ×1.15 向上取整。

积分包 积分数量 会员价 折合单价 免费用户等量价格
体验包 100 ¥9.9 ¥0.099/积分 ¥10.89
基础包 500 ¥39 ¥0.078/积分 ¥42.9
专业包 2,000 ¥129 ¥0.065/积分 ¥141.9

简化为 3 档,覆盖轻度、中度、重度用户。

4.3 积分消耗场景清单

功能模块 消耗场景 会员消耗 免费用户消耗(×1.15↑) 真实成本 毛利率
AI 配音 生成 1 分钟配音 1 积分 2 积分 ¥0.024 ~76%
智能混剪 生成 1 条视频(≤30s) 3 积分 4 积分 ¥0.03 >85%
智能混剪(加长) 每增加 30s +1 积分 +2 积分 — —
AI 数字人 生成 1 分钟口播视频 15 积分 — (不可用) ¥1.03 ~30%
声音克隆训练 训练一个声音模型 免费 — (不可用) ¥0 —
声音克隆合成 用克隆声音生成 1 分钟配音 1 积分 — (不可用) ¥0.024 ~76%
抖音链接提取 提取 1 次文案 1 积分 — (不可用) ¥0.03 ~70%
AI 改写文案 改写 1 次 1 积分 — (不可用) <¥0.001 ~99%
AI 标题生成 生成 1 次(≤10 条) 1 积分 — (不可用) <¥0.001 ~99%
AI 封面生成 生成 1 张 1 积分 — (不可用) <¥0.001 ~99%

免费用户专属规则:每天可免费混剪 2 条视频(含 TTS),不消耗积分。但其他所有 AI 功能对免费用户不可用,需先开通付费会员。

4.4 积分有效期

  • 用户付费购买的积分:永久有效,不清零
  • 会员每月赠送的积分:有效期 12 个月(到期未使用则清零)
  • 任务奖励赠送的积分:永久有效

五、数据库设计

5.1 users 表新增字段

-- 在现有 users 表上新增字段
ALTER TABLE users ADD COLUMN is_member BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN member_type VARCHAR(20) DEFAULT NULL;  -- monthly / quarterly / yearly
ALTER TABLE users ADD COLUMN member_expires_at TIMESTAMP DEFAULT NULL;
ALTER TABLE users ADD COLUMN points_balance INTEGER NOT NULL DEFAULT 0;

-- 保留但可废弃的字段(兼容期)
-- subscription_plan → 迁移期间映射为 is_member + member_type
-- subscription_status → 迁移期间保留
-- max_projects / max_storage_gb → 不再限制,保留字段但不再使用

5.2 积分账户表 points_accounts

每个用户一条记录,记录余额和累计值。

CREATE TABLE IF NOT EXISTS points_accounts (
    id VARCHAR(36) PRIMARY KEY,
    user_id VARCHAR(36) NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
    balance INTEGER NOT NULL DEFAULT 0,              -- 当前可用积分
    total_earned INTEGER NOT NULL DEFAULT 0,         -- 累计获得
    total_spent INTEGER NOT NULL DEFAULT 0,          -- 累计消耗
    total_purchased INTEGER NOT NULL DEFAULT 0,      -- 累计购买
    total_gifted INTEGER NOT NULL DEFAULT 0,         -- 累计赠送(会员月赠 + 任务奖励)
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_points_accounts_user ON points_accounts(user_id);

5.3 积分流水表 points_transactions

每笔积分变动一条记录,只追加不修改,保证审计完整性。

CREATE TABLE IF NOT EXISTS points_transactions (
    id VARCHAR(36) PRIMARY KEY,
    user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    account_id VARCHAR(36) NOT NULL REFERENCES points_accounts(id) ON DELETE CASCADE,
    type VARCHAR(20) NOT NULL,          -- earn(获得) / spend(消耗) / refund(退还)
    source VARCHAR(50) NOT NULL,        -- recharge / membership_gift / task_reward / ai_voice / ai_digital_human / ai_video / ai_rewrite / ai_title / ai_cover / douyin_extract / voice_clone
    amount INTEGER NOT NULL,            -- 变动数量(正数)
    balance_after INTEGER NOT NULL,     -- 变动后余额
    description VARCHAR(255) DEFAULT '',
    ref_id VARCHAR(100) DEFAULT '',     -- 关联业务 ID(订单号、任务 ID 等)
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_points_tx_user ON points_transactions(user_id);
CREATE INDEX idx_points_tx_type ON points_transactions(type);
CREATE INDEX idx_points_tx_source ON points_transactions(source);
CREATE INDEX idx_points_tx_created ON points_transactions(created_at);

5.4 积分订单表 points_orders

记录积分充值订单。

CREATE TABLE IF NOT EXISTS points_orders (
    id VARCHAR(36) PRIMARY KEY,
    user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    package_name VARCHAR(50) NOT NULL,       -- 体验包/基础包/专业包
    points_amount INTEGER NOT NULL,          -- 积分数量
    price_cents INTEGER NOT NULL,            -- 支付金额(分)
    currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
    discount REAL NOT NULL DEFAULT 1.0,      -- 享受的折扣
    original_price_cents INTEGER NOT NULL,   -- 原价(分)
    status VARCHAR(20) NOT NULL DEFAULT 'pending',  -- pending / paid / failed / refunded
    payment_method VARCHAR(50),              -- wechat_pay / alipay
    payment_id VARCHAR(100),                 -- 第三方支付流水号
    paid_at TIMESTAMP,
    expire_at TIMESTAMP,                     -- 未支付自动过期时间
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_points_orders_user ON points_orders(user_id);
CREATE INDEX idx_points_orders_status ON points_orders(status);

5.5 每日免费混剪计数 daily_usage_records

CREATE TABLE IF NOT EXISTS daily_usage_records (
    id VARCHAR(36) PRIMARY KEY,
    user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    usage_date DATE NOT NULL,                -- 使用日期(UTC+8)
    usage_type VARCHAR(50) NOT NULL,         -- free_clip(免费混剪)
    count INTEGER NOT NULL DEFAULT 0,        -- 当日已使用次数
    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
    UNIQUE(user_id, usage_date, usage_type)
);

CREATE INDEX idx_daily_usage_user_date ON daily_usage_records(user_id, usage_date);

Redis 辅助:用 Redis INCR 做实时计数(key: daily_usage:{user_id}:{date}:{usage_type}),TTL 48 小时自动过期;DB 作为持久化兜底,定期同步。

5.6 表关系图

users (1) ──── (1) points_accounts
                    │
                    └── (1:N) points_transactions
                              │
                              └── ref_id → generation_tasks / points_orders / ...

users (1) ──── (1:N) points_orders
users (1) ──── (1:N) daily_usage_records

六、API 设计

6.1 积分账户

方法 路径 说明
GET /api/v1/points/balance 查询积分余额 + 会员状态
GET /api/v1/points/transactions 积分流水(分页 + 筛选)

GET /api/v1/points/balance 响应

{
  "balance": 580,
  "total_earned": 1200,
  "total_spent": 620,
  "is_member": true,
  "member_type": "yearly",
  "member_expires_at": "2027-09-14T00:00:00+08:00",
  "monthly_gift_remaining": 180,
  "next_gift_date": "2026-10-01T00:00:00+08:00"
}

GET /api/v1/points/transactions 请求参数

?page=1&page_size=20&type=spend&source=ai_voice&start_date=2026-09-01&end_date=2026-09-30

6.2 积分充值

方法 路径 说明
GET /api/v1/points/packages 查询可购买的积分包列表
POST /api/v1/points/recharge 创建充值订单
POST /api/v1/points/payment-callback 支付回调(内部)

GET /api/v1/points/packages 响应

{
  "packages": [
    {
      "id": "starter_pack",
      "name": "体验包",
      "points": 100,
      "price": 990,
      "discounted_price_for_monthly": 891,
      "discounted_price_for_quarterly": 861,
      "discounted_price_for_yearly": 792
    },
    {
      "id": "basic_pack",
      "name": "基础包",
      "points": 500,
      "price": 3900,
      "discounted_price_for_monthly": 3510,
      "discounted_price_for_quarterly": 3393,
      "discounted_price_for_yearly": 3120
    },
    {
      "id": "pro_pack",
      "name": "专业包",
      "points": 2000,
      "price": 12900,
      "discounted_price_for_monthly": 11610,
      "discounted_price_for_quarterly": 11223,
      "discounted_price_for_yearly": 10320
    }
  ],
  "user_discount": "yearly",
  "unit_price_yuan": 0.10
}

价格单位为分(避免浮点精度问题)。折扣根据用户当前会员类型自动计算。

6.3 积分消耗(内部服务接口)

方法 路径 说明
POST /api/v1/points/check 消费前余额检查
POST /api/v1/points/deduct 消费扣减(事务性)
POST /api/v1/points/refund 消费退还(业务失败时)

POST /api/v1/points/check 请求

{
  "scene_key": "ai_digital_human",
  "duration_minutes": 1,
  "user_id": "xxx"
}

POST /api/v1/points/check 响应

{
  "allowed": true,
  "required_points": 15,
  "current_balance": 580,
  "remaining_after": 565,
  "is_free_quota": false
}

6.4 每日免费额度

方法 路径 说明
GET /api/v1/usage/daily 查询今日免费额度使用情况

GET /api/v1/usage/daily 响应

{
  "free_clips_used": 1,
  "free_clips_limit": 2,
  "free_clips_remaining": 1,
  "reset_at": "2026-09-15T00:00:00+08:00"
}

6.5 会员订阅(改造现有接口)

方法 路径 说明 改动
GET /api/v1/subscription/current 当前会员信息 增加积分余额、会员类型
POST /api/v1/subscription/subscribe 开通/续费会员 替换原 change-plan
POST /api/v1/subscription/cancel 取消自动续费 保留
POST /api/v1/subscription/payment-callback 支付回调 改造

POST /api/v1/subscription/subscribe 请求

{
  "member_type": "yearly",
  "payment_method": "wechat_pay"
}

6.6 消耗规则查询

方法 路径 说明
GET /api/v1/points/rules 查询所有积分消耗规则
{
  "rules": [
    { "scene_key": "ai_voice", "scene_name": "AI 配音", "points_per_use": 1, "unit": "分钟" },
    { "scene_key": "ai_video", "scene_name": "智能混剪", "points_per_use": 3, "unit": "条", "extra_per_30s": 1 },
    { "scene_key": "ai_digital_human", "scene_name": "AI 数字人", "points_per_use": 15, "unit": "分钟", "member_only": true },
    { "scene_key": "voice_clone_train", "scene_name": "声音克隆训练", "points_per_use": 0, "member_only": true },
    { "scene_key": "voice_clone_synth", "scene_name": "声音克隆合成", "points_per_use": 1, "unit": "分钟", "member_only": true },
    { "scene_key": "douyin_extract", "scene_name": "抖音链接提取", "points_per_use": 1, "unit": "次", "member_only": true },
    { "scene_key": "ai_rewrite", "scene_name": "AI 改写文案", "points_per_use": 1, "unit": "次", "member_only": true },
    { "scene_key": "ai_title", "scene_name": "AI 标题生成", "points_per_use": 1, "unit": "次", "member_only": true },
    { "scene_key": "ai_cover", "scene_name": "AI 封面生成", "points_per_use": 1, "unit": "张", "member_only": true }
  ],
  "free_user_multiplier": 1.15,
  "note": "免费用户消耗 = 会员消耗 × 1.15,向上取整"
}

七、前端页面改造要点

7.1 会员购买页(改造 Plans.tsx)

现状:写死 4 档(free/standard/pro/enterprise),与后端不一致。

改造为:

┌──────────────────────────────────────────────┐
│           选择适合您的方案                      │
├────────────────────┬─────────────────────────┤
│    免费会员         │     付费会员 ★ 推荐      │
│    ¥0              │  ¥19.9/月                │
│                    │  ¥39.9/季(省33%)        │
│  ✓ 每日2条免费混剪  │  ¥159/年(省33%)         │
│  ✓ 720p 导出       │                          │
│  ✗ AI 功能不可用    │  ✓ 无限混剪               │
│                    │  ✓ 全部 AI 功能            │
│                    │  ✓ 每月赠送 200 积分       │
│                    │  ✓ 积分购买最高 8 折       │
│                    │  ✓ 最高 4K 导出           │
│                    │                          │
│  [当前方案]         │  [立即开通]                │
├────────────────────┴─────────────────────────┤
│  💰 积分充值(付费会员专享折扣)                │
│  ┌────────┐  ┌────────┐  ┌────────┐         │
│  │100积分  │  │500积分  │  │2000积分│          │
│  │ ¥9.9   │  │ ¥39    │  │ ¥129   │          │
│  └────────┘  └────────┘  └────────┘         │
└──────────────────────────────────────────────┘

改动要点:

  • 移除 4 档卡片,改为左右对比布局
  • 付费会员区增加月卡/季卡/年卡切换
  • 底部增加积分包快捷购买区

7.2 新增积分中心页 /app/points

┌──────────────────────────────────────────────┐
│  积分中心                                     │
├──────────────────────────────────────────────┤
│                                              │
│  💎 580 积分          会员到期: 2027-09-14    │
│  本月获得 +200   本月消耗 -120                │
│                                              │
├──────────────────────────────────────────────┤
│  [积分明细]  [充值记录]  [消耗规则]            │
│  ─────────                                   │
│  时间          类型    场景       数量  余额    │
│  09-14 10:30  消耗   AI配音     -1   579     │
│  09-14 09:15  消耗   智能混剪   -5   580     │
│  09-01 00:00  获得   会员赠送  +200  585     │
│  08-28 14:20  获得   充值+500  +500  385     │
│  ...                                         │
│                                              │
└──────────────────────────────────────────────┘

7.3 Header 积分徽章

  • 位置:顶部导航栏用户头像左侧
  • 显示:💎 + 积分余额
  • 余额 < 10 时变橙色警告
  • 点击展开快捷面板:余额、本月消耗、充值入口

7.4 功能入口消耗提示

在每个 AI 功能操作按钮旁显示:

[生成配音]  💎 -1 积分
[生成视频]  💎 -5 积分(约1分钟)
[数字人口播] 💎 -15 积分/分钟

免费用户看到 AI 功能时显示 🔒 锁定图标 + "开通会员解锁"。

7.5 免费额度提示

免费用户在工作台顶部显示:

今日免费混剪:已用 1/2 条  [升级会员享无限混剪 →]

7.6 前端文件改动清单

文件 改动类型 说明
Plans.tsx 重写 两档会员 + 积分包购买
pages/points/Center.tsx 新增 积分中心主页
pages/points/Transactions.tsx 新增 积分明细
pages/points/Packages.tsx 新增 积分包充值
components/common/PointsBadge/ 新增 Header 积分徽章
components/common/PointsCost/ 新增 消耗提示组件
api/points.ts 新增 积分相关 API 封装
api/subscription.ts 改造 对接新会员接口
store/pointsStore.ts 新增 积分状态管理

八、配额检查中间件设计

8.1 整体流程

用户请求 → API Route
    │
    ├─ 1. 检查会员状态(is_member + member_expires_at)
    │
    ├─ 2. 免费用户专属检查
    │     ├─ 是否为"混剪"功能?→ 检查每日免费额度(Redis)
    │     │   ├─ 未超限 → 放行,不扣积分
    │     │   └─ 已超限 → 返回 402,提示开通会员或购买积分
    │     └─ 非混剪功能 → 返回 403,提示需开通会员
    │
    ├─ 3. 付费用户检查
    │     ├─ 查询积分余额
    │     ├─ 计算本次消耗(考虑折扣?不,折扣只影响购买价格)
    │     ├─ 余额充足 → 事务扣减 → 放行
    │     └─ 余额不足 → 返回 402,提示充值
    │
    └─ 4. 业务执行失败 → 自动退还积分

8.2 装饰器实现

# packages/middleware/points_gate.py

from functools import wraps
from fastapi import HTTPException

def points_gate(scene_key: str, per_unit: int = None, unit_field: str = None):
    """
    AI 功能入口的积分扣费装饰器
    
    Args:
        scene_key: 消耗场景标识
        per_unit: 每单位消耗积分(固定值时直接传)
        unit_field: 动态计算时,从 request 中取数量的字段名
                    如 "duration_minutes" 表示按时长计费
    """
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            # 提取用户
            current_user = _extract_user(args, kwargs)
            request = _extract_request(args, kwargs)
            
            # 计算消耗量
            if per_unit is not None:
                total_points = per_unit
            elif unit_field is not None:
                units = getattr(request, unit_field, 1)
                # 查配置表获取每单位消耗
                base_points = get_scene_points(scene_key)
                total_points = base_points * units
            else:
                total_points = get_scene_points(scene_key)
            
            # 免费用户检查
            if not current_user.user.is_member:
                if scene_key == "free_clip":
                    # 检查每日免费额度
                    if not check_and_incr_daily_usage(current_user.user.id, "free_clip"):
                        raise HTTPException(
                            status_code=402,
                            detail={
                                "code": "DAILY_LIMIT_EXCEEDED",
                                "message": "今日免费混剪次数已用完,开通会员享无限混剪",
                                "upgrade_url": "/app/subscription"
                            }
                        )
                    # 免费混剪不扣积分
                    kwargs["_points_deducted"] = 0
                    return await func(*args, **kwargs)
                else:
                    raise HTTPException(
                        status_code=403,
                        detail={
                            "code": "MEMBER_REQUIRED",
                            "message": "此功能需要开通付费会员",
                            "upgrade_url": "/app/subscription"
                        }
                    )
            
            # 付费用户:检查并扣减积分
            points_service = get_points_service()
            result = points_service.check_and_deduct(
                user_id=current_user.user.id,
                scene_key=scene_key,
                amount=total_points,
            )
            
            if not result.success:
                raise HTTPException(
                    status_code=402,
                    detail={
                        "code": "INSUFFICIENT_POINTS",
                        "message": f"积分不足,需要 {total_points} 积分,当前余额 {result.balance}",
                        "recharge_url": "/app/points/recharge"
                    }
                )
            
            kwargs["_points_deduct_id"] = result.transaction_id
            kwargs["_points_deducted"] = total_points
            
            try:
                return await func(*args, **kwargs)
            except Exception as e:
                # 业务失败,退还积分
                points_service.refund(
                    user_id=current_user.user.id,
                    transaction_id=result.transaction_id,
                    reason=f"业务执行失败: {scene_key}"
                )
                raise
        
        return wrapper
    return decorator

8.3 每日免费额度检查(Redis)

# packages/middleware/daily_usage.py

import redis
from datetime import datetime, timezone, timedelta

# Redis key: daily_usage:{user_id}:{date}:{usage_type}
# TTL: 48 小时自动过期

def check_and_incr_daily_usage(user_id: str, usage_type: str, limit: int = 2) -> bool:
    """
    检查并递增每日使用次数
    返回 True 表示允许,False 表示已超限
    """
    tz = timezone(timedelta(hours=8))
    today = datetime.now(tz).strftime("%Y%m%d")
    key = f"daily_usage:{user_id}:{today}:{usage_type}"
    
    r = get_redis_client()
    current = r.get(key)
    
    if current and int(current) >= limit:
        return False
    
    pipe = r.pipeline()
    pipe.incr(key)
    pipe.expire(key, 48 * 3600)  # 48 小时过期
    results = pipe.execute()
    
    # 同步到 DB(异步或定时任务)
    sync_daily_usage_to_db(user_id, today, usage_type)
    
    return True

8.4 与现有 QuotaChecker 的集成

现有 QuotaChecker 保留,负责检查存储、并发等基础配额。新增 PointsGate 负责积分扣费。两者在中间件层协作:

# 统一中间件入口
async def unified_quota_check(user, scene_key, **kwargs):
    # Step 1: 基础配额(存储、并发等)
    plan = "paid" if user.is_member else "free"
    base_check = quota_checker.check_multiple(plan, {
        QuotaDimension.STORAGE_GB.value: get_used_storage(user.id),
        QuotaDimension.MAX_CONCURRENT.value: get_concurrent_count(user.id),
    })
    for result in base_check:
        if not result.allowed:
            raise QuotaExceededError(result)
    
    # Step 2: 积分扣费
    await points_gate_check(user, scene_key, **kwargs)

九、支付接入

9.1 支付渠道

渠道 优先级 说明
微信支付 P0(首期) 覆盖绝大多数用户
支付宝 P1(后续) 二期接入

9.2 支付流程

用户选择套餐/积分包
    → POST /api/v1/subscription/subscribe 或 /api/v1/points/recharge
    → 后端创建订单,调用微信支付统一下单 API
    → 返回支付参数(prepay_id / code_url)
    → 前端展示二维码 or 调起支付
    → 用户支付完成
    → 微信回调 POST /api/v1/subscription/payment-callback
    → 验签 → 更新订单状态 → 发放积分/开通会员

9.3 发票

  • 初期不做自助开票
  • 用户有需求时通过客服人工处理

9.4 退款策略

  • 虚拟商品原则上不退
  • 特殊情况(如系统故障导致积分异常消耗)由客服人工处理

十、老用户迁移方案

10.1 迁移策略

现有用户类型 迁移方案
free 用户 直接标记 is_member = false,享受每日 2 条免费混剪
standard 用户(¥99/月) 自动转为年卡付费会员,有效期 12 个月(从迁移日起算),到期后按新价格续费
pro 用户(¥299/月) 同上,转年卡,赠送 500 额外积分
enterprise 用户(¥999/月) 同上,转年卡,赠送 2000 额外积分 + 客服 1v1 通知

10.2 迁移执行

-- Step 1: 新增字段
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_member BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS member_type VARCHAR(20) DEFAULT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS member_expires_at TIMESTAMP DEFAULT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS points_balance INTEGER NOT NULL DEFAULT 0;

-- Step 2: 迁移现有付费用户为年卡会员
UPDATE users SET
    is_member = TRUE,
    member_type = 'yearly',
    member_expires_at = NOW() + INTERVAL '12 months',
    points_balance = CASE
        WHEN subscription_plan = 'standard' THEN 200
        WHEN subscription_plan IN ('pro', 'enterprise') THEN 200
        ELSE 0
    END
WHERE subscription_plan IN ('standard', 'pro', 'enterprise')
  AND subscription_status = 'active';

-- Step 3: 额外赠送积分
UPDATE users SET points_balance = points_balance + 500
WHERE subscription_plan = 'pro' AND subscription_status = 'active';

UPDATE users SET points_balance = points_balance + 2000
WHERE subscription_plan = 'enterprise' AND subscription_status = 'active';

-- Step 4: 创建积分账户
INSERT INTO points_accounts (id, user_id, balance, total_earned, total_gifted)
SELECT uuid(), id, points_balance, points_balance, points_balance
FROM users
WHERE points_balance > 0;

-- Step 5: 免费用户标记
UPDATE users SET is_member = FALSE WHERE subscription_plan = 'free' OR subscription_plan IS NULL;

10.3 迁移通知

  • 迁移前 7 天:站内信 + 邮件通知
  • 迁移当天:弹窗说明变更内容
  • 重点说明:
    • "您的权益不会减少"
    • "已为您升级为年卡会员,有效期至 XXXX"
    • "赠送 XX 积分,可用于 AI 配音、数字人等高级功能"

十一、文件改动总览

类型 文件路径 说明
新增 migrations/007_membership_points.sql 新建积分相关表
新增 packages/domain/points_service.py 积分核心服务
新增 packages/middleware/points_gate.py 积分扣费中间件
新增 packages/middleware/daily_usage.py 每日免费额度检查
新增 apps/api/app/api/routes/points.py 积分 API 路由
新增 apps/api/app/schemas/points.py 积分 Schema
新增 apps/web/src/pages/points/Center.tsx 积分中心页
新增 apps/web/src/pages/points/Transactions.tsx 积分明细页
新增 apps/web/src/pages/points/Packages.tsx 积分包充值页
新增 apps/web/src/components/common/PointsBadge/ Header 积分徽章
新增 apps/web/src/components/common/PointsCost/ 消耗提示组件
新增 apps/web/src/api/points.ts 积分 API 封装
新增 apps/web/src/store/pointsStore.ts 积分状态管理
修改 packages/domain/quota.py 简化配额维度
修改 packages/adapters/sqlalchemy_impl/models.py 新增积分 Model + users 字段
修改 apps/api/app/api/routes/subscription.py 改造为两档会员
修改 apps/api/app/schemas/subscription.py 简化 Schema
修改 apps/web/src/pages/subscription/Plans.tsx 两档会员 UI
修改 各 AI 功能路由 添加 @points_gate 装饰器

十二、实施排期

阶段 内容 预估工时
P1 数据库迁移 + Model + Repository 2 天
P2 PointsService 核心逻辑 + 每日额度 2 天
P3 积分 API + 微信支付接入 3 天
P4 中间件改造(各 AI 功能接入) 2 天
P5 前端页面改造 + 新增页面 4 天
P6 老用户迁移脚本 + 通知 1 天
P7 集成测试 + 端到端测试 2 天
合计 ~16 天

十三、开放问题(待后续讨论)

# 问题 当前决策
1 积分是否需要有效期? 永久有效(已确认)
2 退款策略? 虚拟商品不退,特殊情况人工处理(已确认)
3 企业版定制需求? 暂不做,后续按需(已确认)
4 支付渠道优先级? 微信优先,支付宝二期(已确认)
5 发票需求? 暂不做,后续按需(已确认)
6 免费用户每日 2 条混剪是否限制分辨率? 建议限制 720p
7 会员过期后未使用的积分是否清零? 购买的不清零,赠送的 12 个月后清零

本方案基于真实 AI 调用成本核算,经用户确认后制定。待确认后即可进入开发阶段。