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

34 KiB
Raw Permalink Blame History

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

Issue: #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 模式:

# 新增模块注册示例
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

每个用户一个积分账户,记录余额和累计值。

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

每笔积分变动都记录一条流水,支持对账和审计。

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

记录用户充值积分的支付订单。

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

集中管理各场景的积分消耗规则,支持动态调整。

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 表新增字段

-- 会员等级字段保留现有 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

获取当前积分余额。

// 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
// 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

创建积分充值订单。

// 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

支付回调(内部接口 + 第三方支付通知)。

// 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

内部扣费接口(供各功能模块调用)

# 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

查询当前所有积分消耗规则(前端展示用)。

// 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)

-- 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:代码兼容层

# 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

"""积分扣费中间件 - 用于 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 积分服务层

# 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 的集成

# 改造后的检查流程
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 各功能模块接入方式

# 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,
):
    # 视频按秒计费,需要在中间件外动态计算
    ...

注意:对于视频生成等按量计费场景(时长不确定),中间件支持动态计算消耗量:

@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. 发票需求:积分充值是否需要单独开发票?与订阅发票合并还是分开?

本文档为方案设计阶段产物,待确认后将按「实施计划」分阶段开发。