Commit Graph

3 Commits

Author SHA1 Message Date
lixi a8ffd02175 feat(creation): Java 侧接通 patbond-ai——Feign 客户端、两类错误分流、三值重试(T4-03)
CI / backend-test (push) Successful in 6m22s
按 openapi-internal.yaml 把 AiServiceGenerationProvider 从空实现位做成真实现。
全量 410 → 427,零失败零跳过。

**骨架阶段的三处假设被现实推翻,一并纠正:**

1. **去掉 submit() 的 idempotencyKey 参数。** 当时按「透传给厂商幂等头」设计,
   但火山**没有任何幂等字段**(取证见 providers/volcengine-seedance.md §2-①)。
   留一个无处可去的参数会让人以为双重扣费已经解决——它没有,只由 max_attempts
   兜住,恢复路径是已落库的 provider_request_id 而不是某个 key。
2. **Worker 不再在 submit 前调 uploadTargets.presignedPutFor。** 那个方法目前
   抛异常,而它在 submit **之前**被调用——**所以在此之前整条链一步都跑不动**。
   契约把 upload_target 标为可选未启用,故传 null 才是正确而非权宜。
3. **PollResult 扩容 + retryable 由布尔改三值 Retry。** 布尔表达不了最关键的
   区别:限流几秒后恢复,配额耗尽要等到明天。把后者当可重试会白烧完整个重试
   预算再失败,而用户看到的是转圈而不是「额度用完」的出口(ADR-023 §9)。
   同时补齐 assetUrl/expiresIn/coverUrl/尺寸/时长/outputFormat/billingTokens
   ——这些最终都要写进 media.assets,现在丢掉后面就得再改一遍。

**两类错误落在两个异常上**(AiProviderException / AiServiceException),判别用
error.kind 而非 HTTP 状态码(状态码在两类之间会撞)。若这个分流塌成一个笼统的
FeignException,Worker 就只能猜要不要重试。未知的 retry/user_facing/state 取值
一律偏向「可重试 / 仍在运行」:契约可能先长出一个取值、代码后知道,把可恢复的
抖动判成用户可见的失败是更糟的错误,而重试预算本身有上限。

**snake_case 只作用于这一个客户端**:用 SpringEncoder/SpringDecoder 换 ObjectMapper,
不引 feign-jackson(其他模块都不需要它),也不用逐字段 @JsonProperty——后者漏一个
注解就会静默发出对端忽略的字段,而「静默被忽略的字段」是最难察觉的失败。
另配 followRedirects=false:内部服务没有理由重定向我们,跟随会把服务票发给
重定向指定的任意主机。

**新增两个 429 码**(ADR-023 §9):42900 配额耗尽 / 42901 频率过快。必须是两个
——配额等的是次日或充值(弹 sheet 给出口),频率等的是数秒(弹 SnackBar 可自动
重试),共用一码必然使一半场景的文案与出口是错的。

写进任务行的 error_code 用适配层的 userFacing 类别而非厂商原始码:厂商词汇不该
渗进我们的数据,它会随换供应商而变,客户端会据此分支。原始码留在 error_message
里供排查。

safety_identifier 传 userId 的 SHA-256 hex 而非明文(ADR-023 §5 数据最小化),
测试断言明文 userId 不出现在请求体里。

**新增 AiGenerationClientWireTest(17 项)**,照 community 的
AuthorProfileClientWireTest 先例用 JDK 内置 HttpServer 起桩:snake_case 双向、
服务票每次现签(断言两次调用的票不同——缓存复用会让短期凭证悄悄变成长期凭证)、
两类错误各自的分流、配额与限流的 retry 不同、无法识别的错误体按可重试并说明原因。

**一处真实缺陷由全量跑抓出**:@FeignClient 的 url 占位符原先无默认值,而 Feign 在
**上下文启动时**解析它——导致任何没显式配置该属性的上下文都启动失败,包括与 AI
毫无关系的两个仓储测试类(17 个测试全红)。已给占位符加上与 CreationProperties
一致的默认值。单跑某个测试类时不会暴露这个问题,只有全量跑才会。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:43:28 +08:00
lixi 91ae016470 feat(db): V7 目录种子——两个已开通的 Seedance 视频模型(T4-01b)
CI / backend-test (push) Successful in 8m45s
按账号实际开通的模型播种:doubao-seedance-2-5-260628 与
doubao-seedance-2-0-260128,provider_code 为 volcengine-seedance。

与目标模型 patbond_postgresql.sql:1495 的种子**有意不同**:正典那份是服务商
未定时写下的 provider_code='fixture' 占位(这也是开工审计据以判定「AI provider
BLOCKED」的证据之一),现已接入真实服务商。

三处刻意不做的事,文件头逐条写明原因:
- **不播种图片模型**:gpt-image-2 适配层未落地,播种一个没有适配器的模型会让
  客户端渲染出一个点了没反应的入口(目录端点按 media_kind 聚合可用模型)。
