Sfoglia il codice sorgente

docs(test): video pricing table test plan with mock server design

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin 3 settimane fa
parent
commit
32603ba385
1 ha cambiato i file con 341 aggiunte e 0 eliminazioni
  1. +341
    -0
      docs/testing/video-pricing-test-plan.md

+ 341
- 0
docs/testing/video-pricing-test-plan.md Vedi File

@@ -0,0 +1,341 @@
# 视频定价表测试方案

> 分支:`feat/video-pricing-table-codex` | 日期:2026-06-18

## 概述

本文档定义了多维定价表系统的完整测试方案,包括两部分:
- **手动验证清单**:覆盖全部功能点,含 curl 示例和预期结果
- **自动化测试脚本**(Python):覆盖核心 CRUD + 计费路径

## 测试环境

```bash
BASE_URL=http://127.0.0.1:3000 # 或 http://127.0.0.1:23000 (WSL CN)
ADMIN_USERNAME=root
ADMIN_PASSWORD=123456
API_TOKEN=sk-... # 任意有效用户 token,配额充足 ≥ 100000
```

前置条件:服务运行中、测试用户存在、Hailuo/Doubao 渠道可用、`QuotaPerUnit = 500000`。

---

## 一、定价配置 CRUD(P01–P12)

| 编号 | 测试点 | 方法 | 路径 | 预期 |
|------|--------|------|------|------|
| P01 | 获取空配置列表 | GET | `/api/option/model_pricing` | 200, `{}` |
| P02 | 创建配置 | PUT | `/api/option/model_pricing/hailuo-video` | 200, 返回配置 |
| P03 | 读取单配置 | GET | `/api/option/model_pricing/hailuo-video` | 200 |
| P04 | 更新配置 | PUT | 同上 + 修改后 table | 200 |
| P05 | 删除配置 | DELETE | `/api/option/model_pricing/hailuo-video` | 200 |
| P06 | 删除后读取 | GET | 同上 | 404 |
| P07 | 批量列表 | GET | `/api/option/model_pricing` | 200, 含所有已创建 |
| P08 | 未认证请求 | PUT | 不带 Cookie | 401/403 |
| P09 | 错误 Content-Type | PUT | `Content-Type: text/plain` | 400 |
| P10 | 非法 JSON | PUT | 损坏 JSON | 400 |
| P11 | 幂等覆盖 | PUT | 再次 PUT 已存在 | 200 |
| P12 | 删除不存在 | DELETE | `.../nonexistent` | 200 |

### P02 请求体示例

```json
{
"schema_version": 1,
"scope": "model",
"billing_unit": "per_call",
"preconsume_strategy": "exact",
"dimensions": [
{"key": "resolution", "source": "request.resolution", "type": "string", "default": null},
{"key": "duration", "source": "request.duration", "type": "number", "default": null}
],
"table": [
{"resolution": "768P", "duration": 10, "price": 0.04, "source": "manual"}
],
"fallback": {"strategy": "reject"}
}
```

---

## 二、配置校验规则(V01–V17)

| 编号 | 校验规则 | 触发方式 | 预期 |
|------|---------|---------|------|
| V01 | schema_version ≠ 1 | `"schema_version": 2` | 400 |
| V02 | scope ≠ model | `"scope": "channel"` | 400 |
| V03 | dimensions 为空 | `"dimensions": []` | 400 |
| V04 | table 为空 | `"table": []` | 400 |
| V05 | billing_unit 非法 | `"billing_unit": "per_hour"` | 400 |
| V06 | preconsume_strategy 非法 | `"preconsume_strategy": "average"` | 400 |
| V07 | per_call + minimum 冲突 | billing_unit=per_call, strategy=minimum | 400 |
| V08 | per_1m_tokens + exact 冲突 | billing_unit=per_1m_tokens, strategy=exact | 400 |
| V09 | 不支持 matrix usage 模型 | hailuo-video + per_1m_tokens | 400 |
| V10 | dimension key 保留字 | key="price" | 400 |
| V11 | dimension key 非法格式 | key="reso lution" | 400 |
| V12 | dimension type 非法 | type="integer" | 400 |
| V13 | dimension source 非法 | source="header.X-Custom" | 400 |
| V14 | row 缺少维度 | table 行不含某维度值 | 400 |
| V15 | row price 负数 | `"price": -1` | 400 |
| V16 | 模糊冲突行 | 相同 wildcard count 的行重叠 | 400 |
| V17 | optional 缺 default | optional=true + default=null | 400 |

---

## 三、维度解析(D01–D06)

| 编号 | 测试点 | 说明 |
|------|--------|------|
| D01 | JSON 请求体浅层提取 | `{"resolution": "1080P"}` → 字符串 |
| D02 | JSON 嵌套提取 | `request.options.size` 深层路径 |
| D03 | Multipart 提取 | `Content-Type: multipart/form-data` |
| D04 | Optional 默认值 | 请求不传 → 用 default |
| D05 | 缺失必填维度 | 该维度为 nil,不匹配任何行 |
| D06 | Derived 维度 | megapixels/aspect_ratio/input_mode 自动计算 |

