lixi a8ffd02175
CI / backend-test (push) Successful in 6m22s
feat(creation): Java 侧接通 patbond-ai——Feign 客户端、两类错误分流、三值重试(T4-03)
按 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
2026-09-03 17:59:56 +08:00
2026-09-03 17:59:56 +08:00
2026-09-03 17:59:56 +08:00

Patbond API

Patbond 后端,Spring Boot 多模块。

模块

模块 端口 职责
patbond-common —— 跨模块稳定契约(响应信封、错误码、共享 DTO)。刻意不引入 web/消息依赖,保持纯契约。
patbond-auth 8081 注册与登录,签发 RS256 access token。经 OpenFeign 静态直连调用 patbond-user(MVP 不做服务发现,ADR-002)。私钥只在本服务。
patbond-user 8082 用户与会话、当前用户资料、媒体上传流程(ADR-016/017)、埋点接收端。独占 Flyway 迁移链。
patbond-pet 8083 宠物档案与健康记录(M2,ADR-009)。与 patbond-user 共库,只读写 pet_health schema。
patbond-community 8084 社区 Feed、帖子、评论、点赞收藏与关注(M3,ADR-017)。只读写 community schema,另按 ADR-017 例外只读 media.assets。
patbond-creation 8085 AI 生成任务编排(M4,ADR-023)。见下节。

patbond-creation(M4 新增)

承载 AI 图片与视频生成的任务编排。它编排任务,但不调用 AI 服务商——上游调用在独立的 patbond-ai(Python)仓库,经 REST 到达。