- **不播种风格**:风格是产品内容决策(提供哪些艺术风格、叫什么、配图是什么),
  无法从服务商文档推导;正典那 4 条均为 image 类且 preview_asset_id 指向 demo
  资产,而 V1..V7 链路不含该 demo 种子,照抄会违反外键。style_id 可空,不阻塞。
- **不在目录表加能力列**:正典的 generation_models 没有能力列,本迭代不发明
  超出已评审模型的列。两个模型的限制差异(2.5 时长到 30 秒但无 4k;2.0 有 4k
  但时长上限 15 秒;2.5 图生视频时 ratio 锁 adaptive)由 patbond-ai 的
  capabilities.py 承载并在提交前本地预校验。

新增 CreationCatalogSeedTest(6 项),钉住的是几个**跨语言、跨仓库约定**——
它们对不上时症状都离根因很远:
- provider_code 必须与 patbond-ai 适配器的 PROVIDER_CODE 逐字一致。对不上时
  Worker 按任务行冻结的快照查不到适配器,任务永远停在排队,看起来像队列坏了。
- provider_model_name 必须是实际开通的 ID,否则撞 InvalidEndpointOrModel.NotFound
  且不可重试。
- 目录里不得出现没有适配器的 media_kind。
- 模型 ID 尾部须为日期版本——CatalogRepository 以 provider_model_name 兼作版本
  快照,该做法只在 ID 自带版本时成立。

全量 404 → 410,零失败零跳过。另修测试里一处 Java 21 API(List.getFirst()):
项目 release 为 17,本地 JDK 26 不代表能用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:13:57 +08:00
lixi 4b2550f88c feat(creation): 新增 patbond-creation 模块骨架——端点、租约队列 Worker、provider 适配层(T4-02)
CI / backend-test (push) Successful in 5m43s
M4 新模块,:8085,前缀 /api/v1/creation/**(ADR-023 §2,E3 拍板方案 A)。
它编排生成任务但不调用 AI 服务商——上游调用在独立的 patbond-ai(Python)
仓库,经 REST 到达。

落地内容:
- 客户端端点 4 个(创建/查询/列表/取消)+ 目录端点。创建强制 Idempotency-Key
  (ADR-019:生成在上游要花钱,重试绝不能变成第二个计费任务)
- 租约队列仓储:FOR UPDATE SKIP LOCKED 领取 + 租约续期 + 回收器。目标模型
  只给了索引没给回收器代码,这里补上
- Worker 三条独立调度通道(提交/轮询/回收),任一卡住不饿死其余两条。
  轮询与续租在同一语句内完成——分开做会让长任务在跑时租约过期被抢,
  那正是重复扣费的经典路径
- provider 适配层接口按「两阶段 submit/poll」定形 + 两个实现位
- 服务身份 JWT 签发器(独立密钥对)
- common 增 3 个错误码(40407/40408/42206);配额与限流的两个 429 码随
  配额工单落地,此处不加死枚举

关键实现纪律(注释中已逐条说明原因):
- provider_request_id 在上游产生任何计费动作之前落库。顺序反过来,每次
  超时都会变成重复扣费
- 租约回收时刻意不清 provider_request_id:保留它,下次尝试才知道「已提交过,
  该轮询而非重发」
- provider 按 provider_code_snapshot 而非当前目录解析,故目录改指向后
  在途任务仍由原适配器轮询
- Worker 各 pass 捕获异常:Spring 会取消抛异常的 fixedDelay 任务,
  不捕获等于一次偶发失败静默停掉整个队列

刻意未实现且会响亮失败(不装作能用):provider 两个实现位(待服务商文档
确认幂等键支持、查询接口与频率限制、单任务最长时长、产物返回形式、
限流与配额响应形态五项)、预签名上传目标获取、产物入 media.assets
(后两者取决于挂起中的对象存储决策,ADR-023 §7)。

共享物按 ADR-023 §2 复制 3 件(JWT 校验栈、UuidV7、RequestHashes);
**存储适配层不复制**——presignPut 已存在,产物走预签名上传由 patbond-user
签发,故无需第 4 份存储层,也使 patbond-ai 零对象存储凭证。

测试:新增 GenerationJobQueueIntegrationTest 11 项,全部针对「写错了也
能跑、只在生产表现为丢任务或重复扣费」的语义:SKIP LOCKED 不把同一行
交给两个 worker、轮询确实续了它依赖的租约、回收器保留 provider_request_id、
first_attempt_at 跨重排队存活、write-once 保护、取消被状态守卫。
全量 393 → 404,零失败零跳过。

过程中被测试抓出两个真问题:UPDATE...FROM 的 RETURNING 列表未限定别名
导致 id 歧义(列表已改为单一来源派生两种形式,避免漂移);以及夹具伪造
了物理上不可能的历史(租约过期但 started_at 是当下),被
ck_generation_jobs_lease 正确拒绝。

Readme.md 全文改中文并补齐 M2 之后的内容:原文停在 M2(pet 骨架阶段)、
完全没有 patbond-community、配置表缺 M2 之后全部条目。现覆盖六模块、
迁移链归属说明、两对密钥的区别与原因、以及各服务端点。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:36:52 +08:00