---

## 四、定价匹配 + 回退策略(L01–L08)

| 编号 | 场景 | 预期 |
|------|------|------|
| L01 | 精确匹配 | 维度 = 行值 → 返回该行价格 |
| L02 | Wildcard 匹配 | `*` 匹配任意值 |
| L03 | 最小 wildcard 优先 | 多行匹配选 wildcard 最少 |
| L04 | 无匹配 → reject | 400 + 不扣 quota |
| L05 | 无匹配 → max | 取表中最高价 |
| L06 | 无匹配 → default | 取 default_price |
| L07 | 歧义行配置拒绝 | V16 |
| L08 | per_1m_tokens 预扣 | minimum 策略预扣 |

---

## 五、Task 计费生命周期(B01–B08)

| 编号 | 场景 | 说明 |
|------|------|------|
| B01 | 提交 + 预扣 | PreConsume → DB quota 变化 |
| B02 | 成功 + 结算 | per_call 精确不补扣 |
| B03 | 失败 + 退款 | Refund 退还 |
| B04 | Adaptor 调整差额 | AdjustBillingOnSubmit → 差额 |
| B05 | 免费模型 | price=0 → 不预扣 |
| B06 | 分组倍率 | groupRatio≠1 → quota 按倍率 |
| B07 | BillingContext 持久化 | DB 记录完整 |
| B08 | 消费日志 | log 表字段正确 |

---

## 六、Matrix Usage 计费(M01–M06)

| 编号 | 场景 | 说明 |
|------|------|------|
| M01 | 预扣最小值 | ≈ PreConsumedQuota × tokenUnitPrice × ratio / 1M |
| M02 | Token 结算 | 上游返回 total_tokens → 按实际计算 |
| M03 | 补扣差额 | 实际 > 预扣 → 补扣 |
| M04 | 退还差额 | 实际 < 预扣 → 退还 |
| M05 | 不支持模型被拒 | hailuo-video + per_1m_tokens → 400 |
| M06 | 渠道类型不匹配 | 非 DoubaoVideo → 不支持 |

---

## 七、Remix 计费(R01–R05)

| 编号 | 场景 | 说明 |
|------|------|------|
| R01 | BillingContext 继承 | 复用原任务定价快照 |
| R02 | 旧格式兼容 | 从 seconds/size 解析 |
| R03 | 锁渠道 | 必须用原渠道 |
| R04 | 原任务不存在 | 400 |
| R05 | Matrix remix | 按相同方式计费 |

---

## 八、渠道选择(C01–C04)

| 编号 | 场景 | 说明 |
|------|------|------|
| C01 | Matrix 渠道路由 | 只选匹配渠道 |
| C02 | 无可用渠道 | 错误 |
| C03 | 普通模型不限渠道 | per_call 不需 matrix |
| C04 | 模型映射后匹配 | 上游映射后仍正确 |

---

## 九、实际计费请求(E01–E22)

### Per Call 计费(E01–E08)

| 编号 | 场景 | 预期 |
|------|------|------|
| E01 | 精确匹配 768P/10s=0.04 | quota 扣 20000 |
| E02 | 提交前后 quota 差 | 差值 = price × QuotaPerUnit × groupRatio |
| E03 | Wildcard 匹配 | `*` 匹配任意同维度值 |
| E04 | 不匹配 → reject | quota 不变 + 400 |
| E05 | Fallback=max | 扣最高价 |
| E06 | Fallback=default | 扣 default_price × 500000 |
| E07 | 三维度匹配 | resolution × duration × input_mode |
| E08 | Optional 默认值 | 不传 → 自动补 default → 匹配 |

### Per 1M Tokens 计费(E09–E14,需 mock 上游)

| 编号 | 场景 | 说明 |
|------|------|------|
| E09 | 预扣最小值 | 预扣 ≈ 1–2 quota |
| E10 | 按 tokens 结算 | tokens × price × ratio / 1M |
| E11 | 补扣差额 | tokens 多 → 补扣 |
| E12 | 退还差额 | tokens 少 → 退还 |
| E13 | 定价表 + usage | 维度匹配得单价 → × total_tokens |
| E14 | 预扣量对比 | per_1m_tokens vs per_call 预扣差 |

### Remix 计费完整性(E15–E18)

| 编号 | 场景 | 说明 |
|------|------|------|
| E15 | Remix per_call | 扣费与原任务相同 |
| E16 | Remix per_1m_tokens | 相同 token 单价 |
| E17 | Remix 旧格式 | seconds/size 推断 |
| E18 | Remix 渠道不可用 | 400 |

### 边界场景(E19–E22)

