From 32603ba3854059a973a651f7b27c17f117e5179e Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 18 Jun 2026 11:28:13 +0800 Subject: [PATCH] docs(test): video pricing table test plan with mock server design Co-Authored-By: Claude --- docs/testing/video-pricing-test-plan.md | 341 ++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 docs/testing/video-pricing-test-plan.md diff --git a/docs/testing/video-pricing-test-plan.md b/docs/testing/video-pricing-test-plan.md new file mode 100644 index 0000000..5c60721 --- /dev/null +++ b/docs/testing/video-pricing-test-plan.md @@ -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 个)