04e467e433
后端: - 微信回调 db.commit 失败仍返回 SUCCESS,避免无限重试 - recharge() 加 order_id 幂等保护,防重复充值 - time_expire 使用北京时间(UTC+8),修复时区 bug - 充值档位后端配置化(points-config.yaml + /recharge-options API) - 代码审查 20 项修复(认证加固、扣费顺序、错误响应、状态同步等) 前端: - 充值弹窗:自动轮询 + 【我已支付】手动兜底 - 二维码倒计时显示,过期后遮罩 + 刷新按钮 - 充值档位从后端动态加载 - 去掉 select/qrcode 弹窗标题,金额红色突出显示 - 全项目命名统一(视频生成/压制成片/配音合成/声音复刻等) - Modal 关闭按钮独立于 title 显示
8.8 KiB
8.8 KiB
积分消耗完善方案
基于后端
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. 无积分预估查询 API
tts和video的积分依赖seconds参数,前端需要自己算- 没有
/points/cost?source_type=tts&seconds=10这样的接口
5. 过期回收未自动化
expire_batches()已完整实现,但没有任何定时任务/调度器调用- 180 天过期积分不会自动回收
P2 — 优化项
6. 前端无全局积分状态
- 余额存在
Profile.tsx的局部useState,Sidebar、创作页都无法实时感知 - 充值成功后只有 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 |
输入素材秒数 * 5 或 300 |
无素材时用默认值 60 秒 |
| 字幕生成 | caption |
ceil(seconds / 5) |
输入视频秒数 |
输入视频时长已知 |
| 压制成片 | compose |
seconds * 5 |
分镜总秒数 * 5 |
分镜 duration 前端已知 |
关键规则:
- 余额 >= 预估上限:放行执行业务,出结果后按实际扣费(可能欠费)
- 余额 < 预估上限:直接返回 402,不执行业务
- 固定积分业务:预估上限 = 实际积分,不欠费
- 按秒计费业务:预估上限是保守估计,实际可能超出,差额形成欠费
- 欠费用户不可继续使用:下次发起任何业务前检查余额,若
balance < 0,直接返回 402 提示充值
3.2 后端 — 简化积分服务(P1)
现状:point_service.py 中有 freeze_for_consumption、settle_consumption、refund_consumption、expire_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 优化
五、需要确认的问题
- 脚本生成固定 5 积分? 不按场景数?
- 声音复刻 200 积分?
- 视频/配音秒数从哪取? Rust 层合成完成后 FFmpeg 可以取时长,是否已有现成方法?
- 是否需要免费额度/体验积分? 新用户注册是否赠送一定积分?
- 用户 model 是否有 role 字段? 用于管理员充值权限检查。