Files
patbond-doc/docs/architecture/decisions.md
T
lixi 39cf422f89
CI / docs-build (push) Successful in 46s
docs: ADR-023 M4 架构与关键决策成文 + ADR-018 视频后置标记取代 + 补仓内 .gitignore
ADR-023 收录 M4 全部跨角色决策(13 节),来源为开工分析八份报告与逐项
讨论拍板。要点:

- 范围:视频与图片同批交付,**取代 ADR-018 的「视频后置」**(该条已加
  supersede 标记,其余裁剪继续有效)。schema 侧本无障碍——目标模型自始
  按 media_kind IN ('image','video') 建模;成本集中在契约与客户端。
  供应商:视频 Seedance、图片 gpt-image-2;生产服务器无 GPU 故一律云 API。
- 服务划分:新建 Java 模块 patbond-creation(:8085) 持有队列与客户端端点;
  patbond-ai 为独立 Python 仓、只调服务商 API、不访问数据库。
- 队列:Postgres FOR UPDATE SKIP LOCKED + 租约,不引入 Redis/MQ
  (偏离 development-plan.md:205,判例同 ADR-002 移除 Nacos)。
- Java↔Python:统一异步、REST 非 gRPC、内部契约独立 openapi-internal.yaml
  不进快照锁、轮询为保证回调仅预留字段、服务端轮询节律与租约 TTL 约束。
  产物走 presignPut 直传——**据此更正开工报告的「服务端零对象写能力=缺失
  地基」推论**:缺 putObject 是事实,但服务端需要的是签发上传凭证,
  presignPut/presignGet 早已存在;附带使 Python 零对象存储凭证。
- 鉴权:服务身份 JWT 独立密钥对,禁止转发用户 token。事实依据:现有
  JwtVerifier 仅验签名、不校验 aud/iss,且用户 token 无 aud claim,
  复用密钥对会使四个模块把服务票当合法用户 token 接受。
- 数据模型:V6 两处偏离(input_asset_id 可空、新增 first_attempt_at)
  逐条记录原因;V7 目录种子待官方文档,不编造取值。
- 对象存储:**决策挂起,现有本地实现一行不动**。已核实 media.assets 的
  storage_type IN ('object','external') 使两种存储可共存、渐进迁移,
  任一选择均无需 DDL;另登记「六容器栈当前仅在开发机运行」与「MinIO 9000
  对真机不可达」两项上线前置。
- 埋点:eventVersion 定型为每事件 props schema 版本 + bump 规则;服务端
  指标以 generation_jobs 为权威不建第二管道;毒丸批次放大器须修;
  A/B 前置重置 5 绿 3 半、首个实验为 A/A;北极星改事件驱动触发条件。
- 契约 v1.5.0 纯增量、429 拆两码、AI 来源须由服务端从 generation_job_id
  派生(不得由客户端声明或由 purpose 推断,否则可被任意图片伪造)。
- 客户端:自适应轮询非 SSE、超时按 media_kind 由服务端下发、不承诺推送、
  不押注百分比进度、第六个 UI 态僵死/超时。
- 遗留:widthPx/heightPx 两侧同批必修、SegmentedButton 降 P2(实测
  7.19:1 达标)、收藏与草稿两页搭车、草稿类目静默降级须与放开写侧同提交修。
- 门禁:必需上下文已改 (pull_request) 并复核生效;patbond-ai 纳入发布语义。
- 版本号:v0.5.0。

另补本仓 .gitignore:site/ 此前仅靠机器级 ~/.gitignore_global 屏蔽,
换机器或进 CI 时一次 git add -A 会提交上百个构建产物(ADR-023 §12)。

验证:secret-scan exit 0;mkdocs build --strict exit 0。

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

