Files
meijiaka-zy/docs/point-consumption-plan.md
小鱼开发 7550559aa0 refactor: 清理未使用IPC命令、修正point_service注释与扣费逻辑、修复camelToSnake正则、优化vidu import
- 删除8个未使用IPC命令,保留validate_media_path
- file.rs返回类型优化为ApiResponse<()>
- point_service.consume()注释与签名一致
- VideoGeneration改为拼接成功后扣费
- 添加漏扣费风险注释
- 删除过时测试文件
- 修复camelToSnake连续大写字母问题
- vidu.py import移至模块顶层

Refs: P1-1~P1-6 技术债务清理
2026-05-14 17:45:28 +08:00

8.9 KiB
Raw Permalink Blame History

积分消耗完善方案

基于后端 point_service.py 三阶段模型(预扣 → 结算/退款)和前端现状调研。


一、现状总览

后端(已具备)

模块 状态 说明
模型层 UserPoint / PointBatch / PointTransaction / PointRechargeOrder 完整
充值流程 微信支付 Native 扫码 + 回调 + 补单
预扣/结算/退款 freeze_for_consumption / settle_consumption / refund_consumption 已实现
过期回收 ⚠️ expire_batches() 已实现,但未接入定时任务
定价常量 POINTS_COST 已定义(script=5, polish=1, title=1, voice_clone=200, tts/video 按秒计费)

前端(已具备)

模块 状态 说明
余额展示 Profile 页显示可用积分
充值弹窗 6 档位微信支付,轮询状态
流水查询 UsageDetail 页展示最近 50 条
全局积分 Store 无 Zustand Store,各页面独立 useState

二、核心问题清单

P0 — 阻塞业务使用

1. AI 服务 API 未集成积分消耗

  • script.py(脚本生成)、voice.py(配音)、vidu.py(数字人)、caption.py(字幕)等没有任何积分调用
  • 用户可无限免费使用 AI 服务,积分系统形同虚设

2. 前端创作前无余额预检

  • 脚本生成、压制成片等操作点击即触发,不检查余额
  • 后端返回 402 后前端没有统一拦截和充值引导

P1 — 影响体验和数据准确性

3. 结算逻辑多预扣边界问题

  • settle_consumption() 查询批次条件是 frozen > 0没有按 source_id 关联
  • 若用户同时有两个活跃预扣(如同时生成脚本+配音),结算时会互相影响

4. 积分预估

  • ttsvideo 的积分依赖 seconds 参数,前端自行计算预估值
  • /points/cost 预估接口已删除,各业务前端根据 points-config.yaml 规则独立计算

5. 过期回收未自动化

  • expire_batches() 已完整实现,但没有任何定时任务/调度器调用
  • 180 天过期积分不会自动回收

P2 — 优化项

6. 前端无全局积分状态

  • 余额存在 Profile.tsx 的局部 useStateSidebar、创作页都无法实时感知
  • 充值成功后只有 Profile 页刷新,其他页面看不到最新余额

7. /admin/recharge 缺少管理员权限检查

  • 任何人都可以调用管理员充值接口

三、完善方案

