视频定价表测试方案
分支:feat/video-pricing-table-codex | 日期:2026-06-18
概述
本文档定义了多维定价表系统的完整测试方案,包括两部分:
- 手动验证清单:覆盖全部功能点,含 curl 示例和预期结果
- 自动化测试脚本(Python):覆盖核心 CRUD + 计费路径
测试环境
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 请求体示例
{
"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
// 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}
// 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
// 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}
// 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 实现框架:
# 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 |
列出所有任务及其状态 |