lixi 4b2550f88c
CI / backend-test (push) Successful in 5m43s
feat(creation): 新增 patbond-creation 模块骨架——端点、租约队列 Worker、provider 适配层(T4-02)
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
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%