3.1 后端 — 统一后置扣费(P0

设计原则:桌面端单用户操作,无并发超扣风险。所有业务统一走「预估上限检查 → 执行业务 → 出结果 → 直接扣费」。

为什么放弃预扣模式

  • 桌面端按钮有 loading 状态,天然防重复点击
  • 单用户单设备,不存在同时发起多个相同请求的场景
  • 预扣/结算/退款三阶段增加复杂度,却无实际收益

扣费策略:允许欠费,但执行前余额不得低于预估上限

async def generate_tts(request, db, current_user):
    # 1. 计算预估上限(调用前就知道)
    estimated_points = estimate_tts_cost(request.text)  # 按字数预估
    
    # 2. 检查余额是否 >= 预估上限
    user_point = await get_user_point_for_update(db, current_user.id)
    if user_point.balance < estimated_points:
        raise HTTPException(status_code=402, detail=f"余额不足,预估需 {estimated_points} 积分")
    
    # 3. 执行业务(余额够预估上限就放行)
    result = await _do_tts(...)
    
    # 4. 出结果后计算实际积分
    actual_points = math.ceil(result.duration / 5)
    
    # 5. 直接扣费(允许欠费:actual_points 可能 > estimated_points
    await point_service.consume(
        db, user_id=current_user.id,
        points=actual_points, source_type="tts",
        source_id=result.task_id,
        description=f"TTS 配音 {result.duration} 秒"
    )
    
    return result

预估上限规则

业务 source_type 计费规则 预估上限 说明
脚本生成 script 5 5 固定值,预估 = 实际
润色 polish 1 1 固定值,预估 = 实际
标题生成 title 1 1 固定值,预估 = 实际
声音复刻 voice_clone 200 200 固定值,预估 = 实际
TTS 配音 tts ceil(seconds / 5) ceil(字数 × 0.3 / 5) 按字数保守预估
视频生成 video seconds * 5 输入素材秒数 * 5300 无素材时用默认值 60 秒
字幕生成 caption ceil(seconds / 5) 输入视频秒数 输入视频时长已知
压制成片 compose seconds * 5 分镜总秒数 * 5 分镜 duration 前端已知

关键规则

  • 余额 >= 预估上限:放行执行业务,出结果后按实际扣费(可能欠费)
  • 余额 < 预估上限:直接返回 402,不执行业务
  • 固定积分业务:预估上限 = 实际积分,不欠费
  • 按秒计费业务:预估上限是保守估计,实际可能超出,差额形成欠费
  • 欠费用户不可继续使用:下次发起任何业务前检查余额,若 balance < 0,直接返回 402 提示充值

3.2 后端 — 简化积分服务(P1

现状point_service.py 中有 freeze_for_consumptionsettle_consumptionrefund_consumptionexpire_batches 等多个方法。

简化:统一后置扣费后,只需要保留:

# 核心方法
async def consume(db, user_id, points, source_type, source_id, description)
async def recharge(db, user_id, points, source, description)  # 充值
async def expire_batches(db)  # 定时过期回收
  • consume():直接扣减 balance,按 FIFO 扣减批次 remaining
  • 删除 freeze / settle / refund 三阶段相关方法
  • 删除 UserPoint.frozen 字段(不再需要冻结概念)
  • 删除 PointBatch.frozen 字段

3.3 后端 — 积分预估查询 API(P1)

新增接口:

GET /points/cost?source_type=tts&seconds=12
→ { "source_type": "tts", "param": {"seconds": 12}, "points": 3 }

前端在点击「生成」前调用,拿到所需积分后做余额校验。


3.4 后端 — 过期回收接入调度(P1)

方案:在 Async Engine Scheduler 中增加一个定时 Job,每天执行一次 expire_batches()

或更简单:在 app/scheduler/handlers/ 下新增 point_handler.py,注册为每日定时任务。


3.5 后端 — 管理员充值加权限(P2)

/admin/recharge 增加管理员权限检查:

  • 方案 1:基于 JWT role 字段判断(需在 User 模型增加 role
  • 方案 2:基于 IP 白名单(配置 ADMIN_IPS

3.6 前端 — 全局积分 Store + 余额不足拦截(P0/P2

新增 pointStore.ts

interface PointState {
  balance: PointBalance | null;
  isLoading: boolean;
  fetchBalance: () => Promise<void>;
}
  • 应用启动时自动拉取余额
  • 充值成功后自动刷新
  • Sidebar 显示当前可用积分

余额不足拦截

后置扣费模式下,前端不需要在调用前预检余额(因为不知道实际要扣多少)。统一由后端拦截:

// client.ts 全局错误拦截
if (error.code === 402) {
  toast.error(error.message); // "余额不足,本次需扣除 xxx 积分"
  usePointStore.getState().setShowRechargeModal(true);
}

充值弹窗全局化

  • RechargeModal 提升到 App.tsx 级别,通过全局状态控制开关
  • 任何页面余额不足时都可以一键唤起

四、实施优先级

优先级 事项 涉及文件 预估工时
P0 简化 point_service(删除冻结相关方法) services/point_service.py 1h
P0 AI 服务统一集成后置扣费 api/v1/script.py, voice.py, vidu.py 2h
P0 前端全局积分 Store + 402 拦截 store/pointStore.ts + App.tsx + client.ts 2h
P1 数据库迁移(删除 frozen 字段) models/user_point.py, models/point_batch.py + alembic 1h
P1 过期回收接入调度 scheduler/handlers/point_handler.py 1h
P2 侧边栏余额展示 Sidebar.tsx 0.5h
P2 管理员充值加权限 api/v1/points.py + models/user.py 1h

建议执行顺序:P0 后端(简化服务 + 统一扣费) → P0 前端 → P1 数据库迁移 → P1 调度 → P2 优化


五、需要确认的问题

  1. 脚本生成固定 5 积分? 不按场景数?
  2. 声音复刻 200 积分?
  3. 视频/配音秒数从哪取? Rust 层合成完成后 FFmpeg 可以取时长,是否已有现成方法?
  4. 是否需要免费额度/体验积分? 新用户注册是否赠送一定积分?
  5. 用户 model 是否有 role 字段? 用于管理员充值权限检查。