Files
xiaoxia-saas/docs/membership-points-design.md
xiaoxia 35b18e5e16
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m45s
CI/CD Pipeline / Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / Validate - Style (push) Successful in 4m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m24s
CI/CD Pipeline / Unit Tests (push) Successful in 10m43s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 45h29m11s
CI/CD Pipeline / PR Build Web Image (push) Failing after 45h29m47s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 45h29m19s
CI/CD Pipeline / PR Build API Image (push) Failing after 45h29m19s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 45h28m43s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 45h28m43s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h29m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 45h29m19s
docs: #1895 会员+积分替代订阅制方案设计 (#1903)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 17:53:57 +08:00

908 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 会员制 + 积分方案设计文档
> **Issue**: [#1895](https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/issues/1895)
> **版本**: v1.0
> **状态**: 方案设计(待确认)
> **创建时间**: 2026-09-14
---
## 一、设计背景与目标
### 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 调用成本波动,高频用户和低频用户无法差异化变现
### 1.2 目标
- **会员制**保留基础权益(存储、项目数、并发数等),提供稳定收入
- **积分制**覆盖 AI 消耗型功能(配音、数字人、视频生成等),按需付费、用多少扣多少
- 两者结合,既降低轻度用户入门门槛,又提升重度用户 ARPU
- 在现有 `QuotaChecker` + `ModuleRegistry` 架构上扩展,不推倒重来
---
## 二、会员等级设计
### 2.1 等级定义
保留 4 档,与现有后端 `PLAN_QUOTAS` 对齐,重新定义权益:
| 等级 | 月付价格 | 年付价格 | 定位 |
|------|----------|----------|------|
| **免费版(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` 统一管理
---
## 三、积分体系设计
### 3.1 积分获取方式
| 获取方式 | 说明 | 频率 |
|----------|------|------|
| **会员每月赠送** | 各等级每月自动到账(见上表) | 每月 1 日 00:00 自动发放 |
| **单独充值** | 用户按需购买积分包 | 随时可买 |
| **任务奖励** | 完成指定任务赠送(新手引导、邀请好友、反馈 Bug 等) | 一次性 |
### 3.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/积分 | |
> 积分永久有效,不随会员过期清零。会员过期后停止每月赠送,但已有积分不受影响。
### 3.3 任务奖励规则
| 任务 | 奖励积分 | 次数限制 |
|------|----------|----------|
| 新用户注册 | 50 | 1 次 |
| 完善个人信息 | 20 | 1 次 |
| 邀请新用户注册 | 30/人 | 每月限 10 人 |
| 首次完成视频生成 | 20 | 1 次 |
| 提交有效 Bug 反馈 | 50 | 不限(审核通过后发放) |
---
## 四、积分消耗场景
### 4.1 消耗清单
| 功能模块 | 消耗场景 | 每次消耗积分 | 说明 |
|----------|----------|-------------|------|
| **AI 配音** | 生成一条配音 | 1 | 已存在于 `module_registry.py` |
| **AI 数字人** | 生成一段数字人视频片段 | 5 | 新场景 |
| **视频生成** | 生成一段 AI 视频(≤10s) | 10 | 新场景 |
| **视频生成(长时长)** | 每增加 10s | +5 | 累进计费 |
| **抖音文案提取** | 提取一次 | 1 | 新场景 |
| **AI 改写** | 改写一段文案 | 1 | 新场景 |
| **AI 标题生成** | 批量生成一次(≤10 条) | 1 | 新场景 |
| **AI 封面生成** | 生成一张封面 | 2 | 新场景 |
| **AI 字幕翻译** | 翻译一条字幕(≤50 字) | 1 | 新场景 |
### 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 积分")],
),
],
))
```
新增 `QuotaDimension.POINTS = "points"` 作为通用积分维度,所有消耗型功能统一通过积分维度扣费。
### 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, -- 累计赠送积分(会员赠送 + 任务奖励)
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);
```
**设计说明**:
- `user_id` 设为 UNIQUE,每个用户只有一个积分账户
- 余额通过 `total_earned - total_spent` 可交叉校验 `balance`,保证数据一致性
- 不使用悲观锁,而是通过事务 + 乐观锁(`updated_at`)保证并发安全
#### 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 等)
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);
```
**设计说明**:
- `source` 字段标识具体来源/场景,新增消耗场景时只需新增 source 值,不需要改表结构
- `ref_id` 关联具体业务,方便追溯(如充值关联订单号,消费关联生成任务 ID)
- 流水只追加不修改,保证审计完整性
#### 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, -- 支付金额(分),避免浮点精度问题
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending / paid / failed / refunded
payment_method VARCHAR(50), -- alipay / wechat_pay / ...
payment_id VARCHAR(100), -- 第三方支付流水号
paid_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expire_at TIMESTAMP -- 订单过期时间(未支付自动关闭)
);
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`
集中管理各场景的积分消耗规则,支持动态调整。
```sql
CREATE TABLE IF NOT EXISTS points_consumption_config (
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()
);
```
### 5.2 现有表变更
#### 5.2.1 users 表新增字段
```sql
-- 会员等级字段保留现有 subscription_plan,无需改动
-- 无需在 users 表加 credits 字段,积分独立在 points_accounts 表管理
```
### 5.3 表关系
```
users (1) ──── (1) points_accounts
│
└── (1:N) points_transactions
│
└── ref_id ──> generation_tasks / points_orders / ...
users (1) ──── (1:N) points_orders
```
### 5.4 与现有模型的映射
| 现有模型 | 改动 |
|----------|------|
| `UserModel` | 不新增积分字段,积分由独立表管理 |
| `BillingRecordModel` | 保留,用于订阅支付记录;积分充值走新的 `points_orders` |
| `QuotaDimension` | 新增 `POINTS = "points"` 维度 |
| `QuotaTier` | 各等级新增 `monthly_credits` 配额 |
| `QuotaRule` | `dimension` 支持 `"points"` 值 |
---
## 六、API 设计
### 6.1 积分账户 API
#### `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"
}
```
#### `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
```
```json
// Response
{
"total": 156,
"page": 1,
"page_size": 20,
"items": [
{
"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"
}
]
}
```
### 6.2 积分充值 API
#### `POST /api/v1/points/recharge`
创建积分充值订单。
```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"
}
```
#### `POST /api/v1/points/payment-callback`
支付回调(内部接口 + 第三方支付通知)。
```json
// Request (来自支付平台)
{
"order_id": "po_xxx",
"payment_method": "alipay",
"payment_id": "2026xxx",
"status": "paid"
}
```
回调处理流程:
1. 验证支付签名
2. 更新 `points_orders.status = "paid"`
3. 增加 `points_accounts.balance += points_amount`
4. 写入 `points_transactions` 流水(type=earn, source=recharge)
### 6.3 积分消费 API
#### 内部扣费接口(供各功能模块调用)
```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
```
#### `GET /api/v1/points/consumption-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 },
...
]
}
```
### 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`(保持不变)
**改动要点**:
1. 将 3 档对齐为 4 档,与后端一致
2. 每档卡片增加"每月赠送 XXX 积分"标识
3. 按钮文案按当前状态动态显示("当前方案"/"升级"/"降级"/"联系我们")
4. 底部增加积分包购买入口
```
┌─────────────────────────────────────────────────────────────┐
│ 选择适合您的方案 │
├──────────┬──────────┬──────────┬──────────┐ │
│ 免费版 │ 标准版 │ 专业版 ★ │ 企业版 │ │
│ ¥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 │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 7.2 积分余额展示位置
#### 顶部导航栏(Header 组件)
在用户头像旁边增加积分余额徽章:
```
┌─────────────────────────────────────────────────┐
│ Logo 工作台 模板 素材 🔔 💎 580 👤 │
└─────────────────────────────────────────────────┘
```
- 点击积分徽章展开快捷面板,显示余额、本月已用、充值入口
- 积分不足时徽章变为警告色(橙色)
#### 功能入口处的消耗提示
在每个 AI 功能的操作按钮旁,显示本次操作将消耗的积分:
```
[生成配音] 💎 -1 积分
[生成视频] 💎 -10 积分
```
### 7.3 积分中心页面
**路由**: `/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 档 + 积分展示 |
---
## 八、与现有订阅制的迁移方案
### 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';
```
#### Step 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 功能入口的统一检查与扣费"""
from functools import wraps
from fastapi import HTTPException
def require_points(scene_key: str, amount: int = None):
"""
装饰器:在 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)):
# 到这里积分已扣减成功
...
"""
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="未登录")
# 2. 获取消耗数量
consume_amount = amount or get_consumption_config(scene_key)
# 3. 检查会员权益(原有 QuotaChecker 逻辑)
user = current_user.user
tier = quota_registry.get_tier(user.subscription_plan or "free")
# ... 检查存储、并发等基础权益
# 4. 检查并扣减积分
points_service = get_points_service()
result = points_service.check_and_deduct(
user_id=user.id,
scene_key=scene_key,
amount=consume_amount,
)
if not result.success:
raise HTTPException(
status_code=402, # Payment Required
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {consume_amount} 积分,当前余额 {result.balance}",
"recharge_url": "/app/points/recharge"
}
)
# 5. 将扣减信息注入请求上下文,供业务层使用
kwargs["points_deduct_id"] = result.transaction_id
try:
# 6. 执行业务逻辑
return await func(*args, **kwargs)
except Exception as e:
# 7. 业务失败时退还积分
points_service.refund(
user_id=user.id,
transaction_id=result.transaction_id,
reason=f"业务执行失败: {scene_key}"
)
raise
return wrapper
return decorator
```
### 9.3 积分服务层
```python
# packages/domain/points_service.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
```
### 9.4 与现有 QuotaChecker 的集成
```python
# 改造后的检查流程
async def check_all_quotas(user, scene_key: str, consume_amount: int):
"""统一配额检查入口"""
# 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)
# 2. 积分检查
# 新增 PointsChecker
balance = points_service.get_balance(user.id)
if balance < consume_amount:
raise InsufficientPointsError(
required=consume_amount,
balance=balance
)
return True
```
### 9.5 各功能模块接入方式
```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,
):
# 视频按秒计费,需要在中间件外动态计算
...
```
> **注意**:对于视频生成等按量计费场景(时长不确定),中间件支持动态计算消耗量:
> ```python
> @require_points(scene_key="ai_video", dynamic=True)
> # dynamic=True 时,中间件只检查余额 > 0,实际扣费由业务层调用 points_service.deduct()
> ```
---
## 十、实施计划
### 10.1 开发阶段拆分
| 阶段 | 内容 | 预估工时 |
|------|------|----------|
| **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 徽章实时更新 |
| 迁移测试 | 老用户数据迁移正确性验证 |
---
## 十一、风险与注意事项
| 风险 | 应对方案 |
|------|----------|
| 并发扣费导致余额不一致 | 数据库事务 + 行锁,`points_accounts` 使用 `SELECT ... FOR UPDATE` |
| 支付回调延迟导致积分未到账 | 订单创建后 30 分钟未支付自动关闭;回调支持幂等重试 |
| 积分消耗规则变更影响用户 | 变更前 7 天公告通知;已购买的服务按旧价格执行 |
| 前后端积分展示不一致 | 统一从 `GET /api/v1/points/balance` 获取,前端不本地缓存余额 |
| 老用户迁移产生不满 | 过渡期权益不降低 + 额外赠送积分礼包 |
---
## 十二、开放问题(待确认)
1. **积分有效期**:当前设计为永久有效。是否需要设置有效期(如 1 年)?
2. **退款策略**:积分充值后是否支持退款?已消费的积分如何计算?
3. **企业版定制**:企业版是否需要支持自定义积分消耗规则?
4. **支付渠道**:第一期接入支付宝 + 微信支付,是否需要支持其他渠道?
5. **发票需求**:积分充值是否需要单独开发票?与订阅发票合并还是分开?
---
*本文档为方案设计阶段产物,待确认后将按「实施计划」分阶段开发。*