# 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` 只有一份、不拆。 ## 构建与测试 ```bash ./mvnw clean test ``` 集成测试经 Testcontainers 拉起一次性的 `postgres:18`,因此需要一个在跑的 Docker daemon(不需要本机装 PostgreSQL、也不需要本机库凭证)。 默认 JDK 不是 17 时,先指向一个 JDK 17: ```bash 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。先复制样例(默认值本机可用,实际值用环境变量覆盖): ```bash 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 ``` 先装一次共享模块,然后各服务各开一个终端: ```bash ./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` 用私钥签,其余服务用公钥验;两者都不入库): ```bash 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` 需要另一对独立的密钥**,不要复用上面那对: ```bash 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 上自给自足。 冒烟: ```bash 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_*` 环境变量注入实际值。 ```bash # 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`。