按 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>
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。