Files
patbond-ai/openapi-internal.yaml
T
lixi fd23dfc0b9
CI / ai-gates (push) Successful in 33s
feat(ai): 内部契约 openapi-internal.yaml + 像素对照表(127 测试)
按用户拍板的四点定型 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>
2026-09-20 16:26:49 +08:00

384 lines
16 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
openapi: 3.1.0
info:
title: patbond-ai 内部接口
version: 0.1.0
description: |
`patbond-creation`(Java)调用 `patbond-ai`(Python)执行 AI 生成的内部契约。
**这份契约不对客户端暴露**,与 `patbond-doc/docs/api/openapi.yaml` 是两份独立
文档、独立版本号(ADR-023 §4)。刻意不合并:内部适配端点若进入公开契约,会污染
其路径/操作计数,也会让「对客户端的承诺」与「对内部实现的约定」混为一谈。
本文档也**不进** patbond-api 的四模块快照锁。
## 字段命名为 snake_case
与项目对客户端的 camelCase 契约不同。这是有意的:本服务是 Python,snake_case
使 JSON 与领域 dataclass 字段逐一对应、无需转换层;Java 侧只需在这一个 Feign
客户端上配置命名策略。**用 camelCase 反而要在两端都加转换。**
## 两类失败必须分开(本契约的核心设计)
| 类别 | 含义 | 形态 |
| --- | --- | --- |
| **service** | Java↔Python 之间的问题:鉴权失败、请求体非法、任务不存在、本服务内部错误、适配器未装配 | 非 2xx + `error.kind = "service"` |
| **provider** | Python↔AI 服务商之间的问题:限流、配额耗尽、内容被拒、厂商内部错误 | 非 2xx + `error.kind = "provider"`,**并携带完整分类** |
| **任务失败** | HTTP 调用本身成功,但生成任务最终失败 | **200** + `state = "failed"` + `failure` |
第三类刻意不走错误通道:查询一个失败的任务,**HTTP 请求是成功的**,失败的是
任务。把它做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。
判别字段是 `error.kind`,**不是 HTTP 状态码**。Java 侧据此分流:
`service` 类属我方 bug 或配置问题(除 `internal_error`/`provider_unavailable`
外均不可重试);`provider` 类一律以 `retry` 字段为准,不要自己解读厂商码。
## 鉴权
所有端点要求服务身份 JWT:`Authorization: Bearer <token>`。本服务强制校验
`aud = patbond-ai` 与 `typ = service`(ADR-023 §5)。用户 token 一律拒绝——
该密钥对与用户 token 的密钥对是独立的两套。
servers:
- url: http://127.0.0.1:8000
description: 本机开发
- url: http://patbond-ai:8000
description: 容器网络内
security:
- serviceJwt: []
paths:
/internal/generation/tasks:
post:
summary: 提交生成任务
operationId: submitGenerationTask
description: |
把任务交给上游服务商,返回**服务商的任务 ID**。
**不阻塞等待生成完成。** 但会阻塞一次到服务商的短往返——服务商的提交接口
本身是异步的(只入队即返回任务 ID),所以这一跳很快。
调用方**必须在上游产生任何计费动作之前**把返回的 `provider_request_id`
落库:火山不支持幂等键,这是提交超时后唯一能判断「是否已提交」的依据。
**一个无法消除的窗口**:若本服务到服务商的调用超时,调用方会收到
`provider` 类错误且拿不到 ID,而服务商**可能已经创建了任务**。无幂等键可用,
该窗口只能由 `max_attempts` 兜住(ADR-023 §4 已如实登记)。服务商支持
回调,将来启用回调可作为孤儿任务的回收通道。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitRequest'
responses:
'200':
description: 已提交
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitResponse'
'400':
$ref: '#/components/responses/ServiceError'
'401':
$ref: '#/components/responses/ServiceError'
'502':
$ref: '#/components/responses/ProviderError'
'503':
$ref: '#/components/responses/ServiceError'
/internal/generation/tasks/{provider_request_id}:
get:
summary: 查询生成任务
operationId: getGenerationTask
description: |
路径参数是**服务商的任务 ID**,不是我方 `job_id`。
这是刻意的:本服务不持有任何状态,唯一能用来问服务商的就是这个 ID。若用
`job_id`,本服务就得存一份 `job_id → 厂商 ID` 的映射,「无状态」不再成立,
且会多出一个「Java 有 ID 但查不到任何东西」的中间态。
注意火山**只保留最近 7 天**的任务记录,更早的任务会以 `provider` 类错误
返回。
parameters:
- name: provider_request_id
in: path
required: true
schema:
type: string
example: cgt-20260920-abc123
responses:
'200':
description: |
任务当前状态。**任务失败也是 200** —— HTTP 调用成功了,失败的是任务。
content:
application/json:
schema:
$ref: '#/components/schemas/TaskResponse'
'401':
$ref: '#/components/responses/ServiceError'
'404':
$ref: '#/components/responses/ServiceError'
'502':
$ref: '#/components/responses/ProviderError'
'503':
$ref: '#/components/responses/ServiceError'
components:
securitySchemes:
serviceJwt:
type: http
scheme: bearer
bearerFormat: JWT
description: |
由 `patbond-creation` 用**独立于用户 token 的**密钥对签发。claim 约定:
`iss=patbond-api`、`aud=patbond-ai`、`sub=service:creation-worker`、
`typ=service`、短 `exp`(5 分钟)、带 `jti`。
responses:
ServiceError:
description: Java↔Python 之间的问题
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceErrorEnvelope'
ProviderError:
description: Python↔服务商之间的问题,携带完整分类
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderErrorEnvelope'
schemas:
SubmitRequest:
type: object
required: [job_id, media_kind, model]
description: |
**刻意不含 `user_id`**(数据最小化,ADR-023 §5):本服务干活只需要任务参数。
需要给服务商做滥用检测时传的是 `safety_identifier`——一个哈希,不是明文 ID。
properties:
job_id:
type: string
format: uuid
description: |
我方任务 ID。**本服务不用它查任何东西**,仅用于日志关联,使一条报错能
对应回 `creation.generation_jobs` 的行。
media_kind:
type: string
enum: [image, video]
description: 决定路由到哪个适配器。
model:
type: string
description: |
服务商侧的模型标识,取自任务行冻结的 `provider_model_snapshot`。
**本服务不自行选模型**:选型是目录的职责,且任务行里的快照必须与实际
调用的模型一致,否则事后无法对账。
example: doubao-seedance-2-5-260628
prompt:
type: [string, 'null']
maxLength: 10000
description: 与 `input_url` **至少要有一个**,都为空则本地即拒。
input_url:
type: [string, 'null']
description: |
参考图/首帧的**公网可达** URL。给了即图生视频,不给即文生视频
(参考图选填,决策 A2)。
ratio:
type: [string, 'null']
description: |
缺省 `adaptive`。**会按模型限制本地预校验**:Seedance 2.5 在图生视频时
只支持 `adaptive`,指定具体比例会被本服务直接拒(省掉一次往返)。
example: adaptive
resolution:
type: [string, 'null']
description: |
缺省 `720p`。按模型预校验:2.5 无 4k,2.0 系列有。
example: 720p
duration_sec:
type: [integer, 'null']
description: |
缺省 5。按模型预校验:2.5 为 `[4,30]`,2.0 系列为 `[4,15]`;
`-1` 表示由模型自选,不参与范围校验。
generate_audio:
type: boolean
default: true
watermark:
type: boolean
default: false
safety_identifier:
type: [string, 'null']
maxLength: 64
description: |
终端用户标识,供服务商做滥用检测。**必须是哈希值,不可传明文用户 ID**。
callback_url:
type: [string, 'null']
description: |
预留,当前不启用(ADR-023 §4:轮询为保证,回调为优化)。留此字段使
将来启用无需改契约。
upload_target:
type: [string, 'null']
description: |
产物的预签名 PUT 地址。**当前不启用**——转存阻塞于对象存储决策
(ADR-023 §7)。启用后本服务会把产物直传该地址,从而无需持有任何
对象存储凭证。
SubmitResponse:
type: object
required: [provider_request_id]
properties:
provider_request_id:
type: string
description: 服务商任务 ID。调用方须在上游计费前落库。
example: cgt-20260920-abc123
TaskResponse:
type: object
required: [provider_request_id, state, progress]
properties:
provider_request_id:
type: string
state:
type: string
enum: [pending, running, succeeded, failed, cancelled]
description: |
**刻意比服务商的状态集更小。** 火山有 queued/running/cancelled/
succeeded/failed/expired 六个:`queued` 映射为 `pending`,`expired`
归入 `failed`(它是超时,属失败的一种,重试策略由 `failure` 表达,
不需要独立状态)。
progress:
type: integer
minimum: 0
maximum: 100
description: |
火山不返回百分比,故成功前恒 0、成功即 100。**这不是缺陷**:客户端
契约本就不承诺平滑进度(ADR-023 §10),一个诚实的 0 不该被读成「卡住」。
asset_url:
type: [string, 'null']
description: |
产物 URL。**可能是有时效的服务商地址**——火山为 24 小时。调用方必须在
时效内转存,**不可长期保存此值**(存进 `media.assets.external_url`
会在 24 小时后变死链)。
asset_url_expires_in_sec:
type: [integer, 'null']
description: |
`asset_url` 的剩余有效期。与 URL 一起给出,使调用方没有理由把它当
永久地址。火山为 86400。
cover_url:
type: [string, 'null']
description: |
封面图 URL。火山的尾帧图(png、尺寸与视频一致、无水印)可免费充当此
用途,省掉抽帧。
ratio:
type: [string, 'null']
resolution:
type: [string, 'null']
width_px:
type: [integer, 'null']
description: |
由 (模型族, resolution, ratio) 查静态对照表得到,**不需要下载产物或
解码**。`ratio` 为 `adaptive` 或模型族未知时为 `null`——留空而不猜,
一个错的尺寸会让客户端按错误比例渲染且看不出是错的。
height_px:
type: [integer, 'null']
duration_ms:
type: [integer, 'null']
description: |
有 `frames`+`fps` 时按帧精确换算;只有服务商的整数秒时退回它,而那是
「帧数/24 向下取整」的**约数**(最多可能短 1 秒)。两者都缺时为 null,
不编 0。
output_format:
type: [string, 'null']
example: mp4
billing_tokens:
type: [integer, 'null']
description: 计费对账依据(火山为 `usage.completion_tokens`)。
failure:
oneOf:
- $ref: '#/components/schemas/Failure'
- type: 'null'
description: |
仅 `state = failed` 时非空。**`failed` 必然携带它**——否则调用方无法
决定重试策略,只能盲目重试或盲目放弃,两者都错。
Failure:
type: object
required: [retry, user_facing, provider_code]
properties:
retry:
type: string
enum: [never, backoff, quota]
description: |
**Worker 据此决定重试,不必解读服务商码。**
`never`:同样输入必然同样结果(参数错、内容被拒、鉴权失败)。
`backoff`:限流、突发流量保护、服务过载,退避几秒到几十秒。
`quota`:额度耗尽,要等到下一个计费周期或充值——不该把重试预算花在
这上面,应直接终态失败并让用户看到出口。
user_facing:
type: string
enum:
[quota_exhausted, rate_limited, content_rejected, server_error, upstream_unavailable]
description: |
映射到客户端呈现。`quota_exhausted` 与 `rate_limited` **必须分开**:
前者等的是明天或充值(弹 sheet 给出口),后者等的是几秒(弹 SnackBar
可自动重试)。合成一类必然有一半场景的文案和出口是错的(ADR-023 §9)。
provider_code:
type: string
description: |
原始服务商错误码,**原样保留、绝不丢弃**——排查时往往是唯一线索。
上层不应据此分支判断(那是适配层的职责),只记录与展示给开发者。
example: OutputVideoSensitiveContentDetected
message:
type: string
description: 服务商原始错误描述,仅供排查与开发者可见的日志。
ServiceErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [kind, code, message]
properties:
kind:
type: string
const: service
code:
type: string
enum:
- unauthenticated # 服务票缺失/过期/aud 或 typ 不符
- invalid_request # 请求体不合契约,或参数超出模型限制
- task_not_found # 路径里的服务商任务 ID 不存在
- provider_unavailable # 适配器未装配(如缺凭证)或 provider_code 未知
- internal_error # 本服务 bug
message:
type: string
ProviderErrorEnvelope:
type: object
required: [error]
description: |
服务商调用失败。**与任务失败(200 + state=failed)是两回事**:这里是调用
没成功,那里是调用成功但任务最终失败。
properties:
error:
type: object
required: [kind, retry, user_facing, provider_code, message]
description: |
字段与 `Failure` 一致,故 Java 侧可复用同一段分类处置逻辑,无需为
「提交时被限流」和「任务失败于限流」写两套分支。
properties:
kind:
type: string
const: provider
retry:
type: string
enum: [never, backoff, quota]
user_facing:
type: string
enum:
[quota_exhausted, rate_limited, content_rejected, server_error, upstream_unavailable]
provider_code:
type: string
message:
type: string