diff --git a/docs/membership-points-design.md b/docs/membership-points-design.md index 21f505f2e..60e3b6748 100644 --- a/docs/membership-points-design.md +++ b/docs/membership-points-design.md @@ -1,177 +1,202 @@ -# 会员制 + 积分方案设计文档 +# 会员制 + 积分方案设计文档(v2) > **Issue**: [#1895](https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/issues/1895) -> **版本**: v1.0 +> **版本**: v2.0 > **状态**: 方案设计(待确认) > **创建时间**: 2026-09-14 +> **更新**: 2026-09-14 — 基于用户确认方案重写,简化为两档会员 + 基于真实 AI 调用成本定价 --- -## 一、设计背景与目标 +## 一、方案概述 -### 1.1 背景 +### 1.1 核心思路 -当前系统采用**纯订阅制**计费模式(4 档:free/standard/pro/enterprise),存在以下问题: +**两档会员 + 积分消耗制**: -1. **灵活性不足**:用户付费后只能按套餐配额使用,无法按需消费高频 AI 能力 -2. **前后端不一致**:前端 Plans.tsx 写死 3 档,后端硬编码 4 档 -3. **积分能力缺失**:`quota.py` 已预留 `AI_VOICE_CREDITS` 枚举,`module_registry.py` 已注册消耗规则,但无实际账户、流水、扣费和充值链路 -4. **收入天花板**:纯订阅制难以覆盖 AI 调用成本波动,高频用户和低频用户无法差异化变现 +- **免费会员**:每天可免费混剪 2 条视频;其他 AI 功能需购买积分包 +- **付费会员**:解锁全部功能 + 每月赠送积分 + 积分购买折扣 -### 1.2 目标 +### 1.2 设计原则 -- **会员制**保留基础权益(存储、项目数、并发数等),提供稳定收入 -- **积分制**覆盖 AI 消耗型功能(配音、数字人、视频生成等),按需付费、用多少扣多少 -- 两者结合,既降低轻度用户入门门槛,又提升重度用户 ARPU -- 在现有 `QuotaChecker` + `ModuleRegistry` 架构上扩展,不推倒重来 +| 原则 | 说明 | +|------|------| +| **极简** | 只分免费/付费两档,不搞多梯度会员 | +| **成本驱动** | 积分定价严格基于真实 AI 调用成本,毛利率 60-75% | +| **可扩展** | 新增消耗场景只需注册,不改动核心逻辑 | +| **平滑迁移** | 老用户统一转为年卡会员,无感知过渡 | --- -## 二、会员等级设计 +## 二、真实成本基准 -### 2.1 等级定义 +> 数据来源:《小虾 SaaS AI 能力成本核算报告》(生产实测 + API 官方定价) -保留 4 档,与现有后端 `PLAN_QUOTAS` 对齐,重新定义权益: +| 能力 | 调用方式 | 真实成本 | +|------|---------|----------| +| 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/条/月 | -| 等级 | 月付价格 | 年付价格 | 定位 | -|------|----------|----------|------| -| **免费版(free)** | ¥0 | ¥0 | 体验用户,验证产品价值 | -| **标准版(standard)** | ¥99/月 | ¥999/年(约 ¥83/月) | 个人创作者 | -| **专业版(pro)** | ¥299/月 | ¥2,999/年(约 ¥250/月) | 专业团队 | -| **企业版(enterprise)** | ¥999/月 | ¥9,999/年(约 ¥833/月) | 企业 / 工作室 | - -### 2.2 会员权益对照表 - -| 权益维度 | 免费版 | 标准版 | 专业版 | 企业版 | -|----------|--------|--------|--------|--------| -| 项目数量 | 3 个 | 10 个 | 不限 | 不限 | -| 存储空间 | 10 GB | 50 GB | 100 GB | 1 TB | -| 并发任务数 | 3 | 10 | 20 | 50 | -| 导出分辨率 | 720p | 1080p | 4K | 4K | -| 模板数量 | 3 个 | 15 个 | 不限 | 不限 | -| 标题库 | 50 条 | 500 条 | 500 条 | 不限 | -| 配音库 | 10 条 | 100 条 | 100 条 | 不限 | -| AI 配音 | ✗ | ✓ | ✓ | ✓ | -| 批量导出 | ✗ | ✓ | ✓ | ✓ | -| 多平台发布 | ✗ | ✗ | ✓ | ✓ | -| 去重检测报告 | ✗ | ✗ | ✓ | ✓ | -| **每月赠送积分** | 0 | 200 | 800 | 3,000 | -| 技术支持 | 社区 | 邮件 | 优先响应 | 专属客服 | - -> 注:`AI_VOICE_ENABLED`、`BATCH_EXPORT_ENABLED`、`MULTI_PLATFORM_ENABLED`、`DEDUP_REPORT_ENABLED` 等开关类权益由现有 `QuotaRegistry` 直接控制,无需积分参与。 - -### 2.3 与现有代码的映射 - -- 后端 `PLAN_QUOTAS` 字典扩展,新增 `monthly_credits` 字段 -- 前端 `Plans.tsx` 对齐后端 4 档,与后端保持一致 -- 现有 `QuotaTier.limits` 中扩展 `monthly_credits` 维度,由 `QuotaRegistry` 统一管理 +**典型场景成本:** +- 1 分钟基础混剪(TTS + FFmpeg + 封面 + 存储 1 月):**≈ ¥0.03/条** +- 1 分钟数字人口播(TTS + lip-sync + 封装):**≈ ¥1.03/条**(是混剪的 35 倍) +- 免费用户每天 2 条混剪:单活跃用户月成本 **≈ ¥2.3**(完全可承受) --- -## 三、积分体系设计 +## 三、会员等级设计 -### 3.1 积分获取方式 +### 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),替换为: + +```python +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` 中硬编码: + +```python +MEMBER_DISCOUNT = { + "monthly": 0.9, # 月卡 9 折 + "quarterly": 0.87, # 季卡 8.7 折 + "yearly": 0.8, # 年卡 8 折 +} +``` + +--- + +## 四、积分体系设计 + +### 4.1 积分获取 | 获取方式 | 说明 | 频率 | |----------|------|------| -| **会员每月赠送** | 各等级每月自动到账(见上表) | 每月 1 日 00:00 自动发放 | -| **单独充值** | 用户按需购买积分包 | 随时可买 | -| **任务奖励** | 完成指定任务赠送(新手引导、邀请好友、反馈 Bug 等) | 一次性 | +| **会员每月赠送** | 开通/续费时到账 200 积分 | 每月 1 次 | +| **积分包充值** | 用户按需购买,付费会员享受折扣 | 随时 | +| **任务奖励** | 注册送 50、首次生成视频送 20、邀请好友送 30/人(月上限 10 人) | 一次性/周期性 | -### 3.2 积分包定价 +### 4.2 积分包定价 -| 积分包 | 积分数量 | 价格 | 单价 | 备注 | -|--------|----------|------|------|------| -| 体验包 | 50 积分 | ¥9.9 | ¥0.198/积分 | 首次购买限购 1 次 | -| 基础包 | 200 积分 | ¥36 | ¥0.18/积分 | | -| 标准包 | 500 积分 | ¥80 | ¥0.16/积分 | | -| 专业包 | 1,500 积分 | ¥210 | ¥0.14/积分 | 热门 | -| 企业包 | 5,000 积分 | ¥600 | ¥0.12/积分 | | +积分定价核心:**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.3 任务奖励规则 +> 简化为 3 档,覆盖轻度、中度、重度用户。 -| 任务 | 奖励积分 | 次数限制 | -|------|----------|----------| -| 新用户注册 | 50 | 1 次 | -| 完善个人信息 | 20 | 1 次 | -| 邀请新用户注册 | 30/人 | 每月限 10 人 | -| 首次完成视频生成 | 20 | 1 次 | -| 提交有效 Bug 反馈 | 50 | 不限(审核通过后发放) | +### 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 个月(到期未使用则清零) +- 任务奖励赠送的积分:永久有效 --- -## 四、积分消耗场景 +## 五、数据库设计 -### 4.1 消耗清单 +### 5.1 users 表新增字段 -| 功能模块 | 消耗场景 | 每次消耗积分 | 说明 | -|----------|----------|-------------|------| -| **AI 配音** | 生成一条配音 | 1 | 已存在于 `module_registry.py` | -| **AI 数字人** | 生成一段数字人视频片段 | 5 | 新场景 | -| **视频生成** | 生成一段 AI 视频(≤10s) | 10 | 新场景 | -| **视频生成(长时长)** | 每增加 10s | +5 | 累进计费 | -| **抖音文案提取** | 提取一次 | 1 | 新场景 | -| **AI 改写** | 改写一段文案 | 1 | 新场景 | -| **AI 标题生成** | 批量生成一次(≤10 条) | 1 | 新场景 | -| **AI 封面生成** | 生成一张封面 | 2 | 新场景 | -| **AI 字幕翻译** | 翻译一条字幕(≤50 字) | 1 | 新场景 | +```sql +-- 在现有 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; -### 4.2 消耗规则扩展机制 - -复用现有 `ModuleRegistry` + `QuotaRule` 模式: - -```python -# 新增模块注册示例 -module_registry.register(Module( - name="ai_digital_human", - version="1.0.0", - description="AI 数字人生成模块", - capabilities=[ - ModuleCapability( - name="generate_digital_human", - description="生成数字人视频片段", - quota_rules=[QuotaRule("points", 5.0, "每段数字人视频消耗 5 积分")], - ), - ], -)) +-- 保留但可废弃的字段(兼容期) +-- subscription_plan → 迁移期间映射为 is_member + member_type +-- subscription_status → 迁移期间保留 +-- max_projects / max_storage_gb → 不再限制,保留字段但不再使用 ``` -新增 `QuotaDimension.POINTS = "points"` 作为通用积分维度,所有消耗型功能统一通过积分维度扣费。 +### 5.2 积分账户表 `points_accounts` -### 4.3 消费折扣(预留) - -未来可扩展按会员等级设置折扣: - -| 等级 | 积分消耗折扣 | -|------|-------------| -| 免费版 | 无折扣 | -| 标准版 | 9.5 折 | -| 专业版 | 9 折 | -| 企业版 | 8 折 | - -> 折扣仅在会员有效期内生效,过期后恢复原价。实现时通过 `discount_rate` 配置字段支持。 - ---- - -## 五、数据库表设计 - -### 5.1 新增表结构 - -#### 5.1.1 积分账户表 `points_accounts` - -每个用户一个积分账户,记录余额和累计值。 +每个用户一条记录,记录余额和累计值。 ```sql 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_recharged INTEGER NOT NULL DEFAULT 0, -- 累计充值积分 - total_gifted 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() ); @@ -179,26 +204,21 @@ CREATE TABLE IF NOT EXISTS points_accounts ( CREATE INDEX idx_points_accounts_user ON points_accounts(user_id); ``` -**设计说明**: -- `user_id` 设为 UNIQUE,每个用户只有一个积分账户 -- 余额通过 `total_earned - total_spent` 可交叉校验 `balance`,保证数据一致性 -- 不使用悲观锁,而是通过事务 + 乐观锁(`updated_at`)保证并发安全 +### 5.3 积分流水表 `points_transactions` -#### 5.1.2 积分流水表 `points_transactions` - -每笔积分变动都记录一条流水,支持对账和审计。 +每笔积分变动一条记录,只追加不修改,保证审计完整性。 ```sql 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(退还) / expire(过期) - source VARCHAR(50) NOT NULL, -- recharge(充值) / membership_gift(会员赠送) / task_reward(任务奖励) / ai_voice / ai_digital_human / ai_video / ... - amount INTEGER NOT NULL, -- 变动数量(正数) - balance_after INTEGER NOT NULL, -- 变动后余额 - description VARCHAR(255) DEFAULT '', -- 描述 - ref_id VARCHAR(100) DEFAULT '', -- 关联业务 ID(订单号、任务 ID 等) + 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() ); @@ -208,511 +228,451 @@ CREATE INDEX idx_points_tx_source ON points_transactions(source); CREATE INDEX idx_points_tx_created ON points_transactions(created_at); ``` -**设计说明**: -- `source` 字段标识具体来源/场景,新增消耗场景时只需新增 source 值,不需要改表结构 -- `ref_id` 关联具体业务,方便追溯(如充值关联订单号,消费关联生成任务 ID) -- 流水只追加不修改,保证审计完整性 +### 5.4 积分订单表 `points_orders` -#### 5.1.3 积分订单表 `points_orders` - -记录用户充值积分的支付订单。 +记录积分充值订单。 ```sql 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, -- 支付金额(分),避免浮点精度问题 + 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), -- alipay / wechat_pay / ... - payment_id VARCHAR(100), -- 第三方支付流水号 + payment_method VARCHAR(50), -- wechat_pay / alipay + payment_id VARCHAR(100), -- 第三方支付流水号 paid_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - expire_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.1.4 积分消耗配置表 `points_consumption_config` - -集中管理各场景的积分消耗规则,支持动态调整。 +### 5.5 每日免费混剪计数 `daily_usage_records` ```sql -CREATE TABLE IF NOT EXISTS points_consumption_config ( +CREATE TABLE IF NOT EXISTS daily_usage_records ( id VARCHAR(36) PRIMARY KEY, - scene_key VARCHAR(50) NOT NULL UNIQUE, -- 场景标识,如 ai_voice, ai_video - scene_name VARCHAR(100) NOT NULL, -- 场景显示名称 - points_per_use INTEGER NOT NULL DEFAULT 1, -- 每次消耗积分数 - is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用 - description VARCHAR(255) DEFAULT '', - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() + 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); ``` -### 5.2 现有表变更 +> **Redis 辅助**:用 Redis `INCR` 做实时计数(key: `daily_usage:{user_id}:{date}:{usage_type}`),TTL 48 小时自动过期;DB 作为持久化兜底,定期同步。 -#### 5.2.1 users 表新增字段 - -```sql --- 会员等级字段保留现有 subscription_plan,无需改动 --- 无需在 users 表加 credits 字段,积分独立在 points_accounts 表管理 -``` - -### 5.3 表关系 +### 5.6 表关系图 ``` users (1) ──── (1) points_accounts │ └── (1:N) points_transactions │ - └── ref_id ──> generation_tasks / points_orders / ... + └── ref_id → generation_tasks / points_orders / ... users (1) ──── (1:N) points_orders +users (1) ──── (1:N) daily_usage_records ``` -### 5.4 与现有模型的映射 - -| 现有模型 | 改动 | -|----------|------| -| `UserModel` | 不新增积分字段,积分由独立表管理 | -| `BillingRecordModel` | 保留,用于订阅支付记录;积分充值走新的 `points_orders` | -| `QuotaDimension` | 新增 `POINTS = "points"` 维度 | -| `QuotaTier` | 各等级新增 `monthly_credits` 配额 | -| `QuotaRule` | `dimension` 支持 `"points"` 值 | - --- ## 六、API 设计 -### 6.1 积分账户 API +### 6.1 积分账户 -#### `GET /api/v1/points/balance` +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/points/balance` | 查询积分余额 + 会员状态 | +| GET | `/api/v1/points/transactions` | 积分流水(分页 + 筛选) | -获取当前积分余额。 +#### `GET /api/v1/points/balance` 响应 ```json -// Response { "balance": 580, "total_earned": 1200, "total_spent": 620, - "membership_monthly_gift": 200, - "membership_expires_at": "2027-01-15T00:00:00Z" + "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` - -查询积分流水,支持分页和筛选。 +#### `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` 响应 + ```json -// Response { - "total": 156, - "page": 1, - "page_size": 20, - "items": [ + "packages": [ { - "id": "tx_xxx", - "type": "spend", - "source": "ai_voice", - "amount": 1, - "balance_after": 579, - "description": "AI 配音 - 温柔女声", - "ref_id": "task_xxx", - "created_at": "2026-09-14T10:30:00Z" + "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.2 积分充值 API +> 价格单位为**分**(避免浮点精度问题)。折扣根据用户当前会员类型自动计算。 -#### `POST /api/v1/points/recharge` +### 6.3 积分消耗(内部服务接口) -创建积分充值订单。 +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/points/check` | 消费前余额检查 | +| POST | `/api/v1/points/deduct` | 消费扣减(事务性) | +| POST | `/api/v1/points/refund` | 消费退还(业务失败时) | + +#### `POST /api/v1/points/check` 请求 ```json -// Request { - "package_id": "standard_pack" // 或自定义 amount -} - -// Response -{ - "order_id": "po_xxx", - "package_name": "标准包", - "points_amount": 500, - "price_cents": 8000, - "payment_url": "https://pay.alipay.com/...", - "expire_at": "2026-09-14T16:30:00Z" + "scene_key": "ai_digital_human", + "duration_minutes": 1, + "user_id": "xxx" } ``` -#### `POST /api/v1/points/payment-callback` - -支付回调(内部接口 + 第三方支付通知)。 +#### `POST /api/v1/points/check` 响应 ```json -// Request (来自支付平台) { - "order_id": "po_xxx", - "payment_method": "alipay", - "payment_id": "2026xxx", - "status": "paid" + "allowed": true, + "required_points": 15, + "current_balance": 580, + "remaining_after": 565, + "is_free_quota": false } ``` -回调处理流程: -1. 验证支付签名 -2. 更新 `points_orders.status = "paid"` -3. 增加 `points_accounts.balance += points_amount` -4. 写入 `points_transactions` 流水(type=earn, source=recharge) +### 6.4 每日免费额度 -### 6.3 积分消费 API +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/usage/daily` | 查询今日免费额度使用情况 | -#### 内部扣费接口(供各功能模块调用) +#### `GET /api/v1/usage/daily` 响应 -```python -# packages/domain/points_service.py -class PointsService: - def deduct(self, user_id: str, scene_key: str, amount: int, ref_id: str = "") -> DeductResult: - """ - 扣减积分 - 1. 检查积分余额是否充足 - 2. 在事务中扣减余额、写入流水 - 3. 返回扣减结果 - """ - pass - - def check_balance(self, user_id: str, scene_key: str) -> CheckResult: - """ - 检查余额是否充足某场景消耗 - """ - pass +```json +{ + "free_clips_used": 1, + "free_clips_limit": 2, + "free_clips_remaining": 1, + "reset_at": "2026-09-15T00:00:00+08:00" +} ``` -#### `GET /api/v1/points/consumption-rules` +### 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` 请求 + +```json +{ + "member_type": "yearly", + "payment_method": "wechat_pay" +} +``` + +### 6.6 消耗规则查询 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/points/rules` | 查询所有积分消耗规则 | ```json -// Response { "rules": [ - { "scene_key": "ai_voice", "scene_name": "AI 配音", "points_per_use": 1 }, - { "scene_key": "ai_digital_human", "scene_name": "AI 数字人", "points_per_use": 5 }, - { "scene_key": "ai_video", "scene_name": "视频生成", "points_per_use": 10 }, - ... - ] + { "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,向上取整" } ``` -### 6.4 会员订阅 API(改造) - -保留现有 `subscription.py` 路由结构,新增以下逻辑: - -- `POST /subscription/change-plan`:变更套餐时,自动创建/更新积分账户,发放当月赠送积分 -- `GET /subscription/current`:返回中增加 `monthly_credits` 和 `points_balance` 字段 -- 新增 `POST /subscription/claim-monthly-credits`:手动领取每月赠送积分(兜底入口) - -### 6.5 API 路由汇总 - -| 方法 | 路径 | 说明 | 类型 | -|------|------|------|------| -| GET | `/api/v1/points/balance` | 查询积分余额 | 用户 | -| GET | `/api/v1/points/transactions` | 积分流水查询 | 用户 | -| POST | `/api/v1/points/recharge` | 创建充值订单 | 用户 | -| POST | `/api/v1/points/payment-callback` | 支付回调 | 内部 | -| GET | `/api/v1/points/consumption-rules` | 消耗规则查询 | 用户 | -| POST | `/api/v1/points/check` | 消费前余额检查 | 内部 | -| POST | `/api/v1/points/deduct` | 消费扣减 | 内部 | -| POST | `/api/v1/points/refund` | 消费退还 | 内部 | - --- -## 七、前端页面设计 +## 七、前端页面改造要点 ### 7.1 会员购买页(改造 Plans.tsx) -**路由**: `/app/subscription`(保持不变) +**现状**:写死 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 │ │ +│ └────────┘ └────────┘ └────────┘ │ +└──────────────────────────────────────────────┘ +``` **改动要点**: -1. 将 3 档对齐为 4 档,与后端一致 -2. 每档卡片增加"每月赠送 XXX 积分"标识 -3. 按钮文案按当前状态动态显示("当前方案"/"升级"/"降级"/"联系我们") -4. 底部增加积分包购买入口 +- 移除 4 档卡片,改为左右对比布局 +- 付费会员区增加月卡/季卡/年卡切换 +- 底部增加积分包快捷购买区 + +### 7.2 新增积分中心页 `/app/points` ``` -┌─────────────────────────────────────────────────────────────┐ -│ 选择适合您的方案 │ -├──────────┬──────────┬──────────┬──────────┐ │ -│ 免费版 │ 标准版 │ 专业版 ★ │ 企业版 │ │ -│ ¥0/月 │ ¥99/月 │ ¥299/月 │ ¥999/月 │ │ -│ │ │ │ │ │ -│ 3 项目 │ 10 项目 │ 无限项目 │ 无限项目 │ │ -│ 10GB │ 50GB │ 100GB │ 1TB │ │ -│ 0 积分 │ 200积分 │ 800积分 │ 3000积分 │ ← 新增 │ -│ │ │ │ │ │ -│ [当前] │ [升级] │ [升级] │ [联系] │ │ -├──────────┴──────────┴──────────┴──────────┘ │ -│ │ -│ 💰 积分充值 [查看全部积分包] │ -│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ -│ │50积分 │ │200积分│ │500积分│ │1500积分│ │5000积分│ │ -│ │ ¥9.9 │ │ ¥36 │ │ ¥80 │ │ ¥210 │ │ ¥600 │ │ -│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │ -└─────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────┐ +│ 积分中心 │ +├──────────────────────────────────────────────┤ +│ │ +│ 💎 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.2 积分余额展示位置 +### 7.3 Header 积分徽章 -#### 顶部导航栏(Header 组件) +- 位置:顶部导航栏用户头像左侧 +- 显示:💎 + 积分余额 +- 余额 < 10 时变橙色警告 +- 点击展开快捷面板:余额、本月消耗、充值入口 -在用户头像旁边增加积分余额徽章: +### 7.4 功能入口消耗提示 + +在每个 AI 功能操作按钮旁显示: ``` -┌─────────────────────────────────────────────────┐ -│ Logo 工作台 模板 素材 🔔 💎 580 👤 │ -└─────────────────────────────────────────────────┘ +[生成配音] 💎 -1 积分 +[生成视频] 💎 -5 积分(约1分钟) +[数字人口播] 💎 -15 积分/分钟 ``` -- 点击积分徽章展开快捷面板,显示余额、本月已用、充值入口 -- 积分不足时徽章变为警告色(橙色) +免费用户看到 AI 功能时显示 🔒 锁定图标 + "开通会员解锁"。 -#### 功能入口处的消耗提示 +### 7.5 免费额度提示 -在每个 AI 功能的操作按钮旁,显示本次操作将消耗的积分: +免费用户在工作台顶部显示: ``` -[生成配音] 💎 -1 积分 -[生成视频] 💎 -10 积分 +今日免费混剪:已用 1/2 条 [升级会员享无限混剪 →] ``` -### 7.3 积分中心页面 +### 7.6 前端文件改动清单 -**路由**: `/app/points`(新增) - -**页面结构**: - -``` -┌─────────────────────────────────────────────────┐ -│ 积分中心 │ -├─────────────────────────────────────────────────┤ -│ │ -│ 当前余额 本月获得 本月消耗 │ -│ 💎 580 +200 -120 │ -│ │ -├─────────────────────────────────────────────────┤ -│ │ -│ [积分明细] [充值记录] [消耗规则] │ -│ ───────── │ -│ │ -│ 时间 类型 场景 数量 余额 │ -│ 09-14 10:30 消耗 AI配音 -1 579 │ -│ 09-14 09:15 消耗 AI视频 -10 580 │ -│ 09-01 00:00 获得 会员赠送 +200 590 │ -│ 08-28 14:20 获得 充值+500 +500 390 │ -│ ... │ -│ │ -└─────────────────────────────────────────────────┘ -``` - -### 7.4 前端组件清单 - -| 组件 | 路径 | 说明 | -|------|------|------| -| `PointsBadge` | `components/common/PointsBadge/` | 顶部积分余额徽章 | -| `PointsPanel` | `components/common/PointsPanel/` | 点击徽章展开的快捷面板 | -| `PointsCost` | `components/common/PointsCost/` | 功能入口的消耗提示标签 | -| `PointsCenter` | `pages/points/Center.tsx` | 积分中心主页面 | -| `PointsTransactions` | `pages/points/Transactions.tsx` | 积分明细子页 | -| `PointsPackages` | `pages/points/Packages.tsx` | 充值积分包子页 | -| `Plans` (改造) | `pages/subscription/Plans.tsx` | 对齐 4 档 + 积分展示 | +| 文件 | 改动类型 | 说明 | +|------|---------|------| +| `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 迁移原则 +### 8.1 整体流程 -1. **向前兼容**:迁移期间老用户权益不降低 -2. **平滑过渡**:不需要用户手动操作,自动完成 -3. **灰度发布**:按用户批次逐步迁移,降低风险 - -### 8.2 迁移步骤 - -#### Step 1:数据准备(后端 + DBA) - -```sql --- 1. 创建新表(points_accounts, points_transactions, points_orders, points_consumption_config) --- 2. 为所有现有用户创建积分账户 -INSERT INTO points_accounts (id, user_id, balance, total_earned, total_gifted) -SELECT - uuid(), id, - CASE subscription_plan - WHEN 'standard' THEN 200 - WHEN 'pro' THEN 800 - WHEN 'enterprise' THEN 3000 - ELSE 0 - END, - CASE subscription_plan - WHEN 'standard' THEN 200 - WHEN 'pro' THEN 800 - WHEN 'enterprise' THEN 3000 - ELSE 0 - END, - CASE subscription_plan - WHEN 'standard' THEN 200 - WHEN 'pro' THEN 800 - WHEN 'enterprise' THEN 3000 - ELSE 0 - END -FROM users WHERE subscription_status = 'active'; +``` +用户请求 → API Route + │ + ├─ 1. 检查会员状态(is_member + member_expires_at) + │ + ├─ 2. 免费用户专属检查 + │ ├─ 是否为"混剪"功能?→ 检查每日免费额度(Redis) + │ │ ├─ 未超限 → 放行,不扣积分 + │ │ └─ 已超限 → 返回 402,提示开通会员或购买积分 + │ └─ 非混剪功能 → 返回 403,提示需开通会员 + │ + ├─ 3. 付费用户检查 + │ ├─ 查询积分余额 + │ ├─ 计算本次消耗(考虑折扣?不,折扣只影响购买价格) + │ ├─ 余额充足 → 事务扣减 → 放行 + │ └─ 余额不足 → 返回 402,提示充值 + │ + └─ 4. 业务执行失败 → 自动退还积分 ``` -#### Step 2:代码兼容层 +### 8.2 装饰器实现 ```python -# packages/domain/quota.py 扩展 -class QuotaDimension(str, Enum): - # ... 现有维度保持不变 - POINTS = "points" # 新增:通用积分维度 - -# QUOTA_TIERS 扩展 -"standard": QuotaTier( - name="standard", - limits={ - # ... 现有配额保持不变 - QuotaDimension.MONTHLY_CREDITS: 200, # 新增 - }, -), -``` - -#### Step 3:双轨运行期(1 个月) - -- 订阅制功能不变,老用户正常续费 -- 积分系统上线后,所有 AI 消耗型功能改为积分扣费 -- 会员权益中的"基础配额"(存储、项目数等)继续由订阅制控制 -- 在用户首次登录后,弹出迁移通知弹窗,说明变更内容 - -#### Step 4:完全切换 - -- 停止订阅制的 AI 配额逻辑(`AI_VOICE_CREDITS` 等旧维度废弃) -- 所有 AI 功能统一使用积分扣费 -- 订阅制仅控制基础权益(存储、项目数、并发数、导出分辨率等) - -### 8.3 老用户过渡策略 - -| 用户类型 | 过渡方案 | -|----------|----------| -| 当前 free 用户 | 不变,积分余额为 0,可充值 | -| 当前 standard 用户 | 赠送 200 积分作为过渡礼包,当前周期内权益不变 | -| 当前 pro 用户 | 赠送 800 积分 + 延长 1 个月有效期 | -| 当前 enterprise 用户 | 赠送 3000 积分 + 延长 1 个月有效期 + 专属客户经理通知 | -| 年付用户 | 按剩余月数比例折算赠送积分 | - -### 8.4 前端迁移 - -- `Plans.tsx` 从 3 档改为 4 档,增加积分信息展示 -- 新增 `/app/points` 积分中心页面 -- 顶部 Header 增加积分余额徽章 -- 各 AI 功能页增加积分消耗提示 - ---- - -## 九、配额检查中间件设计 - -### 9.1 整体架构 - -``` -用户请求 → API Route → 积分检查中间件 → 业务逻辑 → 返回结果 - │ - ├─ 检查会员权益(QuotaChecker) - ├─ 检查积分余额(PointsChecker) - └─ 扣减积分(PointsService.deduct) -``` - -### 9.2 中间件设计 - -#### `apps/api/app/middleware/points_check.py` - -```python -"""积分扣费中间件 - 用于 AI 功能入口的统一检查与扣费""" +# packages/middleware/points_gate.py from functools import wraps from fastapi import HTTPException -def require_points(scene_key: str, amount: int = None): +def points_gate(scene_key: str, per_unit: int = None, unit_field: str = None): """ - 装饰器:在 AI 功能入口检查积分余额并扣费 + AI 功能入口的积分扣费装饰器 Args: - scene_key: 消耗场景标识,如 "ai_voice", "ai_video" - amount: 指定消耗数量,为 None 时从 points_consumption_config 读取 - - 使用方式: - @router.post("/generate-voice") - @require_points(scene_key="ai_voice") - async def generate_voice(request: VoiceRequest, current_user = Depends(get_current_user)): - # 到这里积分已扣减成功 - ... + scene_key: 消耗场景标识 + per_unit: 每单位消耗积分(固定值时直接传) + unit_field: 动态计算时,从 request 中取数量的字段名 + 如 "duration_minutes" 表示按时长计费 """ def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): - # 1. 从 kwargs 或 args 中提取 current_user - current_user = kwargs.get("current_user") or next( - (a for a in args if isinstance(a, AuthenticatedUser)), None - ) - if not current_user: - raise HTTPException(status_code=401, detail="未登录") + # 提取用户 + current_user = _extract_user(args, kwargs) + request = _extract_request(args, kwargs) - # 2. 获取消耗数量 - consume_amount = amount or get_consumption_config(scene_key) + # 计算消耗量 + 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) - # 3. 检查会员权益(原有 QuotaChecker 逻辑) - user = current_user.user - tier = quota_registry.get_tier(user.subscription_plan or "free") - # ... 检查存储、并发等基础权益 + # 免费用户检查 + 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" + } + ) - # 4. 检查并扣减积分 + # 付费用户:检查并扣减积分 points_service = get_points_service() result = points_service.check_and_deduct( - user_id=user.id, + user_id=current_user.user.id, scene_key=scene_key, - amount=consume_amount, + amount=total_points, ) if not result.success: raise HTTPException( - status_code=402, # Payment Required + status_code=402, detail={ "code": "INSUFFICIENT_POINTS", - "message": f"积分不足,需要 {consume_amount} 积分,当前余额 {result.balance}", + "message": f"积分不足,需要 {total_points} 积分,当前余额 {result.balance}", "recharge_url": "/app/points/recharge" } ) - # 5. 将扣减信息注入请求上下文,供业务层使用 - kwargs["points_deduct_id"] = result.transaction_id + kwargs["_points_deduct_id"] = result.transaction_id + kwargs["_points_deducted"] = total_points try: - # 6. 执行业务逻辑 return await func(*args, **kwargs) except Exception as e: - # 7. 业务失败时退还积分 + # 业务失败,退还积分 points_service.refund( - user_id=user.id, + user_id=current_user.user.id, transaction_id=result.transaction_id, reason=f"业务执行失败: {scene_key}" ) @@ -722,186 +682,214 @@ def require_points(scene_key: str, amount: int = None): return decorator ``` -### 9.3 积分服务层 +### 8.3 每日免费额度检查(Redis) ```python -# packages/domain/points_service.py +# packages/middleware/daily_usage.py -class PointsService: - """积分服务 - 核心扣费逻辑""" - - def __init__(self, account_repo, transaction_repo, config_repo): - self.account_repo = account_repo - self.transaction_repo = transaction_repo - self.config_repo = config_repo - - def check_and_deduct(self, user_id: str, scene_key: str, amount: int, ref_id: str = "") -> DeductResult: - """ - 检查余额并扣减积分(事务操作) - - 流程: - 1. 查询积分账户 - 2. 检查余额是否 >= amount - 3. 在事务中:扣减余额 + 写入流水 - 4. 返回扣减结果 - """ - pass - - def refund(self, user_id: str, transaction_id: str, reason: str = "") -> bool: - """退还积分(业务失败时调用)""" - pass - - def gift(self, user_id: str, amount: int, source: str, ref_id: str = ""): - """赠送积分(会员赠送 / 任务奖励)""" - pass - - def get_balance(self, user_id: str) -> int: - """查询余额""" - pass - - def get_transactions(self, user_id: str, page: int = 1, page_size: int = 20, - type: str = None, source: str = None) -> list: - """查询流水""" - pass -``` +import redis +from datetime import datetime, timezone, timedelta -### 9.4 与现有 QuotaChecker 的集成 +# Redis key: daily_usage:{user_id}:{date}:{usage_type} +# TTL: 48 小时自动过期 -```python -# 改造后的检查流程 -async def check_all_quotas(user, scene_key: str, consume_amount: int): - """统一配额检查入口""" +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}" - # 1. 基础配额检查(存储空间、项目数、并发数等) - # 复用现有 QuotaChecker - plan = user.subscription_plan or "free" - quota_results = quota_checker.check_multiple(plan, { - QuotaDimension.STORAGE_GB.value: get_used_storage(user.id), - QuotaDimension.VIDEOS_PER_MONTH.value: get_monthly_video_count(user.id), - QuotaDimension.MAX_CONCURRENT.value: get_concurrent_count(user.id), - }) - for result in quota_results: - if not result.allowed: - raise QuotaExceededError(result) + r = get_redis_client() + current = r.get(key) - # 2. 积分检查 - # 新增 PointsChecker - balance = points_service.get_balance(user.id) - if balance < consume_amount: - raise InsufficientPointsError( - required=consume_amount, - balance=balance - ) + 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 ``` -### 9.5 各功能模块接入方式 +### 8.4 与现有 QuotaChecker 的集成 + +现有 `QuotaChecker` 保留,负责检查存储、并发等基础配额。新增 `PointsGate` 负责积分扣费。两者在中间件层协作: ```python -# apps/api/app/api/routes/ai_voice.py(示例) -@router.post("/generate") -@require_points(scene_key="ai_voice") -async def generate_voice( - request: VoiceGenerateRequest, - current_user: AuthenticatedUser = Depends(get_current_user), - points_deduct_id: str = None, # 由中间件注入 -): - # 积分已扣减,直接执行业务逻辑 - result = await voice_service.generate(request, current_user.user.id) - return VoiceGenerateResponse( - voice_id=result.id, - points_consumed=1, - ) - -# apps/api/app/api/routes/ai_video.py(示例) -@router.post("/generate") -@require_points(scene_key="ai_video") -async def generate_video( - request: VideoGenerateRequest, - current_user: AuthenticatedUser = Depends(get_current_user), - points_deduct_id: str = None, -): - # 视频按秒计费,需要在中间件外动态计算 - ... +# 统一中间件入口 +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) ``` -> **注意**:对于视频生成等按量计费场景(时长不确定),中间件支持动态计算消耗量: -> ```python -> @require_points(scene_key="ai_video", dynamic=True) -> # dynamic=True 时,中间件只检查余额 > 0,实际扣费由业务层调用 points_service.deduct() -> ``` - --- -## 十、实施计划 +## 九、支付接入 -### 10.1 开发阶段拆分 +### 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 迁移执行 + +```sql +-- 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 | 3 天 | -| **P2 - 核心服务** | PointsService 核心逻辑(扣费/退还/赠送/查询) | 3 天 | -| **P3 - API 层** | 积分 API 路由、中间件、与现有订阅 API 集成 | 3 天 | -| **P4 - 前端页面** | Plans.tsx 改造、积分中心页面、Header 积分徽章、消耗提示 | 5 天 | -| **P5 - 功能接入** | 各 AI 功能模块接入 `@require_points` 中间件 | 3 天 | -| **P6 - 迁移与测试** | 数据迁移脚本、灰度方案、集成测试、端到端测试 | 3 天 | - -### 10.2 文件改动清单(预览) - -| 类型 | 文件路径 | 改动说明 | -|------|----------|----------| -| 新增 | `migrations/007_membership_points.sql` | 新建积分相关 4 张表 | -| 新增 | `packages/domain/points_service.py` | 积分核心服务 | -| 新增 | `packages/domain/points_models.py` 或追加到 `models.py` | 积分相关 ORM Model | -| 新增 | `packages/adapters/sqlalchemy_impl/points_repository.py` | 积分 Repository | -| 新增 | `apps/api/app/middleware/points_check.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/` | 积分徽章组件 | -| 新增 | `apps/web/src/components/common/PointsCost/` | 消耗提示组件 | -| 修改 | `packages/domain/quota.py` | 新增 POINTS 维度和 monthly_credits 配额 | -| 修改 | `packages/adapters/sqlalchemy_impl/models.py` | 新增积分相关 Model | -| 修改 | `apps/api/app/api/routes/subscription.py` | 集成积分逻辑 | -| 修改 | `apps/api/app/schemas/subscription.py` | 返回中增加积分信息 | -| 修改 | `apps/web/src/pages/subscription/Plans.tsx` | 对齐 4 档 + 积分展示 | -| 修改 | 各 AI 功能路由文件 | 添加 `@require_points` 装饰器 | - -### 10.3 测试计划 - -| 测试类型 | 覆盖范围 | -|----------|----------| -| 单元测试 | PointsService 扣费/退还/赠送逻辑、PointsChecker 余额检查 | -| 集成测试 | API 端到端:充值→到账→消费→扣减→流水查询 | -| 并发测试 | 同一用户多请求并发扣费的余额一致性 | -| 前端测试 | Plans 页面渲染、积分中心交互、Header 徽章实时更新 | -| 迁移测试 | 老用户数据迁移正确性验证 | +| P1 | 数据库迁移 + Model + Repository | 2 天 | +| P2 | PointsService 核心逻辑 + 每日额度 | 2 天 | +| P3 | 积分 API + 微信支付接入 | 3 天 | +| P4 | 中间件改造(各 AI 功能接入) | 2 天 | +| P5 | 前端页面改造 + 新增页面 | 4 天 | +| P6 | 老用户迁移脚本 + 通知 | 1 天 | +| P7 | 集成测试 + 端到端测试 | 2 天 | +| **合计** | | **~16 天** | --- -## 十一、风险与注意事项 +## 十三、开放问题(待后续讨论) -| 风险 | 应对方案 | -|------|----------| -| 并发扣费导致余额不一致 | 数据库事务 + 行锁,`points_accounts` 使用 `SELECT ... FOR UPDATE` | -| 支付回调延迟导致积分未到账 | 订单创建后 30 分钟未支付自动关闭;回调支持幂等重试 | -| 积分消耗规则变更影响用户 | 变更前 7 天公告通知;已购买的服务按旧价格执行 | -| 前后端积分展示不一致 | 统一从 `GET /api/v1/points/balance` 获取,前端不本地缓存余额 | -| 老用户迁移产生不满 | 过渡期权益不降低 + 额外赠送积分礼包 | +| # | 问题 | 当前决策 | +|---|------|---------| +| 1 | 积分是否需要有效期? | **永久有效**(已确认) | +| 2 | 退款策略? | **虚拟商品不退**,特殊情况人工处理(已确认) | +| 3 | 企业版定制需求? | **暂不做**,后续按需(已确认) | +| 4 | 支付渠道优先级? | **微信优先**,支付宝二期(已确认) | +| 5 | 发票需求? | **暂不做**,后续按需(已确认) | +| 6 | 免费用户每日 2 条混剪是否限制分辨率? | 建议限制 720p | +| 7 | 会员过期后未使用的积分是否清零? | 购买的不清零,赠送的 12 个月后清零 | --- -## 十二、开放问题(待确认) - -1. **积分有效期**:当前设计为永久有效。是否需要设置有效期(如 1 年)? -2. **退款策略**:积分充值后是否支持退款?已消费的积分如何计算? -3. **企业版定制**:企业版是否需要支持自定义积分消耗规则? -4. **支付渠道**:第一期接入支付宝 + 微信支付,是否需要支持其他渠道? -5. **发票需求**:积分充值是否需要单独开发票?与订阅发票合并还是分开? - ---- - -*本文档为方案设计阶段产物,待确认后将按「实施计划」分阶段开发。* +*本方案基于真实 AI 调用成本核算,经用户确认后制定。待确认后即可进入开发阶段。*