按用户拍板的四点定型 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>
patbond-ai
Patbond 的 AI 生成服务(Python)。负责调用 AI 服务商 API 完成图片与视频生成, 是四仓中唯一直接持有 AI 服务商凭证的仓库。
架构与边界的完整决策见 patbond-doc docs/architecture/decisions.md ADR-023。
以下为要点,改动前请以 ADR 原文为准。
职责边界
- 只负责调用 AI 服务商 API。 不访问数据库——生成任务状态的唯一事实来源是
Java 侧的
creation.generation_jobs。 - 不持有对象存储凭证。 产物由 Java 下发预签名 PUT 地址,本服务直传后回报,
再由 Java 核实并建
media.assets行(即客户端两步上传,本服务站在客户端位置)。 - 对上游同步或异步一律对外呈现为异步:
submit立即返回任务 id,同步型 服务商(长连接等待返回)在本服务内部以后台任务承载。这样 Java 侧无长连接占线程, 且provider_request_id立即可得,超时不再产生「是否已扣费」的歧义。
与 Java 侧的接口
- 传输为 REST(JSON),非 gRPC(理由见 ADR-023 §4:避免引入第二套无门禁契约体系)。
- 契约为本仓内独立的
openapi-internal.yaml,独立版本号, 不进 patbond-api 的四模块快照锁,也不写入面向客户端的openapi.yaml。 - 鉴权为 服务身份 JWT(独立密钥对,非用户 token 转发):本服务强制校验
aud=patbond-ai与typ=service。 - Java 主动轮询本服务查询进度;回调是将来的可选优化,当前不启用。
- 请求体不含
userId(数据最小化):完成工作只需jobId、生成参数与预签名上传地址。
代码结构
分四层,依赖单向向内(config → providers → domain),由
tests/test_layering.py 自动守着——靠纪律守不住的东西,靠测试才守得住。
app/
├── domain/generation.py 与服务商无关的领域模型
├── providers/
│ ├── base.py 生成能力抽象(新增供应商 = 实现它)
│ ├── registry.py provider_code → 适配器
│ └── volcengine/
│ ├── client.py HTTP 传输与鉴权(不认识「视频」)
│ ├── errors.py 火山错误码 → 领域 Failure
│ ├── models.py 厂商请求/响应 DTO
│ └── video.py Seedance 视频业务
├── config.py 集中配置(全部旋钮从环境变量读)
└── assembly.py 装配根(唯一同时知道配置与具体供应商的地方)
为什么这么切:
domain/对厂商与 httpx 零依赖。 这是「换供应商不动上层」的支点。判断一个 概念该放哪:问「换一家供应商,它还在吗」——Retry/UserFacing描述的是 Worker 与客户端该怎么做,与谁家无关,故在领域层;expired状态、QuotaExceeded这类码是火山特有的,故在适配层。client.py与video.py分开。 client 只管鉴权、超时、错误解包,不认识 「视频」。以后接火山的图片或语音接口时新建兄弟模块复用同一个 client, 而不是再抄一遍鉴权与错误解包——这是拆它出来的全部理由。- 适配层不读配置,全部通过构造参数注入。否则单测得摆弄环境变量,且同一进程 内没法跑两套不同配置的实例。
新增一家供应商 / 一种能力
- 实现
app/providers/base.py的GenerationProvider; - 在
app/assembly.py的build_registry()里登记一行; - 目录表的
provider_code与适配器的provider_code必须同值——Java 侧按 任务行冻结的provider_code_snapshot查适配器(不按当前目录,因为目录 可能被改指向,而在途任务必须仍由当初提交它的适配器轮询)。
上层与领域层一行都不用改。
已接入的服务商
| 服务商 | 能力 | provider_code | 状态 |
|---|---|---|---|
| 火山方舟 Seedance | 视频生成 | volcengine-seedance |
提交与查询已实现,产物转存待存储决策 |
接口事实与错误码映射的完整取证见 patbond-doc
docs/development/providers/volcengine-seedance.md。其中两条尤其要记住:
- 火山不支持幂等键,「提交超时后不知道有没有扣费」只能由
max_attempts兜住; - 产物 URL 只有 24 小时有效期,因此厂商 URL 不能当存储用,必须在时限内转存。
配置
全部旋钮见 app/config.py,常用项:
| 环境变量 | 默认值 | 说明 |
|---|---|---|
ARK_API_KEY |
(无) | 火山方舟密钥。缺失时该适配器不装配,服务仍可启动 |
ARK_BASE_URL |
北京区官方地址 | |
ARK_TIMEOUT_SEC |
10 |
单次 HTTP 往返超时,不是生成耗时 |
ARK_VIDEO_MODEL |
(无) | 仅作兜底;正常由 Java 侧传入任务行的模型快照 |
ARK_TASK_EXPIRY_SEC |
3600 |
厂商 execution_expires_after,区间 [3600, 259200]。默认取下限——厂商默认 48 小时对交互式应用过长 |
PATBOND_SERVICE_JWT_PUBLIC_KEY |
(无) | 校验 Java 侧服务票的 RS256 公钥 |
PATBOND_SERVICE_JWT_AUDIENCE |
patbond-ai |
必须强制校验,否则独立密钥对失去意义 |
非法值响亮失败而不静默回落:一次配置笔误变成难查的行为偏差是最糟的结果。
本地门禁
与 CI 同一套命令(.gitea/workflows/ci.yml):
sh scripts/check-secrets.sh --all # 凭证防泄漏全量自查
ruff format --check .
ruff check .
pytest -q
每人每仓启用一次 pre-commit 防泄漏钩子:
git config core.hooksPath scripts/hooks
scripts/check-secrets.sh 是四仓同构副本,调整规则时四仓同步提交。
它覆盖 AI 服务商密钥形态(sk-ant-、sk-/sk-proj-/sk-svcacct-)与
api_key/auth_token 实值赋值——本仓正是这些规则的主要适用对象。
凭证纪律
真实密钥只允许存在于被 gitignore 的文件中(.env),占位样例进 .env.sample。
拦下真实凭证时的第一动作是去服务商控制台轮换/禁用该密钥,然后才是清理提交。
分支与发布
dev 直推 + CI 绿,main 经 PR 合并。本仓纳入四仓 tag 同步与发布回归
(ADR-023 §12)。发布流程见 patbond-doc docs/development/releases.md。