| 编号 | 场景 | 说明 |
|------|------|------|
| E19 | 定价配置优先于旧价格 | matrix 优先 |
| E20 | 无定价配置走旧逻辑 | legacy path |
| E21 | Quota 不足 | 提交失败 |
| E22 | GroupRatio 非 1 | quota 按倍率 |

---

## 自动化脚本范围

`test-scripts/test_model_pricing_api.py`,预估覆盖约 33 个用例:

- **CRUD**:P01–P12(约 8 个)
- **校验规则**:V01–V17(约 12 个)
- **Per Call 计费**:E01–E08(约 6 个)
- **Per 1M Tokens**:E09–E14(约 4 个,需 mock 上游)

---

## 十、Mock 上游服务器

由于本地/测试环境没有真实的 Doubao 和 Hailuo 视频生成 API,需要一个 mock server 来模拟上游行为。

### 为什么需要 mock

| 测试类别 | 依赖上游做什么 | 没有 mock 的后果 |
|---------|--------------|-----------------|
| Per Call 计费(E01-E08) | 接受提交 → 后续 polling 返回 succeeded | 任务一直 pending,永远不结算 |
| Per 1M Tokens(E09-E14) | 提交 → polling 返回 succeeded + `usage.total_tokens` | 无法验证按 token 结算逻辑 |
| Remix(E15-E18) | 基于已完成任务再次提交 → polling 返回 succeeded | 无法验证 remix 计费链路 |

### Mock 需要模拟的 API

#### Hailuo(Minimax)API

**提交任务** — `POST /v1/video/generation`
```json
// Request (new-api 转发)
{"model": "hailuo-video", "prompt": "...", "resolution": "768P", "duration": 10}

// Response
{"data": {"task_id": "mock_hailuo_xxx"}, "base_resp": {"status_code": 0, "status_msg": "success"}}
```

**查询任务** — `GET /v1/query/video_result?task_id={id}`
```json
// Response (succeeded)
{"data": {
"status": "Success",
"video_url": "https://mock.local/video.mp4"
}, "base_resp": {"status_code": 0}}
```

#### Doubao(Seedance)API

**提交任务** — `POST /api/v3/contents/generations/tasks`
```json
// Request (new-api 转发)
{"model": "seedance-2", "content": [{"type": "text", "text": "..."}], "resolution": "...", "duration": 5}

// Response
{"id": "mock_doubao_xxx"}
```

**查询任务** — `GET /api/v3/contents/generations/tasks/{id}`
```json
// Response (succeeded + usage)
{
"id": "mock_doubao_xxx",
"model": "seedance-2",
"status": "succeeded",
"content": {"video_url": "https://mock.local/video.mp4"},
"usage": {"completion_tokens": 10000, "total_tokens": 10000}
}
```

### Mock 核心设计

**关键思路:** mock 将请求中的信息存储起来,在 polling 时原样返回给 new-api。`total_tokens` 通过请求体中的特殊字段控制。

**状态机:**
```
submit → "queued" (首次 GET)
→ "processing" (第二次 GET)
→ "succeeded" (第三次 GET,返回 usage)
```

**控制 total_tokens 的方式:** 在提交请求中包含 `_mock_total_tokens` 字段,mock 提取后不传给上游逻辑,只在 fetch 时返回该值作为 `usage.total_tokens`。

**Python 实现框架:**

```python
# mock_server.py - 使用 Python stdlib http.server,无外部依赖
# 用法: python test-scripts/mock_video_upstream.py --port 18999

import json
from http.server import HTTPServer, BaseHTTPRequestHandler

class MockHandler(BaseHTTPRequestHandler):
tasks = {} # task_id → {request, poll_count, total_tokens}

def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
task_id = f"mock_{len(self.tasks):04d}"
# 提取 _mock_total_tokens 并移除
total_tokens = body.pop("_mock_total_tokens", None)
self.tasks[task_id] = {
"request": body,
"poll_count": 0,
"total_tokens": total_tokens or 10000,
}
# 根据 path 返回 Hailuo 或 Doubao 格式
...

def do_GET(self):
# 解析 task_id,增加 poll_count
# poll_count 0: queued, 1: processing, 2+: succeeded
...
```

### 测试流程(使用 mock)

```
1. 启动 mock server: python test-scripts/mock_video_upstream.py --port 18999
2. 在 new-api 中创建指向 mock 的渠道:
- Hailuo 渠道: base_url = http://host.docker.internal:18999
- Doubao 渠道: base_url = http://host.docker.internal:18999, type = DoubaoVideo
3. 运行测试脚本: python test-scripts/test_model_pricing_api.py
4. 停止 mock server: Ctrl+C
```

### Mock Server 配置端点

| 端点 | 方法 | 用途 |
|------|------|------|
| `/mock/reset` | POST | 清空所有任务状态 |
| `/mock/stats` | GET | 查看已完成/进行中的任务数 |
| `/mock/tasks` | GET | 列出所有任务及其状态 |
- **Remix**:E15–E18(约 3 个)

Caricamento…
Annulla
Salva