# 视频定价表测试方案 > 分支:`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 个)