feat(ai): 内部契约 openapi-internal.yaml + 像素对照表(127 测试)
CI / ai-gates (push) Successful in 33s

按用户拍板的四点定型 Java↔Python 的 JSON 契约。

**① 字段命名 snake_case**(与对客户端的 camelCase 契约不同,有意为之):本服务是
Python,snake_case 使 JSON 与领域 dataclass 字段逐一对应、无需转换层;Java 侧只需
在这一个 Feign 客户端上配置命名策略。用 camelCase 反而要在两端都加转换。

**② 两类失败分开**,判别字段是 `error.kind` 而非 HTTP 状态码:
- `service`(Java↔Python):unauthenticated / invalid_request / task_not_found /
  provider_unavailable / internal_error。属我方 bug 或配置问题。
- `provider`(Python↔服务商):非 2xx + 完整分类(retry / user_facing /
  provider_code / message),字段与 Failure 一致,故 Java 侧可复用同一段处置
  逻辑,不必为「提交时被限流」和「任务失败于限流」写两套分支。
- **任务失败是第三类,走 200 + state=failed + failure**:查询一个失败的任务,
  HTTP 调用本身是成功的。做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。

**③ 像素对照表已实现**——此处需更正一个理解:该表**不需要查询任何东西、也不依赖
存储方案**。它是纯静态数据(官方文档「不同宽高比对应的宽高像素值」一节),厂商
查询响应已返回 ratio 与 resolution,查表即可,无需下载产物或解码。故
widthPx/heightPx 在 AI 视频产物上可零成本填真值(ADR-023 §11)。

实现上一个关键观察:**2.5 与 2.0 的 24 格里只有两格不同**(480p 的 16:9 与
9:16),其余完全一致。故写成「共享表 + 两格覆盖」,而非抄两份 24 行——后者每次
改动要改两处,迟早只改一处。另 4k 六格仅 2.0 有。
查不到时返回 None 而不猜(ratio=adaptive、未知模型族、该模型不支持的组合三种
情形):一个错的尺寸会让客户端按错误比例渲染,且看不出是错的。
连带给 SeedanceTask 补解析响应里的 model 字段——同一适配器实例可能同时处理两个
模型族的任务,按响应里的实际模型定族才对。

**④ 路径参数用服务商任务 ID**,并在契约里写明原因:本服务不持有状态,唯一能用来
问服务商的就是这个 ID;用 job_id 就得存一份映射,「无状态」不再成立,且会多出
一个「Java 有 ID 但查不到任何东西」的中间态。你指出的那点完全对——submit 会阻塞
一次到服务商的短往返才能拿到 ID,但服务商的提交接口本身是异步的(只入队即返回),
所以这一跳很快。契约里也如实登记了那个**无法消除的窗口**:该调用超时时 Java 拿
不到 ID 而服务商可能已创建任务,无幂等键可用,只能由 max_attempts 兜住。

测试 123 → 127:旧的「像素刻意留空」断言已作废,换成断言真实换算,并覆盖两族
差异的那两格、4k 仅 2.0、adaptive 与未知模型族的留空回退。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 16:26:49 +08:00
parent d4213a6ece
commit fd23dfc0b9
5 changed files with 586 additions and 11 deletions
+21 -2
View File
@@ -24,8 +24,10 @@ from app.domain.generation import (
)
from app.providers.base import GenerationProvider
from .capabilities import limits_for
from .capabilities import validate as validate_limits
from .client import ArkClient
from .dimensions import pixels_for
from .models import (
ASSET_URL_TTL_SEC,
TASKS_PATH,
@@ -149,6 +151,14 @@ class SeedanceVideoProvider(GenerationProvider):
),
)
# 像素宽高由 (模型族, resolution, ratio) 查静态对照表得到——厂商只返回后两者,
# 不返回像素值。查表不需要下载产物也不需要解码,故 widthPx/heightPx 在 AI
# 视频产物上可零成本填真值(ADR-023 §11)。查不到时留空而不猜:一个错的
# 尺寸会让客户端按错误比例渲染,且看不出是错的。
width_px, height_px = pixels_for(
limits_for(self._model_of(task)), task.resolution, task.ratio
)
return GenerationResult(
provider_request_id=request_id,
state=state,
@@ -160,14 +170,23 @@ class SeedanceVideoProvider(GenerationProvider):
cover_url=task.last_frame_url,
ratio=task.ratio,
resolution=task.resolution,
# 像素尺寸留空:火山只返回 ratio 与 resolution,换算需要一张随模型族
# 变化的对照表。在 V7 目录种子定下模型族之前不编造尺寸。
width_px=width_px,
height_px=height_px,
duration_ms=task.duration_ms,
output_format=task.output_format,
failure=failure,
billing_tokens=task.completion_tokens,
)
def _model_of(self, task: SeedanceTask) -> str:
"""任务所用的模型。
查询响应带 `model` 字段(形如 `doubao-seedance-2-0-260128`),优先用它——
这样同一个适配器实例处理不同模型的任务时,像素换算也能各按其族。缺失时
才回落到配置的默认模型。
"""
return task.model or self._default_model
@staticmethod
def _map_state(task: SeedanceTask) -> TaskState:
match task.status: