|
|
@@ -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 个) |