# 积分消耗完善方案

> 基于后端 `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. 积分预估**
- `tts` 和 `video` 的积分依赖 `seconds` 参数，前端自行计算预估值
- ~~`/points/cost` 预估接口已删除~~，各业务前端根据 `points-config.yaml` 规则独立计算

**5. 过期回收未自动化**
- `expire_batches()` 已完整实现，但没有任何定时任务/调度器调用
- 180 天过期积分不会自动回收

### P2 — 优化项

**6. 前端无全局积分状态**
- 余额存在 `Profile.tsx` 的局部 `useState`，Sidebar、创作页都无法实时感知
- 充值成功后只有 Profile 页刷新，其他页面看不到最新余额

**7. `/admin/recharge` 缺少管理员权限检查**
- 任何人都可以调用管理员充值接口

---

## 三、完善方案

### 3.1 后端 — 统一后置扣费（P0）

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

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

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

```python
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` 等多个方法。

**简化**：统一后置扣费后，只需要保留：

```python
# 核心方法
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`**：

```typescript
interface PointState {
  balance: PointBalance | null;
  isLoading: boolean;
  fetchBalance: () => Promise<void>;
}
```

- 应用启动时自动拉取余额
- 充值成功后自动刷新
- Sidebar 显示当前可用积分

**余额不足拦截**：

后置扣费模式下，前端**不需要**在调用前预检余额（因为不知道实际要扣多少）。统一由后端拦截：

```typescript
// 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 字段？** 用于管理员充值权限检查。