本模块拥有:creation schema 的读写、/api/v1/creation/** 客户端端点、以及租约队列 Worker。

本模块刻意不拥有:

  • AI 服务商调用 —— 在 patbond-ai。Java 侧持有队列与任务状态,Python 侧只负责调厂商 API,两者经 REST(不用 gRPC)通信,鉴权用独立密钥对签发的服务身份 JWT(不转发用户 token)。
  • 对象存储 —— 本服务从不直连。产物上传凭证来自 patbond-user 的内部媒体接口(预签名 PUT),由生成方直传,ADR-017 的边界不动。

队列为什么在数据库里而不是消息中间件(ADR-023 §3):已评审的目标数据模型本就把整套租约机制建成了列(lease_owner/lease_expires_at/next_attempt_at/attempt_count/max_attempts/priority),外加两条列序与出队语句逐列对齐的偏索引。因此领取是索引扫描而非排序,任务状态只有一个事实来源、无双写;引入中间件反而要让这批列作废。「Worker 重启不丢任务」由租约回收器天然满足。

Worker 的三条独立调度通道(任一卡住不会饿死其余两条):领取待办 QUEUED 任务并提交上游 → 轮询本实例持有的 RUNNING 任务并在同一语句内续租 → 回收租约过期的任务重新入队。

两处值得留意的实现纪律:

  • provider_request_id 在上游产生任何计费动作之前落库。顺序反过来,每次超时都会变成重复扣费。
  • 租约回收时不清 provider_request_id。保留它,下次尝试才知道「这活已经提交过了,该去轮询而不是重发」。

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

技术栈

  • Java 17(构建基线;发布构建请用 JDK 17)
  • Spring Boot 3.5.16
  • Spring Cloud 2025.0.3(只用 OpenFeign)
  • PostgreSQL 18 + Flyway
  • Maven(用仓库内的 wrapper ./mvnw)

Flyway 迁移链由 patbond-user 独占,V1..V6 覆盖 identity/media/platform(V1)、pet_health(V3/V4)、community(V5)、creation(V6)。注意「谁拥有 schema」与「迁移文件放哪」在本项目是分开的:pet、community、creation 的业务代码各在自己的模块,迁移文件全部在 patbond-user,flyway_schema_history 只有一份、不拆。

构建与测试

./mvnw clean test

集成测试经 Testcontainers 拉起一次性的 postgres:18,因此需要一个在跑的 Docker daemon(不需要本机装 PostgreSQL、也不需要本机库凭证)。

默认 JDK 不是 17 时,先指向一个 JDK 17:

JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test

本机运行

patbond-user 启动时 Flyway 会自动应用 patbond-user/src/main/resources/db/migration 下的版本化迁移,因此它需要一个可达的 PostgreSQL 18。开发种子数据(db/dev)通过 dev profile 可选启用,见 application.yml.sample。

每个服务都提交了 application.yml.sample,真实的 application.yml 被 gitignore。先复制样例(默认值本机可用,实际值用环境变量覆盖):

for m in user auth pet community creation; do
  cp patbond-$m/src/main/resources/application.yml.sample \
     patbond-$m/src/main/resources/application.yml
done

先装一次共享模块,然后各服务各开一个终端:

./mvnw -pl patbond-common install
./mvnw -pl patbond-user spring-boot:run
./mvnw -pl patbond-auth spring-boot:run
./mvnw -pl patbond-pet spring-boot:run
./mvnw -pl patbond-community spring-boot:run
./mvnw -pl patbond-creation spring-boot:run

密钥对

运行需要一对 RS256 密钥用于 access token(patbond-auth 用私钥签,其余服务用公钥验;两者都不入库):

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem
openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
export PATBOND_JWT_PRIVATE_KEY=$PWD/jwt-private.pem   # 只给 patbond-auth
export PATBOND_JWT_PUBLIC_KEY=$PWD/jwt-public.pem     # 其余服务验签用

patbond-creation 调用 patbond-ai 需要另一对独立的密钥,不要复用上面那对:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out service-jwt-private.pem
openssl pkey -in service-jwt-private.pem -pubout -out service-jwt-public.pem
export PATBOND_SERVICE_JWT_PRIVATE_KEY=$PWD/service-jwt-private.pem  # patbond-creation
# service-jwt-public.pem 交给 patbond-ai 验签

为什么必须独立(ADR-023 §5):现有 JwtVerifier 只验签名,既不校验 aud 也不校验 iss,而 patbond-auth 签发的用户 token 不含 aud claim。若服务票复用同一密钥对,user/pet/community/creation 四个模块都会把它当合法用户 token 接受——sub 不是 UUID 大概会在后续解析时炸掉,但那是意外挡住的,不是设计挡住的。用独立密钥对则无需改动任何已发布的验证器。

测试不需要上述复制与密钥步骤:patbond-auth/src/test/resources/application.yml 让 ./mvnw clean test 在干净 checkout 上自给自足。

冒烟:

curl -X POST http://127.0.0.1:8081/api/v1/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"demo_user","password":"secret123"}'

配置

环境变量 默认值 使用方
PATBOND_AUTH_PORT 8081 patbond-auth
PATBOND_USER_PORT 8082 patbond-user
PATBOND_PET_PORT 8083 patbond-pet
PATBOND_COMMUNITY_PORT 8084 patbond-community
PATBOND_CREATION_PORT 8085 patbond-creation
PATBOND_DB_URL jdbc:postgresql://127.0.0.1:5432/patbond 全部数据库使用方
PATBOND_DB_USER patbond 同上
PATBOND_DB_PASSWORD patbond 同上
PATBOND_USER_SERVICE_URL http://127.0.0.1:8082 auth / community / creation(Feign 指向 patbond-user)
PATBOND_INTERNAL_TOKEN dev-only-internal-token 全部(/internal/** 服务间共享密钥;本机开发之外必须注入强随机值)
PATBOND_JWT_PRIVATE_KEY (无,必填) patbond-auth(RS256 私钥:PEM 路径或内联 PEM)
PATBOND_JWT_PUBLIC_KEY (无) user / pet / community / creation(RS256 公钥)
PATBOND_ACCESS_TTL 15m patbond-auth(access token 有效期,ADR-003)
PATBOND_REFRESH_TTL 30d patbond-user(refresh token 有效期,ADR-003)
PATBOND_LOGIN_LOCK_MAX_FAILURES 5 patbond-user(锁定前允许的失败次数)
PATBOND_LOGIN_LOCK_WINDOW 15m patbond-user(失败计数窗口)
PATBOND_LOGIN_LOCK_DURATION 15m patbond-user(锁定时长)
PATBOND_MINIO_ENDPOINT http://minio:9000 patbond-user(服务自身访问对象存储的地址)
PATBOND_MINIO_PUBLIC_ENDPOINT http://127.0.0.1:9000 user / community(客户端可达地址,预签名 URL 的 host 由它决定;容器内外不同)
PATBOND_MINIO_ACCESS_KEY / _SECRET_KEY (无) user / community
PATBOND_MINIO_BUCKET patbond-media user / community
PATBOND_AI_SERVICE_URL http://127.0.0.1:8000 patbond-creation(指向 patbond-ai)
PATBOND_SERVICE_JWT_PRIVATE_KEY (无) patbond-creation(独立于用户 token 的 RS256 私钥)
PATBOND_CREATION_WORKER_ENABLED true patbond-creation(关掉则只提供端点、不消费队列)
PATBOND_CREATION_LEASE_TTL 60s patbond-creation(必须大于最长轮询间隔,否则任务在跑租约就过期被抢)

各服务的 application.yml.sample 是环境变量的完整清单与说明,本表只列常用项。机器相关的实际值放在被 gitignore 的 application.yml 里;样例文件里永远不要提交真实密钥。

Docker Compose

编排(ADR-007/016):postgres:18 + 自托管 minio + 无状态应用容器。配置与本机运行同一套约定 —— 容器内挂载 application.yml.sample 作为配置,PATBOND_* 环境变量注入实际值。

# 1. 生成 RS256 密钥对与 .env(DB 口令、内部令牌、MinIO 凭证;产物被 gitignore)
./deploy/init-secrets.sh

# 2. 构建可执行 jar
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package

# 3. 启动(首次会构建镜像)
docker compose up -d --build

# 冒烟
curl -s -X POST http://127.0.0.1:8081/api/v1/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"demo_user","password":"secret123"}'

# 停止(-v 同时删除数据库与对象存储数据)
docker compose down

patbond-creation 尚未加入 compose——它的 Worker 依赖 patbond-ai 与对象存储方案,两者都还没落地。

注意:数据库端口不对宿主机发布;8082(user)在 MVP 阶段直连暴露以提供 /api/v1/me,/internal/** 由服务间令牌保护,规模化阶段应改由网关统一入口。

服务端点

Auth(8081)

  • 注册:POST /api/v1/auth/register
  • 登录:POST /api/v1/auth/login
  • 刷新(轮换 refresh token):POST /api/v1/auth/refresh
  • 登出(吊销当前会话):POST /api/v1/auth/logout

User(8082)

  • 当前用户资料:GET /api/v1/me、PATCH /api/v1/me
  • 社区统计:GET /api/v1/me/community-stats
  • 媒体上传:创建上传凭据与确认(ADR-016:私有桶 + 预签名)
  • 埋点接收:POST /api/v1/events(批量 1–50、逐条结果、eventId 幂等、白名单剥离)
  • 内部接口(需 X-Internal-Token):POST /internal/users、POST /internal/users/verify-password、GET /internal/users/{id}、GET /internal/users/by-username/{username}、GET /internal/users/profiles、POST /internal/sessions{,/refresh,/revoke}

Pet(8083)

宠物档案、体重与疫苗记录、照护提醒;权限按 ADR-015 三档模型(owner / caregiver / viewer)。

Community(8084)

Feed 游标分页、帖子草稿与发布、单层评论、点赞与收藏(幂等)、关注与我的收藏。

Creation(8085)

  • 创建生成任务:POST /api/v1/creation/jobs(必带 Idempotency-Key,ADR-019——生成在上游是要花钱的,重试绝不能变成第二个计费任务)
  • 查询任务:GET /api/v1/creation/jobs/{jobId}(轮询端点)
  • 我的任务列表:GET /api/v1/creation/jobs
  • 取消任务:DELETE /api/v1/creation/jobs/{jobId}
  • 模型与风格目录:GET /api/v1/creation/catalog

进度靠轮询上面那个 GET,没有流式端点:SSE 会旁路整条「信封解包 + 401 单飞刷新重放」的客户端网络层、形成第二套鉴权路径,而任何推送方案都必须有这个 GET 兜底(ADR-023 §10)。

目录端点会返回「哪些 media_kind 真的有可用模型」,客户端据此决定是否渲染类型切换器——所以目录为空的类型不会出现,不会给用户一个承诺了却做不到的控件;V7 种子落地后 UI 会自己长出视频分组,无需发版。

任务响应里的 timeoutSec 与 stalled 由服务端下发而非客户端硬编码:图片与视频差一个量级,硬编码两个常量意味着换 provider 就要发版。stalled(第六个 UI 态)不是错误态——任务仍可能完成。

用户数据落在 PostgreSQL(identity.users / identity.user_credentials,bcrypt 口令散列,应用侧生成 UUIDv7)。refresh 会话在 identity.auth_sessions(只存 SHA-256 摘要,每次刷新轮换,重用即吊销整个 token family,ADR-003)。错误统一走 {code, message, data} 信封与稳定业务码;对外契约见 patbond-doc/docs/api/openapi.yaml,架构决策见 patbond-doc/docs/architecture/decisions.md。

S
Description
Patbond api
Readme 1.1 MiB
Languages
Java 98.3%
PLpgSQL 1.1%
Shell 0.5%
Dockerfile 0.1%