lixi 86d63ab1cb
CI / ai-gates (push) Failing after 31s
docs: 补正 4762358 的提交记录——该提交实际包含 AI 服务分层重构
**这是一条记录补正,不含代码改动。**

4762358 的提交信息只写了「凭证扫描放行依赖注入传参 + 四仓口径更正」,但它实际
包含了 patbond-ai 的整个分层重构(22 个文件、约 1900 行增、约 700 行删)。

成因:前一次提交尝试被 pre-commit 钩子拦下(它正确地拦了 assembly.py 里
`api_key=volcengine.api_key` 这个形态),但 `git add -A` 已经把重构文件留在
暂存区;随后修规则的那次提交只 `git add` 了脚本,却把暂存区里既有的内容一并
提交了。提交前未核对 `git status` 的暂存清单——正是 git-workflow.md「禁止事项」
第一条要求逐一核对的那一步。

不用 amend 重写历史:dev 是共享分支,git-workflow.md 明确禁止对其 force push。
故以本条补正记录说明 4762358 的真实内容,其完整说明如下。

---

**refactor(ai): 按高内聚低耦合分层重构 + 集中配置(100 测试)**

原先 seedance.py 一个文件里混了 HTTP 传输、鉴权、厂商 DTO、状态映射与视频业务,
接第二个火山接口(图片/语音)时只能整段再抄一遍。按依赖单向向内重切四层:

    config.py → assembly.py → providers/ → domain/

- **domain/generation.py**:与服务商无关的领域模型,对厂商与 httpx **零依赖**
  ——「换供应商不动上层」的支点。判断归属的准绳是「换一家供应商它还在吗」:
  Retry/UserFacing 描述 Worker 与客户端该怎么做,故在领域层;expired 状态、
  QuotaExceeded 这类码是火山特有的,故在适配层。GenerationResult 拒绝
  「FAILED 但无 Failure」与「SUCCEEDED 但无 asset_url」——半个失败态放给上层
  只会让它盲目重试或盲目放弃。
- **providers/base.py + registry.py**:能力抽象与 provider_code 分发,对具体
  供应商零依赖。registry 拒绝重复注册(静默覆盖会让「哪个实例在处理任务」
  取决于注册顺序)。
- **providers/volcengine/** 拆四块:client.py(HTTP 传输与鉴权,**不认识
  「视频」**,以后接火山图片/语音接口复用它——这是拆它的全部理由;并修正连接
  归属:只关自己创建的连接池)、errors.py(火山码 → 领域 Failure,补 message
  透传)、models.py(厂商 DTO 与常量,base URL 从 config 收拢至此)、
  video.py(仅视频业务)。
- **config.py**:集中配置。非法值响亮失败而不静默回落;厂商区间
  [3600,259200] 启动时校验;密钥绝无默认值。另加 .env.sample。
- **assembly.py**:装配根,唯一同时知道配置与具体供应商的地方。缺凭证的供应商
  被跳过而非让启动失败。
- **tests/test_layering.py**:用 AST 断言架构约束本身。分层的价值全在「换供应商
  不动上层」,而这个性质只要有人无意加一行 import 就会悄悄失效——靠纪律守不住,
  靠测试才守得住。写完它当即抓到一处真问题:厂商 base URL 硬编码在 config.py。

测试 58 → 100,全部离线。另两处新行为:succeeded 但缺 video_url 判为可重试失败;
asset_url 随 URL 给出 24 小时时效,使上层没有理由把厂商地址当永久存储。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:19:50 +08:00

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, 而不是再抄一遍鉴权与错误解包——这是拆它出来的全部理由。
  • 适配层不读配置,全部通过构造参数注入。否则单测得摆弄环境变量,且同一进程 内没法跑两套不同配置的实例。

新增一家供应商 / 一种能力

  1. 实现 app/providers/base.py 的 GenerationProvider;
  2. 在 app/assembly.py 的 build_registry() 里登记一行;
  3. 目录表的 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。

S
Description
No description provided
Readme 294 KiB