312 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构与技术决策记录(ADR)
> 状态:Accepted
> 决策日期:2026-09-03
> 决策人:产品负责人
> 背景:项目正式启动第一版(第一迭代「真实登录纵切」),基于 `iteration-1-reports/` 六份开工前分析报告确认以下决策。
## ADR-001 升级 Spring Boot 3 后再开发业务代码
**决策**:在编写第一迭代业务代码之前,先将 `patbond-api` 从 Spring Boot 2.7 升级到 Spring Boot 3(JDK 17 基线),升级工作时间盒为 1-2 天。
**理由**:
- 当前业务代码不足 10 个类,迁移成本处于历史最低点;随业务增长成本只会上升。
- 本迭代要新引入的 JWT、Flyway(PostgreSQL 16+)、Testcontainers、springdoc 在 Boot 2.7 下均为降级选型,按 2.7 编写等于主动制造返工。
- Spring Boot 2.7 / Spring Cloud 2021 已进入旧技术代际(开发计划第 11 节风险 8)。
**影响**:`javax.*` → `jakarta.*` 命名空间迁移;依赖版本随 Boot 3 BOM 统一管理。
## ADR-002 MVP 阶段移除 Nacos,微服务化阶段再引入服务发现
**决策**:MVP 阶段从 `patbond-api` 移除 Nacos(服务发现与配置中心)。`patbond-auth` 调用 `patbond-user` 改为通过配置注入的静态地址:
```java
@FeignClient(name = "patbond-user", url = "${patbond.user-service.url}")
```
**理由**:
- MVP 只有 auth/user 两个服务,服务发现解决的是「服务多、地址动态」的问题,当前并不存在。
- 移除后干净检出即可启动,消除本地开发和 CI 对 Nacos 实例的硬依赖。
- 升级 Boot 3 后原 Spring Cloud Alibaba 2021 版本线不再兼容,移除可避免本迭代被版本配套拖住。
**回补路线(重要)**:本决策是阶段性的,不是永久放弃。在后续真正拆分微服务(多服务、多实例、地址动态)时,重新引入服务发现:
1. 选用与当时 Spring Boot / Spring Cloud 版本配套的 Spring Cloud Alibaba 版本线(Boot 3 对应 2022/2023+,不得沿用 2021 坐标)。
2. 加回 discovery 依赖与注册配置,删除 Feign 的 `url` 属性即可切回服务发现,调用方接口代码无需改动。
3. 届时同步评估配置中心是否一并启用。
**影响**:开发计划第 5.1、5.2、5.3 节中涉及 Nacos 的环境要求与启动步骤在 MVP 阶段不再适用,以本决策为准。
## ADR-003 Token 有效期与多设备策略
**决策**:
- Access token 有效期 15 分钟。
- Refresh token 有效期 30 天,每次刷新即轮换(rotation),旧 token 立即失效。
- 允许多设备并行会话;退出仅撤销当前会话。
- 上述值实现为配置项,不硬编码。
## ADR-004 首版登录方式仅账号 + 密码
**决策**:第一版仅支持用户名 + 密码注册登录。不实现手机号登录、短信验证码和第三方登录,但凭证数据模型(`identity.user_credentials`)保留扩展能力,登录页 UI 预留不渲染的扩展区。
## ADR-005 品牌色以 iOS 设计手稿为正典
**决策**:产品视觉以 `AI宠物_iOS_UI设计稿.html`(珊瑚橙主色 `#FF6F4C`、奶油底、暖色系)为品牌正典。`patbond-flutter` 当前的靛蓝 `#4F46E5` 主题属早期脚手架占位实现,正式启动后迁移到手稿色系。
**补充规范**(详见 `iteration-1-reports/04-ui-login-design-spec.md`):
- 主题以语义 token 组织(primary / canvas / ink / error 等),集中在单一主题文件。
- 新增 error 色 `#D0342C`(设计稿与现有主题均缺失)。
- 实心按钮使用加深的 `primaryStrong #D6431A` 以满足 WCAG AA 对比度;原珊瑚橙 `#FF6F4C` 用于装饰与非文字承载场景。
## ADR-006 测试与交付容器化策略
**决策**(2026-09-04):
- 自动化测试中的数据库一律通过 Testcontainers 使用 Docker 临时容器(`postgres:18`,版本基线见 ADR-008),不依赖开发者本机数据库;每次测试在干净实例上执行全量 Flyway 迁移。
- 最终交付将提供 Docker 镜像/编排包(对应开发计划 M6 的容器镜像项)。
- 开发者本人手动测试/联调时可使用自己本机的 PostgreSQL,连接信息经环境变量注入,不入库。
**理由**:测试可复现、与目标版本(PostgreSQL 16)对齐、不受本机数据库账号/版本差异影响;交付形态与测试基础设施统一到 Docker。
## ADR-007 部署形态:容器化应用 + 分阶段数据库策略
**决策**(2026-09-04):
- 应用(auth、user 及后续模块)以 Docker 容器交付部署,**容器保持无状态**:文件走对象存储、会话在数据库,任何状态不落容器本地。
- **MVP 阶段**:数据库使用 Docker 容器运行 PostgreSQL(数据挂载 volume 持久化),与应用同机以 docker compose 编排;配套每日 `pg_dump` 备份到独立存储,并演练过恢复流程。
- **规模化阶段**:当用户量与数据重要性上升后,数据库迁移至云托管数据库(RDS 类)或独立数据库服务器,应用容器不动。
- 数据库连接信息始终经环境变量注入(`PATBOND_DB_URL` 等),保证迁移数据库时应用层零改动。
**理由**:双人团队运维预算有限,容器数据库 + volume + 备份在 MVP 阶段完全够用,且与 Testcontainers 测试、Docker 交付包(ADR-006)同一体系;把高可用、故障转移等重运维在需要时外包给云托管,是成本与可靠性的最优路径。守住「应用无状态」这一条纪律,数据库放哪都可随时更换。
**注意**:数据库大版本升级即使在 Docker 中也需要数据迁移(`pg_upgrade` 或 dump/restore),属 ADR 级决策,不随镜像标签随意变更;小版本安全更新随镜像自动跟进。
## ADR-008 PostgreSQL 版本基线定为 18
**决策**(2026-09-04):数据库版本基线从开发计划最初的「PostgreSQL 16+」明确定为 **PostgreSQL 18**。Testcontainers 测试镜像、未来的 compose 编排与交付镜像统一锁 `postgres:18`。
**理由**:
- 项目尚无生产数据,大版本选择处于零成本窗口;一旦有数据,大版本升级即为一次真实迁移(见 ADR-007 注意事项)。
- PostgreSQL 18 已是发布满一年的稳定版本,与团队本机开发库(18.6)一致,消除本机与基线的版本偏差。
- 不违背开发计划「16+」的原始约束。
**验证**:切换当日 `./mvnw clean test` 全量 37 测试在 postgres:18(18.6)容器上通过,Flyway V1 baseline 迁移执行无兼容问题。
**影响**:后续大版本变更须以新 ADR 决策并附全量测试验证;数据库特性使用以 18 为可用上限参考。
## ADR-009 M2 宠物健康档案新建 patbond-pet 模块
**决策**(2026-09-07):宠物与健康档案域在 `patbond-api` 内新建独立 Maven 模块 `patbond-pet` 承载,不并入 `patbond-user`。
**背景**:开工评估中 PM(iteration-2/01)建议新建模块,后端评估(iteration-2/02)建议 user 内独立包。用户裁定采用新建模块方案,为后续微服务化(ADR-002 预留方向)保持模块边界清晰。
## ADR-010 照片/附件剪出 M2
**决策**(2026-09-07):宠物头像上传、疫苗证书与健康事件附件等 media 能力不进入 M2。头像 M2 阶段使用占位/预设方案。
**理由**:对象存储供应商未定(第一迭代 D4 遗留);后端 media 仅有表结构、上传流程零代码(iteration-2/02 评估)。待对象存储选型拍板后另立迭代实现。
## ADR-011 分支策略:dev 为日常主干,master 为发布分支
**决策**(2026-09-07):
- 日常开发一律只推 `dev` 分支。
- `master` 保留作为发布分支:正式版本发布时由 `dev` 合并至 `master`。
- 契约先行提交顺序沿用 iteration-2/08 规范:doc(openapi 独立提交)→ api → flutter;波次收尾三仓 commit + push + CI 绿才算闭环。
**备注**:iteration-2/08 建议的「Flyway 迁移、契约破坏性变更、依赖升级、两人并行期四类改动走短命分支 + PR 合入 dev」与本决策兼容,作为推荐实践保留,PR 目标分支为 `dev`。
## ADR-012 M2 北极星指标与产品假设
**决策**(2026-09-07):采纳 iteration-2/06 定稿:
- 北极星 = **7 日回访记录率**(分母:当 ISO 周产生生命周期首条 `health_record_create_succeeded` 的去重用户;分子:其中在首记日之后第 1–7 个 UTC 自然日内再次创建成功者;不含首记当日;首记日 +8 天出数)。
- 产品假设 H1–H4 及其判定阈值按 06 号报告冻结,上线前不再调整判定线。
- M2 不启动 A/B;按 06 号报告 8 项前置条件推进,目标 M3 末全绿、M4 首实验。
## ADR-013 废弃 health_record_action 保留位
**决策**(2026-09-07):从后端 EventDictionary 白名单直接移除 `health_record_action`(客户端零引用,废弃零成本)。M2 事件按字典 v2(iteration-2/06)以具体事件落地。
**备注**:后续如出现新埋点需求,按 v1「结果编码进事件名」惯例新增具体事件,不复活通用 actionType 设计(多套指标共享分母、枚举扩充相互污染)。
## ADR-014 设计债 DEBT-1 随 M2 偿还
**决策**(2026-09-07):TagPill 文字对比债(DEBT-1)随 M2 偿还,采用 iteration-2/05 的深变体映射方案(一行映射表 + 可选 `inkColor` 参数,既有调用零参数回归)。DEBT-2(muted 次级文字对比不足)M2 内按 05 号报告以既有正典色 `inkSoft` 局部规避,全局翻修另立决策。
## ADR-015 照护人邀请流程后置出 M2
**决策**(2026-09-07):owner/caregiver/viewer 权限模型与校验进入 M2,但照护人邀请/绑定流程后置到后续迭代;M2 权限校验以测试数据覆盖三角色场景验证。
## ADR-016 对象存储:自托管 MinIO 起步,预留迁云
**决策**(2026-09-08):M3 媒体存储采用**自托管 MinIO**(部署在现有腾讯云服务器,随 compose 编排),S3 兼容 API + 预签名直传;代码经存储适配层隔离供应商,本地开发与 Testcontainers 用同一 MinIO 镜像,三环境零分叉。
**背景**:现有腾讯云服务器仅含本地盘、未购对象存储(用户确认);后端评估(iteration-3/02)指出 Feed 图片下行将受限于单机公网带宽——此约束**接受为当前限制**并作为迁移触发条件:当图片下行带宽成为可感知瓶颈或预算允许时,迁移至云对象存储(COS 类,S3 API 兼容、适配层保证仅换配置与凭证)。本地磁盘直存方案违反 ADR-007 无状态容器纪律,排除。
## ADR-017 社区模块归属与作者信息取数
**决策**(2026-09-08):
- 社区域新建 Maven 模块 `patbond-community`(:8084),沿 ADR-009 先例(新模块 + 共库 + patbond-user 单迁移链)。
- media 上传流程实现在 `patbond-user`(横切基础能力、与 V1 media schema 同源,避免业务模块被反向依赖)。
- Feed/评论的作者公开信息(昵称/头像)由 community 模块**跨 schema 只读** identity 域取数(同库零网络开销);微服务化拆库时改为内部批量接口,与单迁移链同一演进逻辑。
## ADR-018 M3 范围裁剪
**决策**(2026-09-08):M3 MVP = 图片媒体上传闭环 + 帖子草稿/发布/删除 + 公共 Feed 游标分页 + 单层评论 + 点赞/收藏幂等 + Flutter 三页(home/create/post_detail)替换 demo 与乐观更新回滚。关注做最小数据接口(follow/unfollow + 数量);**话题首版剪出**;评论仅单层不做楼中楼。视频后置。
> **⚠️ 「视频后置」一项已被 ADR-023 取代(2026-09-18)**:M4 视频与图片同批交付。本条其余裁剪(话题首版剪出、评论单层、区域系统属 M5)继续有效。
## ADR-019 写接口幂等形态按域选择
**决策**(2026-09-08):二元状态互动(点赞/收藏/关注)用 **PUT/DELETE 语义幂等**(重复调用同终态,无键管理);创建型写入(发帖/评论/媒体登记)用**表内幂等列(request_hash)**。M2 的 Idempotency-Key 键派生机制在 pets 域维持不变,不回改。
## ADR-020 M3 埋点与实验决策
**决策**(2026-09-08):
- Feed 曝光采用**聚合 `feed_viewed`**(浏览段聚合),否决逐卡曝光(量级测算 7~14 个月击穿分区阈值且接收端无限流背压,见 iteration-3/06);逐帖曝光留 backlog 待 M4+ 排序实验走服务端日志。
- 事件字典 v3 增量 19 事件 + `experiment_exposed` 提前进字典(A/B 前置 #5 顺带变绿)。
- 北极星保持「7 日回访记录率」不变,复评点 = M3 收官 + H7 读数。
- 埋点队列三项遗留(30s 定时冲刷、上传退避、anonymousId 持久化)升为 M3 第一波必做。
## ADR-021 Git 工作流修订(修订 ADR-011)
**决策**(2026-09-08):
- ADR-011 中「master」统一更正为 **main**(远端实际分支名;master 从未存在于远端)。
- PR 触发条件由「改动类别」改为「情形」:仅**两人并行同仓期间**与**首次发布后影响 main 的变更**强制走 PR;其余直推 dev + CI 绿(M2 全程直推零风险事件的机制归因见 iteration-3/08)。
- M3 末执行首次 dev→main 发布(8 步 checklist 见 iteration-3/08);patbond-api 远端 main 与 dev 历史不相干,届时经 Gitea 平台删除重建 main,禁止 force push 缝合。
- 对象存储凭证(MinIO AK/SK)防泄漏:CI 兜底 grep 在第一波、**先于凭证进开发机**落地。
- E2E 烟囱不进 push 门禁,保持波次手动 + 可选 workflow_dispatch。
## ADR-022 M3.5 体验补齐的范围与关键决策
**决策**(2026-09-10,用户实测反馈后拍板):
- **首页 demo 裁剪范围**:本迭代**仅做问候语真实化**(改用真实昵称)。天气与位置(需接外部服务,含 key 与配额管理)、圈子入口(实为话题,ADR-018 已剪出)、促销卡(属 M5 服务域)三项**留待对应里程碑**;保留期间须在代码注释与迭代报告显式标注为「刻意保留的 demo 占位」,避免后续实测重复反馈。
- **昵称不设唯一约束**:`identity.users.nickname` 维持现状(仅 `ck_users_nickname` 的 btrim + 1~32 长度校验),**允许重名**,靠 userId 区分(社区产品常规做法)。加唯一约束需迁移,且会破坏本迭代零迁移前提。
- **注册流程不加昵称输入**:沿 ADR-004 的最小注册面,注册仍只收用户名/手机号/密码;昵称在资料页设置,未设置时展示层回退 username(回退逻辑已在 `/internal/users/profiles` 的 SQL 层实现,M3 T3-05 交付)。
- **宠物头像的写权限为 WRITE 档**(owner + caregiver 均可改,ADR-015 三档权限模型下):头像属日常照护信息,与体重/疫苗记录同档;viewer 只读。
- **本迭代零 Flyway 迁移**:开工审计确认所需列均已存在——`identity.users.nickname` 与 `avatar_asset_id`(V1)、`pet_health.pets.avatar_asset_id`(V3)、`community.posts.like_count` 等冗余列(V5);`media.assets.purpose` 无 CHECK 约束、白名单为配置项 `MediaProperties.allowedPurposes`,新增 `user_avatar`/`pet_avatar` 只改配置与契约枚举。下一个 Flyway 版本号 V6 留给后续真正需要建表的迭代。
- **获赞总数走读侧实时聚合**(`SUM(like_count)` over 本人未删帖),**不引入新冗余列**:写侧维护成本高于读侧聚合收益,且数据量级远未到瓶颈。端点为新增的 `GET /api/v1/me/community-stats`,不并入既有 `follow-stats`(后者主体是「某用户的关注数」,混入「我的获赞」会造成主体歧义)。
**版本号**:本迭代交付按 **v0.4.0** 发布(新增端点与字段属功能增量,非纯补丁)。
## ADR-023 M4 AI 创作的架构、边界与关键决策
**决策**(2026-09-18,开工分析八份报告 + 与用户逐项讨论后拍板)。本 ADR 覆盖 M4 全部跨角色决策;单角色内的细节决策留在各自报告中。
### 1. 范围:视频与图片同批
- **视频与图片同批交付**,**取代 ADR-018 的「视频后置」**。数据库侧本无障碍:目标模型的 `creation` 三表自始按 `media_kind IN ('image','video')` 建模、目录按 `(id, media_kind)` 复合唯一键约束,`media.assets.kind` 的 CHECK 亦已含 `'video'`、`duration_ms` 已在。
- 因此视频的成本集中在**契约与客户端**,不在 schema:契约 `kind` 枚举加 `video`、Flutter 需引入播放器依赖(现为零)、缩略图与时长呈现(`MediaType.video` 已备未启用)。`iteration-4/03` R13 评级 🟠 中高。
- **供应商**:AI 视频 = 火山引擎 Seedance;AI 图片 = gpt-image-2。生产服务器为腾讯云轻量、**无 GPU**,自托管模型不可行,故一律走云 API。
- **`patbond-ai` 先完成两类 provider 的接口适配**,客户端视频路径随契约扩展跟上。
- `iteration-4/05` 的目录呈现为**数据驱动**(目录返回哪些 `media_kind` 就渲染哪些分组),故契约扩到 video 时 UI 自动长出第二段,无需重画。
### 2. 服务划分:patbond-ai 为独立 Python 仓,Java 持有队列
- **新建 Java 模块 `patbond-creation`(:8085)**,前缀 `/api/v1/creation/**`,承载客户端端点、Worker、Python 客户端与 `generation_jobs` 读写。沿 ADR-009 / ADR-017 两次先例(新模块 + 共库 + patbond-user 单迁移链)。
- **`patbond-ai` 为独立仓、Python 实现**,只负责调用 AI 供应商 API。**不访问数据库**:任务状态的唯一事实来源是 Java 侧的 `creation.generation_jobs`。
- **理由(为何不让 Python 直接消费队列)**:目标模型已把租约、退避、优先级、幂等、provider 去重全部设计在 Java 侧表中(`lease_owner` / `lease_expires_at` / `next_attempt_at` / `attempt_count` / `max_attempts` / `priority` 六列 + 两条与出队/回收语句逐列对齐的偏索引)。让 Python 也轮询该表等于用两种语言实现同一套租约逻辑,并使事实来源分裂。
- **迁移链归属不变**:V6 仍放 `patbond-user`。这是既有约定而非新决策——V3(`pet_health`)与 V5(`community`)的业务代码在各自模块,迁移文件全部在 `patbond-user`。
- **共享物**:`patbond-creation` 需复制 3 件(JWT 资源侧校验、`UuidV7`、`RequestHashes`)。**存储适配层不复制**,见 §4。是否上提 `patbond-common` 作为独立前置工单评估,退路是各抄一份。
### 3. 队列载体:Postgres `FOR UPDATE SKIP LOCKED` + 租约,不引入 broker
- **不引入 Redis、不引入 MQ**,偏离 `development-plan.md:205` 的 RabbitMQ 设想。
- 三条理由:① 目标模型已把租约队列设计完(含现成领取 SQL),换中间件等于让这批列作废、推翻已评审模型;② 实测 `docker-compose.yml` 仅 postgres/minio/auth/user/pet/community,**无 Redis 无 MQ**,引入即净增容器与运维面——与 ADR-002 移除 Nacos 同一判例;③ 任务状态真值保持单一(Postgres),无 dual-write,「Worker 重启不丢任务」由租约回收器天然满足。
- 缺口:目标模型给了索引但没给回收器代码,**租约超时回收器为 M4 新增工单**。
### 4. Java↔Python 边界
- **统一异步,无论上游同步还是异步**。Seedance 类为异步(提交 + 查询),gpt-image 类为同步长连接;该差异**不得渗透到客户端契约与 job 表**——`generation_jobs` 状态机本就是异步形状,`iteration-4/05` 的等待态设计亦只有一条路径。Python 收到 submit 立即返回任务 id,同步 provider 在 Python 内部以后台任务承载长连接。
- **收益**:Java 侧无长连接占线程(轻量服务器关键);`provider_request_id` 立即可得,`uq_generation_jobs_provider_request` 唯一索引才真正生效;超时不再产生「是否已扣费」的歧义——同 id 重新轮询即可,而非重发。
- **双重扣费的残余风险如实登记**:Java 将自身 `idempotency_key` 透传 Python,Python 转发为上游幂等头。**上游不支持幂等头时风险窗口无法消除**,仅由 `max_attempts` 兜住并记录,不假装已解决。各供应商是否支持须逐一核对官方文档。
- **传输为 REST(JSON),不用 gRPC**。理由不是性能——瓶颈是上游数秒至数分钟的出图出片,序列化差异无意义。理由是**契约体系**:本项目全部契约纪律建在 OpenAPI 上(快照、计数断言、契约矩阵、逐迭代冻结),引入 `.proto` 等于第二套无门禁契约体系;而本迭代开工审计恰好证明「没有门禁的契约必然漂移」(四模块快照锁实际不存在,白名单 42/41 之误四份文档一路不可见)。需要类型安全时**从 OpenAPI 生成 Python 客户端**,保持单一来源。
- **内部契约独立成文**:`patbond-ai` 仓内单独一份 `openapi-internal.yaml`,独立版本号,**不进四模块快照锁**,也**不写入面向客户端的 `openapi.yaml`**——否则内部适配端点会污染公开契约的路径/操作计数。
- **产物落盘走 presigned PUT**:Java 经 `patbond-user` 新增的 `/internal/media/**` 取得预签名上传凭证,交给 Python 直传对象存储,再由 Java 核实并建 `media.assets` 行。这正是客户端现用的两步上传,Python 站到客户端位置。
- 由此**服务端无需新增 `putObject`/`getObject` 原语**:`ObjectStorage` 已有 `presignPut` 与 `presignGet`。开工报告中「服务端零对象写能力 = M4 缺失地基」的**推论据此更正**——缺 `putObject` 是事实,但服务端需要的是签发上传凭证,该能力早已存在。
- 附带收益:**Python 零对象存储凭证**,少一处凭证面。`MediaProperties` 已区分 `endpoint`(容器内)与 `publicEndpoint`(客户端可达),预签名 URL 的 host 分发本就分离。
- **轮询为保证,回调为优化**:绝不可只靠回调(网络抖动、任一侧重启、乱序都会使任务永久卡在 running)。**M4 只做轮询**:submit 请求体预留可选 `callbackUrl` 字段但不启用,将来启用无需契约变更。若日后启用,回调鉴权用 **Java 下发的一次性 job 绑定能力票**(Python 原样带回),使 Python 侧无需任何私钥。
- **服务端轮询节律**(Java → Python,Docker 网络内):前 60 秒 3s、60s~5min 10s、5min 后 30s。**租约 TTL 必须大于单次轮询间隔**(设 60s,每次轮询顺带续租),否则会出现「任务仍在跑但租约过期被他人抢占」。`ix_generation_jobs_running` 即为「捞出待轮询的 running 任务」而建。
### 5. 鉴权:服务身份 JWT,独立密钥对
- Java→Python **签发服务身份 JWT,使用独立密钥对**;**禁止转发用户 JWT**(Worker 异步执行时用户 token 可能已过期,且不应让 Python 持有用户凭证)。
- Claim 约定:`iss=patbond-api`、`aud=patbond-ai`、`sub=service:creation-worker`、`typ=service`、短 `exp`(5 分钟)、带 `jti`。Python 侧强制校验 `aud` 与 `typ`。
- **选独立密钥对的事实依据**:现有 `JwtVerifier` **仅验签名,不校验 `aud` 也不校验 `iss`**,而 `JwtSigner` 签发的用户 token **不含 `aud` claim**(仅 `sub`/`iss`/`sessionId`/`exp`)。若服务票复用同一密钥对,user/pet/community(很快加 creation)四个模块都会把它当合法用户 token 接受——`sub` 非 UUID 大概会在后续解析时失败,但那是**意外挡住而非设计挡住**。独立密钥对无需改动任何已发布的验证器。
- **两套内部鉴权并存的说明**:Java↔Java 的既有内部通道继续用 `InternalAuthFilter` 的共享 token(header 携带,未配置则 `/internal/**` 全拒;该 token 在 2026-09-11 安全事件中轮换过)。Python 那一跳改用 JWT 是有意选择更强机制(有效期、`aud`、`jti`,无长期共享秘密)。**本迭代不改造既有 Java↔Java 通道**,避免范围蔓延。
- **数据最小化**:请求体不传 `userId`,除非 Python 确有需要。Python 完成工作只需 `jobId` + 生成参数 + 预签名上传 URL。
### 6. 数据模型:V6 两处偏离,V7 待供应商文档
- V6(`creation` schema 三表 + 14 索引 + 3 触发器 + 补回 `posts.generation_job_id` 外键)已落地,**零跨 schema 外键需裁剪**——首个不必砍外键的 baseline 迁移。
- **偏离一:`input_asset_id` 改可空**(目标模型 NOT NULL)。参考图为**选填**,主流生图与生视频均同时支持文生与参考生,由用户是否上传参考图选择路径;原 NOT NULL 会使文生图无法表达。**未发明 `requires_input` 标记**(不超出已评审模型),「某模型是否必须有输入」由应用层查目录判断。
- **偏离二:新增 `first_attempt_at`**(目标模型无此列)。`iteration-4/06` §4.2 的指标口径要求分离排队时长与纯执行耗时,而目标模型无法表达——其 `ck_generation_jobs_state` 强制 `queued` 分支 `started_at IS NULL`,故失败重排队必然擦除首次尝试时间。该列仅首次进入 `running` 时写入、永不覆盖,并**刻意排除在 `queued` 分支的置空要求之外**;`ck_generation_jobs_first_attempt` 钉住其与 `attempt_count` 的双向不变式。
- **V7 目录种子暂缓**:种子需填 `provider_code` / `provider_model_name` / 版本快照等真实取值,须待 Seedance 与 gpt-image-2 官方文档到位后再写,否则即为编造数据。沿 V3/V4「结构与种子分离」先例。
- **`posts.region_id` 仍为裸列**,区域系统属 M5(ADR-018 该项仍有效)。
### 7. 对象存储:决策挂起,现有实现不动
- **本地 MinIO 与云对象存储之间的选择本迭代不作决定**,理由是分布式、磁盘容量、备份恢复等考量尚未收敛。**现有本地存储代码一行不动、不删除。**
- 已核实的两点供决策参考:
- **`media.assets` 已同时支持两种模型**:`storage_type` 的 CHECK 为 `IN ('object','external')`,object 用 `bucket`+`object_key`,external 用 `external_url`,`ck_media_location` 强制二选一。**两种存储可共存于同一张表**,允许存量资产留 `object`、新资产走 `external` 的渐进迁移,**任一选择均无需 DDL 迁移**。
- **现状是宿主机本地磁盘**:MinIO 数据落在 Docker 命名卷 `minio-data`,无任何云对象存储。且 `server-exposure.md` §2 显示生产服务器**只运行 gitea/nginx/文档站/act_runner/dockerd/sshd**,六容器栈(含 MinIO)当前**仅在开发机运行**。
- **由此挂起两项**:① M4 是否将六容器栈部署到服务器(不部署则交付仍为「本机可验、真机不可达」);② MinIO 的 9000 端口既不在防火墙放行范围(仅 22/80/443)也无 nginx 路由,**预签名 URL 目前对真机不可达**——这构成客户端媒体链路的上线前置,也是「媒体上传弱网」真机验证长期挂起的深层原因之一。**注意 M4 的 AI 产物上传不受此影响**:Python 在容器网络内走内部 endpoint。
### 8. 埋点与实验
- **`eventVersion` 定型为「每个事件自身的 props schema 版本」**,否决「字典世代」读法。决定性依据是**可修复性不对称**:此读法下客户端现行硬编码 `1` 本就正确(零改动零回填);「字典世代」读法下全部历史行皆错且不可修复(`server_ts` 无法反推事件当初属于哪代字典)。配套 bump 规则:新增可选属性不 bump;删属性、改语义单位、改既有枚举含义、改触发时机必须 bump,新旧版本在字典中并列为两条。
- 落地时白名单 key 改 `(name, version)` 二元组,并新增逐条拒绝原因 `unknown_event_version`——**必须置于逐条校验路径,不得放在 DTO 校验**,否则重犯整批连坐错误(见下条)。
- **服务端生成侧不建第二条埋点管道**:以 `creation.generation_jobs` 事实表为服务端指标权威,经 `creationTaskId` 与客户端事件拼接。`platform.product_events` 的 `anonymous_id`/`session_id` NOT NULL 与 `platform IN ('android','ios')` CHECK 使 Worker 事件无法入表,放宽这两个 NOT NULL 是不可逆的反向操作。
- **客户端埋点整批丢弃缺陷须修**:`TrackEventsRequest` 的 `@Valid` 级联至列表元素,任一元素校验失败即整请求 400,客户端进而判为永久拒绝并**整批删段**。这是**与平台无关的毒丸批次放大器**(任一条事件的任一字段可毒死整批 50 条),桌面 `platform=linux` 100% 丢事件只是其最显眼的表现。`analytics_service.dart` 注释所称「逐条 rejected、不影响客户端」与实现相反,且该错误表述已转述进 5 处文档。**枚举排除 linux 是设计意图,不动枚举;修客户端本地短路 + 调试覆盖开关。**
- **A/B 前置状态重置为「5 绿 3 半」**(原记「6 绿 1 半」):分流哈希、Flutter 曝光封装、feature flag **三者代码零实现**,须作为前置工单而非既有资产。按旧口径排期会在实验启动日才发现要先写三个组件。
- **M4 首个「实验」为 A/A 基建验证空跑**(零产品风险,≥200 曝光即可暴露分流失衡与曝光漏报);真实 A/B 设计冻结存档,**放行条件挂流量而非挂日期**。
- **北极星首次出数改为事件驱动触发条件**(原 2026-09-21 日期承诺作废,其 W37 入队窗口已于 09-13 关闭,无补救可能):`T0` = 首个满足「≥1 名真实移动端用户产生生命周期首条 `health_record_create_succeeded`」的 ISO 周,首次出数 = `T0 + 8 天`后的首个周一。**明确否决**用桌面 override 或 curl 造数凑读数。另须把北极星 SQL 落成仓库内可执行载体——它目前只存在于报告正文。
- **补一个断言埋点白名单条数的测试**,列为 M4 前置:白名单实际 41 条而四份文档记 42(`experiment_exposed` 被重复计数),漏了整整一个迭代未被发现,根因正是 `EventDictionaryTest` 零条数断言。
### 9. 契约
- `openapi.yaml` **v1.4.0 → v1.5.0,纯增量**:零删除、零重命名、零必填收紧。「一键建草稿」复用既有 `POST /api/v1/posts`,**不新增路径**。
- **429 与配额拆为两个错误码**,不共用一码:配额耗尽等的是次日(呈现为 sheet 并给出出口),频率过快等的是数秒(呈现为 SnackBar);共用一码必然使一半场景的文案与出口错误。契约当前**完全没有 429、`Retry-After` 或任何响应头机制**(`components.headers` 为空),M4 为首次引入,同时兑付客户端 `Retry-After` 死分支的历史挂账。
- **两处配置扩展,零迁移**:`MediaProperties.allowedMimeTypes` 加视频类型(现仅 jpeg/png/webp)、`allowedPurposes` 加 AI 产物 purpose。`media.assets.purpose` 为裸 varchar 无 CHECK。
- **须一并修正契约内部既有矛盾**:`openapi.yaml:1228` 称「purpose 仅 `post_image`」与 `:3460` 的三值枚举冲突(M3.5 加值时漏改端点描述)。M4 再加值若不同时修,即为第三次漂移。
- **「AI 创作」标签不得由客户端声明或由 purpose 推断**:社区侧 `PostService.validateAssets` 只校验归属与 ready、不校验 purpose,故仅凭 purpose 或客户端标记可被任意图片伪造。AI 来源**须由服务端从 `generation_job_id` 派生**。
### 10. 客户端
- **进度反馈用自适应轮询**(1s×3 → 2s×5 → 3s,上限 5s),**不做 SSE/WebSocket**。决定性理由:现有网络层全部围绕「信封解包 + 401/40101 单飞刷新重放一次」构建,SSE 两者皆用不上,等于旁路整条既建鉴权链、形成第二套鉴权路径——而 M3.5 端口事故的根因正是「跨模块调用绕开既有接线纪律」。且任何推送方案都必须有「查状态」GET 兜底,有了它轮询已免费到手。
- **AI 对话类若进 M5 需重新评估流式**,本条不构成对流式的一般性否决。
- **硬超时按 `media_kind` 分档**,且**由服务端在任务响应中下发**(如 `estimatedTimeoutSec`)而非客户端硬编码:图片 5 分钟对视频不足,硬编码两个数会使换 provider 就要发版。
- **绝不承诺推送**:客户端无任何推送能力(无 WS/SSE/FCM)。等待页写「完成后通知你」会使用户锁屏等待而永不到达;正确表述为「回到 App 就能看到结果」。
- **不押注百分比进度**:`ck_generation_jobs_progress` 与状态机使 `queued` 时 progress 恒 0、`running` 时无下限约束,押注百分比的后果是最常见观感变成「0% 卡住」。主形态为阶段文字 + 不确定进度环,`progress>0` 才叠确定型数字;**展示已用时而非 ETA**(已用时是事实,ETA 是承诺,M4 给不出可信 ETA)。
- 须定义服务端五个 status 之外的**第六个 UI 态:僵死/超时**(`running` 超阈值),且它不是错误态。
### 11. 遗留搭车与优先级调整
- **`widthPx`/`heightPx` 列 M4 必修**,且**必须两侧同批**——只修一侧用户零感知。该债的三段事实:详情页客户端**已正确消费**(有 clamp,服务端一给即生效);Feed 卡片是**客户端硬编码 4:3 缺陷**(维度可用却被忽略);客户端**根本无法提供维度**(`CreateMediaUploadRequest` 契约无该字段)。M4 严重度上升的实质不是「更严重」而是**「从看不见变成看得见」**:结果页按真实构图渲染、发到 Feed 被裁成 4:3,是同一次会话内的直接前后对照(9:16 竖图裁掉约 58%)。而 AI 输出路径**不需要图片解码**,Worker 写产物时维度就在手里。
- **`SegmentedButton` 粉底降为 P2 品牌一致性债**(原记 P1 无障碍债):实测 `secondaryContainer #FFDAD2` + `onSecondaryContainer #5D4038` 对比度 **7.19:1 达标**。仍搭车修(根因与 M3.5-01 修 DatePicker 完全同构,主题层无 `segmentedButtonTheme` 亦无 `chipTheme`,一处补全修好 6 个使用点),但**不为它拖 M4 范围**。
- **「我的收藏与草稿」两页搭车**:后端与客户端仓库方法均已就绪且在测试覆盖内,**只缺 2 个页面**,为本次盘点投入产出比最高项。
- **必须与放开写侧同一提交修掉的数据损坏路径**:`post_compose_page.dart` 将恢复的 AI 草稿类目静默降级为 `general`。现无害(写侧被封),**M4 放开写侧后会把类目改错并写回服务端**。
- **`ai_creation` 与话题无关**:它只是 category 枚举值,与 topics 表零字段关联,M3 所提「话题随 `ai_creation` 一起做」的搭车理由不成立,话题继续挂起。
### 12. 门禁、安全与发布
- **必需状态检查上下文改为 `(pull_request)`**(已执行并复核生效):原为 `CI / backend-test (push)`,而 `ci.yml` 的 push 触发器限定 `branches: [dev]`,造成双重后果——门禁实际由「推 dev」满足使 **PR 侧检查红也能合并**,且**任何非 dev 分支 → main 的 PR 永久无法合并**(其 head 永不产生 `(push)` 状态),使 hotfix 短命分支纪律实际失效。**改动为替换而非追加**(追加不解除死锁)。建议以一次空 PR 验证,勿以正式发布为试验场。
- **M4 继续 dev 直推**:`push` 触发器只覆盖 dev,推 `feat/**` 不跑任何 CI,开分支等于自断反馈。仅 `spike/*`(探完不合并)与 `refactor/*`(rebase 后 ff-only 合回)开短命分支,且**均不得直接开 PR 到 main**。
- **`check-secrets.sh` 已补 AI 服务商密钥形态**(三仓同构):新增 `AK-ANTHROPIC`(`sk-ant-`)与 `AK-OPENAI`(`sk-`/`sk-proj-`/`sk-svcacct-`),`KEY-ASSIGN` 扩至 `api[-_]?(key|secret|token)` 与 `auth[-_]?token`。修复前 5 种真实形态全部漏网。**须先于任何 AI 凭证落地**——现已完成。
- **`patbond-ai` 纳入发布语义**:参与四仓 tag 同步、CI 门禁、防泄漏脚本与 E2E 回归。**它是唯一将存放 AI 凭证的仓,不得成为唯一没有防泄漏门禁的仓。**
- **契约跨仓漂移无 CI 兜底**:api 的 CI 只读 api 仓内文件、只验「快照↔守卫↔矩阵」内部自洽,**完全不校验与 doc 正典的字节一致性**。升版须按「doc 升正典单独提交 → 记录 md5 → api 侧一次性改完全部快照/常量/计数断言/矩阵 → 四道校验后单个原子提交」执行;最危险的反模式是手改快照而非从正典 `cp`——CI 不会发现。
- **发布流程须固化进 `git-workflow.md`**:现正典是一份标题仍写「草案」的迭代报告,8 步中 4 步已过期,全部修正只以散文形式存在于 `releases.md`。**E2E 份数只在一处写死**,其余位置表述为「全份」并链接过去。M4 引入对外端点,故须新写第五份 E2E 脚本,发布回归变为五份。
- **`patbond-doc` 须补 `.gitignore`**:`site/` 目前仅靠机器级全局忽略屏蔽,换机器或进 CI 时一次 `git add -A` 会提交上百个构建产物。
- **三仓 `core.hooksPath` 均为 unset**,防泄漏第一层从未生效,须逐仓启用。
### 13. 版本号
本迭代交付按 **v0.5.0** 发布(新增模块、新增端点与新增 schema,属功能增量)。