diff --git a/docs/development/iterations/iteration-4/00-kickoff-index.md b/docs/development/iterations/iteration-4/00-kickoff-index.md new file mode 100644 index 0000000..267edda --- /dev/null +++ b/docs/development/iterations/iteration-4/00-kickoff-index.md @@ -0,0 +1,206 @@ +# M4「AI 创作」开工汇总 + +**日期**:2026-09-14 **状态**:开工分析完成,待拍板 **上一迭代**:M3.5 收官、v0.4.0 已发布 + +--- + +## 0. 本页的使用纪律 + +本页是 8 份开工报告(合计 7448 行)的**转述层**,存在的目的是让拍板不必翻全文。 + +但本批分析最大的发现恰恰是**「转述即污染」**:十余处文档与注释所写与代码实际所做相反,且多数已被下游报告采信,直接造成过规模误判(见 §6)。因此: + +- 本页每条结论都标注**来源报告编号**(如 `[02]`)。要动手实现或据此拍板时,**回读该报告的取证行号,不要止步于本页**。 +- 本页**不新增任何结论**。凡本页出现而来源报告中没有的判断,视为本页的错误。 +- 各报告末尾均有「取证边界 / 未取证项」附录,汇总见 §7。**未取证项不得当事实使用**。 + +--- + +## 1. 报告索引 + +| # | 报告 | 行数 | 一句话 | +|---|---|---|---| +| 01 | [任务分解](01-pm-task-breakdown.md) | 742 | 28 工单 / 4 波 / 14 决策;推翻 8 处既有结论 | +| 02 | [后端技术评估](02-backend-technical-assessment.md) | 1114 | 队列方案、新模块、V6+V7、契约 +4/+5/+8;10 决策 | +| 03 | [Flutter 技术评估](03-flutter-technical-assessment.md) | 1670 | 自适应轮询、13 处 AI 模拟定位、测试 +300;11 决策 | +| 04 | [现实核查](04-reality-check.md) | 687 | 总体 **NEEDS WORK**,AI provider **BLOCKED** | +| 05 | [AI 创作 UI 规格](05-ai-create-ui-spec.md) | 1117 | 8 界面单元、等待态形态、新建 8 复用 16;18 决策 | +| 06 | [埋点与实验规划](06-experiment-tracking-plan.md) | 762 | 字典 41→49、eventVersion 定型、A/A 空跑;10 决策 | +| 07 | [基线证据审计](07-evidence-baseline-audit.md) | 550 | 基线逐条对账 6 对 3 错、真机项清点、发布流程一致性 | +| 08 | [Git 流程规划](08-git-workflow-plan.md) | 806 | 门禁漏洞、分支策略、契约升版顺序、v0.5.0 门禁;10 决策 | + +决策总计约 **73 项**,本页只提炼需要跨角色协调或阻塞开工的部分(§3、§4);其余为各报告内的细节决策,随工单执行时就地拍板。 + +--- + +## 2. 基线实测(开工基准) + +三仓工作区洁净、均停 `v0.4.0`;HEAD:api `dev@3cd8005` / flutter `dev@fbcd734` / doc `main@5cc6361`。`[07][04]` + +| 项 | 文档声称 | 实测 | 裁决 | +|---|---|---|---| +| api 测试 | 379 | **381** | 文档错(成因待仲裁,见 §4-2)`[07][04]` | +| flutter 测试 | 597 | 597 通过 **+ 2 skipped** | 数字对,skip 从未披露 `[07]` | +| 埋点白名单 | 42 | **41** | 文档错,根因 `experiment_exposed` 重复计数 `[07][06]` | +| 真机验证挂起 | 8(或 4/6) | **10**(M2 2 + M3 4 + M3.5 4) | 文档错,转述链每跳丢项 `[07][04]` | +| E2E 场景 | 42 | **42** | 对 `[07][04]` | +| E2E 断言 | 234 | 机械可数 **226** | 差异待仲裁,见 §4-6 `[04]` | +| 契约 v1.4.0 | 32/45/75 | 逐格相符 | 对 `[07][04]` | +| 四模块快照 | 字节一致 | md5 `a7081fb8…5801` 五处一致 | **副本一致为真,但无 CI 保证**(§6-2)`[07][04]` | +| 契约矩阵 | 181 格 | 表格自洽,**不可机械复核** | 只存在于报告散文;代码唯一规模断言是 `operations()==45` `[04]` | +| Flyway | V1~V5 | V1~V5,无 V6;`V5:48` `generation_job_id uuid,` 零 `REFERENCES` | 对 `[07][04]` | +| ADR | 001~022 | 22 条无缺号 | 对 `[07]` | +| 容器 | 六容器 | postgres/minio/auth/user/pet/community | 对;**无 Redis、无 MQ** `[07][02]` | +| 文档站 | 可构建无死链 | `mkdocs --strict` exit 0,102 页零孤立 | 对 `[07]` | +| CI | 「仍红」(`iteration-3.5/04 §7.3`) | 三仓五上下文 09-14 全 `success` | **过期快照,勿当风险继承** `[04][08]` | +| v0.4.0 发布时间 | 09-11 | **09-14 11:17** | 文档错 `[08]` | + +--- + +## 3. 多方独立收敛的结论(证据已定,无需拍板) + +| 结论 | 独立证实方 | +|---|---| +| **队列用 Postgres `FOR UPDATE SKIP LOCKED` + 租约列,不引入 Redis/MQ**。目标模型已备好六列租约字段与两条与出队/回收语句逐列对齐的部分索引(`patbond_postgresql.sql:605-716`,现成领取 SQL 在 `:1896-1917`);换中间件等于让这批列作废、推翻已评审模型。缺的只有租约超时回收器。 | `[01][02][04]` | +| **服务端零对象写能力**:`ObjectStorage` 仅 4 方法,**零 `putObject`、零 `getObject`**。故「媒体链路可直接复用」只对读侧与客户端上传侧成立,Worker 落产物与喂输入均为净新增。**这是 M4 立足点缺失的一块地基。** | `[01][02][04]` | +| **「四模块字节级快照锁 CI」不存在**:CI 只有 `check-secrets.sh` + `mvnw test`,无任何 diff/校验和;实际门禁是结构断言(版本串 + 计数 + tag 操作集)。后果:只改 description 的契约漂移,四模块全都不会红。 | `[01][02][04][08]` | +| **定稿模型是图生图,不支持文生图**:`input_asset_id uuid NOT NULL`。 | `[01][02][05]` | +| **「等真机设备」是伪阻塞**:本机 `Pixel_7` AVD 可启动、`/dev/kvm` 为 `crw-rw-rw-`、android-36 镜像在位,而 `device-verification.md:9` 明文接受「Android 真机(推荐)**或 Android 模拟器**」。 | `[04][06]` | +| **契约完全没有 429 / `Retry-After` / 任何响应头机制**(`components.headers: []`,全契约 `headers:` 零命中);后端 429 亦零命中。M4 是首次引入。 | `[03][04][05]` | +| **跨 schema 外键的裁剪理由已不成立,补回是 V5 自己下的指令**:`V5:15-19`/`V3:14-19` 原话为「FKs into schemas **not yet migrated** are STRIPPED」——理由是目标 schema 当时不存在;反证有 `V1:262-264`、`V5:45-46`、`V5:107` 三条在案的跨 schema 外键。 | `[02]` | + +--- + +## 4. 必须开工前拍板 + +### 阻塞 V6 定稿与开工 + +| # | 决策 | 现状与推荐 | +|---|---|---| +| **A** | **AI provider 选型** | **BLOCKED**。零 provider SDK、零 endpoint、零额度;密钥面仅 4 项(DB/internal/MinIO×2);本机 AMD Vega 集显无 CUDA,自托管不可行。**最硬的反证:正典种子自己写的就是 `provider_code='fixture'`**,且 `generation_jobs` 三个快照列 `NOT NULL`,逼迫显式选边。推荐 **fixture provider 起步 + 可插拔适配层**(M4 四条验收标准全部与图好不好看无关,fixture 还能注入真实供应商难复现的故障)。**缺失的外部输入:生产服务器 GPU 情况、云 API 报价** —— 这是唯一真正卡住的外部依赖。`[01][02][04]` | +| **B** | **参考图必填(图生图)还是可选(文生图)** | 三方独立指出与工单描述「可选参考图」冲突;定稿模型 `input_asset_id NOT NULL`,正典原文亦为「上传一张照片…」。推荐**沿用 NOT NULL、图生图先行**。此项同时决定 V6 能否照抄目标模型、create 页整个交互、A2 的 gating 形态。`[01][02][05]` | +| **C** | **上游调用形态** | 推荐可插拔 `GenerationProvider` **两阶段 submit/poll**。两阶段是硬要求:否则「不重复扣费」的恢复路径永不被测试覆盖。stub 用 JDK 内置 ImageIO/Java2D 出真实字节,零新依赖。`[02]` | +| **D** | **新模块归属与命名** | 推荐新建模块、端口 `:8085`、前缀 `/api/v1/creation/**`。**命名存在冲突**:`patbond-creation``[01]` vs `patbond-ai``[02]`。ADR-009 原文记载「后端评估建议 user 内包,**用户裁定新建模块**」。代价:`BearerAuthFilter` 将成第 4 份(community 版 Javadoc 自称 "Third copy")、存储/配置第 4 份、`UuidV7` 第 3 份。`[01][02]` | +| **E** | **4 件共享物是否上提 common** | 推荐同波次上提,退路是各抄一份。**本迭代最大回归面**,与 D 强耦合。`[02]` | + +### 流程与安全(建议第一波,先于相关代码落地) + +| # | 决策 | 现状与推荐 | +|---|---|---| +| **F** | **门禁必需上下文改为 `(pull_request)`** | 现状必需上下文为 `CI / backend-test (push)`,而 `ci.yml:16-17` 把 push 触发器限定在 `branches: [dev]`。**双重后果**:① 门禁实际由「推 dev」满足,**PR 侧检查红也能合并**;② **任何非 dev 分支 → main 的 PR 永久无法合并**(head 永不产生 `(push)` 状态),使 `git-workflow.md:9` 的 hotfix 短命分支纪律实际是死的。正确上下文真名已从 CI 实跑记录取得(非猜测):`CI / backend-test (pull_request)` / `CI / flutter-gates (pull_request)`。**推荐替换而非追加**(追加不解除 hotfix 死锁),改完做一次**空 PR 验证**,勿拿正式发布当试验场。`[04][08]` | +| **G** | **`check-secrets.sh` 补 AI 凭证规则** | 现有 7 条规则对 `sk-`/`sk-ant-` **零覆盖**,`KEY-ASSIGN` 只认 `access_key`/`secret_key` 不认 `api_key`,故 `ANTHROPIC_API_KEY=sk-ant-…` 全漏网。**必须先于任何 AI key 落地。** 另:三仓 `core.hooksPath` 全部 unset,防泄漏第一层完全未生效。`[08]` | +| **H** | **北极星 09-21 时限处置** | **窗口已于昨日关闭**,不是「来不及」:09-21 是 W37 队列(首记 09-07~09-13)+8 天的成熟日,而 09-13 = 周日 = W37 最后一天,今日 09-14 已是 W38 第 1 天,此后产生的首记永远进不了 W37。**补救无从下手。** 且 W37 分母极可能为 0(真机执行记录全空、无分发、API 未对外、桌面 100% 丢弃)。推荐:档 A 把首次出数由**日期承诺改为事件驱动触发条件**(改写 `device-verification.md:40` 等两处)+ 档 B 09-21 仍出一次「基建就绪读数」(只报 SQL 可执行 + 实测分母 + 链路证据)。**明确否决**用桌面 override 或 curl 造数凑读数。另:北极星出数**至今无可执行 SQL 载体**,`iteration-2/06 §2.1` 的 SQL 只存在于报告正文。`[06]` | +| **I** | **发布流程固化去处** | 正典是一份标题至今写着「草案」的迭代报告,8 步里 4 步过期(第 2/3/4/6 步);`git-workflow.md` 全文 65 行无任何发布/PR/E2E 内容。推荐固化进 `git-workflow.md` 新章节,**E2E 份数只在一处写死**,其余位置表述为「全份」并链接过去(防复发)。`[07][08]` | +| **J** | **文档站访问控制** | 登记项仅为 `server-exposure.md` §2 表格里一个内联 `⚠️`,无责任人、无期限、「归属决策」列为空、未进 §5 行动记录。**102 页全部公开,含 `server-exposure.md` 自身、`ci-runner-setup.md`、以及 09-11 安全事件复盘**(端口清单、常驻服务、内部 API 路径、刚被攻击那台主机的注入路径与处置手法)。与 Gitea 同机同 nginx、共用 443、无鉴权层。**防护方式存在分歧**,见 §4-3。`[07][04]` | +| **K** | **进度反馈机制** | 推荐**自适应轮询**(1s×3 → 2s×5 → 3s,上限 5s,硬超时 5min),不做 SSE/WebSocket。决定性理由:现有网络层全部围绕「信封解包 + 401/40101 单飞刷新重放一次」构建(`api_client.dart:97-131,163-176`),SSE 两条都用不上,**等于旁路整条已建好的鉴权链、形成第二套鉴权路径——而 M3.5 端口事故的根因正是「跨模块调用绕开既有接线纪律」**;且两侧流式基础设施均为零(后端 `SseEmitter` 0 命中、客户端 `ResponseType` 0 命中);**A 是 B 的真子集**(任何推送方案都必须有「查状态」GET 兜底,有了它轮询已免费到手)。`[03][05]` | +| **L** | **中断恢复的事实来源** | 推荐服务端「我的进行中任务」端点,**不做本地持久化**。理由:与「服务端是唯一事实来源」纪律一致;全仓刻意没有任何业务任务态被持久化(M3 防泄漏成果),开这个口子须在 `app.dart:242-249` 再加一处清理,漏则是安全缺口;换设备场景下本地方案完全失效。`[03]` | +| **M** | **eventVersion 定型** | 定为「**每个事件自身的 props schema 版本**」,否决「字典世代」读法。决定性理由是**可修复性不对称**:此读法下客户端现行硬编码 `1` **本就正确**(零改动零回填);「字典世代」读法下全部历史行皆错且不可修复(`server_ts` 无法反推事件当初属于哪代字典)。落地:白名单 key 改 `(name, version)` 二元组 + 新增逐条拒绝原因 `unknown_event_version`——**必须放逐条路径,不可放 DTO 校验,否则重犯 platform 的整批连坐错误**(见 §6-1)。`[06]` | + +--- + +## 5. 待仲裁:agent 之间的分歧(6 项) + +| # | 分歧 | 两方主张 | +|---|---|---| +| 1 | **新模块命名** | `patbond-creation`(`[01]`)vs `patbond-ai`(`[02]`)。端口 `:8085` 与前缀一致,仅名称不同。 | +| 2 | **api 测试 381 的成因** | `[04]`:`3cd8005` 就是那次「379→381」的契约冻结提交,`releases.md:17/:35`、`feature-checklist.md:5` 照抄了冻结前的数(给出 3+131+40+100+107 分解)。`[01]`:379 是 surefire 报告数、381 是注解数,属参数化展开差异、非回归。**两解释互斥**;`[04]` 给了可核的分解,倾向 `[04]`,但两方均未联合复跑。 | +| 3 | **文档站防护方式** | `[07]`:IP 白名单(单读者、纪律 6 下不必新增凭证),或内容分层把三份敏感文件剔出公开构建。`[04]`:basic auth。 | +| 4 | **A/B 前置状态口径** | `[01]`:**5 绿 3 半**(分流哈希、Flutter 曝光封装、feature flag 三者代码零实现)。`[06]`:**放行条件 0/6 满足**,故 M4 首个「实验」应为 **A/A 基建验证空跑**(零产品风险,≥200 曝光即可暴露分流失衡与曝光漏报)。两者可能在数不同的东西(前置组件 vs 放行条件),需统一口径后再排期。**注意:原文档「6 绿 1 半」两方均否定** —— 按它排期会在实验启动日才发现要先写三个组件。 | +| 5 | **429 错误码个数** | `[02]`:单码 `42900`。`[05]`:**必须拆两个码** —— 配额耗尽等的是明天(弹 sheet 给出口),频率过快等的是几秒(弹 SnackBar),共用一码必然有一半场景的文案和出口是错的。 | +| 6 | **E2E 断言数 226 vs 234** | `[04]`:机械可数 226(210 `check(` + M1 16 `✗`)。`[07]`:静态调用点 207,缺口**可能**由循环执行产生但原报告从未说明。声称值 234 无来源可复核。 | + +**两处属细化而非冲突,一并记录**: + +- **SegmentedButton 债的优先级**:`[01]` 改判搭车 M4(create 页分段器正是改造对象,一次修 6 处,边际 S);`[05]` 实测 `secondaryContainer #FFDAD2` + `onSecondaryContainer #5D4038` **对比度 7.19:1 达标**,故是 **P2 品牌一致性债、非 P1 无障碍债**,不应为它拖 M4 范围。`[03]` 补根因:主题层无 `segmentedButtonTheme` 亦无 `chipTheme`(0 命中),与 M3.5-01 修 DatePicker **完全同构**,`app_theme.dart:215-296` 是现成样板。→ 结论:搭车但降级为 P2。 +- **`widthPx/heightPx` 的归因**:`[01][04]` 记为服务端缺尺寸探测;`[03]` 拆成**三段事实** —— 详情页客户端**已正确消费**(`post_detail_page.dart:515-521` 有 clamp,服务端一给即生效)、Feed 卡片是**客户端硬编码缺陷**(`post_card.dart` 与 `post_media_grid.dart:326-327` 硬编码 4:3,维度可用却被忽略)、**客户端根本无法提供维度**(`CreateMediaUploadRequest` 契约无该字段)。→ 必须两侧同批改,只修一侧用户零感知。`[05]` 补严重度判断:不是「更严重了」而是**「从看不见变成看得见」**——A4 用 job 自己的 width/height 画出完整构图、发到 Feed 后被裁成 4:3,是**同一次会话内的直接前后对照**,9:16 竖图裁掉约 58%,用户结论会是「发布把我的图裁坏了」。修复成本极低(Worker 写 output asset 时 job 的 width/height 就在手里)。 + +--- + +## 6. 「文档与代码相反」清单(本批核心教训) + +M3.5 曾因一句转述(报告写「无 nickname 字段」实指「契约未暴露」)把规模预估**高估一整档**。本批发现该模式**远非孤例**: + +| # | 说谎处 | 实际情况 | 已污染范围 | +|---|---|---|---| +| 1 | `analytics_service.dart:99-102` 注释称「逐条 rejected、不影响客户端」 | **与实现相反**:`TrackEventsRequest.java:16` 的 `@Valid` 级联到列表元素 → 任一元素失败即整请求 400 → 客户端 `:273-281` 视为永久拒绝并**整批删段**。**这是一个与平台无关的「毒丸批次放大器」**:任何一条事件的任何一个字段失败都会毒死整批 50 条。桌面采集能力为零。 | 转述进 **5 处文档** `[03][06]` | +| 2 | CI 自称 "byte-identical"、文档称「四模块字节级快照锁 CI」 | CI 零 diff、零校验和;md5 比对是人工步骤 | 4 份报告 `[01][02][04][08]` | +| 3 | `releases.md` 同一文件 `:36/:56`「四份」vs `:131`「双份」 | 前瞻侧(给 M4 用的那条)写错,照它执行漏跑 **17** 个场景 | `[07][04][08]` | +| 4 | 发布 checklist `iteration-3/08:84`「跑 M2+M3 **两份**」 | 应为全份;且该 checklist 标题至今是「草案(验证后固化进 `git-workflow.md`)」,**固化从未发生**(grep `git-workflow.md` 的 `checklist\|发布\|E2E\|PR` 零命中) | `[07][08]` | +| 5 | `openapi.yaml:1228`「purpose 仅 `post_image`」 | 与 `:3460` 三值枚举矛盾(M3.5 加值时漏改端点描述)。M4 再加值必须一并修,否则第三次漂移 | `[03]` | +| 6 | `PostMediaAttachIntegrationTest:44-45` 断言 640/480 **是绿的** | 靠 `CommunityTestData:55` 夹具直插。**测试绿 ≠ 生产有值**,生产恒 null | `[04]` | +| 7 | `feature-checklist.md:218`「community 域 19 条」 | 算术错误,代码自身注释拆分为 8+2+8=**18**,`experiment_exposed` 被重复计数;`EventDictionaryTest` **零条数断言**故漂移不可见 | 4 份文档 `[07][06]` | +| 8 | `releases.md:17/:35`、`feature-checklist.md:5` 的 379 | 照抄了契约冻结前的数,实测 381 | `[04][07]` | +| 9 | `releases.md:121` 整段漏掉 M3.5 | 真机项转述链 **4→6→8→10 每跳都在丢项** | `[04][07]` | +| 10 | `releases.md:47`「分支保护确实要求该 (pull_request) 检查」 | 表象对、机制归因错:真正解锁合并的是 09:44:05 转绿的 `(push)` | `[08]` | +| 11 | `iteration-3.5/04 §7.3`「CI 仍红」 | 过期快照;同一 run 73 已于 09-14 09:38 重跑、09:44 转绿 | `[04][08]` | +| 12 | `iteration-3.5/05` 引 `integration_test/` 证「桌面链路可用」 | 那 4 份真机测试**完全在 `flutter test` 之外**,无任何门禁会跑。凡「桌面/真机实测已验证」一律降级为「有脚本,无门禁」 | `[07][03]` | +| 13 | M3 建议「话题随 `ai_creation` 一起做」 | `ai_creation` 只是 category 枚举值,与 topics 表**零字段关联**,搭车理由不成立 | `[01]` | + +**由此造成的既有误判(本批已纠正)**: + +- `widthPx/heightPx` 规模 **M→S**(列早已存在于 `V1:217-218`,缺的只是尺寸探测)`[01]` +- 「M4 需设计数据模型」→ **完整设计早已定稿**(3 表 40 列 11 索引),且**零跨 schema FK 需裁剪**,T4-01 由 **L→M** `[01]` +- 「社区侧有 purpose 白名单校验」→ **不存在**。`PostService.validateAssets:343-358` 只校验归属 + ready,`MediaAssetRef.java:9-16` 与 `MediaAssetGateway` 的 SQL 均不含 `purpose` 列(对比 pet 侧 `PetService.java:196` 确有校验)。**后果:不补强制校验,「AI 创作」标签可被任意图片伪造。** `[02]` +- 「需评估 DB / Redis / MQ 三选一」→ 前提本身多余,目标模型已把租约队列设计完(§3)`[02][04]` +- 「A/B 前置 6 绿 1 半」→ 两方均否定(§5-4)`[01][06]` + +--- + +## 7. 未取证项汇总(不得当事实使用) + +| 项 | 缺什么 | 阻塞谁 | +|---|---|---| +| 生产服务器 GPU 情况、云 API 报价 | 外部输入 | **决策 A(阻塞开工)** `[01]` | +| E2E 断言精确值(226 / 234) | 脚本无计数器;循环执行未证 | 门禁数字口径 `[04][07]` | +| 契约矩阵 181 格 | 只存在于报告散文,无机械可数载体 | 契约漂移检测可信度 `[04]` | +| 分支保护「禁直推」的具体机制 | `/branch_protections` 返 401(`/branches/main` 匿名可读故主结论仍成立) | 决策 F 的复核 `[08]` | +| E2E 零失败重跑、零迁移实证 | compose 未起 | 发布门禁复现 `[04][07]` | +| `check-secrets.sh` 实际效力 | 未实跑 | 决策 G `[04][07]` | +| 服务器侧实际暴露面 | 只做了文档取证,未探测服务器 | 决策 J `[04][07]` | +| 服务端是否探测图片尺寸、`page_viewed` props 取值集合 等 6 项 | 见 `[03]` 附录 A | 相关工单 `[03]` | +| 实验设计模板文档实体、北极星出数 SQL 载体 | 均无实体 | 决策 H `[01][06]` | +| 厂商侧参数配比、配额默认值、单机资源余量 | 需产品/运维确认 | 后端工单 `[02]` | + +--- + +## 8. 规模概览(各报告独立估算,未经交叉校准) + +| 维度 | M4 预期 | 来源 | +|---|---|---| +| 工单 | 28(含 3 条件单),S×5 / M×16 / L×4,4 波 | `[01]` | +| 关键路径 | 4 个 L 单:后端队列 2 + 客户端状态机 2(与 M3「媒体双端占其二」同型) | `[01]` | +| Flyway | **2 个**:V6(creation schema 三表 + 13 索引 + 3 触发器 + 补回 `posts.generation_job_id` 外键)、V7(目录种子)。creation schema 零结构偏离,逐列抄目标模型 | `[02]` | +| 契约 | v1.4.0 → **v1.5.0 纯增量**:+4 路径 / +5 操作 / +8 schema(32/45/75 → **36/50/83**)、+3 错误码、改 4 个既有 schema;零删除零重命名零必填收紧。「一键建草稿」复用既有 `POST /api/v1/posts`,**0 新增路径** | `[02]` | +| 客户端页面 | 6 页(4 AI + 2 搭车:我的收藏 / 我的草稿) | `[03]` | +| UI 界面单元 | 8(6 页 + 1 sheet + 1 全局层),新建组件 8 / 复用 16(6 需小改) | `[05]` | +| 客户端测试 | 597 → **约 897**(建议 +300) | `[03]` | +| E2E | 42 → **54 场景**;新增第五份 `test_e2e_m4_manual.dart` | `[03][08]` | +| 埋点字典 | **41 → 49**(新增 8 条 `creation` 域 + 2 处既有事件加属性 + 启用 `experiment_exposed`,废弃 0) | `[06]` | +| demo 消亡 | `create_page.dart` **563 行 → 约 40 行**(13 处模拟) | `[03]` | + +--- + +## 9. 今天即可执行的两件事 + +1. **M2 两项真机验证** —— 约 **55 分钟**(其中 35 分钟纯等待),模拟器合规、`Pixel_7` AVD 已就位,只差导出 `ANDROID_HOME`/`PATH`。**已白拖 6 天**;虽已错过 W37 窗口(决策 H),但它把最早可得读数从「无限期」拉到 **2026-09-28**。`[04][06]` +2. **`check-secrets.sh` 补 AI 凭证规则** —— 决策 G,须先于任何 AI key 落地。`[08]` + +--- + +## 10. 必须与写侧同批修掉的两处(否则 M4 制造新缺陷) + +1. **数据损坏路径**:`post_compose_page.dart:209-211` 把恢复的 AI 草稿类目静默降级为 `general`。现在无害(写侧被封),**M4 放开写侧后会把类目改错并写回服务端** —— 必须与放开写侧同一个提交改掉。`[03]` +2. **`CommunityMigrationIntegrationTest.java:81-88` 断言 `fkCount == 0`** —— V6 补外键**必然让它变红**,需拆成 generation=1 / region=0。提前知道可省一轮排查。`[02]` + +--- + +## 11. 两条会影响 M4 观感的客户端纪律 + +1. **绝不能承诺推送**:客户端**无任何推送能力**(无 WS/SSE/FCM/相关端点)。等待页写「完成后通知你」会让用户锁屏等推送然后什么都收不到。正确说法是「回到 App 就能看到结果」。`[05]` +2. **不要押注百分比进度**:`generation_jobs` 的 CHECK 规定 `queued` 时 progress 恒 0、`running` 时 progress 无下限约束,押注「会有百分比」的后果是**最常见观感变成「0% 卡住」**。故主形态为阶段文字 + 不确定进度环,`progress>0` 才叠确定型数字;**不展示 ETA,改展示已用时**(已用时是事实,ETA 是承诺,而 M4 给不出可信 ETA)。另需定义服务端 5 个 status 之外的**第六个 UI 态:僵死/超时**(`running` 超阈值),且它不是错误态。`[05]` + +--- + +## 12. M4 核心指标当前算不出来 + +两个 post 事件(`post_publish_succeeded`、`post_draft_saved`)的现行白名单**无任何标识帖子来源的属性**,因此「AI 生成 → 发帖」这条 **M4 最核心的转化在现行字典下根本不可计算**。处置:各加 `creationTaskId`。另建议补一条**断言白名单条数**的测试——本批 41/42 漂移之所以四份文档不可见,正因 `EventDictionaryTest` 零条数断言。`[06]` diff --git a/docs/development/iterations/iteration-4/01-pm-task-breakdown.md b/docs/development/iterations/iteration-4/01-pm-task-breakdown.md new file mode 100644 index 0000000..ce12a39 --- /dev/null +++ b/docs/development/iterations/iteration-4/01-pm-task-breakdown.md @@ -0,0 +1,742 @@ +# Patbond 第四迭代任务分解(M4 AI 创作) + +> 作者:Senior Project Manager +> 日期:2026-09-14 +> 依据:`docs/development/development-plan.md`(第 7 节 M4、第 4/6 节规范、第 9/10 节质量门禁与 DoD)、`iterations/iteration-3/29-m3-summary.md` §4、`iterations/iteration-3.5/06-wave2-closure.md` §7、`docs/architecture/backend-modules.md`、`docs/architecture/decisions.md`(ADR-001~022)、`docs/database/patbond_postgresql.sql`(`creation` schema 3 表,已评审定稿)、`docs/api/openapi.yaml` v1.4.0(32 路径 / 45 操作 / 75 schema,冻结中) +> 编号约定:本迭代工单以 `T4-` 前缀编号,决策以 `D4-` 前缀编号。 +> 范围声明:严格限定为 M4 AI 创作。本地服务与预约(M5)、通知推送与交付加固(M6)不在本迭代范围;`posts.region_id` 仍仅作预留。范围外需求一律记 backlog。 + +**结论摘要**:工单 **28** 个(其中条件单 3 个)分 **4 波**;待用户拍板决策 **14** 项,头号三项为 AI 提供方选型、模块归属与 Worker 形态、生成输入图是否必填。开工审计**推翻或修正了 8 处既有文档结论**,其中两处直接改变规模预估:`creation` schema 的完整设计早已评审定稿(V6 是提取而非设计,且零跨 schema 外键需裁剪),而「输出写入媒体表」一句话背后隐藏着「服务端目前根本不能写对象存储」这一真实工作量。 + +--- + +## 1. 范围界定与依据 + +### 1.1 开发计划 M4 原文(正典依据) + +开发计划第 7 节 M4(`development-plan.md:246-255`)逐字引用: + +- 目标:「替换上传和生成延时模拟。」(:248) +- 「实现模型/风格目录、生成任务创建、查询和取消。」(:250) +- 「Worker 通过队列消费任务,使用租约、重试和幂等键防止重复生成。」(:251) +- 「输出写入媒体表,成功后可一键创建社区草稿。」(:252) +- 「Flutter 展示排队、生成进度、失败重试和取消状态。」(:253) +- 验收标准:「任务状态流转合法;Worker 重启不丢任务;相同幂等请求不重复扣费或生成;失败原因可追踪。」(:255) + +四条验收标准与工单的映射: + +| 验收标准 | 主责工单 | 取证方式 | +| --- | --- | --- | +| 任务状态流转合法 | T4-06 / T4-07 / T4-08 | `ck_generation_jobs_state` 数据库层强制五态字段组合(目标模型 `patbond_postgresql.sql:667-692`),非法迁移**在库层即被拒**;应用层再补六类路径测试 + T4-25 E2E | +| Worker 重启不丢任务 | T4-08 | 租约过期回收(`lease_expires_at` + `ix_generation_jobs_running` partial 索引);T4-25 E2E 在 running 态重启容器后断言任务被重新领取并完成 | +| 相同幂等请求不重复扣费或生成 | T4-06 | `UNIQUE (user_id, idempotency_key)` + `uq_generation_jobs_provider_request`;配额按 `generation_jobs` 行数计(见 D4-7),幂等命中不新建行即天然不重复扣费 | +| 失败原因可追踪 | T4-07 / T4-08 | `error_code` / `error_message` / `attempt_count` 三列经查询端点外露;provider 原始失败经日志(不含密钥)留痕 | + +### 1.2 其他正典约束(M4 必须遵守) + +- **业务域→schema 映射已定**:`development-plan.md:67`「AI 创作 = 模型、风格、异步任务、重试、生成结果 → `creation` schema」;:64「媒体域含 AI 输入输出」。 +- **写接口幂等强制**:`development-plan.md:173`「创建帖子、**生成任务**和预约等写接口必须支持 `Idempotency-Key`」。 +- **可观测性硬要求**:`development-plan.md:329`「技术指标须含**队列积压和 Worker 失败率**」;:330 产品漏斗须含「**AI 任务成功**」。 +- **`patbond-common` 纪律**:`development-plan.md:79`「不应让所有服务被动引入 RabbitMQ、Feign 等依赖」——这条对 D4-3 队列载体选型有直接约束力。 +- **Flutter feature 拆分已预留 `creation`**:`development-plan.md:96` 明列 `auth/pets/community/creation/marketplace`。 +- **单迁移链**:`backend-modules.md:41`「Flyway 迁移链唯一持有者……**其他模块不得携带 Flyway**」——V6 必须进 `patbond-user`,与模块归属决策(D4-2)解耦。 +- **结构性变更须经 ADR**:`backend-modules.md:3-5`「新增/拆分模块须经 ADR 决策并同步更新本页」。M4 是 **ADR-023 起**的落点(现存最高 ADR-022)。 +- **AI 提供方在正典中仍为未决项**:`development-plan.md:358`「AI 提供方、对象存储、天气/地图供应商和通知渠道尚未确定」、:365 待确认事项同列。对象存储已由 ADR-016 解决,**AI 提供方是 M4 唯一从头拍板的供应商项**(D4-1)。 + +## 2. 开工前的关键事实(PM 逐项核实原始 SQL / 契约 / 源码) + +> **纪律说明**:M3.5 的教训(`iteration-3.5/06-wave2-closure.md:42`「转述不可采信」——一份报告写「无 nickname 字段」实指「契约未暴露」,被当成缺列,规模高估一整档)在本节被严格执行。下列每一条「存在 / 不存在」的判断都注明了核实的**原始文件与行号**,未经原文核实的一律标注「未取证」。 + +### 2.1 `creation` schema 的完整设计早已评审定稿——V6 是**提取**而非设计(推翻「M4 需从零设计数据模型」的隐含预期) + +`docs/database/patbond_postgresql.sql`(评审定稿的目标模型)已含 `creation` schema 全部三张表: + +| 对象 | 行号 | 关键内容 | +| --- | --- | --- | +| `CREATE SCHEMA creation` | :60 | 七个 schema 之一(platform/identity/media/pet_health/community/**creation**/marketplace) | +| `creation.generation_models` | :556-573 | `code`/`display_name`/`provider_code`/`provider_model_name`/`media_kind`/`enabled`/`sort_order`;`UNIQUE (code, media_kind)` + `UNIQUE (id, media_kind)`;索引 :575-577(partial `WHERE enabled`) | +| `creation.generation_styles` | :580-598 | `code`/`title`/`subtitle`/`media_kind`/`preview_asset_id`(FK→media.assets ON DELETE SET NULL)/`enabled`/`sort_order`;索引 :600-602 | +| `creation.generation_jobs` | :605-696 | **40 列**,含完整队列语义(见 2.2) | +| 队列索引 | :711-715 | `ix_generation_jobs_queue (priority DESC, next_attempt_at, created_at, id) WHERE status='queued'`、`ix_generation_jobs_running (lease_expires_at, id) WHERE status='running'` | +| provider 幂等唯一索引 | :699-700 | `uq_generation_jobs_provider_request (provider_code_snapshot, provider_request_id) WHERE provider_request_id IS NOT NULL` | +| `updated_at` 触发器 | :1245-1249 | 三张表均复用 `platform.set_updated_at()`(V1 已建) | +| 开发种子 | :1495-1523 | 2 模型(image+video)/ 5 风格 / 1 已完成任务;`provider_code = 'fixture'` | + +**规模含义**:T4-01 的性质与 M3 的 T3-01 完全同构(「从 bootstrap SQL 提取」,`iteration-3/01:52`),是**抄录 + 裁剪判断**,不是建模。据此定为 **M** 而非 L。 + +### 2.2 M4 的迁移**零跨 schema 外键需要裁剪**——与 V3/V5 的先例相反 + +V3 剪了 4 条 marketplace 外键、V5 剪了 2 条(`V5__community_baseline.sql:15-25` 原文)。M4 的 V6 **一条都不用剪**: + +- `generation_jobs` 的三条跨 schema 外键目标全部已存在:`identity.users`(V1:60)、`pet_health.pets`(V3:35)、`media.assets`(V1:205)。`generation_styles.preview_asset_id → media.assets` 同理。 +- `generation_jobs` 内部两条复合外键(→`generation_models(id, media_kind)`、→`generation_styles(id, media_kind)`,:644-647)同一迁移内建表即可满足。 +- **反向的一条要补回**:`community.posts.generation_job_id` 目前是裸列无外键——`V5__community_baseline.sql:47` 行内注释原文 `-- generation_job_id: bare nullable uuid, FK to creation.generation_jobs stripped (M4 补回)`,列定义 :48 `generation_job_id uuid`(nullable),索引 `ix_posts_generation_job` 已在 :95 建好。V5 文件头 :17-19 亦言明「the M4 migration that creates the creation schema re-adds this constraint」。 + +### 2.3 队列语义在库层已完备——**不需要引入任何新中间件** + +`generation_jobs` 已含全套 DB-as-queue 列(`patbond_postgresql.sql` 行号):`status`(:625)、`progress`(:626)、`priority`(:627)、`attempt_count`/`max_attempts`(:628-629)、`provider_request_id`(:630)、`error_code`/`error_message`(:631-632)、`idempotency_key`/`request_hash`(:633-634 均 NOT NULL)、`next_attempt_at`(:635)、`lease_owner`/`lease_expires_at`(:636-637)、`started_at`/`completed_at`(:640-641)、`version`(:642)。 + +配合 :711-715 的两条 partial 索引,`SELECT … FOR UPDATE SKIP LOCKED` 即构成带优先级、退避与租约回收的完整队列。 + +**与开发计划的偏离提示**:`development-plan.md:205` 写「RabbitMQ 在异步任务阶段启用」。核实基础设施现状后(见 2.4),PM 建议本迭代**不引入 RabbitMQ**,改用 DB 队列——这属于对正典的建议性偏离,须经 D4-3 拍板并落 ADR。 + +### 2.4 现有异步基础设施:几乎为零(这是 T4-08 的真实起点) + +| 事实 | 核实位置 | +| --- | --- | +| 无 Redis / RabbitMQ / Kafka(依赖与容器均无) | `docker-compose.yml` 六服务:postgres:13 / minio:34 / user:49 / auth:79 / pet:97 / community:125;各模块 pom 依赖清单(如 `patbond-user/pom.xml:28-104`)无任何 MQ/Redis 坐标 | +| `@EnableScheduling` 仅一处 | `patbond-user/.../UserApplication.java:7` | +| `@Scheduled` 唯一使用点 | `patbond-user/.../session/SessionCleanupJob.java:32-41`(`fixedDelayString = "${patbond.session.cleanup-interval:PT6H}"`),配置 `patbond-user/src/main/resources/application.yml:24-26` | +| `@Async` / `@EnableAsync` / `TaskExecutor` / `ThreadPoolTaskScheduler` | **全仓零命中**(主代码与测试) | +| 队列消费循环 / MQ 监听器 / outbox 发布器 | **不存在**。`platform` schema 注释(V1:24)承诺了 "reliable outbox",但表与消费代码均未落地 | +| 无限流 | 全仓无 `RateLimit`/`bucket4j`/`429`;`patbond-common/.../error/ErrorCode.java` 无 429 段(最接近的 `LOGIN_LOCKED(42300, 423, …)`:36 是账号锁定不是限流) | +| 无 feature flag 设施 | 全仓零实现(详见 2.9) | + +结论:M4 的 Worker 只有**一个先例可循**(`SessionCleanupJob` 式的 `@Scheduled` + DB 幂等操作,Spring 默认单线程 scheduler、多实例并跑无锁)。这既是 D4-2/D4-3 的现实约束,也说明 T4-08 必须自带线程池与并发度配置。 + +### 2.5 「输出写入媒体表」一句话背后:**服务端目前根本不能写对象存储**(本迭代最易被低估的工作量) + +`ObjectStorage` 接口只有四个方法(`patbond-user/.../media/ObjectStorage.java:15-37`):`ensureBucket()`:18、`presignPut(...)`:25、`stat(...)`:28、`presignGet(...)`:31。实现 `S3ObjectStorage.java` 中 `S3Client` 实际只用于 `headBucket`/`createBucket`(:66-79) 与 `headObject`(:102);`putObject` 仅出现在**预签名构造**里(:83-86 `presigner.presignPutObject(...)`)。 + +即:现有媒体链路是「客户端直传,服务端只签名与校验」。AI 输出的字节由 provider 返回、必须**由服务端落盘**,因此 M4 必须: + +1. 在 `ObjectStorage` 上新增写方法 + `S3ObjectStorage` 实现 + `MediaStorageConfig.java:33-59` 的 `UnconfiguredObjectStorage` 补桩; +2. 解决「谁来写 `media.assets` 行」——ADR-017(`decisions.md:156`)定死「media 上传流程实现在 `patbond-user`」,且 `media` 写入侧代码全在 `patbond-user/.../media/`(`MediaAssetRepository.insertUploading`:32-41、`markReady`:81-86);`pet`/`community` 侧只有 `MediaUrlSigner`(本地 SigV4 签名,不持 `S3Client`)与只读 `MediaAssetGateway`。 + +这直接决定 D4-2 的子问题:新模块如何把生成结果落进 media 域。已单列为工单 **T4-03**。 + +### 2.6 `media.assets` 的尺寸列早已存在——`widthPx/heightPx` 恒 null 不是缺列(M3.5 同型教训的复现) + +- `width_px integer` / `height_px integer` 均在 `V1__identity_media_baseline.sql:217-218`,约束仅 `ck_media_dimensions`(:241-245,正数校验),**nullable**。 +- 契约侧唯一关于回填的原文在 `openapi.yaml:3536`:`description: complete 后回填,可空`;但 `POST /api/v1/media/uploads/{assetId}/complete`(:1256-1299)**没有 requestBody**(:1274 直接进 parameters),也没有任何字段承载尺寸;`MediaAsset.required` = `[id, kind, purpose, mimeType, status, createdAt]`(:3516),两个尺寸字段不在其中。 +- 故此项的真实缺口是「**服务端无尺寸探测**」+「契约声明了来源不存在的回填」,规模为 **S**(配合 T4-03 新增的对象读能力),不是「加列迁移」。处置方案见 D4-9。 + +### 2.7 `ai_creation` 分类在写侧被**双重封死**(代码 + 契约) + +- 数据库允许:`V5__community_baseline.sql:68` `CONSTRAINT ck_posts_category CHECK (category IN ('general', 'help', 'ai_creation'))`。 +- 后端写侧拒绝:`patbond-community/.../dto/CreatePostRequest.java:26` `@Pattern(regexp = "general|help", message = "category 仅支持 general/help")`。 +- 契约写侧拒绝:`openapi.yaml:3657` `CreatePostRequest.category` enum `[general, help]`,:3659 注明「`ai_creation` 为 M4 预留值,M3 不开放写入(提交 400/40000)」;读侧 `Post.category`(:3747-3748) 与 `FeedCard.category`(:3826) 已含三值。 +- `generationJob` **在契约里没有字段本体**——`openapi.yaml:136` 与 :3717 只是说明文字,`PostResponse.java:12` 亦注明该字段「do not appear at all」。 +- 落库通路也缺:`patbond-community/.../repository/PostRepository.java:56-58` 的 `insertPost(...)` 签名与 :60-64 的 INSERT 列清单**均无 `generation_job_id`**。 + +即 T4-10 需同时改:Java 校验、Repository INSERT、契约 enum、契约新增 `generationJob` 字段。 + +### 2.8 目标模型内部存在一处矛盾,必须在 V6 前裁决 + +`generation_jobs.model_version_snapshot varchar(64) **NOT NULL**`(`patbond_postgresql.sql:622` 区块,:620-623 三个 snapshot 列)——但 `creation.generation_models`(:556-573)**没有任何 version 列**,种子数据把它硬编码为 `'1.0'`(:1514)。这是评审定稿模型里的一处自相矛盾,V6 必须选边(D4-6)。 + +另一处需注意的约束后果(可满足但不直观):`ck_generation_jobs_state`(:667-692)要求 `queued` 态必须 `started_at IS NULL AND progress = 0 AND lease_* IS NULL`。因此**租约过期回收/重试时必须把 `started_at` 清回 NULL**,首次尝试的开始时间会丢失。T4-08 需在实现说明中显式记录这一点。 + +### 2.9 A/B 前置「6 绿 1 半」不成立——实测为 **5 绿 3 半** + +`iteration-3/06-experiment-tracking-plan.md:336-351` 的八项前置表逐项对代码核实后: + +| # | 前置 | 文档判定 | 代码核实 | +| --- | --- | --- | --- | +| 4 | 稳定分流组件 | 绿(:345) | **部分绿**:`anonymousId` 持久化已落地(`analytics_service.dart:51`、:177-190),但 `hash(userId, salt) % buckets` 分流组件**全仓零实现** | +| 5 | 曝光事件 | 绿(:346) | **部分绿**:后端字典已有(`EventDictionary.java:103`,props `{experimentKey, variant}`),Flutter 侧 `grep -rn experiment lib test integration_test` **零命中** | +| 7 | 护栏监控与回滚 | 部分绿(:348,「feature flag 随社区功能发布开关顺带落地」) | **回滚也不绿**:feature flag / 社区发布开关在两仓中**完全不存在**,`feature-checklist.md` 连该条目都未登记 | + +另:#2「指标基线」判绿,但北极星出数 SQL 仅以 Markdown 代码块存在于 `iteration-2/06-experiment-tracking-plan.md:200-229`,三仓 `scripts/` 下**无任何可执行巡检/看板脚本**(只有 `check-secrets.sh` 与 `hooks/`)。 + +含义:「M4 可启首个实验」这一结论的前提比文档描述的弱。若要在 M4 启动实验,需先补分流 + 曝光 + 开关三件(工单 T4-23,条件单,随 D4-11)。 + +### 2.10 埋点白名单实测 **41** 条,文档三处写 42(`experiment_exposed` 被重复计数) + +- 权威定义在**代码常量**:`patbond-user/.../analytics/EventDictionary.java:42-104`,`grep -c 'Map.entry("'` = **41**。无字典表(V4 只 seed `breeds` 与 `vaccine_catalog`),DB 侧仅正则约束 `V2:21`。 +- 文档写 42 的三处:`iteration-3/22-event-whitelist-v3.md:4,14`、`iteration-3/29-m3-summary.md:20`、`feature-checklist.md:218`。 +- 根因:设计源文档自己写的是 19 个新事件(`iteration-3/06-experiment-tracking-plan.md:12,149`),22(v2)+ 19 = 41;22 号报告把 `experiment_exposed` 既算进 19 又单列一次。 +- **无门禁能拦**:`EventDictionaryTest.java` 无任何总量断言(无 `hasSize`/`size()`)。T4-22 顺手补一条总量断言即可永久钉住。 + +### 2.11 真机验证挂起实为 **10** 项(不是 8 项),M2 两项带 2026-09-21 硬时限 + +`device-verification.md` 逐项清点:M2 **2** 项(`:42` 验证一 Android 事件落库、`:63` 验证二 SessionTracker 30 分钟换会话)+ M3 **4** 项(`:114`/`:124`/`:134`/`:144`)+ M3.5 **4** 项(`:176`/`:190`/`:200`/`:209`)= **10 项**,三个执行记录(`:106`/`:168`/`:221`)全为「_(待补)_」,即 10 项全部未执行。 + +M2 两项的时限来自 `iteration-3/29-m3-summary.md:52`(埋点角色建议 2026-09-21 北极星首次出数日前完成,否则首批读数只能标未验收)与 `iteration-2/06-experiment-tracking-plan.md:308-312`(出数日历:2026-09-21 周一首次出数)。**今日 09-14,仅剩 7 天**——已按硬时限单独排为 T4-24 并置于第一波。 + +### 2.12 发布流程与回归门禁的实况(含一处文档自相矛盾) + +- `main` 已受分支保护禁直推,发布必须走 Gitea PR:`releases.md:126-138` 五步流程;状态检查上下文 `CI / backend-test (push)`(api)与 `CI / flutter-gates (push)`(flutter),见 `releases.md:110-114`。api 的 `ci.yml:15-18` 确认 `pull_request` 触发器在位、job 名 `backend-test`。 +- **回归门禁已由两份改为四份**:`releases.md:56` 原文「回归清单由两份改为四份……原则是『**每个引入对外端点的迭代都应有对应的 E2E 脚本,并在此后每次发布回归**』」;v0.4.0 实测 42/42 场景、234 断言(`releases.md:36`)。 +- **文档自相矛盾(须在 M4 收官修正)**:同一文件 `releases.md:131` 的「发布后生效的纪律」第 1 步仍写「E2E **双份**回归 PASS」,与 :36/:56 的四份口径冲突;且该原则**从未固化进 `git-workflow.md`**(该文件 :56-64 的门禁表只列三仓的 format/analyze/test/mkdocs 四命令,无 E2E 条目)。M4 引入生成域对外端点,按 :56 原则须新增**第五份** E2E 脚本(T4-25),并把「双份」改为「五份」+ 把该原则写进 `git-workflow.md`(T4-27)。 +- 现有四份脚本实为纯 Dart CLI(`dart run`,非 `flutter test`,不计入 597):`test_e2e_manual.dart`(M1,7 场景)、`test_e2e_m2_manual.dart`(11)、`test_e2e_m3_manual.dart`(14)、`test_e2e_m35_manual.dart`(10);四份同构骨架(`fail()`/`check()`/`_passed` 计数/`Resp` 包装/通用 `call()`/脚本内现注册账号取 token/token 与预签名 URL 脱敏),T4-25 照抄即可。 + +### 2.13 契约快照锁的实际强度**低于文档口径**(「四模块字节级快照锁 CI」不成立) + +- 正典 `docs/api/openapi.yaml` 与四份快照(`patbond-{auth,user,pet,community}/src/test/resources/contract/openapi-v1.4.0.yaml`)实测 md5 五处一致(`a7081fb84f1207eef579ab94025f5801`),字节级同步这一**事实**成立。 +- 但**CI 里没有任何 md5/checksum 校验**:`patbond-api/.gitea/workflows/ci.yml` 只有三步(secret scan :41-42、装 JDK17 :46、`./mvnw -B clean test` :55);`scripts/` 下只有 `check-secrets.sh`。md5 比对只出现在人工冻结记录里(`iteration-3.5/04-contract-freeze-v140.md:77`、`03-backend-profile-avatar.md:287`「字节级复制正典 + md5 逐一比对」= 人工步骤)。 +- CI 实际锁住的是**每模块一条守卫测试**的「版本 + 三个计数 + tag 分组」:`AuthContractConformanceTest.java:399-407`、`MediaContractConformanceTest.java:246-251`、pet `ContractConformanceTest.java:755-760`、`CommunityContractConformanceTest.java:522-527`,断言值 `1.4.0 / 32 / 45 / 75`。 +- **缺口**:任何「计数守恒的字节修改」(改 description、改 enum 取值、改 min/max、同时增删各一字段)都能悄悄通过。M4 契约面显著扩大(新增生成域 + 可能第五个模块快照),建议顺手补一条真校验和断言(T4-14,S)。 +- 第二道门禁在位且有效:`@Order(99) everyDeclaredResponseCellIsExercised()`(auth :414-425 / pet :771 / community :538 / user :261),v1.4.0 共 181 格、豁免 1 格。 + +### 2.14 Flutter create 页现状:563 行、零测试、全假 + +`lib/features/create/create_page.dart`(563 行,`lib/features/create/` 下仅此一个文件);`grep -rn "CreatePage" test integration_test` **零命中**——这页没有任何 widget 测试。页首注释 :10-15 自述「AI 生成模拟(700/650/500ms 假延时、风格/模型/分辨率设置、结果卡)属 M4 范围,T3-17 原样保留」。 + +| 模拟点 | 位置 | 实况 | +| --- | --- | --- | +| 假上传 | `:55-64 simulateUpload()` | `Future.delayed(700ms)`;展示的图直接取 demo 宠物头像 `appState.pet.avatarUrl`(:162) | +| 假生成 | `:66-92 generate()` | 4 步进度,`650ms × 3` + `500ms` | +| 结果图 | `:86` | `selectedStyle.image`,即 `lib/data/demo_data.dart:206-234` 的 4 个 unsplash 硬编码 URL | +| 结果文案 | `:87-90` | 硬编码「豆豆的{风格}冒险」等 | +| 发布 | `:122-129 publish()` | 纯占位 SnackBar「AI 作品发布随 AI 创作能力上线(M4)」 | +| 模型下拉 | `:179` | 硬编码 `['Patbond-V1','Pet-Art Pro','Cute Motion']` | +| 风格选择器 | `:218-292` | 横向 150×125 卡 + 渐变遮罩 + 选中描边,数据源 `creationStyles`(demo) | +| 进度条 | `:294`,定义 `:532-563` | `LinearProgressIndicator(value: step/4)` + 4 条硬编码 label | +| 结果区 | `:312-386` | 16:10 图 + 标题/正文 TextField + 话题 Chip + 硬编码位置「北京市 · 朝阳区」 | +| 真实入口(保留) | `:136`,定义 `:394-419` | `_ComposeEntryCard` → push 真实 `PostComposePage` | + +**可复用的 UI 骨架**:模式分段器、风格横滑卡、进度条、结果编辑区四块布局可整体保留、只换数据源与状态源——这降低了 T4-17/T4-18 的视觉返工风险(M3 的 R10 风险在此不复现)。 + +**顺带命中一项主题债**:该页 `:138` 的 `SegmentedButton` 选中态粉底根因是主题里**没有 `segmentedButtonTheme`**(`lib/core/theme/app_theme.dart:94-196` 只定制了 card/filledButton/inputDecoration/snackBar/datePicker/navigationBar),因此吃了 `ColorScheme.fromSeed`(:88-92)的派生色。已审计色对表在 `app_theme.dart:199-214`,落地范本是 `_datePickerTheme`(:215-296)。M4 既然要重做该控件,此债搭车成本为 S(详见 §8)。 + +### 2.15 Flutter 架构与可复用资产 + +- **分层**:feature 内平铺 `page/controller/repository/models/display/analytics/exceptions`,约定原文 `lib/features/community/community_controller.dart:14-20`(`Page/Widget → Controller → Repository → ApiClient`)。 +- **状态管理**:无 Riverpod/Bloc/provider——原生 `ChangeNotifier` + `ListenableBuilder`,手写 DI 全在 `lib/app/app.dart:80-147`。 +- **路由**:无 go_router、无路由表;`MaterialApp` 只给 `home:`(`app.dart:308-318`),跳转靠散落的 `Navigator.push`;路由名仅服务埋点(`lib/analytics/analytics_page_name.dart:7-25`)。**新增 AI 页面需自建 `AnalyticsPageName` 枚举值**,否则 `fromRouteName` 返回 null 不上报。 +- **API Client**:dio;四条 baseUrl 由 `--dart-define` 注入(`lib/core/network/api_client.dart:8-34`)。**若 D4-2 新建模块,须新增第五条 `PATBOND_CREATION_API_BASE_URL`**,并同步四份 E2E 脚本的硬编码常量与真机清单的 `flutter run` 参数说明。 +- **429 已有粗分支、无 Retry-After**:`api_client.dart:164-167`(`status == 429 → ApiRateLimitException`);`lib/analytics/analytics_service.dart:268-272` 注释明写「Retry-After 分支待其落地后一并做」。T4-11 落地后此处可一并收口。 +- **`CursorPage` 已有**:`lib/core/models/cursor_page.dart:3-27`。**但没有可复用的分页列表容器**——四态骨架各页各写一份;可照抄的两个范本:Feed(`community_controller.dart:9,12,146-200` + `home_page.dart:456-523,536-580`)与页面级自持游标(`lib/features/pets/health_events_page.dart:19,71-127`,同款 `_ListPhase` 已复制 4 份)。 +- **`MediaUploader` 可直接复用于 AI 输入图**:`lib/features/community/media_uploader.dart:127-144`,`purpose` 由调用方注入(:154-161),已被发布页(9 图并发 2)与头像 sheet(1 图并发 1)两处复用。 + +### 2.16 收藏与草稿两页:后端与仓库层全就绪,**只缺页面** + +- `listMyBookmarks()` → `GET /api/v1/me/bookmarks`:`community_repository.dart:52`、实现 :274-283,**全仓无调用点**。 +- `listMyPosts()` → `GET /api/v1/me/posts?status=`:`community_repository.dart:26-30`、:179-193;唯一调用点是发布页恢复最新一条草稿(`post_compose_page.dart:196-199`,`limit:1`)。 +- 契约侧路径名须注意:**不存在** `/me/drafts` 与 `/me/favorites`;正确路径是 `openapi.yaml:1462`(`/api/v1/me/posts`,含 inline `status` enum `[draft, published]` :1476-1482)与 :1749(`/api/v1/me/bookmarks`,项形态为 `FeedCard`)。全域术语是 **bookmark**,无 favorite。 +- 入口现为 demo SnackBar:`lib/features/profile/profile_page.dart:49` 菜单项 + :235 `showDemoMessage`,注释 :43-46 已自认「后端能力已就位,列表页本单未做」。 +- 故此项规模确为 **M**(两页 + 导航 + 测试,照抄 `_ListPhase` 范本),与 M4 契约零耦合,可在第一波并行消化。 + +### 2.17 基线数字的口径澄清 + +| 指标 | 数字 | 口径说明 | +| --- | --- | --- | +| patbond-api 测试 | **379**(surefire 运行数) / **381**(`@Test`+`@ParameterizedTest`+`@RepeatedTest` 注解静态计数,62 个测试类) | 文档登记的 379 来自 `releases.md:17,35` 的 surefire 汇总;两者差异属参数化用例展开口径,**不是回归**。M4 报告统一以 `./mvnw -B clean test` 输出为准并注明口径 | +| patbond-flutter 测试 | **597 通过 + 2 skip** | `flutter test` 输出原文 `00:30 +597 ~2: All tests passed!`(`iteration-3.5/08-release-e2e-regression.md:277`);2 skip 是环境门控冒烟(`test/smoke/detail_interactions_smoke_test.dart:173`、`media_upload_smoke_test.dart:133`),不含 `integration_test/`(4 份桌面实测)与仓库根四份 E2E | +| 埋点事件白名单 | **41**(非 42) | 见 2.10 | +| 真机挂起项 | **10**(非 8) | 见 2.11 | +| 契约 | v1.4.0 / 32 路径 / 45 操作 / 75 schema / 181 格矩阵 | 自行计数复核一致(`openapi.yaml:4` 版本,路径与操作按缩进计数,schemas 自 :2082 起) | +| Flyway | V1~V5,最高 V5;`flyway_schema_history` 单一,归属 `patbond-user` | `patbond-user/src/main/resources/application.yml:11-12`;pet/community 仅 test 作用域带 Flyway(`patbond-pet/pom.xml:79-100`、`patbond-community/pom.xml:91-113`);auth 无库 | +| ADR | 最高 **ADR-022**,M4 新决策自 **ADR-023** 起 | `docs/architecture/decisions.md:184-195` | +| 错误码 | 27 个,最大段位见 `ErrorCode.java` | 下一可用:404 段 `40407`、403 段 `40302`、422 段 `42206`、**429 段全新** | + +### 2.18 未取证事项(明确声明) + +1. **AI 提供方的可用性与价格**:本机为 AMD Radeon Vega 集显、无 `nvidia-smi`(即无 CUDA),自托管 SD/ComfyUI 在当前工作机不可行;生产侧腾讯云轻量服务器是否有 GPU **未取证**(需用户确认)。云 API 的具体额度与单价**未取证**(需用户提供账号或授权调研)。 +2. **实验设计模板与样本量规则文档**(A/B 前置 #3/#6,文档判绿):两仓中**未定位到该文档实体**,仅在 `iteration-3/06` 表格里被声明为已交付。 +3. **`patbond-doc` 无仓库级 `.gitignore`**:`site/` 目前仅靠用户全局 `~/.gitignore_global:183` 排除(实测未被 tracked)。换机器或 CI 检出时 `mkdocs build` 产物可被误提交,与 `git-workflow.md:62` 的纪律冲突。已并入 T4-27(一行修复)。 + +## 3. 本迭代 MVP 范围 + +PM 建议口径,待 §6 拍板确认。 + +**纳入**: + +- `creation` schema 三表落 V6 + 补回 `posts.generation_job_id` 外键(T4-01) +- 模型/风格只读目录 + 种子(T4-04) +- provider 适配层 + 至少一个可跑通全链路的 provider 实现(T4-05,形态随 D4-1) +- 生成任务创建(`Idempotency-Key` 强制)/ 查询 / 列表 / 取消(T4-06、T4-07) +- Worker 队列消费:DB 队列 + 租约 + 指数退避重试 + provider 幂等(T4-08) +- 输出落 `media.assets`(含服务端对象写入能力)与真实尺寸回填(T4-03、T4-09) +- 一键建社区草稿:开放 `category='ai_creation'` 写侧 + `generationJobId` 落库与外露(T4-10) +- 生成域配额与限流(429 + `Retry-After`,契约首个响应头)(T4-11) +- Flutter:`creation` feature 分层、create 页真实化、排队/进度/失败重试/取消四态、结果页与一键建草稿(T4-16~T4-19) +- 契约 v1.5.0 扩展与冻结(T4-13)+ 快照锁补强(T4-14) +- 埋点字典 v4(AI 创作漏斗)+ `eventVersion` 口径定型 + 白名单总量断言(T4-22) +- 历史遗留搭车五项:`widthPx/heightPx`、429 限流、uploading 超时清理、收藏与草稿两页、真机 M2 两项(详见 §8) +- 第五份 E2E 脚本 + 五份回归 + v0.5.0 走 PR 发布(T4-25、T4-26) + +**待拍板裁剪项**(默认建议见 §6): + +- 视频生成(D4-5,建议剪出,仅保留 `media_kind` 列与枚举) +- 文生图(无输入图,D4-4,建议剪出,图生图先行) +- 我的生成记录列表页(T4-20,条件单,建议纳入——排查失败任务的唯一入口) +- A/B 首个实验(T4-23,条件单,随 D4-11) +- 文档站访问控制(T4-28,条件单,随 D4-12) + +**默认剪出**:真实计费与支付(无计费表,以配额替代)、生成结果的审核与水印、生成完成推送通知(M6)、多模型并行对比、逐帖曝光与服务端排序实验日志(ADR-020 backlog)、完整可观测性栈(M6)——逐条理由见 §7。 + +## 4. 工单列表 + +预估规模口径沿用前三迭代:**S ≈ 半天内,M ≈ 1-2 天,L ≈ 3-5 天**(含测试与文档)。 + +### A 组:数据、地基与工程基础(后端) + +#### T4-01 Flyway V6:creation schema 提取与 `posts.generation_job_id` 外键补回 +- **仓库**:patbond-api(迁移进 `patbond-user/src/main/resources/db/migration/`,单迁移链纪律 `backend-modules.md:41`),patbond-doc(迁移说明) +- **描述**:从目标模型 `docs/database/patbond_postgresql.sql:556-716` 提取 `creation` schema 三表(`generation_models` / `generation_styles` / `generation_jobs`)为 `V6__creation_baseline.sql`,含全部 CHECK、复合外键、11 条索引(两条队列 partial 索引 :711-715 必须逐字照抄)与三个 `updated_at` 触发器(:1245-1249,复用 V1 的 `platform.set_updated_at()`);**补回 `community.posts.generation_job_id → creation.generation_jobs(id) ON DELETE SET NULL`**(V5:47 承诺的 M4 义务,索引 `ix_posts_generation_job` 已存在无需重建)。按 D4-4 决定 `input_asset_id` 是否保持 `NOT NULL`、按 D4-6 处置 `model_version_snapshot` 的来源矛盾——**两处偏离目标模型的地方必须在迁移文件头注释里逐条写明理由**(沿 V5:1-33 的写法)。种子数据独立为不进生产链的 dev 脚本(沿 `db/dev/afterMigrate__dev_seed.sql` 先例)。 +- **验收标准**: + - 全新 postgres:18(Testcontainers)上 V1→V6 全量迁移一次成功;表结构与目标模型逐列比对一致(偏离项除外,差异入迁移说明)。 + - **存量库实证**:在已迁至 V5 的库上单独跑 V6 成功(沿 v0.4.0 的零迁移实证惯例 `releases.md:36`),`flyway_schema_history` 只增一行。 + - 新增迁移测试断言:`creation` schema 存在、三表列数与关键 CHECK 名在位、`posts` 上出现指向 `creation.generation_jobs` 的外键约束(与 `CommunityMigrationIntegrationTest.java:79-85` 现有的「不存在该外键」断言**互为镜像,必须同步反转**,否则该测试必红)。 + - `./mvnw -B clean test` 全绿(既有 379/381 测试不回归)。 +- **依赖**:D4-4、D4-6 拍板(第一波首项,两项决策不定则 V6 无法定稿)。 +- **规模**:M + +#### T4-02 生成域模块落位与鉴权接入(形态随 D4-2) +- **仓库**:patbond-api,patbond-doc(`architecture/backend-modules.md` 与 ADR-023 随收口) +- **描述**:按 D4-2 拍板结果落位生成域。**PM 建议方案**:沿 ADR-009/ADR-017 先例新建 Maven 模块 `patbond-creation`(:8085),复用 JWT 资源侧校验与当前用户解析(照抄 `patbond-community` 的 `security/` 与 `config/SecurityConfig.java` 结构),只读写 `creation` schema,**test 作用域引入 Flyway**(照抄 `patbond-community/pom.xml:91-113` 的注释与依赖,生产侧不携带 Flyway);`application.yml` 走 `.sample` 模式(pet/community 的主 yml 被 `.gitignore:10-11` 排除,compose 直接挂载 `.sample`);compose 编排纳入第七个容器(`depends_on: postgres service_healthy` + `user service_started`)。若 D4-2 选择并入 `patbond-user`,本单退化为「新增 `creation` 包 + 端点前缀 + 属性类」,规模降为 S。 +- **验收标准**: + - 模块编译入构建链,`./mvnw -B clean test` 全绿;无 token / 过期 token 返回 401 + 既有 `40100` 系错误码逐字节一致。 + - compose 起七容器(postgres + minio + auth + user + pet + community + creation)全部 healthy;**跨服务接线以 compose 实测验证**(M3 的 T3-13 曾抓出「media 端点挂错服务,单测无法覆盖」,`iteration-3/29:45`)。 + - `backend-modules.md` 模块表与端口表同步更新;ADR-023 记录模块归属与 Worker 形态决策。 +- **依赖**:D4-2 拍板(骨架期变更成本最低,可先按建议方案搭);T4-01。 +- **规模**:M(若并入 user 则 S) + +#### T4-03 对象存储服务端写入能力与媒体内部登记端点 +- **仓库**:patbond-api(`patbond-user/.../media/`),patbond-doc(媒体链路说明补章) +- **描述**:**本迭代最易被低估的一单,见 §2.5**。现有 `ObjectStorage`(`patbond-user/.../media/ObjectStorage.java:15-37`)只有 `ensureBucket`/`presignPut`/`stat`/`presignGet`,**服务端无法写对象**。本单:① 在 `ObjectStorage` 上新增写方法(建议 `void put(String objectKey, byte[] bytes, String contentType)` 或流式重载)与读方法(`Optional get(...)` 或仅取图片头部字节,供 T4-09 探测尺寸),在 `S3ObjectStorage` 实现并给 `MediaStorageConfig.java:33-59` 的 `UnconfiguredObjectStorage` 补桩(保持未配置即 500 的既有语义);② 新增 `/internal` 媒体登记端点(建议 `POST /internal/media/assets`),一次调用完成「写对象 + 插入 `media.assets` 行并直接置 `ready`」,供生成域落盘输出——**这样 ADR-017「media 上传流程实现在 patbond-user」的边界不被打破**;③ `MediaProperties.allowedPurposes`(`MediaProperties.java:66`,当前 `post_image,user_avatar,pet_avatar`;YAML `application.yml:45`)与 mime 白名单按 D4-14 扩充新用途(**纯配置变更,`media.assets.purpose` 无 CHECK 约束,无需迁移**——ADR-022 已验证同一路径)。若 D4-2 选择并入 user,②可退化为内部服务方法调用。 +- **验收标准**: + - MinIO Testcontainer 全链路:服务端 put → `media.assets` 行 `status='ready'` 且 `ready_at` 非空 → 预签名 GET 可取回同一字节(sha256 比对)。 + - `/internal/media/assets` 无 `X-Internal-Token` / 错误 token 返回 401 + `TOKEN_INVALID`(复用 `InternalAuthFilter.java:28,54-56` 的常量时间比较,**未配置密钥时 fail closed** 语义不变)。 + - `ck_media_location` / `ck_media_ready` / `uq_media_object (bucket, object_key)` 四条数据库约束与应用层校验一致,各有测试。 + - 未配置对象存储时行为与既有一致(500,不静默成功)。 +- **依赖**:T4-01(不强依赖,可与之并行);D4-2 决定②的形态;D4-14 决定新 purpose 命名。 +- **规模**:M + +#### T4-04 模型/风格只读目录端点与种子数据 +- **仓库**:patbond-api,patbond-doc(契约草案) +- **描述**:实现模型与风格的只读目录(建议 `GET /api/v1/creation/models`、`GET /api/v1/creation/styles`,均支持 `mediaKind` 过滤,`enabled=true` 过滤走 partial 索引 `ix_generation_models_kind_order` / `ix_generation_styles_kind_order`,按 `sort_order, id` 排序,**不分页**——沿 care-reminders/breeds/vaccine-catalog 的既有例外先例 `openapi.yaml:98`)。风格响应含 `previewUrl`(`preview_asset_id` 现签预签名 GET,与 `Pet.avatarUrl`/`AuthorSummary.avatarUrl` 同口径:三种情况为 null、键恒在)。种子按 D4-5 只入 image 模型与风格(目标模型 :1495-1508 的 5 条风格里 4 条为 image、1 条为 video);`provider_code` 取值随 D4-1。**风格预览图本身需要真实资产**:种子引用的 `preview_asset_id` 在目标模型里指向 fixture asset,本单需明确预览图来源(建议随种子上传 4 张占位图并记录 assetId,或首版允许 `previewUrl` 为 null 由客户端回落纯色卡)。 +- **验收标准**:目录端点返回按 `sort_order` 稳定排序;`enabled=false` 的行不出现;`mediaKind` 非法值 400/40000;`previewUrl` 为 null 时键仍在;六类测试路径中适用的四类(成功/参数错/无权限/不存在)覆盖。 +- **依赖**:T4-01;T4-02。 +- **规模**:S + +#### T4-05 provider 适配层与首个 provider 实现 +- **仓库**:patbond-api,patbond-doc(provider 接入说明 + ADR-023) +- **描述**:**依赖 D4-1 拍板,是关键路径起点**。定义 provider 抽象接口(提交生成请求 → 返回 `providerRequestId`;轮询或回调获取状态与结果字节/URL;显式区分**可重试失败**与**永久失败**,后者不再消耗 `max_attempts`);把供应商差异全部收敛在适配层(沿 ADR-016 对象存储适配层的同一手法:「代码经存储适配层隔离供应商」)。按 D4-1 实现首个 provider。**PM 建议先实现 fixture provider**:可配置延时与进度、返回内置图片字节、可注入失败/超时/慢响应用于测试——它同时是 CI 与 E2E 的唯一可用 provider(无外部依赖、无密钥、无成本)。若 D4-1 同时批准接入云 API,则云 provider 作为第二实现另计(见 T4-05b 说明:不单列工单,按 D4-1 结果并入本单并把规模提为 L)。 +- **验收标准**: + - 适配层接口下至少一个实现全链路可跑;provider 密钥一律经 `.env` 注入、`.sample` 占位,`scripts/check-secrets.sh --all` 零命中(凭证防泄漏两层门禁在位,`git-workflow.md:36-54`)。 + - fixture provider 可注入四类故障:可重试失败、永久失败、超时、返回损坏字节;各有测试。 + - 日志不含 provider 密钥与完整 prompt 明文(`development-plan.md` 第 6.1 节日志纪律)。 +- **依赖**:**D4-1 拍板**(未拍板期间可先写接口与 fixture 实现,明确止损线:适配层以上不写任何供应商特定代码)。 +- **规模**:M(若同时接云 API 则 L) + +### B 组:后端生成纵切 + +#### T4-06 生成任务创建:幂等键、快照与配额 +- **仓库**:patbond-api +- **描述**:`POST /api/v1/creation/jobs`,**`Idempotency-Key` 必带**(`development-plan.md:173` 强制;沿 `POST /api/v1/posts` 的既有形态 `openapi.yaml:1320` 与 `IdempotencyKeyRequiredHeader` :1928-1944,同键异 payload → 409/40905)。落 `UNIQUE (user_id, idempotency_key)` + `request_hash`(规范化请求体的 sha256,32 字节,`ck_generation_jobs_idempotency` 校验)。校验链:模型/风格存在且 `enabled` 且 `media_kind` 一致(复合外键 `(model_id, media_kind)` 天然兜底)、输入 asset 属本人且 `status='ready'`(复用 community 的 `MediaAssetGateway` 只读模式;非 ready → 422/42203 沿用既有 `MEDIA_NOT_READY`)、宠物归属校验(若带 `petId`)、尺寸在 `ck_generation_jobs_dimensions` 的 64~8192 内。**四个 snapshot 列在创建时一次性冻结**(`provider_code_snapshot` / `provider_model_snapshot` / `model_version_snapshot` / `style_code_snapshot`)——这是「失败原因可追踪」的基础:目录改了不影响历史任务的可复现性。配额检查按 D4-7(PM 建议直接 `COUNT` 本人窗口内的 `generation_jobs` 行,**不新建计数表**:幂等命中不新建行,故天然满足「相同幂等请求不重复扣费」)。任务落库即 `status='queued'`(满足 `ck_generation_jobs_state` 的 queued 分支:`progress=0`、`started_at`/`completed_at`/`output_asset_id`/`error_code`/`lease_*` 全 NULL)。 +- **验收标准**: + - 六类测试路径全覆盖(成功 / 参数错 / 资源不存在 / 无权限 / 并发冲突 / 幂等重试),走真实 PostgreSQL。 + - **相同 `Idempotency-Key` 并发 N 次只产生一行**,且响应为同一 `jobId`(多线程真并发测试,沿 `LikeBookmarkIntegrationTest.java:141,167` 的既有手法);同键异 payload 返回 409/40905。 + - 引用他人 / 非 ready asset 被拒且错误码稳定;`enabled=false` 的模型被拒。 + - 超配额返回 429 + `Retry-After`(依赖 T4-11);幂等重试**不**消耗配额,有专项测试。 + - snapshot 四列非空且与创建时刻的目录值一致;事后改目录不改历史行,有测试。 +- **依赖**:T4-01、T4-02、T4-04、T4-05(接口即可,不需 provider 实现完成);D4-7 拍板配额口径。 +- **规模**:L + +#### T4-07 生成任务查询、列表与取消 +- **仓库**:patbond-api +- **描述**:`GET /api/v1/creation/jobs/{jobId}`(详情,含 `status`/`progress`/`errorCode`/`errorMessage`/`attemptCount`/`outputAsset` 现签 URL)、`GET /api/v1/creation/jobs`(我的生成记录,cursor 分页走 `ix_generation_jobs_user_created (user_id, created_at DESC, id DESC)`,**禁 OFFSET**,支持 `status` 白名单过滤)、`DELETE` 或 `POST .../cancel`(取消,语义随 D4-8 子项)。取消的状态机后果必须逐条对齐 `ck_generation_jobs_state`(:667-692):`cancelled` 要求 `completed_at IS NOT NULL` 且 `lease_*` 全 NULL——因此**取消 `queued` 任务需同时写 `completed_at`**;取消 `running` 任务需清租约(此时 provider 侧可能仍在跑,须明确「取消是尽力而为,已产生的 provider 消耗不退配额」并写入契约描述);`succeeded`/`failed`/`cancelled` 的重复取消为幂等 no-op 还是 422,随 D4-8 定型。他人任务一律 404(防枚举,沿 `POST_NOT_FOUND` 的既有做法 `openapi.yaml:45`)。新增错误码建议:`GENERATION_JOB_NOT_FOUND(40407, 404)`、`GENERATION_STATE_INVALID(42206, 422)`。 +- **验收标准**: + - 五个状态各自的详情响应形态定型并入契约;`errorCode`/`errorMessage` 在 failed 态必非空、在其他态必为 null(与库层 CHECK 互证)。 + - 分页不丢不重:以 `limit=7` 多页与 `limit=100` 单页两次全量翻页,**有序 id 列表逐位相等**(沿 M3 T3-05 的取证手法 `iteration-3/29:39`)。 + - 取消四种源状态(queued / running / 已终态 / 他人任务)各有测试;取消后库层 CHECK 不被违反。 + - 六类测试路径覆盖。 +- **依赖**:T4-06。可与 T4-08 并行。 +- **规模**:M + +#### T4-08 Worker 队列消费:租约、退避重试与幂等 +- **仓库**:patbond-api,patbond-doc(Worker 运行手册 + ADR-023) +- **描述**:**M4 的技术核心,两条验收标准(状态流转合法、Worker 重启不丢任务)的主责单**。按 D4-3 实现 DB 队列消费(PM 建议): + - **领取**:`SELECT … FROM creation.generation_jobs WHERE status='queued' AND next_attempt_at <= now() ORDER BY priority DESC, next_attempt_at, created_at, id LIMIT :n FOR UPDATE SKIP LOCKED`(正对 `ix_generation_jobs_queue` :711-713),随即写 `status='running'` + `started_at` + `lease_owner`(实例标识)+ `lease_expires_at`。 + - **续租**:长任务周期性延长 `lease_expires_at` 并更新 `progress`。 + - **回收**:独立扫描 `status='running' AND lease_expires_at < now()`(正对 `ix_generation_jobs_running` :714-715),重置为 `queued`。**⚠️ 实现陷阱(§2.8)**:`ck_generation_jobs_state` 的 queued 分支要求 `started_at IS NULL AND progress = 0`,故回收时必须把 `started_at` 与 `progress` 清零——首次尝试的开始时间会丢失,须在代码注释与迁移说明中记录该取舍。 + - **重试**:`attempt_count + 1`;未达 `max_attempts` 且属可重试失败 → 回 `queued` 并按指数退避写 `next_attempt_at`;达上限或永久失败 → `failed` + `error_code`/`error_message` + `completed_at`。 + - **provider 幂等**:`provider_request_id` 落库并受 `uq_generation_jobs_provider_request` 保护(:699-700),重试时优先**查询已有 provider 请求状态**而非重新提交——这是「相同幂等请求不重复扣费或生成」在 provider 侧的落点。 + - **调度基座**:`@EnableScheduling` + 显式 `TaskExecutor`(全仓当前无任何线程池配置,§2.4),并发度、租约时长、退避参数、轮询间隔全部配置化。Worker 形态(同进程 vs 独立容器)随 D4-2。 +- **验收标准**: + - **Worker 重启不丢任务**:集成测试在 `running` 态强杀 Worker(或直接令租约过期)后,任务被重新领取并最终 `succeeded`;T4-25 E2E 用 `docker compose restart` 做真容器取证。 + - 多 Worker 并发领取同一批任务,**同一任务不被两个实例同时持有**(`SKIP LOCKED` + 租约双重保证),有并发测试。 + - 四类 provider 故障(可重试 / 永久 / 超时 / 损坏输出,由 T4-05 fixture 注入)各自的终态与 `attempt_count` 正确;退避间隔递增有测试。 + - 全部状态迁移穷举测试:每条合法迁移成功、每条非法迁移被应用层拒绝**且**被库层 CHECK 兜底(两层各有断言)。 + - 队列积压与 Worker 失败率**可查**(`development-plan.md:329` 的最小兑现:SQL 可查 + 结构化日志留痕;完整指标栈 M6,见 §7)。 +- **依赖**:T4-06、T4-05;T4-03(输出落盘经 T4-09);D4-2、D4-3 拍板。 +- **规模**:L + +#### T4-09 生成输出落媒体表与真实尺寸回填(含 `widthPx/heightPx` 遗留清偿) +- **仓库**:patbond-api,patbond-doc(契约描述修订) +- **描述**:Worker 成功后把 provider 输出经 T4-03 的写能力落 `media.assets`(`purpose` 随 D4-14、`owner_user_id` = 请求用户、`storage_type='object'`、`status='ready'` + `ready_at`),回填 `generation_jobs.output_asset_id` 并置 `succeeded`(`ck_generation_jobs_state` 要求 succeeded 时 `progress=100`、`started_at`/`completed_at`/`output_asset_id` 非空、`error_code`/`lease_*` 为 NULL——五个条件同事务写齐)。**AI 输出的 `width_px`/`height_px` 天然已知**(请求参数 + provider 返回),直接写入。同时按 D4-9 处置 M3 遗留:PM 建议在 `POST /api/v1/media/uploads/{assetId}/complete` 里补服务端尺寸探测(读对象头部字节解析 JPEG/PNG/WebP 尺寸),使契约 `openapi.yaml:3536` 的「complete 后回填」名副其实;同时把 `PostMediaItem.widthPx/heightPx`(:3605-3610,当前无 description)补上口径说明。**本单不做**:视频时长探测(D4-5 剪出视频)。 +- **验收标准**: + - 生成成功后 `media.assets` 行 `status='ready'` 且 `width_px`/`height_px` 与请求尺寸一致;预签名 GET 可取回字节。 + - 五个 succeeded 字段条件同事务写齐;中途失败不留「有 output 但非 succeeded」的中间态,有测试。 + - 用户上传路径的尺寸回填:jpeg/png/webp 三种 mime 各有测试,探测失败时回落 null 且不影响 `ready`(向后兼容,`MediaAsset.required` 不含尺寸字段)。 + - 单图帖不再一律回落 4:3(M3 观察项 2 闭环,`iteration-3/29:56`)——客户端侧验收在 T4-19/T4-21。 +- **依赖**:T4-03、T4-08;D4-9 拍板。 +- **规模**:M + +#### T4-10 一键建社区草稿:`ai_creation` 写侧开放与 `generationJobId` 打通 +- **仓库**:patbond-api(`patbond-community`),patbond-doc(契约) +- **描述**:打通「生成成功 → 社区草稿」。改动面(§2.7 已逐处定位):① `CreatePostRequest.java:26` 的 `@Pattern(regexp = "general|help")` 放开为含 `ai_creation`;② `CreatePostRequest` 新增 `generationJobId`;③ `PostRepository.insertPost`(:56-58 签名、:60-64 INSERT 列清单)与 `PostService`(:88 category 默认值、:98/:102 canonicalize 与插入、:151/:178 更新路径)补该列;④ `PostResponse.java` 与 `FeedCardResponse` 按 D4-8 决定是否外露 `generationJob`(当前 `PostResponse.java:12` 注明该字段完全不出现)。**归属与防伪校验**:`generationJobId` 必须属于当前用户且 `status='succeeded'`,否则 404/40407;按 D4-8 定型「`category='ai_creation'` 是否强制要求带 `generationJobId`」(PM 建议强制,防止用户伪造 AI 分类)。草稿创建沿用既有两步语义(`status='draft'` 创建 → `PATCH status='published'` 发布,`openapi.yaml:3660-3664`、:3701-3707),本单**不新增发布路径**。 +- **验收标准**: + - 生成任务 → 建草稿 → 发布 → Feed 可见全链路走真实 PostgreSQL;`posts.generation_job_id` 落库正确且外键约束生效(引用不存在的 jobId 被库层拒绝)。 + - 他人 jobId / 未成功 jobId / 无 jobId 但 `category='ai_creation'` 三种非法组合各有测试与稳定错误码。 + - `ai_creation` 帖在 Feed 与详情的读侧形态与 `general` 一致(`FeedCard.category` 枚举早已含三值 `openapi.yaml:3826`,无需改读侧枚举)。 + - 既有 107 个 community 测试不回归;契约矩阵新增格全部被真实触发(`everyDeclaredResponseCellIsExercised` 门禁)。 +- **依赖**:T4-01(外键)、T4-06/T4-09(succeeded 任务);D4-8 拍板。 +- **规模**:M + +#### T4-11 生成域配额与限流:429 + `Retry-After`(清偿 M3 遗留) +- **仓库**:patbond-api(`patbond-common` 加错误码 + 生成域实现),patbond-doc(契约首个响应头) +- **描述**:**契约史上第一个响应头声明**——`openapi.yaml` 全文当前**零处 `headers:` 键**(§2.13 核实)。新增 `RATE_LIMITED(42900, 429, …)` 到 `ErrorCode.java`(该文件当前无 429 段),按 D4-7 实现配额与限流:建议 per-user 并发上限(`COUNT` 本人 `queued|running` 行)+ 每日次数上限(`COUNT` 本人当日 `created_at` 行),**均直接查 `generation_jobs` 不新建表**;超限返回 429/42900 + `Retry-After`(秒数,指向下一次可提交时间)。**无 Redis 的现实约束**(§2.4)决定了计数只能落 PG 或进程内——建议落 PG(多实例正确,代价是每次创建多一次 COUNT,走 `ix_generation_jobs_user_created`)。同时清偿客户端侧遗留:`lib/core/network/api_client.dart:164-167` 已有 429 粗分支但无 `Retry-After` 解析,`lib/analytics/analytics_service.dart:268-272` 注释明写等待后端落地——本单交付后由 T4-16 收口客户端分支。**本单不做**:全站通用限流(属 M6「限流、审计、结构化日志」范围),只做生成域。 +- **验收标准**: + - 超并发 / 超日限两种超限各返回 429 + `Retry-After`,头值可解析为正整数秒;有集成测试。 + - **幂等重试不计入配额**(同 `Idempotency-Key` 重试在超限后仍返回原任务而非 429),有专项测试——这是「相同幂等请求不重复扣费」的一半取证。 + - 契约新增 429 响应与 `Retry-After` 头声明;契约测试覆盖该格(新增矩阵格必须被真实触发)。 + - 客户端 429 分支与 `Retry-After` 尊重在 T4-16 有测试。 +- **依赖**:T4-06;D4-7 拍板具体数值。 +- **规模**:M + +#### T4-12 uploading 超时未确认 asset 清理定时任务(清偿 M3 遗留) +- **仓库**:patbond-api(`patbond-user`),patbond-doc(feature-checklist 转绿) +- **描述**:M3 T3-13 已有方案未实现(`iteration-3/29:58`、`feature-checklist.md:193`)。本单实现 `@Scheduled` 清理:把超过阈值仍为 `status='uploading'` 的 asset 置 `failed`(或按方案置 `deleted` + `deleted_at`,须满足 `ck_media_deleted`),并按需删除孤儿对象。**搭车理由**:M4 本就要建 Worker 调度基座(T4-08),且 `@Scheduled` 已有先例可照抄(`SessionCleanupJob.java:32-41` 的幂等、可多实例并跑写法 + `application.yml:24-26` 的配置形态);同时 AI 输入图会放大孤儿资产量(每次生成都要先传一张图),此项从「可选清理」升为「成本控制项」。索引已就绪:`ix_media_uploading_created`(V1:255 区域)。 +- **验收标准**:阈值内的 uploading 不被误清;超阈值被置终态且 `identity.users.avatar_asset_id` / `pet_health.pets.avatar_asset_id` / `post_media` / `generation_jobs.input_asset_id` 等引用方**不出现悬空引用**(外键为 RESTRICT 的路径须验证清理不会失败);任务幂等、多实例并跑无害,有测试。 +- **依赖**:T4-02(或直接在 user 模块内);与主线解耦,可任意波次插入。 +- **规模**:S + +### C 组:契约与测试 + +#### T4-13 OpenAPI v1.5.0 扩展与冻结(**本波闸门**) +- **仓库**:patbond-doc(`docs/api/openapi.yaml`),patbond-api(四份或五份快照字节级同步 + 守卫测试期望值更新) +- **描述**:沿用三个迭代验证过的**迭代式契约冻结**:第一波按 §1.1 与目标模型出草案(放在 `iterations/iteration-4/openapi-creation-draft.yaml`,沿 M2/M3 的 `openapi-pets-draft.yaml`/`openapi-community-draft.yaml` 先例),以 `TODO-FREEZE` 标注**四处待定型点**——① 生成任务响应的五态字段形态、② `Retry-After` 头与 429 的声明形态(契约首个响应头)、③ `generationJob` 在 `Post`/`FeedCard` 的外露程度、④ 新 `purpose` 枚举值命名;随 T4-06/07/09/10/11 实现定型回填 → 拍板 → 冻结合入 + api 侧快照同步升版。 + 变更清单(**对 v1.4.0 应为纯增量**,延续 v1.4.0 写入 `info.description` 的承诺):新增生成域路径与 schema;`CreateMediaUploadRequest.purpose` enum 增值(:3458);`CreatePostRequest.category` enum 增 `ai_creation`(:3657,并删去 :3659 那句「M3 不开放写入」);`CreatePostRequest`/`Post` 新增 `generationJobId`/`generationJob`;`MediaAsset.widthPx` description 修订(:3536);新增错误码 `40407`/`42206`/`42900` 入 `info.description` 错误码表(:35-62,现 27 个码);新增 429 响应与 `Retry-After` 头。 + **同步义务(缺一 CI 必红)**:四份守卫测试的期望值 `1.4.0 / 32 / 45 / 75` 必须整体更新(`AuthContractConformanceTest.java:399-407`、`MediaContractConformanceTest.java:246-251`、pet `ContractConformanceTest.java:755-760`、`CommunityContractConformanceTest.java:522-527`),四份 `OpenApiContract.java` 的 `RESOURCE` 常量(pet:35 / auth:39 / user:39 / community:39)须指向 `openapi-v1.5.0.yaml`;若 D4-2 新建模块则为**五份**。 +- **验收标准**:契约评审通过;四/五份快照 md5 与正典完全一致;守卫测试与 `everyDeclaredResponseCellIsExercised` 双门禁全绿、矩阵零漂移;新增格全部被真实触发(不得豁免);`mkdocs build --strict` 零 warning;冻结后任何变更须显著上报两端同步。 +- **依赖**:草案仅依赖目标模型;冻结须 T4-06/07 状态形态 + T4-09 输出形态 + T4-10 权限语义 + T4-11 限流形态定型。**不冻结不放行第三波两端联调。** +- **规模**:M + +#### T4-14 契约快照锁补强:从「计数守恒」到真校验和 +- **仓库**:patbond-api(四/五模块测试),patbond-doc(冻结纪律补章) +- **描述**:§2.13 核实的缺口——CI 只锁 `info.version` + 3 个计数 + tag 分组,md5 比对是**人工冻结步骤**,任何「计数守恒的字节修改」(改 description、改 enum 取值、改 min/max、同时增删各一字段)都能悄悄通过。本单在每模块守卫测试中补一条**快照文件校验和断言**(对 `src/test/resources/contract/openapi-v1.x.y.yaml` 算 sha256 与硬编码期望值比对),把「字节级快照锁」从人工纪律变成 CI 门禁。**注意**:期望值需在 T4-13 冻结后填入,故本单分两步——先在 v1.4.0 上落机制并验证能抓出注毒(可先行于第一波),再随 T4-13 更新期望值。 +- **验收标准**:定向注毒自证——对快照做一处「计数守恒」的字节修改(如改一句 description),断言必红;恢复后转绿。四/五模块各有该断言。文档纪律更新为「升版须同步快照 + 更新校验和」。 +- **依赖**:无(机制可先落);期望值随 T4-13。 +- **规模**:S + +#### T4-15 后端集成测试滚动补齐与 CI(横切单) +- **仓库**:patbond-api +- **描述**:随 B 组滚动补齐 Testcontainers 集成测试与契约一致性测试(机制复用既有三层结构:纯单测 / 契约一致性 / Testcontainers postgres:18 + 一处 MinIO 容器)。**本迭代专项矩阵**:① 状态机穷举(五态两两迁移的合法与非法各一格);② 队列并发(多 Worker 领取不重叠、`SKIP LOCKED` 有效性);③ 租约过期回收(含 `started_at` 清零后仍满足 CHECK);④ 幂等三层(应用层键、DB 唯一约束、provider 请求 id);⑤ 配额边界(超限 / 幂等重试不计数 / 跨日窗口翻转)。MinIO 容器使用面因 T4-03/T4-09 扩大,记录 CI 时长;若超阈值按模块分层执行,但**不降低「提交前全绿」标准**。 +- **验收标准**:每个新业务接口覆盖六类路径;Gitea Actions 全绿(以 commit status API 实查为准,沿 M2/M3 惯例——`iteration-3/29:47` 记录过「一个 agent 识破 Monitor 的假 success 事件」的教训);CI 时长记录在案。 +- **依赖**:随 T4-04~T4-12 滚动。 +- **规模**:M(分摊在各单内) + +### D 组:Flutter 客户端 + +#### T4-16 `creation` feature 分层与 API Client +- **仓库**:patbond-flutter +- **描述**:按 `development-plan.md:96` 已预留的 `creation` feature 拆分(`lib/features/creation/`),对齐 community/pets 的既有结构(`Page → Controller → Repository → ApiClient`,约定原文 `community_controller.dart:14-20`;状态用原生 `ChangeNotifier`,DI 手写进 `lib/app/app.dart:80-147`)。依 T4-13 冻结契约实现 DTO 与 Client(模型/风格目录、任务创建/详情/列表/取消)。**若 D4-2 新建模块,须新增第五条 baseUrl**:`patbondCreationApiBaseUrl` + `PATBOND_CREATION_API_BASE_URL`(照抄 `api_client.dart:8-34` 的四条现有写法),并同步 `lib/app/app.dart` 装配、四份 E2E 脚本的地址常量、`device-verification.md` 通用前置的 `flutter run` 参数说明(当前写「四个 base URL 全传」,须改为五个)。新增 `AnalyticsPageName` 枚举值(`lib/analytics/analytics_page_name.dart:7-25`;不在枚举内 `fromRouteName` 返回 null 即不上报)。**顺带收口 429**:`api_client.dart:164-167` 现有的 `ApiRateLimitException` 补 `Retry-After` 解析与承载(T4-11 交付后),并按注释 `analytics_service.dart:268-272` 的承诺一并处理埋点上传侧的 `Retry-After` 分支。 +- **验收标准**:DTO 映射(含五种 status、null 尺寸、null previewUrl)有单元测试;错误映射类型化(新增 `40407`/`42206`/`42900` 各有分支);`Retry-After` 解析(有头 / 无头 / 非法值)有测试;UI 无关骨架可先行。 +- **依赖**:T4-13 冻结(骨架部分可提前与后端并行)。 +- **规模**:M + +#### T4-17 create 页替换:真实目录、输入与提交 +- **仓库**:patbond-flutter +- **描述**:替换 `lib/features/create/create_page.dart`(563 行、零测试、全假,逐处清单见 §2.14)。保留可用的四块视觉骨架(模式分段器 / 风格横滑卡 / 进度条 / 结果编辑区),只换数据源与状态源:模型下拉与风格卡改读 T4-04 目录(删掉 `:179` 硬编码模型数组与 `demo_data.dart:206-234` 的 unsplash URL 依赖);假上传(`:55-64`)改接 `MediaUploader`(`purpose` 用 T4-03 新增的 AI 输入用途,单图单并发,照抄头像 sheet 的 `maxImages:1, maxConcurrentUploads:1` 用法);prompt 输入、尺寸与高清增强开关映射到真实请求字段(`prompt`/`negativePrompt`/`widthPx`/`heightPx`/`upscale`/`parameters`);提交携带客户端生成并在重试间保持的 `Idempotency-Key`(照抄发布页 `post_compose_page.dart:267` 的 `_idempotencyKey ??= _uuid.v4()` 手法)。**视频模式按 D4-5 处理**(建议:分段器保留但 video 项禁用并给「即将上线」提示,而非删除——避免 UI 大改,也与 `media_kind` 保留枚举一致)。**搭车主题债**:给主题补 `segmentedButtonTheme`,选中态改用 `app_theme.dart:199-214` 已审计色对表中的色对(`_datePickerTheme` :215-296 是落地范本),一次修复全部 6 处 `SegmentedButton` 使用点(create/home/services/pet_form×2/vaccination_form)。 +- **验收标准**: + - 页面不再读任何 demo 常量(`demo_data.dart`、`AppState.pet` 依赖清零);`grep` 断言无 `Future.delayed` 假延时残留。 + - 目录加载四态(loading/empty/error/retry)齐备并有 widget 测试——**该页当前零测试,本单是从 0 建测试基线**。 + - 提交校验与错误提示:非法尺寸、未选风格、未传图、超配额 429(含 `Retry-After` 文案)各有测试。 + - `SegmentedButton` 选中态对比度达 WCAG AA 并在已审计色对表中登记新增行;6 处使用点视觉一致。 +- **依赖**:T4-16;T4-04/T4-06 联调。 +- **规模**:L + +#### T4-18 生成状态机 UI:排队、进度、失败重试与取消 +- **仓库**:patbond-flutter +- **描述**:`development-plan.md:253` 的直接落点(「Flutter 展示排队、生成进度、失败重试和取消状态」)。实现任务状态轮询与展示:`queued`(排队中 + 队列位置或预计时间,视 T4-07 是否外露)、`running`(真实 `progress` 驱动进度条,替换 `:532-563` 现有的 `step/4` 假进度与 4 条硬编码 label)、`succeeded`(转 T4-19)、`failed`(`errorCode`/`errorMessage` 可读化 + 重试按钮,重试语义随 D4-8:**是新建任务还是复用同一任务**须定型)、`cancelled`。轮询策略需明确:间隔、退避、页面不可见时暂停、离页后是否继续(建议离页即停 + 回页重取,避免后台耗电);**尊重 `Retry-After`**。断网与超时的四态齐备。 +- **验收标准**: + - 五种状态各有 widget 测试;状态迁移驱动的 UI 变化(queued→running→succeeded / →failed)有测试。 + - 轮询在页面不可见时暂停、回前台恢复,有测试(M3 曾用 widget 测试抓出「回前台不开新曝光段」的真 bug,`iteration-3/29:46`——同类风险在此复现,须专项覆盖)。 + - 失败重试与取消的乐观/悲观策略明确且不假成功(离线操作提示明确,沿 M3 T3-17 的既有纪律)。 + - `progress` 为真实值(非一跳 100%),有测试。 +- **依赖**:T4-16、T4-17;T4-07/T4-08 联调。 +- **规模**:L + +#### T4-19 结果页与一键建社区草稿 +- **仓库**:patbond-flutter +- **描述**:生成成功后的结果展示与去向。复用现有结果区布局(`create_page.dart:312-386`)但换真实数据:输出图用 `outputAsset.url`(预签名 GET,**缓存 key 须剥 `X-Amz-*` 签名参数**——沿用已有的 `presignedImageCacheKey` 机制,否则每次刷新重下,该机制的回归判据已写进 `device-verification.md:190` 第 2 项);按真实 `widthPx/heightPx` 渲染宽高比(T4-09 交付后不再一律 4:3);删掉硬编码位置「北京市 · 朝阳区」(`:369-374`)与硬编码文案(`:87-90`)。「发布到社区」从占位 SnackBar(`:122-129`)改为真实调用 T4-10:建草稿并跳转 `PostComposePage` 预填(图与 `generationJobId` 已挂),由用户在既有发布页完成文案与发布——**不在本页做完整发布流程**(避免与发布页的草稿/幂等/媒体逻辑重复实现)。 +- **验收标准**:结果图真实渲染且宽高比正确;预签名 URL 不被持久化(纪律 R2)、过期可重取;一键建草稿 → 发布页预填 → 发布 → Feed 出现全链路真实后端;失败四态齐备;有 widget 测试。 +- **依赖**:T4-16、T4-18;T4-09/T4-10 联调。 +- **规模**:M + +#### T4-20 我的生成记录列表(条件单,随 D4-11 之外的独立取舍) +- **仓库**:patbond-flutter +- **描述**:若纳入:接 T4-07 的列表端点做「我的作品/生成记录」页(cursor 分页四态,照抄 `lib/features/pets/health_events_page.dart:19,71-127` 的 `_ListPhase` 范本),支持按 status 过滤、点击进详情、失败任务可重试、可删除记录(随 D4-14)。**PM 倾向纳入**:它是用户排查失败任务与找回历史输出的唯一入口,缺它则一次生成失败后用户无处可查(「失败原因可追踪」在客户端侧没有落点)。若周期紧张可后置,但须在收官总结记范围缺口。 +- **验收标准**:分页不丢不重(widget 测试模拟游标);四态齐备;状态与详情页同源一致。 +- **依赖**:T4-16、T4-18。 +- **规模**:S + +#### T4-21 我的收藏与草稿两页(M3.5 遗留搭车) +- **仓库**:patbond-flutter +- **描述**:清偿 `iteration-3.5/06:71` 遗留(「后端与仓库层均已就位,缺两个页面 + 导航」)。两页均照抄 `_ListPhase` + cursor 分页范本:**我的收藏**接 `listMyBookmarks()`(`community_repository.dart:52,274-283`,**当前全仓零调用点**,项形态为 `FeedCard`);**我的草稿**接 `listMyPosts(status: draft)`(:26-30,179-193,当前唯一调用点是发布页取 `limit:1` 恢复最新草稿)。入口替换 `lib/features/profile/profile_page.dart:49` 菜单项的 demo SnackBar(:235 `showDemoMessage`);顺带把「我的作品」统计数字(`:307-310`,当前不可点)接成可点入草稿/已发布列表。**本单不做**:草稿自动保存(见 §7)。 +- **验收标准**:两页分页不丢不重、四态齐备、有 widget 测试;收藏页的取消收藏与 Feed/详情状态同源一致(复用既有 `toggle_sync.dart` 乐观更新机制);profile 页不再有该项 demo SnackBar(有反向断言)。 +- **依赖**:无(与 M4 契约零耦合,第一波即可并行消化)。 +- **规模**:M + +### E 组:埋点、实验、遗留与收口 + +#### T4-22 埋点字典 v4:AI 创作漏斗 + `eventVersion` 口径定型 + 白名单计数纠偏 +- **仓库**:patbond-flutter(挂接)、patbond-api(白名单扩充)、patbond-doc(字典) +- **描述**:事件定义以 Experiment Tracker 的 M4 埋点方案为准(**本单不自造字典**;沿用「结果编码进事件名」惯例与 ADR-013 纪律)。预期覆盖 `development-plan.md:330` 要求的「AI 任务成功」漏斗:创作页进入 → 输入图上传(复用既有 `post_media_upload_*` 三段还是新增独立事件,由埋点角色定)→ 任务提交成功/失败 → 生成成功/失败/取消 → 建草稿 → 发布。**隐私红线**:props 不含 prompt 明文、不含 assetId/jobId、不含精确字节数(沿 M3 红线,服务端 `EventDictionary.java:38-39,114-116` + 客户端 `analytics_service.dart:288-295` 双层过滤在位)。 + 搭车三项修正:① **`eventVersion` 口径定型**(M3 观察项 2,`iteration-3/29:57`、`feature-checklist.md:222`)——契约描述 `openapi.yaml:2341-2344` 可两读、服务端零取值校验(`TrackEventsRequest.java:38-39` 仅 `@NotNull` 无 `@Min`,唯一取值校验在 DB 的 `V2:22`)、客户端硬编码 1(`analytics_service.dart:139`);定型为「事件 schema 版本」、写入字典纪律、并决定是否补 `@Min(1)` 与契约 `minimum`。② **白名单计数纠偏**:实测 41 条而文档三处写 42(§2.10),修正 `iteration-3/22-event-whitelist-v3.md:4,14`、`iteration-3/29-m3-summary.md:20`、`feature-checklist.md:218`,并在 `EventDictionaryTest.java` 补一条**总量断言**(当前无任何 `hasSize`)永久钉住。③ 新增事件同步进 v4 字典文档与 `EventDictionary.java`,并对刻意不做的事件名(如逐帖曝光类)维持「不在白名单即 unknown」的锁死机制。 +- **验收标准**:关键动作事件端到端落库(compose 实测或 curl 造真实 payload,沿 M3 的 §5c 取证惯例);白名单与字典文档条数一致且有总量断言;`eventVersion` 定型结论写入字典纪律与契约描述;三处文档计数修正;不回归既有 597 前端测试。 +- **依赖**:Experiment Tracker 方案;随 T4-17~T4-19 滚动挂接。 +- **规模**:M + +#### T4-23 A/B 最小分流设施与首个实验(条件单,随 D4-11) +- **仓库**:patbond-flutter、patbond-api、patbond-doc +- **描述**:若 D4-11 拍板启动首个实验,须先补 §2.9 核实的三处零实现:① **稳定分流组件**(前置 #4 的缺失半边)——`hash(userId 或 anonymousId, experimentSalt) % buckets`,建议**纯客户端确定性哈希**(`anonymousId` 持久化已在 `analytics_service.dart:51,177-190` 就位),避免为一个实验搭服务端下发设施;② **Flutter 曝光封装**(前置 #5 的缺失半边)——`experiment_exposed` 事件的强类型封装(服务端字典 `EventDictionary.java:103` 已就位、props `{experimentKey, variant}`,客户端零命中),触发时机按 `iteration-3/06:145` 的既定口径「用户**实际到达**实验触点时(渲染了变体 UI),非分配时」;③ **开关与回滚**(前置 #7 的「回滚绿」实无依据)——最小实现为客户端可远程或配置关闭实验、回落对照组。首个实验对象建议取**低风险、纯展示层**的项(如 create 页风格卡的默认排序,或结果页「发布到社区」的引导文案),**不实验后端生成逻辑**(成本与状态机风险)。 +- **验收标准**:同一用户多次进入分到同一变体(确定性有测试);`experiment_exposed` 落库且 props 键集与字典一致;关闭开关后全量回落对照组,有测试;实验设计与样本量按既有模板评审(**注意**:该模板文档在两仓中未定位到实体,见 §2.18,须先补或现场约定)。 +- **依赖**:D4-11 拍板;T4-17(触点所在页面);T4-22(字典)。 +- **规模**:M + +#### T4-24 真机验证 M2 两项(**硬时限 2026-09-21**) +- **仓库**:patbond-doc(执行记录回填) +- **描述**:**本迭代唯一带外部硬时限的工单**。执行 `device-verification.md:42`(验证一:Android 事件真实落库,~10 分钟)与 `:63`(验证二:SessionTracker 30 分钟后台换会话,~45 分钟含等待),回填 `:104` 的「M2 项执行记录」。时限来自北极星首次出数日 2026-09-21(`iteration-2/06-experiment-tracking-plan.md:308-312`)与埋点角色的提醒(`iteration-3/29:52`:未完成则首批读数只能标未验收)。**今日 09-14,仅剩 7 天,本单必须置于第一波首日。** + **降级方案(做不到时)**:① 首批北极星读数(W37 队列)在数据报告中显式标注「埋点落库未经 Android 真机验收,读数仅作趋势参考」;② 把复评点推到下一个成熟窗口(约 H14,即再迟 7 天);③ 用桌面/模拟器覆盖能覆盖的部分并明确记录**不可替代的缺口**——注意 `device-verification.md:144` 已言明 Linux 桌面的 `platform=linux` 不在契约枚举内、整批 400 被拒,故**事件落库这一项桌面根本无法替代**,降级只能降到「标注未验收」,不能降到「换环境验证」。 +- **验收标准**:两项的通过标准(`:59-61`、`:82-83`)逐条勾选并回填执行记录(日期、机型/Android 版本、psql 输出脱敏摘录、执行人);若未执行,则降级方案的三项动作全部落地并在收官总结显式记录。 +- **依赖**:Android 真机到位(**外部依赖,PM 无法消除**,故列 D4-13 请用户拍板)。 +- **规模**:S + +#### T4-25 第五份 E2E 脚本:`test_e2e_m4_manual.dart` +- **仓库**:patbond-flutter(脚本)、patbond-api(compose 环境)、patbond-doc(证据归档) +- **描述**:按 `releases.md:56` 的既定原则(「每个引入对外端点的迭代都应有对应的 E2E 脚本,并在此后每次发布回归」),M4 引入生成域对外端点,须新增第五份。照抄现有四份的同构骨架(纯 Dart CLI `dart run`、`fail()`/`check()`、`_passed` 计数、`Resp` 包装、通用 `call()`、脚本内现注册账号取 token、token 截断与预签名 URL 签名 `` 脱敏、内联小图 fixture——**注意 `iteration-3.5/06:56` 的教训:1×1 极小 PNG 能传能下但 Flutter 解码器拒绝,夹具须 ≥16×16**)。场景对齐 M4 四条验收标准: + 1. 目录读取(模型/风格,含 `enabled` 过滤与排序) + 2. 传图 → 提交生成任务 → 轮询至 `succeeded` → 输出 asset 可预签名取回且 `widthPx/heightPx` 非空 + 3. **相同 `Idempotency-Key` 重提 → 同一 jobId、不新增行、不消耗配额** + 4. **Worker 重启不丢任务**:任务处 `running` 时 `docker compose restart` 生成域容器 → 任务被重新领取并最终成功 + 5. 非法状态迁移被拒(对 succeeded 任务取消、对 queued 任务重复取消等) + 6. provider 失败 → 重试 → 达上限 → `failed` 且 `errorCode`/`errorMessage`/`attemptCount` 可查 + 7. 取消 queued 与取消 running 各一 + 8. 超配额 → 429 + `Retry-After` + 9. 一键建草稿 → `posts.generation_job_id` 落库 → 发布 → 另一账号 Feed 可见(`category='ai_creation'`) + 10. 他人 jobId / 未成功 jobId 建草稿被拒 + 另需在脚本头补上第五条 baseUrl 常量(若 D4-2 新建模块)。 +- **验收标准**:全场景绿;契约偏差 0;M4 四条验收标准逐条有证据(HTTP transcript 脱敏 + psql 库层交叉核对,沿 M3 T3-21 的双源取证强度);连跑 3 轮零 flake。 +- **依赖**:T4-06~T4-11、T4-19。 +- **规模**:M + +#### T4-26 五份 E2E 回归与 v0.5.0 发布(PR 流程) +- **仓库**:三仓 +- **描述**:按 `releases.md:126-138` 的**新流程**发布(v0.4.0 已实测过一轮):① 三仓 CI 绿 + **五份 E2E 同环境串行回归 PASS**(M1 7 + M2 11 + M3 14 + M3.5 10 + M4 约 10 = 约 52 场景);② Gitea 建 PR `dev → main`(标题写版本号,正文贴门禁证据链接);③ 等状态检查转绿(`CI / backend-test`、`CI / flutter-gates`,`releases.md:110-114`);④ 合并(**须为快进**,若显示分叉先查明原因不得用合并提交掩盖);⑤ 打 tag + 登记发布记录。版本号建议 **v0.5.0**(功能性里程碑,随 M4 收官)。 +- **验收标准**:五份回归全绿并出回归报告(沿 `iteration-3.5/08-release-e2e-regression.md` 格式);PR 快进合并、历史线性、零合并提交;三仓 tag 一致;`releases.md` 追加记录含三仓 tag/哈希、门禁证据、已知遗留。 +- **依赖**:T4-25 及全部交付单。 +- **规模**:M + +#### T4-27 文档与迭代收口 +- **仓库**:patbond-doc +- **描述**:OpenAPI v1.5.0 归档;`architecture/backend-modules.md` 更新(模块表/端口表/生成域归属与 media 写入边界);`architecture/decisions.md` 新增 **ADR-023 起**(AI 提供方、模块与 Worker 形态、队列载体、输入图必填与视频剪出、配额与限流口径——每条拍板决策一条 ADR 或合并为一条复合 ADR);`feature-checklist.md` 新增 M4 章节(当前 AI 创作**零条目**,仅 :183/:208 两处提及)并把清偿项转绿(:193 清理任务、:194 429 限流、:211 widthPx/heightPx、:222 eventVersion、:243 收藏与草稿页)、把已过期条目纠正(**:145「auth 域契约测试补齐 ⬜」实际 M3 T3-19 已交付**);`device-verification.md` 新增 M4 节(真机专属项预登记)并回填 M2 执行记录;迭代报告归档与收官总结。 + **三项文档纪律修正**:① `releases.md:131` 的「E2E **双份**回归」与 :36/:56 的四份口径自相矛盾,须改为**五份**;② 把「每个引入对外端点的迭代都应有对应 E2E 脚本并在每次发布回归」这条原则**固化进 `git-workflow.md`**(该文件 :56-64 的门禁表当前无 E2E 条目,原则只存在于 `releases.md:56` 的一句叙述里);③ **`patbond-doc` 补仓库级 `.gitignore`**(当前无该文件,`site/` 仅靠用户全局 `~/.gitignore_global:183` 排除,换机器或 CI 检出即可被误提交,与 `git-workflow.md:62` 冲突)。 + **`iteration-4` 目录的 mkdocs.yml 导航由文档维护者收口提交统一添加(本拆解报告不改 mkdocs.yml)**——参考 iteration-3.5 的挂法(`mkdocs.yml:100-108`,节点名脱离「第 N 迭代」序列、无 `index.md` 进展看板行),插入位置在 :108 之后、:109 `- API:` 之前。 +- **验收标准**:`mkdocs build --strict` 零 warning;报告索引完整;三项文档纪律修正各有对应 diff;ADR 编号连续无冲突。 +- **依赖**:各波交付。 +- **规模**:S + +#### T4-28 文档站访问控制(条件单,随 D4-12) +- **仓库**:服务器侧配置 + patbond-doc(`server-exposure.md` 状态更新) +- **描述**:若 D4-12 拍板纳入:给文档站加 basic auth 或 IP 白名单。登记原文 `server-exposure.md:32`:「✅ 运行;⚠️ **公开可访问,待评估是否加 basic auth 或 IP 白名单**(无凭证内容,但暴露内部架构细节)」。实现面为 nginx 配置(`sites-enabled/` 已按域名分流,:31)+ 凭据经 `.env`/密码文件不入库(沿 2026-09-11 安全事件后固化的服务器侧纪律,`iteration-3.5/07`)。**本迭代的新论据**:M4 报告将暴露 AI 提供方选型、密钥管理方式、配额策略与队列实现细节,公开可读的价值收益低于信息暴露成本。 +- **验收标准**:未授权访问返回 401/403;授权后文档站功能不变;`server-exposure.md:32` 状态位更新为已处置并记录核对命令;凭据不入库(`check-secrets.sh --all` 零命中)。 +- **依赖**:D4-12 拍板。不占关键路径(纯运维项)。 +- **规模**:S + +## 5. 波次划分与关键路径 + +沿用已验证模式:波次并行 + 迭代式契约冻结 + 同仓串行跨仓并行 + 每波 compose 实测。 + +### 第一波(并行开工) + +| 并行线 | 工单 | 说明 | +| --- | --- | --- | +| **硬时限** | **T4-24 真机 M2 两项** | **首日执行,2026-09-21 前必须闭环或启动降级**;不占技术关键路径但占人 | +| 数据与骨架 | T4-01 → T4-02 | V6 + 生成域落位,一人连续负责;**需 D4-2/D4-4/D4-6 开工前拍板** | +| 存储写能力 | T4-03 | 与 T4-01 并行;是 T4-09 的前置,**不要等到第二波才开** | +| provider 适配 | T4-05 | **需 D4-1 拍板**;未拍板时先写接口 + fixture,止损线:适配层以上不写供应商代码 | +| 目录 | T4-04 | T4-01 后即可 | +| 契约草案 | T4-13(起草态) | 四处 TODO-FREEZE 标注 | +| 门禁补强 | T4-14(机制部分) | 先在 v1.4.0 上落校验和断言并注毒自证 | +| 前端遗留 | T4-21 我的收藏与草稿 | 与 M4 契约零耦合,第一波并行消化 | +| 清理任务 | T4-12 | 与主线解耦,可插任意空档 | +| UI 设计 | 生成状态五态与结果页设计稿 | 供 T4-17~T4-19,不占关键路径 | + +### 第二波(后端纵切,契约收敛) + +| 并行线 | 工单 | 说明 | +| --- | --- | --- | +| 后端主线 | T4-06 → T4-08 / T4-07(06 后两线并行) | T4-06 是全部生成单的前置 | +| 输出落地 | T4-09 | 依 T4-03 + T4-08 | +| 草稿打通 | T4-10 | 依 T4-01 外键 + succeeded 任务 | +| 限流 | T4-11 | 依 T4-06;**契约首个响应头,须早于冻结定型** | +| 前端骨架 | T4-16(不依赖契约部分) | Repository/DTO 骨架先行 | +| 测试滚动 | T4-15 | 即测即绿即提交 | + +**波末闸门:T4-13 契约冻结 v1.5.0**(条件:T4-06/07 状态形态 + T4-09 输出形态 + T4-10 权限语义 + T4-11 限流与 `Retry-After` 形态四处全部定型;四/五份快照同步升版 + 守卫测试期望值整体更新 + T4-14 校验和期望值填入)。**不冻结不放行第三波两端联调。** + +### 第三波(冻结契约下两端并行) + +| 并行线 | 工单 | 说明 | +| --- | --- | --- | +| 前端主线 | T4-16(完成)→ T4-17 → T4-18 → T4-19;条件单 T4-20 | 创作页先通,状态机与结果页才有意义 | +| 埋点/实验 | T4-22;条件单 T4-23 | 随页面落地滚动挂接 | +| 后端旁路 | 契约测试补齐、队列并发压测、provider 故障注入矩阵 | 不占关键路径 | + +### 第四波(收官) + +T4-25 第五份 E2E → T4-26 五份回归与 v0.5.0 发布 → T4-27 文档收口;条件单 T4-28 可并行。 + +### 关键路径 + +```text +[D4-1/D4-2/D4-3/D4-4 拍板] + → T4-01(M) → T4-03(M) → T4-05(M) → T4-06(L) → T4-08(L) → T4-09(M) + → [T4-13 契约冻结闸门] + → T4-16(M) → T4-17(L) → T4-18(L) → T4-19(M) → T4-25(M) → T4-26(M) +``` + +四个 L 工单串在关键路径上(T4-06 / T4-08 / T4-17 / T4-18),**后端队列侧与客户端状态机侧各占其二**——与 M3 的「媒体双端占其二」结构同型。 + +**压缩手段**: + +1. **D4-1~D4-4 置顶开工前裁决**(四项都在关键路径起点,其中 D4-1 阻塞 T4-05、D4-4 阻塞 V6 定稿)。 +2. **T4-03 前移到第一波**:它不依赖任何生成域代码,却是 T4-09 的硬前置;若拖到第二波会把 T4-09 挤到冻结闸门之后。 +3. **T4-05 fixture 优先**:fixture provider 让 T4-06/T4-08 不必等真实供应商接通即可开发与测试。 +4. **T4-13 草案与 T4-16 骨架前移**(M2/M3 两度验证有效)。 +5. **旁路化**:T4-07、T4-10、T4-11、T4-12、T4-20、T4-21、T4-22 均不在关键路径上,可灵活填空档。 +6. **T4-24 首日执行**:它只占 ~1 小时机时但有 7 天外部时限,越早越好。 + +## 6. 需要用户拍板的决策清单 + +以下决策 PM 只给建议,**不替用户拍板**。**D4-1 是头号**(阻塞关键路径起点且正典自认未决:`development-plan.md:358,365`);**D4-1~D4-4 建议开工前裁决**(四项都影响 V6 定稿或关键路径起点)。 + +| # | 决策事项 | 影响 | PM 建议(仅供参考) | +| --- | --- | --- | --- | +| **D4-1** | **AI 提供方选型**(正典三度登记为未决:`development-plan.md:358`「AI 提供方……尚未确定」、:365 待确认事项)。候选:**A. fixture / 本地 stub provider**(可配置延时与进度、返回内置图、可注入四类故障;零密钥零成本零外部依赖,CI 与 E2E 唯一可用);**B. 云 AI API**(通义万相 / 即梦 / Replicate / OpenAI 等:能出真图,但引入账号、密钥、按次成本、网络不可达风险与 CI 外部依赖);**C. 自托管 SD / ComfyUI**(**已核实不可行**:本工作机为 AMD Radeon Vega 集显、无 `nvidia-smi` 即无 CUDA;生产侧腾讯云轻量服务器是否有 GPU 未取证,按 ADR-016 的同类背景推断为无) | 阻塞 T4-05(关键路径起点)、T4-06/T4-08 的开发与测试;决定 provider 抽象的形态(同步返回 vs 轮询 vs 回调)、密钥管理、CI 可跑性、以及验收标准「不重复扣费」中「扣费」是真金钱还是配额 | **A 起步 + 适配层,B 作为末期可选加挂**:M4 的四条验收标准(状态流转合法 / Worker 重启不丢 / 幂等不重复 / 失败可追踪)**全部与图好不好看无关**,fixture 能 100% 取证且能注入真实供应商难以复现的故障;同时把云 API 的接入点留在适配层,若用户愿意提供密钥与预算,第三波末加挂一个云 provider 做一次「真图」验证并入报告。**不建议 C**(无 GPU) | +| **D4-2** | **模块归属与 Worker 形态**:① 生成域——**新建 `patbond-creation`(:8085,七容器)** vs **并入 `patbond-user`**(后者已持 Flyway、已持唯一的 `ObjectStorage` 写入方、已有唯一的 `@EnableScheduling` 先例);② Worker 形态——**与 API 同进程 `@Scheduled`**(省容器)vs **同一 jar 不同 profile 起独立容器**(八容器,「重启不丢任务」的取证更干净、长任务不与在线请求抢线程) | 决定 T4-02/T4-03 骨架、compose 容器数(6/7/8)、CI 时长、客户端是否需要第五条 baseUrl(连带四份 E2E 脚本与真机清单的参数说明)、以及 ADR-023 的内容 | **①新建 `patbond-creation`(:8085)**:沿 ADR-009/ADR-017 两次先例(数据所有权独立、模块边界即未来服务边界,`backend-modules.md:68-72`);且 Worker 是长任务,与在线 API 同模块会互相影响。**媒体写入不下放**——生成输出经 T4-03 新增的 `POST /internal/media/assets` 由 `patbond-user` 落盘,**ADR-017「media 上传流程实现在 patbond-user」的边界不破**。**②同进程 `@Scheduled` + 配置开关**(`patbond.creation.worker.enabled`):DB 队列 + 租约天然支持多实例,需要拆时改配置即可拆;「重启不丢任务」用重启该容器取证同样成立。七容器已是双人团队的运维上限,不建议直接上八 | +| **D4-3** | **队列载体**:**A. DB 队列**(`FOR UPDATE SKIP LOCKED` + 目标模型已设计好的 `lease_owner`/`lease_expires_at`/`next_attempt_at`/`priority` 四列 + 两条 partial 索引)vs **B. 引入 RabbitMQ**(`development-plan.md:205` 原文「RabbitMQ 在异步任务阶段启用」,选 A 即是对正典的建议性偏离)vs **C. 引入 Redis** | 决定是否新增第 7/8 个容器与新依赖;决定 T4-08 的实现复杂度;影响 `patbond-common` 纪律(`development-plan.md:79`「不应让所有服务被动引入 RabbitMQ、Feign 等依赖」) | **A(DB 队列)**,理由三条:① 目标模型的 `generation_jobs` 已把租约、退避、优先级、幂等、provider 请求去重**全部设计在表里**,两条 partial 索引(`patbond_postgresql.sql:711-715`)就是为 `SKIP LOCKED` 队列写的——选 B 等于把已定稿的设计弃用一半;② 零新增基础设施(现状无 MQ 无 Redis,§2.4),运维面不增;③ 单库事务内「领任务 + 改状态」天然原子,比「MQ 消息 + DB 状态」的双写一致性问题简单一个数量级。**偏离正典须落 ADR-023 并注明**:M6「事务 Outbox 发布器」阶段若真需要 MQ,届时再引入,届时 DB 队列可作为 outbox 的对照实现 | +| **D4-4** | **生成输入是否必须有图**(决定 V6 能否照抄目标模型):目标模型 `generation_jobs.input_asset_id uuid **NOT NULL**`(`patbond_postgresql.sql:610`)——**即评审定稿的模型不支持纯文生图**。选项:**A. 沿用 NOT NULL,图生图先行**;**B. 改为 nullable 以支持文生图**(V6 偏离目标模型,须写入迁移说明) | 决定 V6 是否偏离目标模型;决定 create 页整个交互(A 则「先传宠物照片」是硬门槛,现有假上传卡直接转真实;B 则需要「有图/无图」两条分支 UI);决定配额与滥用面(A 天然收窄) | **A(沿用 NOT NULL,图生图先行)**:① 产品定位是宠物 App,核心价值是「用**我的宠物**照片生成」,纯文生图与定位弱相关且与通用 AI 画图工具直接竞争;② 现有 create 页的上传卡(`create_page.dart:159-168`)本就是必经步骤,改造成本最低;③ NOT NULL 让每次生成都有一张已归属校验的输入资产,滥用面与成本可控。若产品坚持文生图,改 nullable 的成本本身很小(V6 一处),但连带 UI 分支与滥用防护为 +1M | +| **D4-5** | **媒体形态**:**A. 仅 image**(`media_kind` 列与枚举保留,种子只入 image)vs **B. image + video 同期** | 视频涉及转码、封面帧、时长、存储量级台阶;`duration_ms` 列已在模型里;现有 create 页已有「AI 视频」分段项(`create_page.dart:138-157`)与 video 时长 ChoiceChip(:191-196);媒体上传契约的 `kind` enum 当前是 `[image]`(`openapi.yaml:3454`,注明 video/document 为向后新增预留) | 决定 T4-04 种子、T4-09 是否需时长探测、T4-17 分段器处置、存储成本量级 | **A(仅 image)**:视频是台阶式复杂度(转码 + 封面帧 + 时长 + 带宽——注意 ADR-016 已把「单机公网带宽」列为接受的限制,视频会立刻击穿它)。**分段器建议保留但 video 项禁用 + 「即将上线」提示**,而非删除:既与保留的 `media_kind` 枚举一致,也避免 UI 大改。视频随 M5/M6 的带宽与存储决策一起重评 | +| **D4-6** | **`model_version_snapshot` 的来源矛盾**(目标模型内部自相矛盾,V6 前必须裁决):`generation_jobs.model_version_snapshot varchar(64) **NOT NULL**`,但 `generation_models`(:556-573)**无任何 version 列**,种子把它硬编码为 `'1.0'`(:1514)。选项:**A. V6 给 `generation_models` 加 `version varchar(64) NOT NULL DEFAULT '1.0'`**(最小增列偏离);**B. 把 snapshot 列改为 nullable**(弱化可追踪性);**C. 让 snapshot 存 `provider_model_name`**(语义重复,两列同值) | 决定 V6 的一处偏离;影响「失败原因可追踪」与「历史任务可复现」的强度 | **A(给目录加 version 列)**:snapshot 四列的全部价值就是「目录改了不影响历史任务的可复现性」,B 会让这条价值在最关键的模型版本维度上失效;C 造成两列恒同值的冗余。A 的成本是 V6 里一行加列 + 一行迁移说明。**偏离须写入迁移文件头注释**(沿 V5:1-33 写法) | +| **D4-7** | **配额与限流口径**(决定「不重复扣费」中「扣费」的定义):目标模型**没有任何计费/额度表**,故 M4 的「扣费」只能理解为「消耗配额」。待定:① 每用户并发上限(建议 1);② 每用户每日次数上限(建议 20);③ 计数落在哪(**建议直接 `COUNT` `generation_jobs` 行,不新建表**);④ 429 的 `Retry-After` 取值口径(下一次可提交的秒数) | 决定 T4-06/T4-11 实现;决定 T4-25 的第 3、8 场景;直接决定成本上限(若 D4-1 选云 API,日限 × 单价 = 每日成本天花板) | **并发 1 + 日限 20 + 直接 COUNT 不建表**:① 并发 1 让排队语义对用户可解释、也让 Worker 并发度调优不受用户维度干扰;② 20 次/日对个人用户足够宽松、对成本足够安全;③ 不建表的关键理由——**幂等键已保证重复请求不新建行,所以「按行数计配额」天然满足『相同幂等请求不重复扣费』**,这是最省事又最正确的实现;④ `Retry-After` 取「最早一个 running 任务的预计完成时间」或「次日零点」的秒数,取小者。**四个数字均请用户确认,尤其若 D4-1 选 B(云 API),日限直接等于每日成本上限** | +| **D4-8** | **生成任务与草稿的语义定型**(四个子项):① 「一键建草稿」是**用户点按钮建**还是**成功后自动建**;② `category='ai_creation'` 是否**强制要求带 `generationJobId`**;③ `generationJob` 在 `Post`/`FeedCard` 读侧**外露到什么程度**(完全不露 / 只露 id / 露模型与风格名);④ 失败任务的「重试」是**新建任务**还是**复用同一任务**;⑤ 对已终态任务重复取消是**幂等 200** 还是 **422** | 决定 T4-07/T4-10/T4-18/T4-19 的实现与契约形态;③ 直接决定契约新增字段的规模;④ 影响幂等键语义(新建任务须换 key,否则命中旧任务) | ①**用户点按钮建**(自动建会产生草稿垃圾,且用户可能只想看看不想发);②**强制带 `generationJobId`**(否则用户可伪造 AI 分类,读侧 `ai_creation` 就失去可信度);③**只露 id + 模型与风格的展示名**(够做「由 Patbond-V1 · 治愈动画 生成」的角标,不露 prompt——prompt 可能含隐私);④**新建任务**(复用同一行会破坏 `attempt_count <= max_attempts` 与 snapshot 语义;客户端须生成新 `Idempotency-Key`,并在 UI 明确「重试会消耗一次配额」);⑤**幂等 200 no-op**(沿 `UpdatePostRequest.status` 对已发布帖重复提交为幂等 no-op 的既有先例 `openapi.yaml:3701-3707`) | +| **D4-9** | **`widthPx`/`heightPx` 恒 null 的处置**(M3 观察项 2,跨两个迭代未决):**A. 服务端在 complete 时探测尺寸**(需 T4-03 顺带加对象读能力,读图片头部字节解析 jpeg/png/webp);**B. 只改契约描述**(把 `openapi.yaml:3536` 的「complete 后回填」改为「仅 AI 输出回填」,用户上传路径承认恒 null);**C. 契约新增 complete 请求体由客户端申报尺寸**(客户端已解码过图,成本最低,但客户端申报值不可信) | 决定单图帖能否按真实宽高比渲染(现一律回落 4:3);决定 T4-09 的规模;A 会让 T4-03 多一个方法 | **A(服务端探测)**:① M4 本就要给 `ObjectStorage` 加写能力,顺带加读只是一个方法,边际成本 S;② AI 输出路径无论如何都要写真实尺寸(尺寸是生成请求参数,服务端已知),若用户上传路径仍恒 null,就会出现「AI 图有宽高比、用户图没有」的分裂体验;③ C 的客户端申报值不可信(可被篡改,且与 `ck_media_dimensions` 的库层校验形成两个事实源)。**探测失败时回落 null 且不阻塞 `ready`**(`MediaAsset.required` 不含尺寸字段,向后兼容) | +| **D4-10** | **契约版本与快照锁补强**:① 版本号 **v1.5.0**(若确认对 v1.4.0 为纯增量);② 是否顺手把快照锁从「计数守恒」补强为**真校验和断言**(§2.13:CI 当前只锁 version + 3 计数 + tag 分组,md5 比对是人工步骤,任何计数守恒的字节改动都能溜过) | ① 决定四/五份快照文件名与守卫测试期望值;② T4-14 是否成立 | ①**v1.5.0,且维持「对既有集成方纯增量」的承诺**:核对全部变更(新增生成域路径、两处 enum 增值、新增字段、一处 description 修订、新增错误码与 429 响应)——**无字段删改、无类型变更、无必填收紧**,纯增量成立,可继续写入 `info.description`。②**纳入(T4-14,S)**:M4 契约面显著扩大(新增域 + 可能第五份快照),且已有一次真实教训——契约测试累计抓修 3 处真问题(`iteration-3/29:44`),其中「校验器对 `nullable + allOf` 静默跳过」正是这类「看不见的漂移」 | +| **D4-11** | **A/B 首个实验是否本迭代启动**(ADR-012 原定「M4 首实验」,`decisions.md:130`):**A. 启动,但极小面**(补分流哈希 + Flutter 曝光封装 + 最小开关三件,实验对象取纯展示层项);**B. 后置到 M5/M6**(等监控设施选型落地一并做) | 决定 T4-23 是否成立(M);决定 ADR-012 的承诺是否兑现 | **A,但请用户知情前提被高估了**:§2.9 核实后前置实为 **5 绿 3 半**(#4 分流组件、#5 Flutter 曝光封装、#7 feature flag 三者**代码零实现**),文档写的「6 绿 1 半」偏乐观。启动首个实验需先补这三件(约 1M),建议用**纯客户端确定性哈希**(`anonymousId` 持久化已就位)避免为一个实验搭服务端下发设施;实验对象取 create 页风格卡默认排序或结果页引导文案这类**纯展示层**项,**不实验后端生成逻辑**。若用户认为 M4 周期已满,选 B 亦合理,但须在收官总结显式记录 ADR-012 承诺顺延,并把「6 绿 1 半」的口径更正为「5 绿 3 半」 | +| **D4-12** | **文档站是否加访问控制**(`server-exposure.md:32` 登记的待评估项):**A. 本迭代纳入(S 独立工单 T4-28)**;**B. 继续挂起** | 纯运维项,不占关键路径;影响内部架构信息的暴露面 | **A(纳入,S)**:本迭代的新论据是 M4 报告将写入 AI 提供方选型、密钥管理方式、配额策略与队列实现细节——公开可读的收益低于暴露成本;且 2026-09-11 安全事件后服务器侧纪律已固化,趁热做成本最低。实现为 nginx basic auth 或 IP 白名单,凭据不入库。若用户偏好继续挂起亦无技术风险,但建议至少把「M4 迭代报告」这一目录先行限制 | +| **D4-13** | **真机 M2 两项的 09-21 硬时限如何应对**(外部依赖,PM 无法消除):**A. 7 天内投入 Android 真机**(借用或采购)并首日执行 T4-24;**B. 接受降级**——首批北极星读数标注「未经真机验收」+ 复评点顺延至下一成熟窗口 | 决定 2026-09-21 首批北极星读数(W37 队列)是否可作为正式基线;连带影响 A/B 前置 #1「数据质量验收」的成色(该项判绿但拦路项正是真机) | **A(投入真机)**:① 两项合计仅约 1 小时机时(`device-verification.md:42` ~10 分钟 + `:63` ~45 分钟含等待),投入产出比极高;② **桌面无法替代**——`device-verification.md:144` 已言明 Linux 桌面 `platform=linux` 不在契约枚举内、整批 400 被拒,故「事件落库」这一项只能在 Android 上验;③ 这两项是 10 项挂起中**唯一带外部时限**的,其余 8 项无时限可继续挂起。若确实无设备,B 的三项降级动作(标注 / 顺延 / 记录不可替代缺口)须全部落地 | +| **D4-14** | **AI 资产的用途命名、归属与生命周期**:① 新 `purpose` 枚举值命名(建议 `ai_input` / `ai_output`,vs 复用 `post_image`);② 输出 asset 的 `owner_user_id` 是否为请求用户(建议是);③ 失败/取消任务的**输入图**是否清理、生成记录是否可删、删除记录是否连带删对象;④ 生成图被建成帖子后,删帖是否影响该资产 | 决定 T4-03 的配置扩充、T4-09 的写入字段、T4-12 的清理范围、T4-20 的删除能力;直接影响长期存储成本(每次生成 ≥1 输入 + 1 输出) | ①**新增 `ai_input` / `ai_output` 两个值**(不复用 `post_image`:用途是审计与清理策略的依据,混用后无法区分「用户主动发的图」与「生成中间产物」)——纯配置变更无需迁移(`MediaProperties.java:66` + `application.yml:45`,ADR-022 已验证同一路径);②**是**(便于按用户清理与配额审计);③**输入图随任务终态保留、不主动清理**(重试与申诉都需要它;靠 T4-12 只清理未确认的 uploading 孤儿);**生成记录允许软删但首版不删对象**(对象清理属 M6 存储治理);④**删帖不影响资产**(`posts.generation_job_id` 是 `ON DELETE SET NULL`、`post_media` 与 assets 的引用关系独立,沿既有语义不动) | + +**决策依赖关系提示**:D4-1 → T4-05;D4-2 → T4-02/T4-03②/T4-16 第五条 baseUrl;D4-3 → T4-08;**D4-4 + D4-6 → V6 定稿(T4-01 的开工前提)**;D4-5 → T4-04 种子 + T4-17 分段器;D4-7 → T4-06/T4-11;D4-8 → T4-07/T4-10/T4-18/T4-19 + 契约;D4-9 → T4-03/T4-09;D4-10 → T4-13/T4-14;D4-11 → T4-23 成立与否;D4-12 → T4-28 成立与否;D4-13 → T4-24 的执行 vs 降级;D4-14 → T4-03 配置 + T4-09 + T4-12 + T4-20。 + +## 7. 刻意不做的事项及理由 + +以下均为**主动裁剪**,不是遗漏。逐条给出理由,供收官总结引用与产品知情。 + +| # | 不做的事 | 理由 | +| --- | --- | --- | +| 1 | **视频生成**(随 D4-5) | 台阶式复杂度:转码 + 封面帧 + 时长探测 + 存储与带宽量级。**ADR-016 已把「单机公网带宽」列为接受的限制**(自托管 MinIO 在腾讯云轻量服务器),视频会立刻击穿它。`media_kind` 列与枚举保留,UI 分段项保留但禁用 | +| 2 | **纯文生图**(随 D4-4) | 评审定稿的目标模型 `input_asset_id NOT NULL`(`patbond_postgresql.sql:610`)本就不支持;且与「用我的宠物照片生成」的产品定位弱相关。改 nullable 的技术成本很小,连带的 UI 分支与滥用防护不小 | +| 3 | **真实计费与支付** | 目标模型**零计费/额度表**。「不重复扣费」以「不重复消耗配额」取证(D4-7),配额直接 `COUNT` 任务行。支付属独立产品决策,不在 M0~M6 任何里程碑原文中 | +| 4 | **引入 RabbitMQ / Redis**(随 D4-3) | `generation_jobs` 已把租约、退避、优先级、幂等、provider 去重全部设计在表里 + 两条为 `SKIP LOCKED` 而建的 partial 索引;引入 MQ 反而带来「消息 + DB 状态」双写一致性问题。`development-plan.md:205` 的 RabbitMQ 承诺顺延到 M6「事务 Outbox」阶段,须落 ADR 记录偏离 | +| 5 | **生成内容审核、敏感词、举报、水印** | 无运营后台(M3 D3-7 已定「`hidden`/`archived` 保留字段不开放端点」)。**但须显式声明敞口**:AI 生图的 UGC 风险高于普通图文(可生成不宜内容且难以事后归因),数据库侧可手工 `hidden` 应急;审核与水印入 backlog,随 M6 或运营后台一起设计 | +| 6 | **生成完成的推送通知** | M6 原文「通知、事务 Outbox 发布器和失败重试」(`development-plan.md:271`)。M4 内客户端靠轮询(T4-18),离页即停、回页重取 | +| 7 | **完整可观测性栈**(指标、Trace、告警、准实时护栏监控) | M6 原文「限流、审计、结构化日志、指标、Trace 和告警」(:272);A/B 前置 #7 的监控半边也已明确「留 M4」实指「依赖监控设施选型」。M4 只做 `development-plan.md:329` 的**最小兑现**:队列积压与 Worker 失败率 **SQL 可查 + 结构化日志留痕**,不建看板不接告警 | +| 8 | **全站通用限流** | T4-11 只做生成域限流(成本刚需)。全站限流属 M6 :272 范围。**注意这意味着 M3 遗留的 429 只被部分清偿**——埋点上报端点(`POST /api/v1/events`)仍无限流,须在 feature-checklist 里如实标注为部分完成 | +| 9 | **草稿自动保存** | T4-21 只做「我的草稿」列表页。自动保存(防抖 Timer + 冲突处理)是独立交互设计问题,且现有埋点枚举已明确「自动保存不埋」(`post_analytics.dart:30-41` 只有 `manual`/`on_exit`)。继续挂起 | +| 10 | **多模型并行生成 / 一次出多图 / 生图质量 A-B 对比** | 目标模型一个任务对一个 `output_asset_id`(单值列),多图需改模型(新增输出表或数组列)。属产品功能扩展,不在 M4 原文 | +| 11 | **逐帖曝光与服务端排序实验日志** | ADR-020 已否决逐卡曝光并把它记为「M4+ backlog 待服务端下发日志」(`decisions.md:169`)。服务端日志下发依赖 M6 的日志设施,本迭代不启 | +| 12 | **话题、关注列表、作者主页** | ADR-018 裁剪项,与 AI 创作零耦合。继续挂起(详见 §8) | +| 13 | **access token 黑名单、`/internal` 改 mTLS** | 跨迭代技术债。**但须记录权重上升**:T4-03 新增 `POST /internal/media/assets`(且承载对象写入),`/internal` 面在本迭代扩大,mTLS 的优先级应在 M6 加固时上调 | +| 14 | **月份网格选择器、大图下滑关闭手势、`SegmentedButton` 之外的主题债** | 与 M4 零功能耦合。大图下滑关闭需引入 `photo_view` 新依赖,不为一个手势引入依赖;月份网格的实测痛点已被年份网格 + 手输 + 「今天」覆盖(`iteration-3.5/06:72`) | +| 15 | **真机 M3 四项与 M3.5 四项** | 无设备且无时限(只有 M2 两项带 09-21 硬时限)。M4 会再新增真机专属登记项,届时一并执行更经济 | + +## 8. 历史遗留处置:搭车 vs 挂起 + +清点范围为 M3(`iteration-3/29-m3-summary.md:52-62`)与 M3.5(`iteration-3.5/06-wave2-closure.md:69-76`)的全部遗留,加上本次审计新发现的文档/门禁缺口。 + +### 8.1 搭车进 M4(11 项) + +| 遗留项 | 判定理由 | 插入位置 | +| --- | --- | --- | +| **`widthPx`/`heightPx` 恒 null**(M3 观察项 2) | AI 输出的尺寸是**生成请求参数,服务端必然已知**,不写就自相矛盾;若只写 AI 路径会造成「AI 图有宽高比、用户图没有」的分裂。且 T4-03 本就要给 `ObjectStorage` 加方法,顺带加读只是边际 S。**核实纠正:不是缺列**(V1:217-218 早已存在),规模 S 非 M | **T4-09**(随 D4-9) | +| **429 限流 + 客户端 `Retry-After` 分支** | AI 生成有真实成本,配额与限流是**刚需而非可选**;客户端 `api_client.dart:164-167` 已有 429 粗分支、`analytics_service.dart:268-272` 的注释明写在等后端落地 | **T4-11**(后端)+ **T4-16**(客户端收口)。**注意只清偿生成域**,埋点端点限流仍挂起 | +| **uploading 超时未确认 asset 清理任务** | M4 本就要建 `@Scheduled` 调度基座;且**每次生成都要先传一张输入图**,孤儿资产量被放大,此项从「可选清理」升为「成本控制项」 | **T4-12** | +| **`eventVersion` 口径未定型** | 字典 v4 本就要动 `EventDictionary` 与契约描述,顺手定型边际成本近零 | **T4-22** | +| **「我的收藏与草稿」两页** | 后端与仓库层**全就绪、零调用点**(`listMyBookmarks` 全仓无调用),与 M4 契约零耦合,可在第一波并行消化,不占关键路径 | **T4-21** | +| **真机 M2 两项** | **唯一带外部硬时限的遗留**(2026-09-21),仅约 1 小时机时 | **T4-24**(第一波首日,随 D4-13) | +| **`SegmentedButton` 粉底(M2 主题债)** | **本次审计改判为搭车**:该控件的使用点之一正是 M4 要重做的 create 页模式分段器(`create_page.dart:138`),根因已定位(主题缺 `segmentedButtonTheme`,`app_theme.dart:94-196`)、已审计色对表与落地范本俱在(:199-214、:215-296),一次修复 6 处使用点,边际成本 S | **T4-17** | +| **A/B 前置 #4/#5/#7 的缺失半边** | ADR-012 承诺「M4 首实验」;且文档「6 绿 1 半」经核实实为「5 绿 3 半」,若不补则实验无法启动 | **T4-23**(条件单,随 D4-11) | +| **契约快照锁强度不足**(本次新发现) | CI 只锁计数不锁字节;M4 契约面显著扩大(新增域 + 可能第五份快照),此时补机制成本最低 | **T4-14** | +| **埋点白名单计数 42→41 纠偏 + 缺总量断言**(本次新发现) | 字典 v4 本就要动白名单;补一条总量断言即永久钉住,边际成本近零 | **T4-22** | +| **三项文档纪律缺口**(本次新发现):`releases.md:131` 仍写「E2E 双份」与 :36/:56 的四份矛盾;E2E 回归原则从未固化进 `git-workflow.md`;`patbond-doc` 无仓库级 `.gitignore`(`site/` 仅靠全局 gitignore 排除) | M4 本就要把回归清单改为五份并新增第五份脚本,三项一并修正;`.gitignore` 是一行 | **T4-27** | + +### 8.2 继续挂起(10 项) + +| 遗留项 | 挂起理由 | 建议落点 | +| --- | --- | --- | +| **真机 M3 四项 + M3.5 四项**(共 8 项) | 无设备且**无外部时限**;M4 会再新增真机专属登记项,届时一并执行更经济。步骤已在 `device-verification.md:110-168`、:172-217 备齐 | 设备到位即插入,不阻塞任何工单(约 1 天) | +| **完整草稿列表的自动保存** | 独立交互设计问题;现有埋点枚举已明确「自动保存不埋」。T4-21 只交付列表页 | M5 或独立客户端体验单 | +| **月份网格选择器** | 实测痛点已被年份网格 + 手输 + 「今天」覆盖(`iteration-3.5/06:72`) | 独立客户端体验单 | +| **大图「下滑关闭」手势** | 需引入 `photo_view` 新依赖,不为一个手势引入依赖 | 独立客户端体验单(与依赖评估一并做) | +| **话题功能**(ADR-018 剪出) | 与 AI 创作零耦合。**注意一处历史推测已被本次审计削弱**:M3 曾建议「topics 端点随 AI 创作分类需求一起做(`ai_creation` category 天然关联)」(`iteration-3/01:285`),但实际 `ai_creation` 只是 `category` 的第三个枚举值、与 `topics` 表无任何字段关联,**该关联不成立**,不构成搭车理由 | M5+ 或独立社区增量单 | +| **关注列表 / 作者主页**(ADR-018 裁剪) | 与 AI 创作零耦合 | M5+ | +| **逐帖 Feed 曝光** | ADR-020 已否决,明确「待 M4+ 服务端下发日志」,而日志设施属 M6 | M6 后重评 | +| **access token 黑名单** | 跨迭代技术债,无 M4 耦合 | M6 加固 | +| **`/internal` 改 mTLS** | 跨迭代技术债。**权重上升须记录**:T4-03 新增 `/internal/media/assets` 且承载对象写入,`/internal` 面在本迭代扩大 | M6 加固(优先级上调) | +| **全站通用限流(生成域之外)** | T4-11 只做生成域;埋点上报端点仍无限流 | M6 :272 | + +## 9. 风险清单 + +| # | 风险 | 影响 | 缓解措施 | +| --- | --- | --- | --- | +| R1 | **AI 提供方未决且正典自认未决**(`development-plan.md:358,365`):若拖延,T4-05 空转,连带 T4-06/T4-08 无法测试;若选云 API,还叠加密钥、成本、网络不可达与 CI 外部依赖四重不确定 | 关键路径起点空转 | D4-1 置顶开工前裁决;**未拍板期间先写适配层接口 + fixture 实现**,止损线明确:适配层以上不写任何供应商特定代码;fixture 无论如何都要做(CI 与 E2E 唯一可用 provider) | +| R2 | **异步基础设施从零起步**(§2.4:无 MQ、无 Redis、无线程池、`@Scheduled` 唯一先例是删会话行):租约、退避、并发领取、多实例安全全是新代码,且 `ck_generation_jobs_state` 的五态字段组合约束严格(回收时必须清 `started_at` 与 `progress`,否则库层直接拒绝) | T4-08(L)估算失准直接拖垮迭代;状态机 bug 密集区 | 选 DB 队列(D4-3)让实现收敛在一条 SQL 模式内;**T4-15 的状态机穷举矩阵作为 DoD 硬项**(每条合法迁移成功 + 每条非法迁移被应用层拒且被库层 CHECK 兜底,两层各有断言);`started_at` 清零的取舍写进代码注释与迁移说明 | +| R3 | **「输出写入媒体表」被一句话低估**(§2.5):服务端目前根本不能写对象存储,且 ADR-017 把 media 写入边界钉在 `patbond-user` | T4-03 若拖到第二波会把 T4-09 挤到契约冻结之后,连锁推迟第三波 | T4-03 前移到第一波(它不依赖任何生成域代码);用 `/internal/media/assets` 保住 ADR-017 边界不破;MinIO Testcontainer 覆盖服务端 put 全链路 | +| R4 | **契约冻结面比 M3 更宽**:四处待定型点(五态响应形态、429 + `Retry-After` 这一契约史上首个响应头、`generationJob` 外露程度、新 purpose 命名),且若 D4-2 新建模块则快照从四份变五份、守卫测试期望值需五处同步更新 | 冻结延迟连锁推迟第三波两端联调 | 四处待定型点第一波即在草案中显式 `TODO-FREEZE` 并限期第二波中期收敛;闸门纪律不放松;T4-14 的校验和断言让「漏同步一份快照」在 CI 立即暴露 | +| R5 | **AI 生成 UGC 的内容风险高于普通图文**:可生成不宜内容,且无审核、无水印、无举报、无运营后台 | 内容风险敞口(虽 MVP 用户面小) | §7 第 5 条已显式声明敞口并要求产品知情;`hidden` 字段可手工应急;prompt 不入埋点明文(隐私);审核与水印入 backlog 并在收官总结列明 | +| R6 | **成本失控**(仅在 D4-1 选云 API 时成立):无配额则单用户可无限提交 | 真金钱损失 | D4-7 的日限即每日成本天花板,**必须与 D4-1 同时拍板**;T4-11 的配额是 T4-06 的准入条件而非可选后置项;fixture provider 让开发与 CI 阶段零成本 | +| R7 | **create 页从零建测试基线**(§2.14:563 行、`grep "CreatePage" test` 零命中) | T4-17/T4-18 是「改代码 + 建测试」双份工作量;假延时改真轮询是状态 bug 高发区 | 保留四块视觉骨架只换数据源(降低视觉返工,M3 的 R10 不复现);轮询的「页面不可见暂停 / 回前台恢复」列为 DoD 硬项——M3 曾用 widget 测试抓出「回前台不开新曝光段」的同类真 bug(`iteration-3/29:46`) | +| R8 | **09-21 硬时限只剩 7 天**且依赖外部设备(PM 无法消除) | 首批北极星读数(W37)只能标未验收,连带 A/B 前置 #1 的成色 | D4-13 请用户即刻拍板;T4-24 排在第一波首日;降级方案三项动作已写入工单,且明确**桌面无法替代**(`platform=linux` 不在契约枚举、整批 400) | +| R9 | **容器数与 CI 时长继续增长**:六 → 七(或八)容器;MinIO 容器使用面因 T4-03/T4-09 扩大;测试数从 379/597 继续上量 | 门禁反馈变慢被绕过 | D4-2 建议同进程 Worker 以省一个容器;T4-15 记录每波 CI 时长,超阈值按模块分层执行,**不降低「提交前全绿」标准** | +| R10 | **文档结论与代码实况的系统性偏差**(本次审计一次性发现 8 处,见 §11):白名单计数、快照锁强度、A/B 前置成色、真机项数、E2E 份数口径…… 说明「转述被当成事实」不是 M3.5 的偶发事故 | 规模预估失准、承诺无法兑现、门禁虚假安全感 | 本报告全部结论标注核实文件与行号;**把「可机验断言」作为纠偏机制**(T4-22 补白名单总量断言、T4-14 补契约校验和断言——两者都是把人工纪律变成 CI 门禁);收官总结须逐条回填修正后的口径 | +| R11 | **未提交/未推送风险**(历次惯例项) | 工作量全损 | 每波每单交付即提交即推送(ADR-011/ADR-021:只推 dev,main 走 PR);PM 每波核对三仓 `git status` 与远端同步 | + +## 10. 质量要求(对全部工单生效) + +- 遵守开发计划第 10 节 DoD 八条:契约/迁移/代码一致;不依赖 demo 常量;权限、校验、幂等、并发已处理;四态齐备;日志可定位且不泄敏;干净环境可复现。 +- 契约规范沿用:`camelCase`、UUID 字符串、ISO 8601 + `timestamptz`、统一信封 `{code, message, data}`、稳定错误码(生成域新码段定死:建议 `40407`/`42206`/`42900`,**永不复用或改号**)、cursor 分页(禁 OFFSET)、**生成任务创建强制 `Idempotency-Key`**(`development-plan.md:173`)、`version` 乐观锁。 +- **契约冻结后 api 侧快照同步升版**,四/五份 md5 与正典一致;守卫测试期望值(version + 3 计数 + tag 分组)与 T4-14 的校验和断言同步更新;`everyDeclaredResponseCellIsExercised` 新增格全部真实触发,不得豁免。 +- **迁移纪律**:V6 进 `patbond-user`(单迁移链);不修改已推送的 V1~V5;任何偏离目标模型之处在迁移文件头注释逐条写明理由(沿 V5:1-33 写法);存量库实证 + 全新库全量迁移双向验证。 +- 集成测试一律 Testcontainers `postgres:18`(ADR-006/008),媒体与生成输出测试用 MinIO 容器;每单交付 `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` 全绿、`dart format --set-exit-if-changed` + `flutter analyze` + `flutter test` 全绿、`mkdocs build --strict` 零 warning(门禁表 `git-workflow.md:56-64`,**门禁不绿不提交**)。 +- 每个业务接口覆盖六类路径:成功 / 参数错 / 资源不存在 / 无权限 / 并发冲突 / 幂等重试。 +- 所有网络页面四态(loading/empty/error/retry)齐备 + 离线提示;图片位另加占位/失败态;预签名 URL **不得持久化**、缓存 key 须剥 `X-Amz-*`。 +- **凭证纪律**:不提交任何密码、token、对象存储密钥、**AI provider 密钥**(`.env` / `.sample` 模式);两层 `check-secrets.sh` + CI 兜底;埋点不含 prompt 明文、不含 assetId/jobId、不含精确字节数。 +- **本迭代不实现**预约、通知推送、运营后台的任何接口或页面;`posts.region_id` 仅预留;范围外需求记 backlog。 + +## 11. 工单统计 + +- **工单总数:28**(A 数据与地基 5 + B 后端生成纵切 7 + C 契约与测试 3 + D Flutter 6 + E 埋点遗留收口 7),其中 **3 个条件单**(T4-20 我的生成记录、T4-23 A/B 首实验、T4-28 文档站访问控制) +- **规模分布**(核心 25 单):S × 5(T4-04 / T4-12 / T4-14 / T4-24 / T4-27)、M × 16、L × 4(T4-06 / T4-08 / T4-17 / T4-18);条件单另计 S × 2、M × 1 +- **波次**:4 波(第一波并行开工 10 线 / 第二波后端纵切 6 线 + 契约冻结闸门 / 第三波两端并行 3 线 / 第四波收官 3 单) +- **关键路径**:D4-1~D4-4 拍板 → T4-01 → T4-03 → T4-05 → T4-06 → T4-08 → T4-09 → **T4-13 冻结闸门** → T4-16 → T4-17 → T4-18 → T4-19 → T4-25 → T4-26;L × 4 在链上,后端队列与客户端状态机各占其二 +- **待拍板决策:14 项**(D4-1~D4-14);**D4-1 头号**(阻塞关键路径起点),**D4-1~D4-4 建议开工前裁决**(D4-4 与 D4-6 是 V6 定稿的前提) +- **遗留处置**:搭车 11 项、继续挂起 10 项 +- **刻意不做**:15 项,逐条附理由 + +### 11.1 本报告推翻或修正的既有文档结论(8 处) + +| # | 既有文档结论 | 核实后的实况 | 影响 | +| --- | --- | --- | --- | +| 1 | 「事件白名单 42 条」(`iteration-3/22:4,14`、`iteration-3/29:20`、`feature-checklist.md:218`) | **41 条**。`EventDictionary.java:42-104` 实测 `Map.entry` 41 个;根因是 `experiment_exposed` 被重复计数(设计源文档 `iteration-3/06:12,149` 自己写的是 19 个新事件,22 + 19 = 41)。**无任何门禁能拦**(`EventDictionaryTest` 无总量断言) | 三处文档纠正 + 补总量断言(T4-22) | +| 2 | 「四模块**字节级快照锁 CI**」 | **CI 里没有任何 md5/checksum 校验**。`ci.yml` 只有三步(secret scan / 装 JDK / `mvnw clean test`);md5 比对只是人工冻结步骤。CI 实际锁的是「version + 3 计数 + tag 分组」,任何**计数守恒的字节修改**都能溜过 | 新增 T4-14(S)把纪律变门禁 | +| 3 | 「A/B 前置 **6 绿 1 半**,M4 可启首个实验」(`iteration-3/29:68`) | **实为 5 绿 3 半**。#4 分流哈希组件、#5 Flutter 曝光封装、#7 feature flag / 社区发布开关**三者代码零实现**(后两者 grep 全仓零命中,#7 的「回滚绿」无任何依据,连 feature-checklist 都未登记) | D4-11 需在知情前提下拍板;T4-23 规模上调 | +| 4 | 「真机挂起**四项**」/ 主会话口径「共 8 项(M3.5 两项)」 | **共 10 项**:M2 2 + M3 4 + **M3.5 4**(`device-verification.md:176/:190/:200/:209`,M3.5 收口报告 `06:75` 自己写的也是「4 项 + 1 备注」)。三个执行记录全为「待补」 | §8.2 按 10 项登记;T4-24 只抢 M2 两项 | +| 5 | 「`widthPx/heightPx` 恒 null」被隐含理解为能力缺口 | **列早已存在**(V1:217-218);真实缺口是「服务端无尺寸探测」+「契约声明了不存在的回填入口」(complete 端点 `openapi.yaml:1256-1299` **无 requestBody**)。**这是 M3.5「转述不可采信」教训的同型复现** | 规模由 M 降为 S,搭车 T4-09 | +| 6 | 「M4 需设计 AI 创作数据模型」(隐含预期) | **完整设计早已评审定稿**:`docs/database/patbond_postgresql.sql:556-716` 含三表 40 列、全套租约/退避/优先级/幂等列、11 条索引(两条为 `SKIP LOCKED` 队列而建)、三个触发器、开发种子。且**零跨 schema 外键需裁剪**(V3 剪 4 条、V5 剪 2 条的先例在 M4 不重演,因 identity/pet_health/media 三个 schema 均已存在) | T4-01 由 L 降为 M;D4-3 有了「不引入 MQ」的硬证据 | +| 7 | 「输出写入媒体表」(`development-plan.md:252` 一句话) | **服务端目前根本不能写对象存储**:`ObjectStorage.java:15-37` 只有 `ensureBucket`/`presignPut`/`stat`/`presignGet`,`putObject` 仅用于构造预签名。需新增写方法 + 实现 + 未配置桩 + 跨模块落库通路(受 ADR-017 边界约束) | 新增独立工单 T4-03(M),并前移到第一波 | +| 8 | 「回归清单已改为四份」与「发布流程」 | 口径**自相矛盾且未固化**:`releases.md:36/:56` 写四份,同一文件 :131 的纪律条目仍写「E2E **双份**回归」;且「每个引入对外端点的迭代都应有 E2E 脚本」这条原则**从未写进 `git-workflow.md`**(该文件门禁表无 E2E 条目)。另 `feature-checklist.md:145`「auth 域契约测试补齐 ⬜」已过期(M3 T3-19 已交付) | 三项文档修正并入 T4-27;第五份脚本单列 T4-25 | + +**附带修正两处口径(非推翻)**:① patbond-api 测试数 **379**(surefire 运行数,`releases.md:17,35`)与 **381**(注解静态计数,62 个测试类)的差异属参数化展开口径,不是回归;② patbond-flutter 是 **597 通过 + 2 skip**(环境门控冒烟),且不含 `integration_test/` 四份桌面实测与仓库根四份 E2E 脚本。 + +### 11.2 尚未取证、需用户或后续角色补齐的三项 + +1. **AI 提供方的可用性与价格**:已核实本工作机无 CUDA(AMD Vega 集显、无 `nvidia-smi`)故自托管不可行;**生产服务器是否有 GPU、云 API 的额度与单价均未取证**,需用户确认或授权调研(阻塞 D4-1)。 +2. **A/B 实验设计模板与样本量规则文档**(前置 #3/#6,文档判绿):两仓中**未定位到文档实体**,仅在 `iteration-3/06` 表格里被声明为已交付(影响 T4-23 的验收标准可执行性)。 +3. **北极星出数与对账 SQL 无可执行载体**:SQL 仅以 Markdown 代码块存在(`iteration-2/06:200-229`、:364-470),三仓 `scripts/` 下只有 `check-secrets.sh` 与 `hooks/`。2026-09-21 首次出数须人工执行,建议后续沉淀为脚本(不在 M4 范围,记 backlog)。 diff --git a/docs/development/iterations/iteration-4/02-backend-technical-assessment.md b/docs/development/iterations/iteration-4/02-backend-technical-assessment.md new file mode 100644 index 0000000..7cf581e --- /dev/null +++ b/docs/development/iterations/iteration-4/02-backend-technical-assessment.md @@ -0,0 +1,1114 @@ +# 02 M4 后端技术评估:AI 创作(模型目录 / 生成任务 / Worker 队列 / 输出落地) + +> 作者:Senior Developer(后端) +> 日期:2026-09-14 +> 输入:patbond-api dev@3cd8005(**381** 测试基线,工作区干净,tag v0.4.0);契约 openapi.yaml v1.4.0(32 路径 / 45 操作 / 75 schema);Flyway V1~V5;ADR-001~022 +> 纪律:本报告全部结论以**原始 Flyway SQL / 原始 openapi.yaml / 原始 Java 源码**取证,逐项标注文件与行号;不采信任何文档转述 + +--- + +## 结论先行 + +1. **队列方案 = DB 表轮询 + `FOR UPDATE SKIP LOCKED` + 租约列**,不引入 Redis、不引入 MQ。决定性证据:本项目 compose **无 Redis、无任何 MQ**(`patbond-api/docker-compose.yml` 仅 postgres/minio/auth/user/pet/community 六服务),而**已评审目标模型早已为 DB 队列备好全部列与两条部分索引**——`lease_owner` / `lease_expires_at` / `next_attempt_at` / `attempt_count` / `max_attempts` / `priority`,以及 `ix_generation_jobs_queue`、`ix_generation_jobs_running`(`patbond-doc/docs/database/patbond_postgresql.sql:605-716`)。换 Redis/MQ 等于让这批列变成死重,并把任务状态真值劈成两份。 +2. **需要新模块 `patbond-ai`(:8085)**,沿 ADR-009 / ADR-017 两次先例(新模块 + 共库 + patbond-user 单迁移链)。但新模块会把已存在的 3 份复制代码变成 4 份(`BearerAuthFilter` 自述「第三份拷贝」),故建议**同波次把 4 件共享物上提 patbond-common**。 +3. **Flyway 迁移 2 个**:`V6__creation_baseline.sql`(建 creation schema 三表 + 索引 + 触发器 + **补回 `posts.generation_job_id` 外键**)、`V7__creation_catalog_seed.sql`(模型/风格目录种子)。沿 V3(结构)/ V4(种子)拆分先例。 +4. **契约影响面:纯增量**,v1.4.0 → **v1.5.0**。新增 4 路径 / 5 操作 / 8 schema;改动 3 个既有 schema(2 处 category 枚举加宽 + `Post` 加 `generationJobId`);新增 1 个错误码 42900 + 1 个 `components.responses`。**零破坏性变更**。 +5. **配额与 429 建议在本迭代落地**(收窄版,非 M6 的横切限流):M4 验收标准写明「相同幂等请求不重复**扣费**」,而全项目**无任何计费/额度表**(目标模型 40 张表无 credits/wallet/balance),「扣费」只能落到配额语义上,因此配额是验收前置,不是可选项。 +6. **上游 AI 服务走可插拔 `GenerationProvider` 接口 + 真实字节 stub 实现**,M4 默认 stub,真实厂商适配器为纯增加实现类。stub 必须做成**两阶段(submit → poll)**,否则「Worker 重启不丢任务 / 不重复扣费」这条最关键的恢复路径无法被端到端验证。 + +--- + +## 章节 + +1. [取证:现有可复用资产逐项清点](#1) +2. [关键发现:目标模型已把队列机制写完了](#2) +3. [Worker 队列选型:租约 / 重试 / 幂等](#3) +4. [生成任务状态机与三层幂等](#4) +5. [模型 / 风格目录(catalog)方案](#5) +6. [生成输出落地媒体链路 + 一键社区草稿](#6) +7. [上游 AI 服务接入与本迭代可验证性](#7) +8. [限流与配额(429 + Retry-After)](#8) +9. [模块归属:patbond-ai 与共享物上提](#9) +10. [数据模型草案与 DDL 草稿](#10) +11. [Flyway 迁移计划](#11) +12. [API 端点清单与契约影响面](#12) +13. [契约同步机械清单(含第 5 份快照)](#13) +14. [风险清单](#14) +15. [待拍板决策](#15) +16. [本报告推翻 / 修正的既有结论](#16) + +--- + + +## 1. 取证:现有可复用资产逐项清点 + +以下每一行都打开过原始文件确认,**没有一条来自文档转述**。路径根为 `patbond-api/`(除标注 doc 仓者)。 + +### 1.1 数据库与迁移链 + +| 资产 | 取证位置 | 实情 | +| --- | --- | --- | +| 迁移链持有者 | `patbond-user/src/main/resources/application.yml:11-12`(`flyway.locations: classpath:db/migration`);auth/pet/community 三模块 `src/main/resources/` 下**只有 application.yml.sample,无 db/migration 目录** | 迁移只进 patbond-user,**V6/V7 无选择余地**,即便新建 patbond-ai 模块也一样 | +| 现有迁移 | `patbond-user/src/main/resources/db/migration/` 共 5 个:V1 identity+media+platform、V2 platform.product_events、V3 pet_health、V4 字典种子、V5 community | **下一个版本号 = V6**(ADR-022 明确「V6 留给后续真正需要建表的迭代」,`decisions.md:192`) | +| 结构/种子拆分先例 | V3 = 纯结构;V4 = 纯种子(`V4__pet_health_dictionary_seed.sql:1-13` 注释自述「production reference data, not development fixtures」,2 条 INSERT) | M4 照此拆 V6(结构)+ V7(目录种子) | +| `posts.generation_job_id` | `V5__community_baseline.sql:47-48`:`-- generation_job_id: bare nullable uuid, FK to creation.generation_jobs stripped (M4 补回)` / `generation_job_id uuid,` — **零 `REFERENCES`**;索引已建于 `:95`(`ix_posts_generation_job`) | 裸列 + 索引俱在,V6 只需 `ADD CONSTRAINT`,**不需要加列、不需要建索引** | +| 裁剪理由是否仍成立 | `V5:15-19` 原文:「Cross-schema FKs into schemas **not yet migrated** are STRIPPED」;`V3__pet_health_baseline.sql:14-19` 同一措辞裁掉 4 条 marketplace 外键 | 裁剪理由是「目标 schema 尚不存在」,**不是「反对跨 schema 外键」**。反证:`V1:262-264` 保留 `identity.users.avatar_asset_id → media.assets`;`V5:45-46,107` 保留 `posts → identity.users / pet_health.pets`、`post_media → media.assets`。**V6 一旦建出 creation schema,裁剪理由即消失,补回外键是 V5 自己下的指令** | +| 存量数据风险 | 全仓 grep `generation_job_id` 命中仅 3 处:V5 SQL、`CommunityMigrationIntegrationTest`、以及**无任何 INSERT/UPDATE 写它**(`PostRepository.insertPost` 列清单为 id/author_user_id/pet_id/category/title/content/status/published_at/idempotency_key/request_hash,见 `PostRepository.java:56-78`) | 存量值恒为 NULL,`ADD CONSTRAINT` **无需 `NOT VALID`**,零回填风险 | +| `region_id` | `V5:54-55` 同为裸列(M5 补回) | **本迭代不碰**,V6 不得顺手补它 | + +### 1.2 目标模型(doc 仓,已评审) + +| 资产 | 取证位置 | 实情 | +| --- | --- | --- | +| creation schema 全量 DDL | `patbond-doc/docs/database/patbond_postgresql.sql:556-716` | 三表 `generation_models` / `generation_styles` / `generation_jobs` + 13 条索引已完整写好,**M4 是抄写不是设计**(详见 §2) | +| schema 注释 | 同上 `:68` `COMMENT ON SCHEMA creation IS 'Asynchronous AI image/video generation';` | V6 照抄 | +| 触发器 | 同上 `:1245-1250` 三张表各一条 `platform.set_updated_at()` 触发器 | 函数 V1 已建(`V1:28-36`),V6 直接挂 | +| 全库规模 | 同上:7 schema / 40 张 `CREATE TABLE` | 建完 V6 后为 6 个 schema(缺 marketplace,属 M5) | +| **无计费表** | 同上全文件 `CREATE TABLE` 清单无 credits / wallet / balance / ledger 任何一张 | 验收标准里的「不重复**扣费**」只能解释为**配额**语义(见 §8) | + +### 1.3 媒体链路(M3 交付,ADR-016/017) + +| 资产 | 取证位置 | 实情 | +| --- | --- | --- | +| `media.assets` 表 | `V1:205-260` | 有 `purpose varchar(32) NOT NULL`,**全表无 `purpose` 的 CHECK 约束**(`:225-248` 六条 ck 分别管 kind/storage_type/location/size/hash/dimensions/status/ready/deleted) | +| purpose 白名单 | `patbond-user/.../media/MediaProperties.java:66` `List.of("post_image", "user_avatar", "pet_avatar")`,Javadoc `:57-65` 自述「新增用途是配置 + 契约枚举变更,never a migration」 | **新增 `ai_input` / `ai_output` 零迁移**,改配置 + 契约枚举即可 | +| 两步上传 | `MediaService.java:51-119`(createUpload / completeUpload) | 客户端直传,**服务端从不经手字节** | +| 存储适配层 | `ObjectStorage.java`(接口)+ `S3ObjectStorage.java`(AWS SDK v2 实现) | 接口仅 4 个方法:`ensureBucket` / `presignPut` / `stat` / `presignGet`。**没有服务端 put,也没有服务端 get** ← M4 必须补的两个原语 | +| 配置三份复制 | `patbond-user/.../MediaProperties.java:15`、`patbond-pet/.../PetMediaProperties.java:16`、`patbond-community/.../CommunityMediaProperties.java:15` — **三个类同一前缀 `patbond.media`** | pet/community 只持读侧子集(无 bucket、无 uploadTtl) | +| 预签名 GET 三份复制 | `patbond-community/.../media/MediaUrlSigner.java:43-53`、`patbond-pet/.../media/MediaUrlSigner.java:43-53`、user 侧走 `S3ObjectStorage.presignGet` | 两个 `MediaUrlSigner` 的 Javadoc 自述与对方「same as」——**已知复制债,新模块会变第 4 份** | +| 凭证下发先例 | `docker-compose.yml`:pet(`:106-109`)与 community(`:139-141`)**都拿到了 `PATBOND_MINIO_ACCESS_KEY/SECRET_KEY`**,注释说明「本地 SigV4 计算,不直连 MinIO,无需 depends_on minio」 | 业务模块持有对象存储凭证是**既有实践**,patbond-ai 照此拿凭证不破规矩 | +| 跨 schema 读媒体先例 | `patbond-community/.../media/MediaAssetGateway.java:37-47` 直接 SQL 读 `media.assets`;Javadoc `:15-23` 自述「Same-database read was chosen over an internal HTTP call to patbond-user (ADR-017 precedent)」 | patbond-ai 跨 schema 读 `media.assets` 有先例;**写** 侧无先例(见 §6 与 D4-4) | + +### 1.4 社区侧(M3 交付) + +| 资产 | 取证位置 | 实情 | +| --- | --- | --- | +| 建帖幂等 | `PostController.java:47-54`(`Idempotency-Key` **强制头**)→ `PostService.create:83-126` → `PostRepository.insertPost:56-78`(`ON CONFLICT ON CONSTRAINT uq_posts_author_idempotency DO NOTHING`) | 与 `generation_jobs` 的 `idempotency_key NOT NULL` + `request_hash NOT NULL` + `UNIQUE(user_id, idempotency_key)` **形状完全一致**,可原样复用 | +| request_hash 算法 | `PostService.canonicalize:361-375`(规范化命令而非原始 JSON)+ `RequestHashes.sha256:22-29` | 32 字节,匹配 `ck_generation_jobs_idempotency` 的 `octet_length(request_hash)=32` | +| 幂等冲突语义 | `PostService.java:104-118`:同键同载荷 → 返回原资源;同键异载荷 → `IDEMPOTENCY_PAYLOAD_MISMATCH` **409/40905** | M4 直接复用 40905,不新增码 | +| 草稿/发布 | `CreatePostRequest.java:29` status 枚举 `draft|published`,缺省 draft(`PostService.java:89`);发布走 `PATCH` + `status=published`(`UpdatePostRequest.java:38`) | **「一键建草稿」不需要新端点**,`POST /api/v1/posts` 已具备 | +| `ai_creation` 可达性 | DB 侧 `V5:68` `ck_posts_category CHECK (category IN ('general','help','ai_creation'))` **已允许**;API 侧被 `CreatePostRequest.java:26` 与 `UpdatePostRequest.java:33` 的 `@Pattern(regexp = "general|help")` 拦死;有测试固化该拦截(`PostLifecycleIntegrationTest.java:102-106` 断言 400/40000) | 解锁 = 改 2 处 `@Pattern` + 改 1 处测试 + 契约枚举加宽,**零迁移** | +| 媒体挂帖校验 | `PostService.validateAssets:343-358` 只校验 **归属**(非本人 → 40405 防枚举)与 **ready**(否则 422/42203) | ⚠️ **无 purpose 校验**:`MediaAssetRef.java:9-16` 与 `MediaAssetGateway` 的 SQL 都**不含 purpose 列**。对比 pet 侧确有校验(`PetService.java:196` `AVATAR_PURPOSE.equals(asset.purpose())`)。这修正了任务书「走 assets / purpose 白名单」的前提——**社区侧目前没有 purpose 闸门**(见 §6 与 D4-6) | +| 乐观锁 | `PostService.java:141-143` 比 version + `PostRepository.updatePost:134-152` `WHERE ... AND version = :expectedVersion`,0 行 → 409/40902 | 生成任务**不需要** version 乐观锁(客户端不改任务字段),但表里有 `version` 列备用 | + +### 1.5 平台能力 + +| 资产 | 取证位置 | 实情 | +| --- | --- | --- | +| 错误码体系 | `patbond-common/.../error/ErrorCode.java:12-38`(27 个码);命名法 = HTTP 三位 + 两位序号 | 已用满:404 段至 40406、409 段至 40905、422 段至 42205。**429 段完全空白** | +| 定时任务先例 | `patbond-user/.../session/SessionCleanupJob.java:32-41`(`@Scheduled(fixedDelayString=..., initialDelayString=...)`,间隔走配置项) | Worker 轮询与 reaper 照此形态;`@EnableScheduling` 见 `UserApplication.java` | +| 全仓限流 | grep `429|TOO_MANY_REQUESTS|Retry-After|RateLimit` over `patbond-*/src/main` → **零命中**;契约同 grep → **零命中** | 429 是**全新地基**,非改造 | +| 服务间调用 | Feign 静态 URL(ADR-002):`SessionClient.java:16`、`UserClient.java:12`、`AuthorProfileClient.java:18` | patbond-ai 若需内部调用照此形态 | +| UUIDv7 | `patbond-pet/.../support/UuidV7.java` 与 `patbond-community/.../support/UuidV7.java` **两份复制** | 新模块会变第 3 份 | +| 鉴权 | `BearerAuthFilter`(community 版 Javadoc `:26` 自述「Third copy of the user/pet filter」)→ 请求属性 `patbond.authenticatedUserId` → 控制器 `@RequestAttribute` | 新模块会变**第 4 份** | +| 埋点白名单 | `patbond-user/.../analytics/EventDictionary.java` **41 条**(实测 `Map.entry(` 计数;无测试断言该数量);grep `generation|creation|ai_` → **零命中** | M4 新增创作域事件 = 改这一个 Java 常量表,零迁移、零契约变更(`/api/v1/events` 形状不变) | +| 测试基线 | **381**(采用协调方 Evidence Collector 实测值;我未重跑 `mvnw test`,按任务书授权跳过耗时命令) | 增量估算以 381 为基数 | + +--- + + +## 2. 关键发现:目标模型已把队列机制写完了 + +这是本次评估**最重要的一条**,它把 §3 的选型从「开放式技术选择」压缩成「确认既有设计」。 + +`patbond-doc/docs/database/patbond_postgresql.sql:605-716` 的 `creation.generation_jobs` 里,下列列**只有 DB 队列这一种用途**: + +| 列(行号) | 用途 | +| --- | --- | +| `next_attempt_at timestamptz NOT NULL DEFAULT now()`(`:635`) | 退避重试的可见时间 | +| `lease_owner varchar(128)`(`:636`) | 租约持有者(worker 实例标识) | +| `lease_expires_at timestamptz`(`:637`) | 租约到期时间 → 崩溃恢复 | +| `attempt_count` / `max_attempts smallint NOT NULL DEFAULT 3`(`:628-629`) | 重试计数与上限 | +| `priority smallint NOT NULL DEFAULT 0`(`:627`) | 出队优先级 | +| `provider_request_id varchar(128)`(`:630`) | 上游请求 id(防重复提交/重复扣费) | + +配套两条**部分索引**,逐列对齐两条出队/回收语句: + +```sql +-- :711-713 出队:正是 (priority DESC, next_attempt_at, created_at, id) 的排序键 +CREATE INDEX ix_generation_jobs_queue + ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id) + WHERE status = 'queued'; +-- :714-716 回收:正是「租约已过期的 running」的扫描键 +CREATE INDEX ix_generation_jobs_running + ON creation.generation_jobs (lease_expires_at, id) + WHERE status = 'running'; +-- :699-701 上游幂等:同一 provider 的同一 request_id 只允许一行 +CREATE UNIQUE INDEX uq_generation_jobs_provider_request + ON creation.generation_jobs (provider_code_snapshot, provider_request_id) + WHERE provider_request_id IS NOT NULL; +``` + +并且 `ck_generation_jobs_state`(`:667-691`)与 `ck_generation_jobs_lease`(`:692-695`)**把状态机与租约的合法组合写进了 DB 约束**——五个状态各自规定了 `started_at` / `completed_at` / `output_asset_id` / `error_code` / `lease_owner` / `lease_expires_at` / `progress` 的取值,`(lease_owner IS NULL) = (lease_expires_at IS NULL)` 且 `lease_expires_at > started_at`。 + +**推论**:目标模型评审时事实上已经拍过「DB 表即队列」。选 Redis 或 MQ 不是「加一个组件」,而是**推翻已评审的数据模型**(这批列与三条索引全部作废,任务状态真值劈成 DB + 中间件两份)。 + +### 2.1 由约束反推出的三条实现约定(容易踩,必须写进工单) + +1. **`started_at` 是「本次尝试的开始时间」,不是「首次入队时间」**。因为 `:669` 规定 `status='queued'` 时 `started_at IS NULL`,所以每次重试重新入队都必须把 `started_at` 置回 NULL。若产品要展示「任务创建至今耗时」,用 `created_at`。 +2. **重新入队必须同时清 5 个字段**:`progress=0`、`started_at=NULL`、`error_code=NULL`、`error_message=NULL`、`lease_owner=NULL`、`lease_expires_at=NULL`,否则违反 `:668-672`。 +3. **出队谓词必须带 `attempt_count < max_attempts`**。`:664-666` 规定 `attempt_count <= max_attempts`,而认领动作会 `attempt_count+1`;一个 `attempt_count = max_attempts` 的行若还留在 `queued`,认领即违约。正确做法是失败时直接判终态,但出队谓词加这一条作为防御。 + +--- + + +## 3. Worker 队列选型:租约 / 重试 / 幂等 + +### 3.1 部署现实(自行核实,非转述) + +`patbond-api/docker-compose.yml` 全文件 `image:` / 服务名清点:`postgres`(postgres:18,`:12-20`)、`minio`(`:33-47`)、`auth`、`user`、`pet`、`community`。**无 redis、无 rabbitmq、无 kafka、无任何 MQ**。volumes 仅 `pgdata` / `minio-data`(`:154-156`)。 + +即:**Redis 与 MQ 都是「新增容器 + 新增运维面」**,不是「用上已有的东西」。 + +### 3.2 三方案对比 + +| 维度 | **A. DB 表轮询 + SKIP LOCKED + 租约列**(推荐) | B. Redis(Streams 消费组) | C. MQ(RabbitMQ / Kafka) | +| --- | --- | --- | --- | +| 新增容器 | **0** | +1 | +1~3(Kafka 还需协调进程) | +| 与已评审模型契合 | **完全契合**,用满 §2 全部列与 3 条索引 | 那批列作废 | 那批列作废 | +| 任务状态真值 | **单一真值(Postgres)** | 双写:Redis 队列 + PG 状态行 → 需处理不一致 | 同 B | +| 租约 | `lease_owner/lease_expires_at` + reaper,语义显式可查 | XCLAIM/XAUTOCLAIM,语义隐含在中间件内 | 需 consumer ack + 可见性超时 | +| 重试 | `attempt_count` + `next_attempt_at` 退避,**可用 SQL 直接审计** | 需自建重试流或 DLQ | 需 DLQ + 延迟队列插件 | +| 幂等 | 表内 `UNIQUE(user_id, idempotency_key)` + `uq_generation_jobs_provider_request`,**DB 强约束** | 需应用层保证 | 需应用层保证 | +| 事务性 | **与任务行同事务**,提交即入队,无 dual-write | 有 dual-write 窗口 | 有 dual-write 窗口(除非上 outbox) | +| 崩溃不丢任务 | 任务本来就在 PG 里,**天然不丢** | 需持久化配置 + AOF 正确性 | 需持久化配置 | +| 出队延迟 | 轮询间隔(建议 1s);必要时 `LISTEN/NOTIFY` 可降到毫秒 | 毫秒 | 毫秒 | +| 吞吐上限 | 单表每秒数百任务量级,**远超 MVP 需求** | 高 | 很高 | +| 运维成本(双人团队) | **最低** | 中 | 高 | +| ADR 一致性 | 契合 ADR-002「MVP 不引入解决『规模问题』的组件」、ADR-007「应用无状态、状态在 DB」 | 与 ADR-002 精神冲突 | 冲突更强;且 `development-plan.md:271` 已把「事务 Outbox 发布器」排到 **M6** | + +**推荐 A**。三条独立理由:目标模型已为 A 备好一切(§2);compose 无现成 Redis/MQ,B/C 都是净新增运维面;ADR-002 的既有判例(为同样理由移除 Nacos)与 development-plan 把 Outbox/限流排到 M6 的排期,都指向「M4 不引入消息中间件」。 + +**A 的已知代价(诚实列出)**:出队有轮询延迟;worker 数量扩大到几十个时轮询会成为无谓负载。两者在 MVP 量级都不成立,且都有不改数据模型的升级路径(`LISTEN/NOTIFY` 降延迟;分片或改中间件时任务表仍是状态真值)。 + +### 3.3 租约:单语句原子认领 + +```sql +-- 认领:SKIP LOCKED 让 N 个 worker 拿到互不相交的集合,且互不阻塞 +UPDATE creation.generation_jobs j +SET status = 'running', + started_at = now(), + lease_owner = :owner, -- 如 "ai-1/pid-17/uuid" + lease_expires_at = now() + :leaseTtl, -- 建议 120s + attempt_count = attempt_count + 1, + progress = 0, + updated_at = now() +WHERE j.id IN ( + SELECT id FROM creation.generation_jobs + WHERE status = 'queued' + AND next_attempt_at <= now() + AND attempt_count < max_attempts -- §2.1 第 3 条 + ORDER BY priority DESC, next_attempt_at, created_at, id -- 对齐 ix_generation_jobs_queue + FOR UPDATE SKIP LOCKED + LIMIT :batch -- 建议 1~2 +) +RETURNING j.id, j.media_kind, j.model_id, j.style_id, j.input_asset_id, + j.prompt, j.negative_prompt, j.width_px, j.height_px, j.parameters, + j.provider_code_snapshot, j.provider_model_snapshot, + j.provider_request_id, j.attempt_count, j.user_id; +``` + +合法性核对:`status='running'` 分支(`:673-677`)要求 `started_at NOT NULL`、`completed_at NULL`、`output_asset_id NULL`、`error_code NULL`、`lease_owner NOT NULL`、`lease_expires_at NOT NULL` —— 全部满足;`ck_generation_jobs_lease` 要求 `lease_expires_at > started_at`,而 `now()` 在同一事务内为同一时刻、`leaseTtl > 0`,严格成立。 + +**心跳续租**(长任务防误回收),每 30s 一次: + +```sql +UPDATE creation.generation_jobs +SET lease_expires_at = now() + :leaseTtl, progress = :progress, updated_at = now() +WHERE id = :id AND status = 'running' AND lease_owner = :owner; +``` +返回 0 行 = 租约已被夺走或任务已被取消 → **worker 立即放弃本次执行**(这也是取消能生效的机制,见 §4.3)。 + +### 3.4 重试:退避 + 回收 + +```sql +-- (a) 可重试失败 → 重新入队(§2.1 第 2 条:必须清 5 个字段) +UPDATE creation.generation_jobs +SET status = 'queued', progress = 0, started_at = NULL, + error_code = NULL, error_message = NULL, + lease_owner = NULL, lease_expires_at = NULL, + next_attempt_at = now() + :backoff, -- 10s * 2^(attempt-1) + 抖动 + updated_at = now() +WHERE id = :id AND status = 'running' AND lease_owner = :owner + AND attempt_count < max_attempts; + +-- (b) 不可重试 或 次数耗尽 → 终态 +UPDATE creation.generation_jobs +SET status = 'failed', completed_at = now(), + error_code = :errorCode, error_message = :errorMessage, + lease_owner = NULL, lease_expires_at = NULL, updated_at = now() +WHERE id = :id AND status = 'running' AND lease_owner = :owner; + +-- (c) reaper:租约过期的孤儿(worker 崩溃/重启),走 ix_generation_jobs_running +UPDATE creation.generation_jobs +SET status = CASE WHEN attempt_count < max_attempts THEN 'queued' ELSE 'failed' END, + progress = 0, + started_at = CASE WHEN attempt_count < max_attempts THEN NULL ELSE started_at END, + completed_at = CASE WHEN attempt_count < max_attempts THEN NULL ELSE now() END, + error_code = CASE WHEN attempt_count < max_attempts THEN NULL ELSE 'lease_expired' END, + lease_owner = NULL, lease_expires_at = NULL, + next_attempt_at = now() + :backoff, updated_at = now() +WHERE status = 'running' AND lease_expires_at < now(); +``` + +(c) 就是验收标准「**Worker 重启不丢任务**」的实现:任务一直在 PG 里,重启后租约到期即被回收重投。reaper 建议 30s 一跑,与 `SessionCleanupJob.java:32-34` 同形态(`@Scheduled(fixedDelayString="${...}")`,间隔走配置)。 + +### 3.5 Worker 跑在哪 + +| 选项 | 说明 | 评价 | +| --- | --- | --- | +| **A1(推荐)** | 与 API 同进程,`@Scheduled` 轮询 + 有界线程池,由 `patbond.creation.worker.enabled` 开关控制 | 零新增容器;先例 `SessionCleanupJob`。**关键**:开关从第一天就做,则 A2 变成纯部署动作、零代码改动 | +| A2 | 同一 jar 起第 7 个容器,`worker.enabled=true` 且不暴露端口 | 隔离更好,M4 不必做;有开关后随时可切 | +| A3 | 独立 worker 模块/镜像 | 过度设计,否决 | + +推荐 **A1 + 开关**:单容器交付,但把 A2 的门留着。租约机制本来就允许多实例并存,A1→A2 无需任何逻辑变更。 + +--- + + +## 4. 生成任务状态机与三层幂等 + +### 4.1 状态机(DB 约束即真值,`patbond_postgresql.sql:660-691`) + +``` + ┌──────────────────────────────────┐ + │ 可重试失败 / 租约过期 (§3.4 a,c) │ + ▼ │ + [提交] ──► queued ──(认领 §3.3)──► running ──────────────┘ + │ │ + │ 取消 ├──► succeeded (progress=100, output_asset_id NOT NULL) + ▼ ├──► failed (error_code NOT NULL, 不可重试或次数耗尽) + cancelled ◄──────────────────┘ 取消(见 §4.3) +``` + +合法迁移共 7 条:`queued→running`、`running→succeeded`、`running→failed`、`running→queued`(重试)、`queued→cancelled`、`running→cancelled`、以及 reaper 的 `running→failed`。**终态 3 个不可再迁移**(succeeded / failed / cancelled)。 + +「任务状态流转合法」这条验收标准由**两道闸门**保证:应用层的条件 UPDATE(`WHERE status=... AND lease_owner=...`),以及 DB 的 `ck_generation_jobs_state`。后者意味着即便应用写错,数据库也会拒绝——建议专门写一组「非法迁移被 DB 拒绝」的集成测试直接断言约束生效。 + +### 4.2 三层幂等(对应验收标准「相同幂等请求不重复扣费或生成」) + +| 层 | 机制 | 取证 / 位置 | 防住什么 | +| --- | --- | --- | --- | +| **L1 提交层** | `Idempotency-Key` 强制头 + `UNIQUE(user_id, idempotency_key)` + `request_hash` 比对 | 表约束 `patbond_postgresql.sql:643,655-659`;算法复用 `RequestHashes.sha256` + 规范化命令(`PostService.canonicalize:361-375` 同形态) | 客户端重试/双击 → 同键同载荷返回**同一 job**;同键异载荷 → 409/40905 | +| **L2 上游层** | 提交给厂商后**立即持久化 `provider_request_id`**;`uq_generation_jobs_provider_request` 唯一索引 | `patbond_postgresql.sql:630,699-701` | worker 在「已提交上游、尚未收到结果」时崩溃 → 重新认领后发现 `provider_request_id != NULL`,**改为继续轮询而不是重新提交**,这是「不重复扣费」的真正防线 | +| **L3 配额层** | 按窗口计数 + 429(见 §8) | 新建 | 防单用户刷量 | + +⚠️ **L2 是全套设计里最容易被漏掉、又最贵的一环**。它要求 worker 的执行流程必须是「submit → 持久化 request_id → poll」三步,中间那步单独提交事务;如果 stub provider 做成同步的一步返回,这条路径**永远不会被测试覆盖**,等接真实厂商时才暴露 → 直接导致重复扣费。故 §7 要求 stub 也做两阶段。 + +### 4.3 取消语义 + +`ck_generation_jobs_state` 的 cancelled 分支(`:687-690`)只要求 `completed_at NOT NULL` 且租约字段为空,**对 `progress` / `started_at` / `output_asset_id` 不作限制** —— 也就是说目标模型允许从 queued 和 running 两处取消。 + +| 选项 | 行为 | 评价 | +| --- | --- | --- | +| **X1(推荐)** | queued 与 running 都可取消。API 直接 `UPDATE ... SET status='cancelled', completed_at=now(), lease_owner=NULL, lease_expires_at=NULL WHERE id=:id AND user_id=:me AND status IN ('queued','running')`。worker 的心跳/收尾条件 UPDATE 因 `status='running'` 不再成立而返回 0 行,**自行放弃并丢弃产物**(§3.3 末) | 无需新列、无需偏离目标模型;UX 好(stub 约 6s,多数取消会落在 running)。代价:running 取消会留下已生成但被丢弃的对象/资产行,需按「孤儿清理」处理 | +| X2 | 仅 queued 可取消,running → 422 | 实现更简单,但 UX 差(大部分点击会被拒),且没省下什么 | +| X3 | 加 `cancel_requested boolean` 列 | 偏离已评审目标模型,且 X1 已能达成同样效果,否决 | + +推荐 **X1**。孤儿处理:worker 发现收尾 UPDATE 返回 0 行时,把刚写的 `media.assets` 行置 `deleted`(`status='deleted', deleted_at=now()`,满足 `ck_media_deleted`,`V1:248`)并尽力删除对象。注意这类孤儿本来就是**已存在的已知缺口**(`V1:258-260` 的 `ix_media_uploading_created` 就是为「从未完成的 uploading 行清理」预留的,`MediaService.java:30-32` 注释自述该清理「M3 未实现」),M4 不必顺手把整套清理做完,但不应新增无标记的孤儿。 + +### 4.4 「失败重试」怎么给客户端 + +`development-plan.md:253` 要求「Flutter 展示排队、生成进度、失败重试和取消状态」。两种解释: + +- **R1(推荐)**:用户点「重试」= 客户端**用新的 `Idempotency-Key` 提交一个新任务**。零新增端点;审计链干净(每次尝试一行);与 L1 幂等不冲突。 +- R2:加 `POST /jobs/{id}/retry` 把 failed 复活成 queued。需要把 `error_code` 清空、`completed_at` 清空、`attempt_count` 归零,等于**擦掉失败证据**,与验收标准「失败原因可追踪」相抵;且 `attempt_count <= max_attempts` 约束下语义混乱。 + +推荐 **R1**,故 §12 的端点清单**不含 retry 端点**。worker 内部重试(§3.4)与用户可见重试是两件事,不要混为一个端点。 + +### 4.5 进度上报 + +`progress smallint` 0~100(`:626`,`ck_generation_jobs_progress` `:663`)。客户端**轮询** `GET /api/v1/creation/jobs/{jobId}`,建议 1.5s 间隔(写进契约 description,不做服务端推送)。不引入 SSE/WebSocket:需要新的连接管理与网关配置,收益在 MVP 量级不成立。 + +--- + + +## 5. 模型 / 风格目录(catalog)方案 + +### 5.1 配置文件 vs 数据库表 —— 这题已被外键锁死 + +`generation_jobs` 对目录是**复合外键**引用(`patbond_postgresql.sql:644-647`): + +```sql +FOREIGN KEY (model_id, media_kind) + REFERENCES creation.generation_models(id, media_kind) ON DELETE RESTRICT, +FOREIGN KEY (style_id, media_kind) + REFERENCES creation.generation_styles(id, media_kind) ON DELETE RESTRICT, +``` + +而 `model_id uuid NOT NULL`(`:610`)。**配置文件方案无法满足 NOT NULL 外键**(没有行可指)。所以:**必须是数据库表**,无可选项。 + +顺带说明这两个外键为什么是复合的:`generation_models` 与 `generation_styles` 各有一条看似冗余的 `UNIQUE (id, media_kind)`(`:568`、`:592`),它存在的唯一目的就是给这两条复合外键提供被引用的唯一约束——它把「任务的 media_kind 必须与所选模型/风格的 media_kind 一致」这条业务规则**下沉成了数据库约束**。抄写 V6 时不要以为它冗余而删掉。 + +### 5.2 是否需要后台可配 + +**M4 不做后台**。理由:全项目**没有任何管理后台/管理端点**(契约 32 条路径全为 `/api/v1/**` 终端用户面,无 `/admin`;`development-plan.md` 也未在 M4 前排入后台)。目录变更用两条既有手段覆盖: + +- `enabled boolean`(`:563`、`:587`)+ `sort_order integer`(`:564`、`:588`):上下架与排序**改一条 SQL 即可**,不需要发版。 +- 新增条目:后续 Flyway 迁移追加(V4 已是这个先例——`V4:5-8` 自述字典是「production reference data」,走版本链而非 dev fixture)。 + +真正需要运营自助时再单开 `/admin` 迭代。 + +### 5.3 种子内容与风格预览图的坑 + +客户端 mock 已有现成的目录内容可直接对齐(`patbond-flutter/lib/data/demo_data.dart:206+` 的 `creationStyles`:`healing`/治愈动画、`3d`/3D 卡通、`comic`/漫画风、`watercolor`/水彩 …;`patbond-flutter/lib/features/create/create_page.dart:179` 的模型下拉 `['Patbond-V1','Pet-Art Pro','Cute Motion']`)。V7 种子建议直接采用这批 code/title/subtitle,使客户端替换 mock 时**文案零变化**。 + +⚠️ **预览图取不到**:`generation_styles.preview_asset_id` 是 `REFERENCES media.assets(id)`(`:586`),而迁移执行时刻**既没有 assets 行、也没有 MinIO 里的对象**,Flyway 无法种子二进制。三个选项: + +| 选项 | 说明 | 评价 | +| --- | --- | --- | +| **P1(推荐)** | V7 种子 `preview_asset_id = NULL`;契约里 `previewUrl` 为 `nullable`;**客户端按 `code` 用内置图片资源**做预览 | 零运维动作、零二进制入库;风格预览是装饰性内容,本就适合随包分发。需与客户端 agent 对齐 `code` 契约 | +| P2 | 上线后人工走一次 `/api/v1/media/uploads` 上传 4 张图,再 `UPDATE generation_styles SET preview_asset_id=...` | 引入不可复现的手工步骤(三环境各做一次),与 ADR-006「测试可复现」精神冲突 | +| P3 | 用 `storage_type='external'` 的 assets 行指向外链图 | `ck_media_location`(`V1:227-238`)允许 external,但把外部 URL 写进生产种子等于引入外部依赖,且 M3.5 已因「需接外部服务」为由把天气/位置推迟(`decisions.md:188`),同理否决 | + +推荐 **P1**,并按 ADR-022 的纪律在代码注释与本报告显式标注为「刻意的客户端内置资源,非缺陷」。→ 待拍板 **D4-7**。 + +### 5.4 `model_version_snapshot` 的来源缺口(取证发现的模型不自洽) + +`generation_jobs.model_version_snapshot varchar(64) **NOT NULL**`(`:623`),但 `generation_models` 表(`:556-574`)**没有任何 version 列**——`code` / `display_name` / `provider_code` / `provider_model_name` / `media_kind` / `enabled` / `sort_order` / 时间戳,仅此。所以这个 NOT NULL 值无处可取。 + +| 选项 | 说明 | 评价 | +| --- | --- | --- | +| **V1x(推荐)** | 由 provider 适配器声明版本(`GenerationProvider.version()`),提交时快照写入 | 零 schema 偏离;语义上也更准——真正决定输出的是**适配器/厂商模型版本**,不是目录行 | +| V2x | V6 给 `generation_models` 加 `model_version varchar(64)` 列 | 偏离已评审目标模型;且目录行的版本与厂商实际版本会漂移 | + +推荐 **V1x**。→ 待拍板 **D4-8**(低风险,但因涉及是否偏离目标模型,列入清单)。 + +### 5.5 图片先行、视频后置 + +目标模型的 `media_kind` CHECK 是 `IN ('image','video')`(`:569`、`:593`、`:648`)。ADR-018 已明确「视频后置」(`decisions.md:161`)。建议**DB 侧照抄含 video 的 CHECK,API 侧枚举只开 `image`** —— 这正是 `ai_creation` 当年的处理手法(DB CHECK 允许、契约描述标注预留、`@Pattern` 拦截),有现成先例可循,M5/M6 开视频时零迁移。 + +--- + + +## 6. 生成输出落地媒体链路 + 一键社区草稿 + +### 6.1 两个新 purpose,且**只有一个**能进客户端白名单 + +`media.assets.purpose` 无 CHECK 约束(§1.3 取证),所以两个新用途**零迁移**。但二者的性质完全不同: + +| purpose | 谁写 | 是否进 `MediaProperties.allowedPurposes` | +| --- | --- | --- | +| `ai_input` | 客户端经 `/api/v1/media/uploads` 两步上传的宠物原图 | **是**(`MediaProperties.java:66` 追加) | +| `ai_output` | **worker 服务端生成**,客户端永不上传 | **否** ⚠️ | + +⚠️ **安全要点**:若把 `ai_output` 也加进 `allowedPurposes`,用户就能自己上传任意图片并声明其为「AI 生成产物」,AI 创作的可信度归零。`allowedPurposes` 是客户端上传端点的闸门(`MediaService.java:53-56`),`ai_output` 必须只由服务端写入路径产生。 + +### 6.2 服务端落地缺两个原语 + +`ObjectStorage` 接口只有 `ensureBucket` / `presignPut` / `stat` / `presignGet`(§1.3 取证)。worker 需要的是: + +- **读入图**:把 `ai_input` 的字节交给 provider(或给厂商一个可拉取的 URL) +- **写出图**:把生成字节落进 MinIO,再写 `media.assets` 行 + +方案对比: + +| 选项 | 做法 | 评价 | +| --- | --- | --- | +| **M1(推荐)** | 把 `ObjectStorage` + `S3ObjectStorage` + 存储配置上提 `patbond-common`,并补 `putObject(key, bytes, contentType)` 与 `getObject(key)` 两个方法;patbond-ai 直接用 | 两个新原语**只实现一次**;顺手偿还三份 `patbond.media` 配置类 + 两份 `MediaUrlSigner` 的复制债;compose 已有给业务模块下发 MinIO 凭证的先例(§1.3),不破规矩。代价:动到 v0.4.0 已发布的 media 代码(由 381 测试兜底),且 `patbond-common` 会引入 aws-sdk 依赖(auth 模块被动带上,无 autoconfig 不产生行为) | +| M2 | patbond-ai 复制第 4 份存储适配层 | 与既有三份复制「一致」,但两个新原语要写两遍,债务继续滚 | +| M3x | patbond-user 新增内部端点 `POST /internal/media/assets`(服务端登记 + 存字节)与取预签名 GET 的内部端点,patbond-ai 经 Feign 调用,**自身不带 S3 SDK** | 严格守住 ADR-017「media 写侧在 user」;patbond-ai 极轻。代价:图片字节走一趟内部 HTTP、新增 2 个 `/internal` 端点与服务间认证面 | + +推荐 **M1**,M2 作为工期压力下的退路。→ 待拍板 **D4-4**(含 ADR-017 边界解释,见下)。 + +**ADR-017 边界说明**(诚实标注):ADR-017 原文(`decisions.md:156`)是「media **上传流程**实现在 patbond-user(横切基础能力、避免业务模块被反向依赖)」。M1 让 patbond-ai 写 `media.assets`,严格说超出了「只读」范围。但 (a) ADR-017 反对的是「业务模块被反向依赖」,M1 是把共享物下沉到 common,方向相反;(b) `ai_output` 是服务端产物,天然不属于「上传流程」。建议 M4 以一条新 ADR 明确「服务端产出型资产由产出域写入 `media.assets`,客户端上传流程仍归 user」。 + +### 6.3 worker 落地时序(含孤儿与幂等) + +``` +1. 认领任务(§3.3) +2. 若 provider_request_id 为空: + 2a. 取 ai_input 对象(getObject 或预签名 GET) + 2b. 提交 provider → 拿到 providerRequestId + 2c. ★ 单独事务立刻持久化 providerRequestId(L2 幂等,见 §4.2) + 否则:跳到 3(恢复路径,绝不重复提交) +3. 轮询 provider 直至完成;每 30s 心跳续租 + 写 progress(返回 0 行 → 已被取消,放弃) +4. 拿到输出字节: + 4a. putObject → objectKey = "ai_output/" + yyyy/MM + "/" + assetId + (与 MediaService.java:68-69 的 key 规则同构:purpose 前缀 + 年月 + assetId,全服务端生成、不含用户输入) + 4b. INSERT media.assets:owner_user_id = job.user_id(★ 必须,否则社区挂帖的归属校验会 404), + kind='image', purpose='ai_output', storage_type='object', + status='ready', ready_at=now(), width_px/height_px 按实际填 + 4c. 条件 UPDATE job → succeeded (progress=100, output_asset_id=:assetId, completed_at=now(), 清租约) + WHERE status='running' AND lease_owner=:owner + 4d. 若 4c 返回 0 行(已被取消)→ 4b 的资产行置 deleted + 尽力删对象(§4.3) +``` + +`owner_user_id = job.user_id` 这一步是**跨模块的隐性契约**:社区侧 `PostService.validateAssets:343-358` 用 `!userId.equals(ref.ownerUserId())` 判归属,若 worker 把 owner 写成别的值(或 NULL——`V1:207` 允许 NULL),用户就无法把自己的 AI 产物发帖,且报错是防枚举的 404/40405,**极难排查**。必须写进工单并配集成测试。 + +顺带填坑:`insertUploading`(`MediaAssetRepository.java:32-51`)从不写 `width_px` / `height_px`,两列在上传链路里恒为 NULL。worker 知道真实尺寸,应当填上——这会让 `MediaAsset` 契约里已存在的 `widthPx`/`heightPx` 字段(openapi.yaml `:3514+`)第一次有值。 + +### 6.4 一键建社区草稿:零新增端点 + +| 选项 | 做法 | 评价 | +| --- | --- | --- | +| **E1(推荐)** | 复用 `POST /api/v1/posts`:加宽 category 枚举放开 `ai_creation`,请求体加可选 `generationJobId`,media 传 `output_asset_id`。草稿语义已有(status 缺省 draft,`PostService.java:89`) | **0 新增路径**;发布仍走既有 `PATCH`;`Post` 响应加 `generationJobId` 一个字段。契约自己就为此留了话:`Post` schema 描述(openapi.yaml `:3717-3718`)写「region/generationJob/topics 等裁剪字段整体不出现……**后续按新增可选字段纯增量补入**」 | +| E2 | 在 patbond-ai 加 `POST /creation/jobs/{id}/post-draft` | patbond-ai 写 `community` schema,破坏「模块边界 = schema 边界」纪律(`backend-modules.md:63,70`),否决 | + +推荐 **E1**。社区侧需补的校验(当 `generationJobId` 非空时,跨 schema 只读 `creation.generation_jobs`,先例见 `MediaAssetGateway`): + +1. `job.user_id = 调用者` 且 `job.status='succeeded'`,否则 404(防枚举,沿 40405/40403 惯例) +2. **是否强制要求 media 里包含 `job.output_asset_id`** —— 建议**强制**。否则用户可以挂一张任意图片却标注 `generationJobId`,把「AI 创作」变成可伪造的标签。→ 待拍板 **D4-6** + +另需注意 §1.4 取证到的既有缺口:社区挂帖**完全没有 purpose 校验**(`MediaAssetRef` 不含 purpose 列)。这意味着 `ai_input`(用户原图)也能挂上 ai_creation 帖子。D4-6 若采纳「强制包含 output_asset_id」,这个缺口在 AI 场景下即被覆盖;是否为社区全域补 purpose 白名单校验属独立议题,建议**不进 M4**(会改动已发布的社区写路径)。 + +--- + + +## 7. 上游 AI 服务接入与本迭代可验证性 + +### 7.1 现状取证 + +全仓无任何 AI 厂商 SDK / 调用代码:`patbond-api/pom.xml` 依赖管理仅 spring-boot-dependencies、spring-cloud-dependencies、aws-sdk bom、hutool、lombok(`:40-67`)。`.env` 与 compose 无任何第三方模型服务凭证。**上游是纯零起点**,且厂商尚未选型(任务书亦未指定)。 + +### 7.2 可插拔接口(推荐) + +```java +public interface GenerationProvider { + String code(); // → provider_code_snapshot(varchar(64)) + String version(); // → model_version_snapshot(varchar(64),见 §5.4 D4-8) + + /** 提交,立即返回上游请求 id;不等待完成 */ + String submit(GenerationCommand command) throws ProviderException; + + /** 轮询一次;返回 进行中(progress) / 完成(bytes+mime+尺寸) / 失败 */ + PollResult poll(String providerRequestId) throws ProviderException; +} +``` + +**两阶段是硬要求**(§4.2 L2):`submit` 与 `poll` 分开,才能让「已提交上游 → 崩溃 → 恢复后继续轮询而非重复提交」这条路径被真实执行和测试。若接口设计成一次性 `generate()`,L2 幂等只是纸面设计。 + +失败分类由 `ProviderException` 承载,直接决定 §3.4 走 (a) 重试还是 (b) 终态: + +| `error_code`(varchar(64),契约可见枚举) | 可重试 | 触发 | +| --- | --- | --- | +| `provider_timeout` | ✅ | 上游超时 | +| `provider_unavailable` | ✅ | 上游 5xx | +| `provider_rate_limited` | ✅ | 上游 429(退避后重试) | +| `provider_error` | ✅ | 其他上游异常 | +| `content_rejected` | ❌ | 上游内容策略拒绝 | +| `input_unreadable` | ❌ | 入图损坏/无法解码 | +| `lease_expired` | ❌ | reaper 判定次数耗尽(§3.4 c) | +| `internal_error` | ❌ | 本侧缺陷 | + +这张表就是验收标准「**失败原因可追踪**」的落点,应同时写进契约的 enum 与 description。 + +### 7.3 让本迭代端到端可验证:真实字节 stub + +| 选项 | 说明 | 评价 | +| --- | --- | --- | +| **S1(推荐)** | `StubGenerationProvider`:`submit` 生成一个假 requestId 并记录起始时刻;`poll` 按配置时长(默认 6s)分档返回 progress,到点后**真的读入图、用 JDK 内置 `javax.imageio` + Java2D 做可见变换(风格色调叠加 + 角标文字),输出真 JPEG 字节** | **零新依赖**(ImageIO/Java2D 在 JDK 内);把「取入图 → 变换 → 写出图 → 写资产行 → 挂帖」整条链路用**真实字节**跑通,而不是塞一个假 URL 蒙混过关;两阶段实现使 L2 恢复路径可测 | +| S2 | 只回写入图(原图复制) | 链路同样通,但产物与入图无差异,人工验收时无法判断「是否真的生成过」 | +| S3 | 返回硬编码占位图 URL | `storage_type='external'` 绕过对象存储,**整条媒体链路不被验证**,否决 | +| S4 | 等真实厂商 | 本迭代无法交付,否决 | + +推荐 **S1**,配置项 `patbond.creation.provider=stub|`,用 `@ConditionalOnProperty` 选实现。另建议提供 `patbond.creation.stub.failure-rate` 与可强制注入指定 `error_code` 的测试开关,用于覆盖 §7.2 那张失败表与重试路径。 + +**注意**(按 ADR-022 纪律):stub 是**刻意的占位实现**,须在类注释、application.yml.sample 与本迭代报告三处显式标注,避免后续实测反馈重复提出「AI 是假的」。同时它不是「demo 代码」——它是真实执行媒体链路的可插拔实现,接厂商时只增加一个实现类,**不动任何调用方**。 + +### 7.4 接真实厂商时才需要新决策的事项(本迭代不做,列出以免遗漏) + +- 第三方 API Key 的注入与轮换(现有 `.env` + `.sample` 模式可承接,但 `check-secrets.sh` 规则表需评估是否覆盖新键名格式) +- 上游超时/并发上限与本侧 `lease-ttl`、`max_attempts` 的配比 +- 上游计费与 L3 配额的对账(届时才可能需要真正的「扣费」表) +- 内容安全审核(`content_rejected` 的判定归属) + +--- + + +## 8. 限流与配额(429 + Retry-After) + +### 8.1 该不该在本迭代做 + +**该做,但只做收窄版。** 三条理由: + +1. **验收标准依赖它**。`development-plan.md:255` 写「相同幂等请求**不重复扣费**或生成」。而目标模型 40 张表**无任何计费/额度表**(§1.2 取证),全项目也无余额概念。「扣费」在 M4 唯一可落地的解释就是**配额消耗**。没有配额,这条验收标准无从验证。 +2. **AI 生成天然需要**。它是全项目第一个**单次调用成本显著 > 0**(真实厂商按次计费)、且**单用户可无限触发**的写接口。既有写接口(发帖/评论/点赞)都无此性质。 +3. **成本可控**。见 8.3,推荐方案**零新表、零新依赖**。 + +**但要与 M6 划清界限**:`development-plan.md:272` 把「限流、审计、结构化日志、指标、Trace、告警」整体排在 M6。M4 做的**不是**横切限流基础设施(无网关级令牌桶、无全端点覆盖、无 Redis 计数器),而是**创作域一个端点的业务配额**。这个区分要写进 ADR,否则容易被误读为「M6 的限流提前做了」而在 M6 重复投入。 + +### 8.2 现状取证 + +- `patbond-*/src/main` grep `429|TOO_MANY_REQUESTS|Retry-After|RateLimit|rateLimit` → **零命中** +- `openapi.yaml` grep 同上 → **零命中**(无 429 响应、无 RateLimit/Retry-After 头) +- `ErrorCode.java:12-38` 27 个码中 **429 段完全空白** + +即:地基全新。 + +### 8.3 方案对比 + +| 选项 | 做法 | 评价 | +| --- | --- | --- | +| **Q1(推荐)** | **派生计数,零新表**。提交前 `SELECT count(*) FROM creation.generation_jobs WHERE user_id=:me AND created_at >= :windowStart AND status <> 'cancelled'`,与配置上限比较;另查 `status IN ('queued','running')` 得在途数。走 `ix_generation_jobs_user_created`(`:702-703`,已存在) | 零迁移、零新依赖、零新表;与 ADR-022「获赞总数走读侧实时聚合,不引入冗余列」的判例完全同构(`decisions.md:193`)。代价:并发提交有小幅超发(见下) | +| Q2 | 新建 `creation.generation_quotas` 计数表 + 原子 upsert | 严格精确,但**目标模型里没有这张表**(凭空新增偏离已评审模型),且为 MVP 精度买了一张需要窗口滚动与清理的表 | +| Q3 | Redis 计数器 | 需新增容器(§3.1),与 §3 的整体判断矛盾 | + +推荐 **Q1**。超发问题:两个并发提交可能都通过检查。若需严格,加 `SELECT pg_advisory_xact_lock(hashtext(:userId::text))` 把同一用户的提交串行化——一行代码、无新表。建议**默认加上**,因为提交本来就是低频操作,串行化无性能代价。 + +### 8.4 建议的两道闸门与响应形态 + +| 闸门 | 配置项 | 建议默认 | 语义 | +| --- | --- | --- | --- | +| 日配额 | `patbond.creation.quota.daily-limit` | 20 | 同一用户当日(建议按 UTC 日切,与埋点 `server_ts` 口径一致)非取消任务数上限 | +| 在途上限 | `patbond.creation.quota.max-in-flight` | 2 | `queued + running` 并发数,防单用户占满队列 | + +响应:**429** + 业务码 **42900**(`GENERATION_QUOTA_EXCEEDED`,符合 `ErrorCode` 的 HTTP 三位 + 两位序号命名法)+ **`Retry-After` 响应头**(日配额 → 距下一个窗口起点的秒数;在途上限 → 建议固定小值如 10)。 + +⚠️ 取消是否退还配额:推荐**退还**(计数条件 `status <> 'cancelled'`),对用户更友好,且取消通常意味着未真正消耗上游。→ 归入 **D4-5**。 + +⚠️ 429 是**全新错误段**,两处需同步:`ErrorCode.java` 新增枚举项,以及契约 `info.description` 的错误码表(openapi.yaml `:32-60`,当前止于 42300)。 + +--- + + +## 9. 模块归属:patbond-ai 与共享物上提 + +### 9.1 新模块 vs 并入既有模块 + +| 选项 | 评价 | +| --- | --- | +| **N1(推荐):新建 `patbond-ai`(:8085)** | 沿 ADR-009(M2 建 patbond-pet)与 ADR-017(M3 建 patbond-community)**两次先例**。注意 ADR-009 原文(`decisions.md:106`)写明:「PM 建议新建模块,后端评估建议 user 内独立包。**用户裁定采用新建模块方案**,为后续微服务化保持模块边界清晰」——后端立场连输两次,本次不再重复主张内包。`creation` schema 也与「模块边界 = schema 边界」纪律(`backend-modules.md:63,70`)对齐。附带收益:worker 是长跑后台消费者,与 API 请求路径隔离在独立进程里是真实的运维好处 | +| N2:并入 patbond-community | 唯一实质优势是「输出→草稿」在同一进程内。但让 community 同时持有 `community` 与 `creation` 两个 schema,破坏纪律;且 §6.4 已论证建草稿复用既有 `POST /api/v1/posts`,跨进程也毫无摩擦。否决 | +| N3:并入 patbond-user | user 已持有 identity + media + platform + 迁移链,再塞 creation 会让它变成事实上的单体。否决 | + +推荐 **N1**:`patbond-ai`,端口 **8085**(8081/8082/8083/8084 顺延),API 路径前缀 **`/api/v1/creation/**`**(与 schema 名和契约 tag 一致,避免出现第三种命名 `ai`/`creation` 混用)。 + +### 9.2 新模块的隐性代价:复制代码从 3 份变 4 份 + +新模块需要下列每一样,而它们目前**都是各模块手抄的副本**: + +| 共享物 | 现状份数 | 取证 | +| --- | --- | --- | +| `BearerAuthFilter` + `JwtVerifier` + `RsaPublicKeyLoader` | **3** → 将成 4 | community 版 Javadoc `:26` 自述「Third copy of the user/pet filter」 | +| `UuidV7` | **2** → 将成 3 | `patbond-pet/.../support/UuidV7.java`、`patbond-community/.../support/UuidV7.java` | +| `patbond.media` 配置类 | **3** → 将成 4 | `MediaProperties:15` / `PetMediaProperties:16` / `CommunityMediaProperties:15`,同一前缀 | +| 预签名 GET(`MediaUrlSigner` / `S3ObjectStorage`) | **3** → 将成 4 | 两个 `MediaUrlSigner` 的 Javadoc 互相自述「same as」 | +| `GlobalExceptionHandler` | 每模块一份 | community 版 `:31-35` | +| `RequestHashes` | 1(community)→ 将成 2 | `RequestHashes.java:22-29` | +| 契约快照 + `OpenApiContract` | **4** → 将成 5 | 见 §13 | + +**建议**:把 **JWT 资源侧校验、`UuidV7`、`RequestHashes`、存储适配层(含 §6.2 的两个新原语)** 四件上提 `patbond-common`,作为 M4 的一个独立前置工单。理由不是洁癖,而是算术:M4 本来就要给存储适配层加两个方法,抄 4 份 vs 抄 1 份;且这批复制**每多一份就多一处将来漂移的地方**(`MediaProperties` 三份已经在字段集上不一致——pet/community 版缺 `bucket` 与 `uploadTtl`)。 + +代价与风险要诚实说:这会改动 v0.4.0 已发布的 auth/user/pet/community 四模块的公共路径,由 381 测试兜底,属**行为保持型重构**,但仍是本迭代最大的回归面。若工期紧,退路是 **M2 方案**(patbond-ai 各抄一份,债务记账留待 M6「交付加固」偿还)。→ 待拍板 **D4-3**。 + +### 9.3 部署形态变化 + +六容器 → **七容器**(新增 `ai`),compose 块可直接照抄 community 块(`docker-compose.yml:118-152`):同库连接、同 JWT 公钥挂载、同 MinIO 凭证与 `PATBOND_MINIO_PUBLIC_ENDPOINT`、`depends_on: postgres(healthy) + user(started)`(等 user 跑完 Flyway)。若采纳 §3.5 的 worker 开关且选 A2,则为八容器(api + worker 同 jar),M4 建议先七容器。 + +需同步更新(**本报告不改,交由对应工单**):`backend-modules.md`(模块图、职责、端口)、`docker-compose.yml`、`deploy/init-secrets.sh`(若需新凭证)、根 `pom.xml` 的 ``。 + +--- + + +## 10. 数据模型草案与 DDL 草稿 + +**总原则:抄写目标模型,不重新设计。** 下面的 DDL 与 `patbond-doc/docs/database/patbond_postgresql.sql:556-716` **逐列一致**;所有偏离都在 §10.4 单独列出并附理由。字段含义已在 §2/§4/§5 逐项论证,此处不重复。 + +### 10.1 目录两表(DDL 草稿) + +```sql +CREATE SCHEMA creation; +COMMENT ON SCHEMA creation IS 'Asynchronous AI image/video generation'; + +CREATE TABLE creation.generation_models ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(64) NOT NULL, + display_name varchar(128) NOT NULL, + provider_code varchar(64) NOT NULL, + provider_model_name varchar(128) NOT NULL, + media_kind varchar(16) NOT NULL, + enabled boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (code, media_kind), + -- 非冗余:为 generation_jobs 的复合外键提供被引用唯一约束(§5.1) + UNIQUE (id, media_kind), + CONSTRAINT ck_generation_models_kind CHECK (media_kind IN ('image', 'video')), + CONSTRAINT ck_generation_models_names CHECK ( + code = btrim(code) AND char_length(code) BETWEEN 2 AND 64 + AND char_length(btrim(display_name)) BETWEEN 1 AND 128 + ) +); + +CREATE INDEX ix_generation_models_kind_order + ON creation.generation_models (media_kind, sort_order, id) + WHERE enabled; + +CREATE TABLE creation.generation_styles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(64) NOT NULL, + title varchar(64) NOT NULL, + subtitle varchar(128), + media_kind varchar(16) NOT NULL, + -- 跨 schema 外键保留:media 自 V1 起存在(同 V5 先例) + preview_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, + enabled boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (code, media_kind), + UNIQUE (id, media_kind), + CONSTRAINT ck_generation_styles_kind CHECK (media_kind IN ('image', 'video')), + CONSTRAINT ck_generation_styles_names CHECK ( + code = btrim(code) AND char_length(code) BETWEEN 2 AND 64 + AND char_length(btrim(title)) BETWEEN 1 AND 64 + ) +); + +CREATE INDEX ix_generation_styles_preview ON creation.generation_styles (preview_asset_id); +CREATE INDEX ix_generation_styles_kind_order + ON creation.generation_styles (media_kind, sort_order, id) + WHERE enabled; +``` + +### 10.2 任务表(DDL 草稿) + +```sql +CREATE TABLE creation.generation_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL, + media_kind varchar(16) NOT NULL, + model_id uuid NOT NULL, + style_id uuid, + input_asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT, + output_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, + prompt varchar(10000), + negative_prompt varchar(3000), + width_px integer, + height_px integer, + duration_ms bigint, + upscale boolean NOT NULL DEFAULT false, + parameters jsonb NOT NULL DEFAULT '{}'::jsonb, + -- 快照四列:目录行改名/停用后历史任务仍可解释(§5.4 说明 model_version_snapshot 取值来源) + provider_code_snapshot varchar(64) NOT NULL, + provider_model_snapshot varchar(128) NOT NULL, + model_version_snapshot varchar(64) NOT NULL, + style_code_snapshot varchar(64), + status varchar(16) NOT NULL DEFAULT 'queued', + progress smallint NOT NULL DEFAULT 0, + priority smallint NOT NULL DEFAULT 0, + -- 队列机制六列(§2) + attempt_count smallint NOT NULL DEFAULT 0, + max_attempts smallint NOT NULL DEFAULT 3, + provider_request_id varchar(128), + error_code varchar(64), + error_message varchar(1000), + idempotency_key varchar(128) NOT NULL, + request_hash bytea NOT NULL, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + lease_owner varchar(128), + lease_expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, -- ★ 本次尝试的开始时间,非首次入队(§2.1) + completed_at timestamptz, + version integer NOT NULL DEFAULT 0, + UNIQUE (user_id, idempotency_key), -- L1 幂等(§4.2) + FOREIGN KEY (model_id, media_kind) + REFERENCES creation.generation_models(id, media_kind) ON DELETE RESTRICT, + FOREIGN KEY (style_id, media_kind) + REFERENCES creation.generation_styles(id, media_kind) ON DELETE RESTRICT, + CONSTRAINT ck_generation_jobs_kind CHECK (media_kind IN ('image', 'video')), + CONSTRAINT ck_generation_jobs_dimensions CHECK ( + (width_px IS NULL OR width_px BETWEEN 64 AND 8192) + AND (height_px IS NULL OR height_px BETWEEN 64 AND 8192) + AND (duration_ms IS NULL OR duration_ms > 0) + ), + CONSTRAINT ck_generation_jobs_parameters CHECK (jsonb_typeof(parameters) = 'object'), + CONSTRAINT ck_generation_jobs_idempotency CHECK ( + idempotency_key = btrim(idempotency_key) + AND char_length(idempotency_key) BETWEEN 1 AND 128 + AND octet_length(request_hash) = 32 + ), + CONSTRAINT ck_generation_jobs_status CHECK ( + status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled') + ), + CONSTRAINT ck_generation_jobs_progress CHECK (progress BETWEEN 0 AND 100), + CONSTRAINT ck_generation_jobs_attempts CHECK ( + attempt_count >= 0 AND max_attempts > 0 AND attempt_count <= max_attempts + ), + -- 状态机下沉为 DB 约束(§4.1):五状态各自规定 7 个字段的合法组合 + CONSTRAINT ck_generation_jobs_state CHECK ( + ( + status = 'queued' AND progress = 0 AND started_at IS NULL AND completed_at IS NULL + AND output_asset_id IS NULL AND error_code IS NULL + AND lease_owner IS NULL AND lease_expires_at IS NULL + ) + OR ( + status = 'running' AND started_at IS NOT NULL AND completed_at IS NULL + AND output_asset_id IS NULL AND error_code IS NULL + AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL + ) + OR ( + status = 'succeeded' AND progress = 100 AND started_at IS NOT NULL + AND completed_at IS NOT NULL AND output_asset_id IS NOT NULL AND error_code IS NULL + AND lease_owner IS NULL AND lease_expires_at IS NULL + ) + OR ( + status = 'failed' AND completed_at IS NOT NULL AND error_code IS NOT NULL + AND output_asset_id IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL + ) + OR ( + status = 'cancelled' AND completed_at IS NOT NULL + AND lease_owner IS NULL AND lease_expires_at IS NULL + ) + ), + CONSTRAINT ck_generation_jobs_lease CHECK ( + (lease_owner IS NULL) = (lease_expires_at IS NULL) + AND (lease_expires_at IS NULL OR started_at IS NULL OR lease_expires_at > started_at) + ), + CONSTRAINT ck_generation_jobs_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_generation_jobs_provider_request -- L2 幂等(§4.2) + ON creation.generation_jobs (provider_code_snapshot, provider_request_id) + WHERE provider_request_id IS NOT NULL; +CREATE INDEX ix_generation_jobs_user_created -- 我的任务列表 + 配额计数(§8.3) + ON creation.generation_jobs (user_id, created_at DESC, id DESC); +CREATE INDEX ix_generation_jobs_pet ON creation.generation_jobs (pet_id); +CREATE INDEX ix_generation_jobs_model ON creation.generation_jobs (model_id); +CREATE INDEX ix_generation_jobs_style ON creation.generation_jobs (style_id); +CREATE INDEX ix_generation_jobs_model_kind ON creation.generation_jobs (model_id, media_kind); +CREATE INDEX ix_generation_jobs_style_kind ON creation.generation_jobs (style_id, media_kind); +CREATE INDEX ix_generation_jobs_input ON creation.generation_jobs (input_asset_id); +CREATE INDEX ix_generation_jobs_output ON creation.generation_jobs (output_asset_id); +CREATE INDEX ix_generation_jobs_queue -- 出队(§3.3) + ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id) + WHERE status = 'queued'; +CREATE INDEX ix_generation_jobs_running -- reaper 回收(§3.4 c) + ON creation.generation_jobs (lease_expires_at, id) + WHERE status = 'running'; +``` + +### 10.3 触发器与外键补回 + +```sql +-- platform.set_updated_at() 自 V1:28-36 起存在,直接挂(目标模型 :1245-1250) +CREATE TRIGGER trg_generation_models_updated_at BEFORE UPDATE ON creation.generation_models + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_generation_styles_updated_at BEFORE UPDATE ON creation.generation_styles + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_generation_jobs_updated_at BEFORE UPDATE ON creation.generation_jobs + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); + +-- ★ V5:17-19 指定的「M4 补回」:裸列 + ix_posts_generation_job 索引均已存在, +-- 仅补约束。存量值恒为 NULL(§1.1 取证),无需 NOT VALID,无回填。 +ALTER TABLE community.posts + ADD CONSTRAINT fk_posts_generation_job + FOREIGN KEY (generation_job_id) REFERENCES creation.generation_jobs(id) ON DELETE SET NULL; + +-- ⚠️ region_id 的外键属 M5(V5:20-22),本迁移不得顺手补 +``` + +### 10.4 相对目标模型的偏离清单(仅此 3 处,全部为「不改 schema」) + +| # | 事项 | 处置 | 理由 | +| --- | --- | --- | --- | +| 1 | `model_version_snapshot` 无来源列 | **不加列**,由 provider 适配器 `version()` 提供(§5.4) | 避免偏离已评审模型;语义更准 | +| 2 | `media_kind` 的 `video` | **DB 照抄含 video**,仅 API 枚举收窄到 `image` | ADR-018 视频后置;沿 `ai_creation` 的「DB 允许 / API 收窄」先例 | +| 3 | 取消不加 `cancel_requested` 列 | 用条件 UPDATE + worker 收尾失败自弃(§4.3 X1) | 目标模型的 cancelled 分支本就允许 running 取消 | + +**结论:M4 对 creation schema 零结构偏离**,`generation_models` / `generation_styles` / `generation_jobs` 三表与目标模型逐列一致。 + +--- + + +## 11. Flyway 迁移计划 + +### 11.1 迁移数量:**2 个** + +| 版本 | 文件名 | 内容 | 依据 | +| --- | --- | --- | --- | +| **V6** | `V6__creation_baseline.sql` | `CREATE SCHEMA creation` + `COMMENT ON SCHEMA` + 三表 + 13 条索引 + 3 条触发器 + **`ALTER TABLE community.posts ADD CONSTRAINT fk_posts_generation_job`**(§10.3) | 结构迁移;V6 是既定的下一个版本号(ADR-022,`decisions.md:192`) | +| **V7** | `V7__creation_catalog_seed.sql` | `generation_models` 与 `generation_styles` 种子行(`preview_asset_id` 全为 NULL,§5.3 P1) | 沿 V3(结构)/ V4(种子)拆分先例(`V4:1-13`) | + +放置位置**唯一**:`patbond-api/patbond-user/src/main/resources/db/migration/`(§1.1 取证:只有 user 模块配了 `flyway.locations`)。即便新建 patbond-ai 模块,迁移也**不进** patbond-ai。 + +### 11.2 单文件内的顺序约束(写错会直接失败) + +V6 内部必须严格按此顺序: + +1. `CREATE SCHEMA creation` +2. `generation_models`、`generation_styles`(含各自的 `UNIQUE (id, media_kind)`) +3. `generation_jobs`(其复合外键引用步骤 2 的唯一约束;其 `input_asset_id`/`output_asset_id` 引用 `media.assets`,V1 已建;`user_id` 引用 `identity.users`,V1 已建;`pet_id` 引用 `pet_health.pets`,V3 已建) +4. 索引与触发器 +5. **最后**才 `ALTER TABLE community.posts ADD CONSTRAINT`(被引用表必须先存在) + +### 11.3 是否需要 V8 及以后 + +不需要。本迭代**没有任何**需要额外迁移的项,逐条确认: + +| 候选 | 是否需要迁移 | 取证 | +| --- | --- | --- | +| 新增 `ai_input` / `ai_output` purpose | **否** | `media.assets.purpose` 无 CHECK(`V1:205-249` 逐行确认);白名单是配置项 `MediaProperties.java:66` | +| 放开 `category='ai_creation'` | **否** | `V5:68` 的 `ck_posts_category` **已含** `ai_creation`;拦截在 Java `@Pattern` | +| `posts.generation_job_id` 加列 | **否** | 裸列 `V5:48` 已存在 | +| `ix_posts_generation_job` 索引 | **否** | `V5:95` 已建 | +| 埋点新增创作域事件 | **否** | `platform.product_events` 是 `event_name varchar(64)` + `props jsonb`(`V2`),白名单在 `EventDictionary.java`(Java 常量表,41 条) | +| 配额计数 | **否** | 派生计数,零新表(§8.3 Q1) | +| 429 错误码 | **否** | Java 枚举 + 契约文本 | + +### 11.4 迁移相关的测试影响(**会红,必须提前排进工单**) + +⚠️ **`CommunityMigrationIntegrationTest.v5PostsColumnsExistWithoutCreationAndRegionFKs` 会因 V6 变红。** 取证:`patbond-user/src/test/java/com/patbond/patbond/user/persistence/CommunityMigrationIntegrationTest.java:81-88` + +```java +int fkCount = jdbcClient.sql( + "SELECT COUNT(*) FROM information_schema.table_constraints " + + "WHERE table_schema = 'community' AND table_name = 'posts' " + + "AND constraint_type = 'FOREIGN KEY' " + + "AND (constraint_name LIKE '%generation%' OR constraint_name LIKE '%region%')") + .query(Integer.class).single(); +assertThat(fkCount).isEqualTo(0); +``` + +Flyway 在测试容器上跑**全链**,V6 之后这个查询会返回 1(`fk_posts_generation_job` 命中 `LIKE '%generation%'`)。**处置**:把该断言拆成两条——generation 类 FK 期望 **1**,region 类 FK 仍期望 **0**(M5 才补)——并同步更新测试类 Javadoc(`:12-18` 现写「generation_job_id(creation belongs to M4)…exist as bare nullable uuid columns without FKs」)。 + +同一文件 `v5PostsKeepsInSchemaAndV1V3FKs`(`:92-108`)用 `IN ('identity.users','pet_health.pets')` 过滤,**不受影响**。 + +**新增测试**:按 `PetHealthMigrationIntegrationTest` / `CommunityMigrationIntegrationTest` 先例补 `CreationMigrationIntegrationTest`,断言 creation schema 存在、3 张表、13 条索引中的关键部分索引(`ix_generation_jobs_queue`、`ix_generation_jobs_running`、`uq_generation_jobs_provider_request`)、3 条触发器、复合外键存在、以及**一组「非法状态迁移被 `ck_generation_jobs_state` 拒绝」的直接断言**(§4.1)。 + +--- + + +## 12. API 端点清单与契约影响面 + +### 12.1 新增端点(4 路径 / 5 操作) + +| # | 方法 + 路径 | operationId | 说明 | 主要错误 | +| --- | --- | --- | --- | --- | +| 1 | `GET /api/v1/creation/catalog` | `getCreationCatalog` | 模型 + 风格**一次取回**(`?mediaKind=image`,缺省 image)。仅 `enabled`,按 `sort_order, id` 排序(走 `ix_generation_models_kind_order` / `ix_generation_styles_kind_order`) | 400/40000、401/40101 | +| 2 | `POST /api/v1/creation/jobs` | `createGenerationJob` | 提交任务。**`Idempotency-Key` 强制头**(同 `POST /api/v1/posts` 先例)。201 | 400/40000、401/40101、404/40405(inputAsset 不存在/非本人/已删)、404/40401(petId 不可见)、409/40905(同键异载荷)、422/42203(inputAsset 非 ready)、422/42206(model/style 与 mediaKind 不匹配或已停用)、**429/42900** | +| 3 | `GET /api/v1/creation/jobs` | `listGenerationJobs` | 我的任务列表,**cursor 分页正典** `{items, nextCursor, hasMore}`,排序 `created_at DESC, id DESC`(走 `ix_generation_jobs_user_created`)。可选 `status` 过滤 | 400/40000、401/40101 | +| 4 | `GET /api/v1/creation/jobs/{jobId}` | `getGenerationJob` | 轮询状态/进度/结果(建议 1.5s 间隔,写进 description)。含 `outputUrl`(预签名 GET,会过期,不得持久化——同 `avatarUrl` 惯例) | 401/40101、404/40407 | +| 5 | `POST /api/v1/creation/jobs/{jobId}/cancel` | `cancelGenerationJob` | 取消 queued 或 running(§4.3 X1)。终态 → 422/42206 | 401/40101、404/40407、422/42206 | + +**不设 retry 端点**(§4.4 R1:用户重试 = 用新 `Idempotency-Key` 重新提交)。 +**不设目录管理端点**(§5.2:全项目无 `/admin` 面)。 + +新增 tag:`creation` —— 「AI 创作:模型/风格目录、生成任务提交/查询/取消(patbond-ai,异步队列)」,插入 openapi.yaml 的 tags 块(当前 11 个 tag,`:190-211`)。 + +### 12.2 既有端点的改动(0 新路径) + +| 端点 | 改动 | 兼容性 | +| --- | --- | --- | +| `POST /api/v1/posts` | 请求体加可选 `generationJobId`(uuid);`category` 枚举 `[general, help]` → `[general, help, ai_creation]` | **向后兼容**(新增可选字段 + 请求枚举**加宽**只会接受更多输入) | +| `PATCH /api/v1/posts/{postId}` | `category` 枚举同上加宽 | 向后兼容 | +| `GET /api/v1/posts/{postId}` 等所有返回 `Post` 的操作 | `Post` 响应加 `generationJobId`(nullable uuid) | 向后兼容;且契约**已预告**此增量(`:3717-3718`「后续按新增可选字段纯增量补入」) | +| `POST /api/v1/media/uploads` | `purpose` 枚举加 `ai_input`(**不加 `ai_output`**,§6.1 安全要点) | 向后兼容 | + +### 12.3 契约影响面汇总 + +| 计量 | v1.4.0 现状 | M4 增量 | v1.5.0 预计 | +| --- | --- | --- | --- | +| `paths` | **32** | **+4** | **36** | +| operations | **45** | **+5** | **50** | +| `components.schemas` | **75** | **+8** | **83** | +| `components.responses` | 现有若干(含 `MediaNotFound:2030`、`MediaNotReady:2070`) | **+1**(`QuotaExceeded`,含 `Retry-After` 头) | — | +| tags | 11 | +1(`creation`) | 12 | +| 错误码表(`info.description:32-60`) | 止于 42300 | +40407、+42206、+42900 | — | +| 既有 schema 修改 | — | **3**(`CreatePostRequest:3641`、`UpdatePostRequest:3676`、`Post:3714`)+ `CreateMediaUploadRequest:3450` 的 purpose 枚举 = 共 **4** | — | + +新增 8 个 schema(按项目「每个列表端点一个 `*ListEnvelope`、每个单资源一个 `*Envelope`」惯例): + +1. `CreationModel` +2. `CreationStyle`(`previewUrl` nullable,§5.3 P1) +3. `CreationCatalog`(`{models, styles}`) +4. `CreationCatalogEnvelope` +5. `CreateGenerationJobRequest` +6. `GenerationJob`(详情与列表项共用,含 `status` / `progress` / `errorCode` 枚举 / `outputAssetId` / `outputUrl` / `modelCode` / `styleCode` / `createdAt` / `completedAt`) +7. `GenerationJobEnvelope` +8. `GenerationJobListEnvelope`(`{items, nextCursor, hasMore}`) + +**是否纯增量:是。** 逐条核验:新增路径/操作/schema/tag 全为增加;请求枚举**加宽**(服务端接受更多,旧客户端不受影响);新增**可选**请求字段;新增响应字段(Flutter 侧非 required 字段被忽略)。**无删除、无重命名、无必填收紧、无类型变更、无枚举收窄**。故版本号 **v1.4.0 → v1.5.0**(功能增量,非补丁),与 ADR-022 的 v0.4.0 定版逻辑一致。 + +### 12.4 新增业务错误码(3 个) + +| 码 | HTTP | 名称 | 语义 | +| --- | --- | --- | --- | +| **40407** | 404 | `GENERATION_JOB_NOT_FOUND` | 任务不存在 / 非本人(防枚举合并,沿 40405 先例) | +| **42206** | 422 | `GENERATION_RULE_VIOLATION` | model/style 与 mediaKind 不匹配、目录项已停用、或对终态任务发起取消 | +| **42900** | 429 | `GENERATION_QUOTA_EXCEEDED` | 日配额或在途上限(§8.4),**配 `Retry-After` 头** | + +复用既有码:40000、40101、40401、40405、40905、42203、50000、50300。命名与编号均遵循 `ErrorCode.java:3-9` 的纪律(「codes are contract, never reuse or renumber a released value」)。 + +--- + + +## 13. 契约同步机械清单(含第 5 份快照) + +### 13.1 先修正一条既有认知:CI 锁的不是字节 + +任务书写的是「四模块**字节级**快照锁 CI」。实测**不成立**: + +- CI 工作流(`patbond-api/.gitea/workflows/ci.yml`)只有两个校验步骤:`sh scripts/check-secrets.sh --all` 与 `./mvnw -B clean test`。**无任何 diff / 校验和 / 文件比对步骤**。 +- 四份 POM 中**无**任何拷贝或比对插件配置。 +- 「byte-identical」只出现在 Javadoc 里,是**约定的措辞**(如 `patbond-pet/.../contract/OpenApiContract.java:20`「this snapshot is a byte-identical copy taken at freeze time」),而该类的实现只用 SnakeYAML 解析(`:54` `new Yaml().load(in)`)并暴露 `version()/paths()/operations()/schemas()`。 +- 真正的门禁是**结构断言**:每模块一个 `frozenSnapshotIsTheExpectedContractVersion()`,断言版本字符串 + 三个计数 + 本域 tag 的操作集合。 + +实测四处断言(全部 `1.4.0` / `32` / `45` / `75`): + +| 模块 | 断言位置 | tag 集合 | +| --- | --- | --- | +| auth | `AuthContractConformanceTest.java:401-405` | `{auth, user, analytics}` | +| user | `MediaContractConformanceTest.java:248-252` | `{media}` | +| pet | `ContractConformanceTest.java:757-761` | `{pets, dictionaries, health-records}` | +| community | `CommunityContractConformanceTest.java:524-528` | (posts/feed/comments/interactions/follows 一组) | + +四份快照当前确实互为字节相同(`diff` 无输出),但**这是纪律的结果,不是机制的保证**。差别对 M4 有实际后果:如果有人只改了 doc 仓的 openapi.yaml 里某个 description 而不动计数,四个模块的守卫**全都不会红**。所以「契约先行」在本项目仍然依赖人工纪律,M4 的契约冻结工单不能只跑 CI 就认为同步到位。 + +### 13.2 升版必须同步改的清单(漏一处 CI 即红) + +1. doc 仓 `docs/api/openapi.yaml`:`info.version` → `1.5.0`;错误码表加 3 行;tags 加 `creation`;新增 4 路径 / 8 schema / 1 response;改 4 个既有 schema +2. `patbond-doc/site/api/openapi.yaml`(当前与正典字节相同的站点镜像;**未取证它是否由 mkdocs 构建自动产出**,若为构建产物则不手改) +3. 四份 → **五份** 快照文件:`patbond-{auth,user,pet,community,ai}/src/test/resources/contract/openapi-v1.5.0.yaml`(新文件名,旧的 v1.4.0 按既有做法删除) +4. 四份 → **五份** `OpenApiContract.java` 的 `RESOURCE` 常量(`:39`,pet 版在 `:35`)→ `/contract/openapi-v1.5.0.yaml` +5. 四处 → **五处** `frozenSnapshotIsTheExpectedContractVersion()`:版本 `1.5.0`、`paths` **36**、`operations` **50**、`schemas` **83** +6. community 的 tag 操作集合:`Post` / `CreatePostRequest` 相关操作签名未变,集合内容不变(仅 schema 内部变化),**但仍要复核** +7. **新增** patbond-ai 的 `OpenApiContract` + `AiContractConformanceTest`(tag 集合 `{creation}`,5 个操作) + +⚠️ 特别注意:**auth 与 pet 两个模块本域一行代码都不改,但它们的守卫会因全局计数变化而变红**,必须一并更新。这正是 M3.5 出现「11 格漂移」的同一机制(`iteration-3.5/03` 报告 §6 记载 auth 2 + pet 9 格漂移)。M4 的漂移格数预计更大(新增 5 操作 + 8 schema),建议**契约冻结单独成一个工单**并放在实现工单之后,与 T3.5-07 同一节奏。 + +--- + + +## 14. 风险清单 + +| # | 风险 | 概率 | 影响 | 缓解 | +| --- | --- | --- | --- | --- | +| R1 | **L2 上游幂等被做成同步单步调用**,「已提交上游 → 崩溃 → 重复提交」路径永不被测试,接真实厂商后重复扣费 | 中 | **高**(真金白银) | §7.2 强制 `submit`/`poll` 两阶段接口;stub 也两阶段;专写「持久化 requestId 后杀 worker,恢复后不得二次 submit」的集成测试 | +| R2 | **`CommunityMigrationIntegrationTest:81-88` 因 V6 变红**,被误当成 V6 写错而反复调试迁移 | **高**(几乎必然) | 中(浪费工时) | §11.4 已定位到行号与断言原文,工单里直接写「这条断言要拆成 generation=1 / region=0」 | +| R3 | worker 写 `media.assets` 时 `owner_user_id` 写错或为 NULL,用户无法把产物发帖,且报错是防枚举 404/40405,**极难排查** | 中 | 中 | §6.3 写进工单 + 集成测试断言 `owner_user_id = job.user_id` | +| R4 | `ck_generation_jobs_state` 严格,重试重新入队时漏清 `started_at`/`error_code`/`progress` → DB 直接拒绝,表现为「重试莫名失败」 | **高** | 中 | §2.1 三条约定 + §3.4 给出完整 UPDATE 语句,照抄即可 | +| R5 | §9.2 的共享物上提改到 v0.4.0 已发布的四模块公共路径,引入回归 | 中 | 中~高 | 381 测试兜底;拆成独立前置工单、单独提交、单独 CI 绿;退路是 M2(各抄一份) | +| R6 | `ai_output` 被误加进 `MediaProperties.allowedPurposes`,用户可伪造「AI 产物」 | 中 | 中(可信度) | §6.1 明确标注;配一条「上传 purpose=ai_output 必须 400」的测试 | +| R7 | running 取消产生的孤儿对象与资产行无人清理,MinIO 缓慢涨 | 中 | 低 | §4.3 X1 要求置 `deleted` + 尽力删对象;彻底清理沿用 `ix_media_uploading_created` 的既有待办(M6) | +| R8 | 契约计数漂移导致 auth/pet 无辜变红被误诊 | **高** | 低 | §13.2 清单;M3.5 已有同类先例可引 | +| R9 | 轮询进度导致客户端请求量放大(每任务 ~6s / 1.5s = 4 次以上) | 中 | 低 | MVP 量级可忽略;`GET /jobs/{id}` 是单行主键查询;必要时加 `ETag`/延长间隔 | +| R10 | 风格预览图缺失(§5.3)被实测反馈为「界面空图」 | 中 | 低 | P1 方案需**与客户端 agent 明确对齐**(按 `code` 内置资源),并按 ADR-022 在报告/注释标注为刻意占位 | +| R11 | 七容器后单机资源(腾讯云服务器同时跑 postgres + minio + 5 应用 + 生成变换)吃紧 | 中 | 中 | stub 的 Java2D 变换是短时 CPU 峰值;worker 线程池上限设 2;`patbond.creation.worker.enabled` 可随时关停 | +| R12 | `duration_ms` / `upscale` / `negative_prompt` 等列为视频与高级参数预留,M4 不用,评审时被质疑「为什么建了不用」 | 低 | 低 | 与 V5 建 `topics` 表但功能剪出(ADR-018)完全同构,先例充分 | +| R13 | 429 被误解为「M6 限流提前做了」,M6 重复投入或反之漏做 | 中 | 低 | §8.1 的边界说明需写进 M4 的 ADR | + +--- + + +## 15. 待拍板决策 + +共 **10 项**。**最关键的 3 项是 D4-1 / D4-2 / D4-3**(分别决定技术路线、迭代能否交付、以及本迭代最大回归面)。 + +### D4-1 ★ 队列实现方案 + +**推荐:DB 表轮询 + `FOR UPDATE SKIP LOCKED` + 租约列(方案 A),不引入 Redis、不引入 MQ。** + +理由:(1) 已评审目标模型 `patbond_postgresql.sql:605-716` 已备好 `lease_owner`/`lease_expires_at`/`next_attempt_at`/`attempt_count`/`max_attempts`/`priority` 六列与 `ix_generation_jobs_queue`/`ix_generation_jobs_running` 两条部分索引——换中间件等于让这批列作废并推翻已评审模型;(2) compose 实测**无 Redis 无 MQ**,B/C 都是净新增容器与运维面,与 ADR-002 为同样理由移除 Nacos 的判例一致;(3) 任务状态真值保持单一(Postgres),无 dual-write;(4) `development-plan.md:271` 已把「事务 Outbox」排到 M6。代价(轮询延迟、极大规模下轮询负载)在 MVP 量级不成立,且升级路径不改数据模型。 + +### D4-2 ★ 上游 AI 服务形态 + +**推荐:可插拔 `GenerationProvider`(两阶段 submit/poll)+ 真实字节 stub 实现(S1),M4 默认 stub。** + +理由:厂商未选型,S1 是本迭代唯一能端到端交付并验收的路径;用 JDK 内置 `javax.imageio`+Java2D 做可见变换**零新依赖**,且让「取入图 → 变换 → 写出图 → 写资产行 → 挂帖」整链走真实字节;两阶段是 L2 幂等(不重复扣费)能被测试覆盖的前提。接厂商时只增加一个实现类,调用方零改动。须按 ADR-022 纪律在三处显式标注 stub 为刻意占位。 + +### D4-3 ★ 是否把共享物上提 patbond-common + +**推荐:上提 4 件(JWT 资源侧校验、`UuidV7`、`RequestHashes`、存储适配层含新增 `putObject`/`getObject`),作为 M4 独立前置工单。退路:patbond-ai 各抄一份(M2)。** + +理由:新模块会把复制份数推到 `BearerAuthFilter` 4 份、存储/配置 4 份、`UuidV7` 3 份(§9.2 逐项取证,community 版 Javadoc 自称「Third copy」);而 M4 本来就要给存储适配层加两个原语——抄 4 遍 vs 抄 1 遍。且三份 `patbond.media` 配置类**已经开始漂移**(pet/community 版缺 `bucket`/`uploadTtl`)。风险是动到 v0.4.0 已发布路径,由 381 测试兜底,属行为保持型重构,建议单独提交单独过 CI。**这是本迭代最大回归面,需明确拍板。** + +### D4-4 生成输出写 `media.assets` 的归属(含 ADR-017 边界) + +**推荐:M1 —— patbond-ai 经上提到 common 的适配层直接写 `media.assets`;并新立一条 ADR 明确「服务端产出型资产由产出域写入,客户端上传流程仍归 patbond-user」。** + +理由:ADR-017 原文反对的是「业务模块被反向依赖」,上提 common 方向相反;`ai_output` 是服务端产物,本不属于「上传流程」;compose 已有给 pet/community 下发 MinIO 凭证的先例。备选 M3x(user 开 `/internal` 端点、字节走内部 HTTP)严格守 ADR-017 但多两个内部端点与一趟字节传输。**与 D4-3 强耦合**:若 D4-3 选退路 M2,则 D4-4 建议改选 M3x(避免第 4 份适配层还带新原语)。 + +### D4-5 配额与 429 是否进本迭代 + +**推荐:进,但只做创作域业务配额(Q1 派生计数,零新表):日配额 20、在途上限 2、429 + 42900 + `Retry-After`;取消退还配额。同时在 ADR 明确它不是 M6 的横切限流。** + +理由:M4 验收标准写明「不重复**扣费**」,而全项目无任何计费/额度表(§1.2 取证 40 张表),「扣费」只能落在配额语义上——配额是验收前置而非可选项;Q1 零迁移零新表,与 ADR-022「获赞走读侧聚合、不引入冗余列」判例同构。默认值需产品侧确认。 + +### D4-6 `ai_creation` 帖子是否强制包含 `output_asset_id` + +**推荐:强制。** `POST /api/v1/posts` 带 `generationJobId` 时,校验 (a) 任务属调用者且 `status='succeeded'`,(b) media 列表**必须包含** `job.output_asset_id`。 + +理由:社区侧挂帖**完全没有 purpose 校验**(实测 `MediaAssetRef.java:9-16` 与 `MediaAssetGateway` 的 SQL 均无 `purpose` 列,与 pet 侧 `PetService.java:196` 有校验形成对比)。不强制则用户可挂任意图片却标注 `generationJobId`,「AI 创作」变成可伪造标签。是否为社区**全域**补 purpose 白名单校验属独立议题,建议**不进 M4**(会改已发布写路径)。 + +### D4-7 风格预览图方案 + +**推荐:P1 —— V7 种子 `preview_asset_id = NULL`,契约 `previewUrl` nullable,客户端按 `code` 使用内置图片资源。** + +理由:Flyway 无法种子二进制(`preview_asset_id` 引用 `media.assets`,迁移时刻既无行也无对象);P2 引入三环境各做一次的不可复现手工步骤(违 ADR-006 精神);P3 走 external URL 引入外部依赖(M3.5 已因同理由推迟天气/位置)。**需与客户端 agent 对齐 `code` 契约**,并按 ADR-022 标注为刻意占位。 + +### D4-8 `model_version_snapshot` 取值来源 + +**推荐:V1x —— 由 provider 适配器 `version()` 提供,不给 `generation_models` 加 version 列。** + +理由:目标模型该列为 `NOT NULL` 但 `generation_models`(`:556-574`)无任何 version 列,存在不自洽(本次取证发现)。V1x 零 schema 偏离,且语义更准——决定输出的是适配器/厂商模型版本,不是目录行。 + +### D4-9 取消的可及范围 + +**推荐:X1 —— queued 与 running 均可取消**;worker 因条件 UPDATE 返回 0 行而自弃产物,并把已写的资产行置 `deleted` + 尽力删对象。 + +理由:目标模型 cancelled 分支(`:687-690`)本就不限制 `started_at`/`progress`/`output_asset_id`,允许 running 取消;stub 约 6s,X2(仅 queued 可取消)会让多数点击被 422 拒绝,UX 差且没省成本;X3(加 `cancel_requested` 列)偏离目标模型且无额外收益。 + +### D4-10 用户可见「失败重试」的形态 + +**推荐:R1 —— 客户端用新的 `Idempotency-Key` 重新提交,不设 retry 端点。** + +理由:R2(复活 failed 任务)需清空 `error_code`/`completed_at`/`attempt_count`,等于擦掉失败证据,与验收标准「失败原因可追踪」相抵,且在 `attempt_count <= max_attempts` 下语义混乱。R1 零新增端点、审计链每次尝试一行。**需与客户端 agent 对齐**(客户端要为「重试」生成新键,而非复用原键——复用原键会命中 L1 幂等返回那个已失败的任务)。 + +### 15.1 决策依赖关系 + +``` +D4-1(队列)── 独立,先拍 +D4-2(stub)── 独立,先拍 +D4-3(上提)──┬─► D4-4(写 media 归属):D4-3 选 M2 时,D4-4 建议改 M3x +D4-5(配额)── 独立(默认值需产品确认) +D4-6 / D4-7 / D4-10 ── 需与客户端 agent 对齐后落契约 +D4-8 / D4-9 ── 低风险,可随实现工单一并确认 +``` + +--- + + +## 16. 本报告推翻 / 修正的既有结论 + +按 M3.5 的教训(一份报告写「无 nickname 字段」实指「契约未暴露」,被误读成缺列需迁移,规模高估一整档),以下每条都注明**原始取证位置**与**误读会造成的后果**。 + +| # | 既有说法 | 实测 | 误读的后果 | +| --- | --- | --- | --- | +| 1 | 任务书:「四模块**字节级**快照锁 CI」 | **不成立**。CI(`.gitea/workflows/ci.yml`)只有 `check-secrets.sh --all` + `mvnw clean test`,**无任何 diff/校验和步骤**;POM 里也没有。「byte-identical」只是 Javadoc 措辞(`OpenApiContract.java:20`),实现只做 SnakeYAML 解析。真正的门禁是**结构断言**(版本串 + 32/45/75 三个计数 + 本域 tag 操作集)。四份快照当前确为字节相同,但那是纪律而非机制 | 以为「改 openapi 忘同步会被机器抓住」→ 只改 description 的变更四个模块**全都不会红**,契约漂移悄悄溜过 | +| 2 | 任务书:「输出写入 M3 已有的媒体表链路(**assets / purpose 白名单**),然后一键建社区草稿」 | **社区侧不存在 purpose 白名单校验**。`PostService.validateAssets:343-358` 只校验归属与 ready;`MediaAssetRef.java:9-16` 与 `MediaAssetGateway` 的 SQL **都不含 `purpose` 列**。purpose 白名单只存在于两处:客户端上传端点(`MediaProperties.java:66` + `MediaService.java:53-56`)与 pet 头像(`PetService.java:196`) | 以为挂帖有 purpose 闸门 → 不做 D4-6 的强制校验,「AI 创作」标签可被任意图片伪造 | +| 3 | 任务书:「M2 期间曾裁剪过跨 schema 外键……补 FK 前请核实当时的裁剪理由是否仍然成立」 | **裁剪理由已不成立,且补回是 V5 自己下的指令**。`V5:15-19` 与 `V3:14-19` 的原话都是「FKs into schemas **not yet migrated** are STRIPPED」——理由是「目标 schema 尚不存在」,**不是反对跨 schema 外键**。反证三条:`V1:262-264`、`V5:45-46`、`V5:107` 都保留了跨 schema 外键 | 若误读为「本项目政策上避免跨 schema 外键」→ 该补的 FK 不补,`generation_job_id` 永久裸列,且与目标模型漂移 | +| 4 | 任务书:「api 379 测试」 | **381**(采用协调方 Evidence Collector 实测;契约冻结报告自身写的是「379→381」,`releases.md` 未跟新) | 增量基数错 2,后续「测试数对不上」被当成丢测试排查 | +| 5 | 我自己的中途假设:「契约的 category 请求枚举已含 `ai_creation`,只需改 Java」 | **不成立**。请求侧 `CreatePostRequest:3657` 与 `UpdatePostRequest:3697` 的枚举都是 `[general, help]`,只有 description 提到预留;**响应侧** `Post:3747` 与 `FeedCard:3826` 才已含 `ai_creation` | 少算了 2 处契约枚举加宽,冻结时才发现 | +| 6 | 隐含预期:「M3 的存储适配层可直接供 worker 落地产物」 | **缺两个原语**。`ObjectStorage` 只有 `ensureBucket`/`presignPut`/`stat`/`presignGet`(全文 4 个方法),**无服务端 put、无服务端 get**。这是 D4-3/D4-4 存在的根因 | 排期时以为「媒体链路已就绪、直接调用即可」→ 低估工作量,且可能各模块自行硬写 S3 调用 | +| 7 | 「ADR-017 = media 写侧只能在 patbond-user」 | 原文(`decisions.md:156`)是「media **上传流程**实现在 patbond-user」,约束的是上传流程与反向依赖方向,**未涵盖服务端产出型资产**。这是一个需要新 ADR 澄清的边界,而非既有禁令 | 以为被 ADR 禁止 → 被迫选 M3x 并让图片字节多走一趟内部 HTTP,或误认为方案违规 | +| 8 | 「模型/风格目录可以先用配置文件,不建表」 | **不可能**。`generation_jobs.model_id uuid NOT NULL` 且为**复合外键** `(model_id, media_kind) REFERENCES generation_models(id, media_kind)`(`:610,644-647`),配置文件方案无行可指 | 按配置文件排期 → 进入实现才发现必须建表,V6 范围临时膨胀 | +| 9 | 「Worker 队列需要先选中间件」 | 目标模型**早已把 DB 队列的列与索引写完**(§2)。这题不是开放选型,是确认既有设计 | 花时间做中间件选型,并可能推翻已评审模型 | +| 10 | 任务书:「埋点白名单 42 条」 | **41 条**(采用协调方实测;`EventDictionary.java` 41 个 `Map.entry(`,且**无测试断言该数量**) | 数量对不上被当成漏加事件排查;另需注意该表**没有计数守卫**,加事件不会有测试提醒 | + +### 16.1 未取证的项(诚实标注缺口) + +| 项 | 缺什么 | +| --- | --- | +| 381 / 41 两个数字 | 我**未自行重跑** `./mvnw clean test`(按任务书授权跳过耗时命令),采用协调方 Evidence Collector 的实测值;`EventDictionary` 我实测到 41 个 `Map.entry(` 与之一致 | +| `patbond-doc/site/api/openapi.yaml` | 未确认它是 mkdocs 构建产物还是需手工同步的第二份副本。影响 §13.2 第 2 项该不该手改 | +| 真实厂商侧参数 | 无厂商 → 无法给出 `lease-ttl`、`max_attempts`、并发上限的实证配比;§3/§7 的默认值是工程估计,接厂商时须以实测重定 | +| 配额默认值(20 / 2) | 纯工程估计,**无产品侧输入**,需 D4-5 拍板时由产品确认 | +| 单机资源余量(R11) | 未实测服务器现有 CPU/内存余量,无法判断第七个容器 + Java2D 变换的实际压力 | + +--- + +> 本报告为**只读调研**产出:未修改任何生产代码,未编写迁移脚本,未改 `mkdocs.yml`,未执行任何 `git commit` / `push`。 + + + + + + + + diff --git a/docs/development/iterations/iteration-4/03-flutter-technical-assessment.md b/docs/development/iterations/iteration-4/03-flutter-technical-assessment.md new file mode 100644 index 0000000..1651a59 --- /dev/null +++ b/docs/development/iterations/iteration-4/03-flutter-technical-assessment.md @@ -0,0 +1,1670 @@ +# M4「AI 创作」客户端技术评估(Flutter) + +**角色**:Frontend Developer(Flutter) +**日期**:2026-09-14 +**基线**:patbond-flutter `dev@fbcd734`(tag `v0.4.0`),工作区干净 +**测试基线**:`flutter test` → **597 通过 + 2 skipped**(实测 `+597 ~2: All other tests passed!`,耗时 01:07) +**契约基线**:openapi.yaml **v1.4.0**,32 路径 / 45 操作 / 75 schema +**Flutter SDK**:3.44.6 stable(framework `ee80f08bbf`) + +> 路径约定:本报告中源码路径均为**仓库相对路径**(`patbond-flutter/...`、`patbond-api/...`、`patbond-doc/...`), +> 不写死本机绝对路径。行号以上述基线 HEAD 为准。 + +--- + +## 一句话结论 + +M4 客户端是**纯增量新建**——契约 v1.4.0 里 AI 面只有一个读侧枚举值 `ai_creation`, +零端点、零 schema、零流式基础设施;进度反馈**推荐自适应轮询**(不是 SSE/WebSocket), +因为现有网络层(信封 + 401 单飞刷新重放)与流式模型结构性不兼容,且后端 `SseEmitter` 零命中。 +create 页的 AI 模拟集中在 `patbond-flutter/lib/features/create/create_page.dart:55-129`(三段假延时 + 假结果 + 占位发布)。 + +--- + +## 目录 + +- [§1 结论摘要与关键数字](#1) +- [§2 现有可复用资产逐项取证](#2) +- [§3 create 页 AI 模拟代码精确定位](#3) +- [§4 进度反馈机制:轮询 vs SSE vs WebSocket](#4) +- [§5 任务中断恢复方案](#5) +- [§6 失败、重试与配额(429/Retry-After)的 UI 契约](#6) +- [§7 生成结果 → 社区草稿的复用面](#7) +- [§8 新增页面与 widget 清单](#8) +- [§9 状态管理方案](#9) +- [§10 埋点增量](#10) +- [§11 demo/占位盘点与「demo 消亡」清单](#11) +- [§12 历史遗留搭车判断](#12) +- [§13 测试增量估计](#13) +- [§14 风险清单](#14) +- [§15 需要用户拍板的决策](#15) +- [§16 我推翻或修正的既有文档结论](#16) + +--- + + +## §1 结论摘要与关键数字 + +| 项 | 数值 | 取证 | +| --- | --- | --- | +| 客户端测试基线 | **597 通过 + 2 skipped** | 实测 `flutter test --reporter compact` → `+597 ~2: All other tests passed!` | +| 客户端 dart 源文件 | 90 个 `lib/`,62 个 `test/` | `find . -name '*.dart'` | +| 契约版本 | v1.4.0(32 路径 / 45 操作 / 75 schema) | `patbond-doc/docs/api/openapi.yaml:4`;YAML 解析统计 | +| 契约中 AI 端点数 | **0** | 搜 `ai/generation/generate/task/job/sse/stream/quota/model/style` → 仅 4 处说明文字 + 1 个枚举值 | +| 后端 AI 实现 | **0 行** | `SseEmitter`/`text/event-stream`/`quota`/`AiTask`/`GenerationTask` 全 0 命中 | +| 后端埋点白名单 | **41 条**(不是 42) | `patbond-api/patbond-user/.../analytics/EventDictionary.java:42-104`,`grep -c "Map.entry("` = 41 | +| 客户端实际发出的事件 | **33 个** | `_track('…')` 28 个 + `auth_repository.dart` 直调 5 个 | +| 手工 E2E 场景 | 42(M1 7 + M2 11 + M3 14 + M3.5 10) | `test_e2e_m35_manual.dart:12-13` 自述 + 各脚本头部 | +| `integration_test/` 真机测试 | **4 份,无任何门禁运行** | 见下方「证据降级」 | +| 新增页面 | **6 个**(4 AI + 2 历史遗留搭车) | §8 | +| 预计测试增量 | 核心 AI +205~240 → **802~837**;含全部搭车 +295~330 → **892~927** | §13 | +| 待拍板决策 | **11 项** | §15 | + +### 证据降级声明(重要) + +仓库根有 4 份手工 E2E 脚本(`test_e2e_manual.dart` 298 行 / `test_e2e_m2_manual.dart` 777 行 / +`test_e2e_m3_manual.dart` 1373 行 / `test_e2e_m35_manual.dart` 1166 行),这些是 `dart run` 手动脚本, +发布门禁要求四份全跑——**这部分证据成立**。 + +但 `integration_test/` 下的 4 份真机测试: + +- `patbond-flutter/integration_test/client_ux_live_test.dart` +- `patbond-flutter/integration_test/feed_live_test.dart` +- `patbond-flutter/integration_test/profile_avatar_live_test.dart` +- `patbond-flutter/integration_test/publish_live_test.dart` + +**完全在 `flutter test` 之外**(`flutter test` 缺省只扫 `test/`),且**没有任何门禁会跑它们**。 +因此凡以「桌面/真机实测已验证」为依据的结论,本报告一律降级为「**有脚本,无门禁**」: +脚本存在证明设计上考虑过真链路,但不构成「链路已验证」的证据。受影响的具体结论: + +1. **媒体两步上传真链路(T3-13)**:`media_uploader.dart:571-577` 注释称「桌面真链路只替换选图与压缩两层, + 其余全为生产实现」——这条设计意图成立,但「实测通过」无门禁背书。M4 复用两步上传时应视为 + **单测覆盖充分、端到端未持续验证**。 +2. **`/api/v1/media/uploads` 归属 user:8082**:这条由 `community_repository.dart:71-74` 注释称 + 「T3-13 真链路实测修正」。所幸该结论**另有独立取证**:`openapi.yaml:199`(tag `media` 的 + description 写明 `patbond-user`)与 `:179`(server `127.0.0.1:8082` description 列出 `/api/v1/media/**`)。 + 故此结论**不降级**——它有契约层证据,不依赖真机脚本。 +3. **发布页真链路(T3-17)**:`publish_live_test.dart` 同样无门禁。发布链路的 widget 测试 + (`test/features/community/post_compose_page_test.dart`)在 597 之内,这部分成立。 + +**M4 建议**:AI 创作链路的长耗时特性使真链路验证比社区功能更关键(轮询、超时、中断恢复 +都无法只靠 widget 测试证明)。参见 §14 风险 R7 与 §15 决策 D4-F11。 + +--- + + +## §2 现有可复用资产逐项取证 + +### 2.1 网络层:分端口直连 + 信封 + 401 单飞刷新重放 + +`patbond-flutter/lib/core/network/api_client.dart`: + +| 资产 | 行号 | 复用判定 | +| --- | --- | --- | +| `patbondApiBaseUrl`(auth,默认 `:8081`) | 8-11 | 直接复用 | +| `patbondUserApiBaseUrl`(user,默认 `:8082`,`/me`+`/events`+`/media/**`) | 15-18 | 直接复用 | +| `patbondPetApiBaseUrl`(pet,默认 `:8083`) | 23-26 | 不涉及 | +| `patbondCommunityApiBaseUrl`(community,默认 `:8084`) | 31-34 | 直接复用 | +| `buildPatbondDio({session, baseUrl})` | 40-53 | 直接复用;**AI 服务若独立端口需加第 5 个常量** | +| `AuthInterceptor`(Bearer + `X-Device-Id`) | 57-76 | 直接复用 | +| `ApiClient.request(...)` 信封解包 + 401/40101 刷新重放一次 | 97-131 | 直接复用 | +| `_unwrap` 中 `status == 429 → ApiRateLimitException` | 164-167 | **需改造**:不读 `Retry-After` 头,见 §6 | + +**跨端口纪律核实(硬性要求)**:`patbond-flutter/lib/app/app.dart` 里每条跨模块调用都单独接线, +并在注释里写明理由——不存在「挂错端口」的悬空风险: + +- `_buildRepository()` 168-193:auth 走 `api`(`:8081`),`/api/v1/me` 单独接 `userApi`(`:8082`), + 注释 176-177 明确「`/api/v1/me` 由 user 服务(:8082)提供,auth(:8081)上没有该路由」。 + 这正是 M3.5 教训的落地物。 +- `_buildPetsRepository()` 195-206:`:8083`。 +- `_buildCommunityRepository()` 211-231:community 走 `:8084`(`api`), + **media 两步上传单独接 `mediaApi` → `patbondUserApiBaseUrl`(:8082)**(221-229)。 +- `_ensureRefresher()` 161-166:全部服务**共享同一个 `TokenRefresher`**,401 单飞刷新不会打成 N 份。 + +**M4 结论**:若 AI 生成端点落在**新服务(如 creation:8085)**,必须新增第 5 个 baseUrl 常量 + +第 5 条 `ApiClient` 接线,并复用同一个 `_sharedRefresher`。若落在 community:8084 或 user:8082, +则零接线改动。**这是 D4-F2 的直接依赖项**(见 §15)。 + +### 2.2 异常与错误码:类型化异常体系可直接扩展 + +`patbond-flutter/lib/core/network/api_exception.dart`: + +| 资产 | 行号 | 说明 | +| --- | --- | --- | +| `ApiCodes` 常量表(auth 7 + pets 8 + community/media 10) | 3-81 | AI 域新错误码在此追加 | +| `sealed class ApiException` | 86-94 | 密封基类;新增子类需同步全部 `switch` | +| `ApiNetworkException` | 97-99 | 复用 | +| `ApiBusinessException{code}`(非 final,可继承) | 103-108 | AI 域异常继承点 | +| `ApiRateLimitException` | 111-113 | **无 `retryAfter` 字段** → §6 改造点 | +| `SessionExpiredException` | 117-119 | 复用 | + +`patbond-flutter/lib/features/community/community_exceptions.dart` 是**领域异常升格的样板**: +10 个 `final class XxxException extends ApiBusinessException`(9-70)+ 一个 +`mapCommunityBusinessException(...)` switch(74-102)。`community_repository.dart:107-109` +在 `_request` 里统一 catch-and-map。**AI 域照抄这套结构即可**(新建 `creation_exceptions.dart`)。 + +### 2.3 仓库层:抽象接口 + Api 实现 + 幂等键调用方持键 + +`patbond-flutter/lib/features/community/community_repository.dart`: + +| 资产 | 行号 | 复用判定 | +| --- | --- | --- | +| `abstract class CommunityRepository`(19 操作全覆盖) | 12-61 | AI 域新建同构抽象 | +| `_request(path, method, body, query, idempotent, idempotencyKey, media)` | 87-110 | **直接照抄**(含 `Idempotency-Key` 与业务异常映射) | +| `createPost({idempotencyKey})` 调用方持键 | 143-155 | AI 提交生成任务应同样调用方持键 | +| `listMyPosts({limit, cursor, status})` → `/api/v1/me/posts` | 179-193 | **草稿列表页零后端工作** | +| `listMyBookmarks({limit, cursor})` → `/api/v1/me/bookmarks` | 274-283 | **收藏列表页零后端工作** | +| `getPost / updatePost / deletePost` | 158-176 | 结果建草稿链路复用 | +| `createMediaUpload / completeMediaUpload`(走 `mediaApi`) | 116-138 | 见 §7 复用判定 | + +**取证结论(确认既有说法)**:`patbond-flutter/lib/features/profile/profile_page.dart:43-46` 注释称 +「『我的收藏与草稿』的后端能力已就位(`/me/bookmarks`、`/me/posts`),但列表页本单未做」。 +**核实成立**——`listMyBookmarks`(273-283)与 `listMyPosts`(178-193)在仓库层已实现且 +在 597 测试内有覆盖(`test/features/community/community_repository_test.dart`)。 +缺的**只有两个页面**,不缺任何数据层。见 §12。 + +### 2.4 分页、模型与图片 + +- `patbond-flutter/lib/core/models/cursor_page.dart`:`CursorPage` 游标分页泛型 + (`community_repository.dart:192, 203, 218, 282` 四处消费)。AI 任务历史列表直接复用。 +- `patbond-flutter/lib/features/community/community_models.dart:8-13`:`_enumFromJson` + **未知值抛 `FormatException`**(12 行),刻意让契约漂移在测试期暴露。AI 模型照此纪律。 +- `patbond-flutter/lib/core/network/signed_network_image.dart`:`presignedImageCacheKey`(10-26) + 剥离 `X-Amz-*` 签名参数作稳定缓存 key,`SignedNetworkImage`(31-67)按剥签名 key 判等。 + **AI 结果图必然是预签名 GET URL(TTL 1 小时),必须走这个 provider**, + 否则轮询期间每次刷新都会重新下载整张大图。这是本迭代最容易漏的一条复用。 + +### 2.5 `MediaUploader`:长耗时多阶段编排器的现成范式 + +`patbond-flutter/lib/features/community/media_uploader.dart`(577 行)是**全仓最接近 AI 任务编排的资产**。 +AI 生成任务与媒体上传的形状高度同构(多阶段 + 进度 + 可重试失败 + 取消作废), +故本类的结构应被 `CreationController` 逐条对照借用: + +| 可借用的设计 | 行号 | 对 AI 任务的映射 | +| --- | --- | --- | +| `enum MediaItemPhase{queued,compressing,uploading,confirming,ready,failed}` | 17-24 | AI 任务态机(见 §9) | +| `@immutable MediaUploadItem` 不可变快照对外 | 27-65 | AI 任务快照 | +| **构造期断言绑定「唯一可交付态」**:`assetId != null` ⟺ `phase == ready` | 37-40 | AI `resultAssetId` ⟺ `succeeded`;从类型上杜绝未完成结果被引用 | +| 内部可变 `_UploadTask` 与对外快照分离;`cancelled` 旗标作废在途结果 | 68-103 | 轮询在途响应作废 | +| `attemptSeq`(从 1 起,retry 递增)+ `attemptStartedAt`(durationMs 口径) | 80-84 | AI 重试埋点同口径 | +| `buildAttachRequests()` **非全 ready 即抛 `StateError`** | 216-228 | 结果建草稿的孤儿防护 | +| 单飞槽位 `_acquireSlot/_releaseSlot`(`maxConcurrentUploads=2`) | 548-564 | AI 并发任务上限闸门 | +| 凭据过期预检 `credentialsSafetyMargin = 30s` | 170, 484-485 | AI 结果 URL 过期即重取 | +| `_failFromApi` 按错误码判定 `retryable`(40000 参数错→终态不可重试) | 487-506 | AI 失败可重试性判定 | +| `_reportCancelled`(在途被删按 `cancelled` 上报一条失败) | 531-540 | AI 取消口径 | +| `MediaUploaderFactory` typedef 注入口(测试/桌面替换选图压缩层) | 573-577 | AI 时钟与轮询器注入口 | +| `DateTime Function()? now` 时钟注入 | 138, 142, 153 | **轮询测试免真实等待的关键** | + +**判定:这是 M4 最高价值的复用资产,但是「结构复用」而非「代码复用」**—— +不应把 AI 任务硬塞进 `MediaUploader`(用途/阶段/交付物都不同), +而应新建 `CreationController` 并逐条对照上表。已有 `test/features/community/media_uploader_test.dart` +是配套的测试写法样板(含 `now` 注入 + `fake_async`)。 + +### 2.6 埋点:强类型封装 + 持久化队列 + 退避 + +| 资产 | 位置 | 复用判定 | +| --- | --- | --- | +| `AnalyticsService.trackEvent(name, props)`(永不抛、永不 await 网络) | `lib/analytics/analytics_service.dart:126-157` | 直接复用 | +| 本地隐私红线正则拦截(`password\|token\|secret\|phone\|...`) | 同上 288-295 | 直接复用 | +| 分段持久化队列(`shared_preferences`,cap 500,oldest-dropped) | `lib/analytics/analytics_event_store.dart:21-27` | 直接复用 | +| 满 20 条 / 30s 定时 / 退后台 / 冷启动四触发点 | `analytics_service.dart:39, 151, 194-213` | 直接复用 | +| 指数退避 30s→×2→封顶 5min(只挡定时冲刷) | 同上 46-47, 204-209, 241-250 | **轮询退避可照抄这套语义** | +| 强类型域封装样板(枚举锁死事件名与属性) | `lib/features/community/post_analytics.dart:1-239` | AI 域照抄 | +| `PostEntryPoint` / `DraftSaveTrigger` / `PostPublishFailureReason` 等枚举带 `.value` | 同上 19-81 | AI 域照抄 | +| `mediaSizeBucketOf`(分桶而非精确值,隐私红线 4) | 同上 110-116 | AI 分桶照抄 | +| `postPublishFailureReasonOf(ApiException)` 异常→原因映射 switch | 同上 91-105 | AI 域照抄 | +| `AnalyticsPageName` 编译期页名枚举 | `lib/analytics/analytics_page_name.dart:7-39` | **需追加 AI 页名** | +| `PageViewTracker` / `AnalyticsRouteObserver` | `lib/analytics/` | push 页自动曝光,AI 页零改动接入 | + +**注意 `AnalyticsPageName` 的先例**(`analytics_page_name.dart:5-6` 注释): +「尚不存在的 M2 页面(petList/petDetail/…)先留枚举定义、不接线」。 +M4 的 AI 页名同样可先登记枚举,但**要与后端 `EventDictionary` 的 `page_viewed` props +白名单对齐**(否则整条 rejected,见 §10)。 + +### 2.7 UI 组件:可直接复用清单 + +| 组件 | 位置 | 在 AI 链路的用途 | +| --- | --- | --- | +| `SectionCard` / `RemoteImage` / `TagPill` | `lib/widgets/common.dart` | 卡片壳与网络图 | +| `PrimaryButton` | `lib/core/widgets/primary_button.dart` | 「开始生成」CTA | +| `InlineErrorBanner` | `lib/core/widgets/inline_error_banner.dart` | 生成失败页内横幅(不用 SnackBar) | +| `UploadProgressOverlay`(六态→四视觉态) | `lib/core/widgets/upload_progress_overlay.dart` | **AI 任务卡进度覆盖层可扩展复用** | +| `EmptyStateIllustration` | `lib/core/widgets/empty_state_illustration.dart` | 「还没有创作」空态 | +| `FeedSkeleton` | `lib/core/widgets/feed_skeleton.dart` | 任务列表首载骨架 | +| `PostMediaEditGrid` | `lib/core/widgets/post_media_grid.dart:128-182` | 源图选择区(AI 图生图输入) | +| `AppTextField` | `lib/core/widgets/app_text_field.dart` | prompt 输入(若做) | +| `appLocalizationsDelegates` / `appLocale` | `lib/app/app_localization.dart` | 已挂 zh-CN,AI 页零改动 | +| `_UploadSummaryBar`(进度条 + 「n/N」) | `lib/features/community/post_compose_page.dart:673-704` | 形态可借(当前是 private) | + +**缺口(未取证部分)**:`lib/core/theme/app_theme.dart` **没有 `segmentedButtonTheme`,也没有 `chipTheme`** +(`grep -n "segmentedButton\|chipTheme"` → 0 命中)。AI 页大量用 `SegmentedButton` 与 `ChoiceChip` +选模型/风格/尺寸,会直接吃到这个主题债,见 §12.4。 + +--- + + +## §3 create 页 AI 模拟代码精确定位 + +**文件**:`patbond-flutter/lib/features/create/create_page.dart`(共 563 行) + +该文件已被前序迭代显式登记为 M4 替换目标——文件头 doc 注释(10-15 行)原文: + +> `/// **AI 生成模拟(700/650/500ms 假延时、风格/模型/分辨率设置、结果卡)` +> `/// 属 M4 范围,T3-17 原样保留**;社区发布半边自 T3-17 起改由真实发布页` +> `/// (PostComposePage,push 全屏)承担` + +`main_shell_page.dart:189` 侧的呼应注释:`// T3-17:发布半边已真实化(发布页 push),AI 生成模拟原样留 M4。` + +### 3.1 逐段定位:它现在假装做了什么 + +| # | 行号 | 代码 | 它假装做了什么 | 真相 | +| --- | --- | --- | --- | --- | +| M1 | **55-64** | `simulateUpload()` | 假装「读取宠物照片」 | **没有任何选图**。`await Future.delayed(700ms)` 后置 `uploaded = true`。真正显示的图是 `widget.appState.pet.avatarUrl`(162 行传入),即 demo 宠物「豆豆」的头像常量 | +| M2 | **66-92** | `generate()` | 假装四阶段 AI 生成 | 循环 `for step = 2..4` 各 `delay(650ms)`(77-81),再 `delay(500ms)`(82)→ 合计 **2.45 秒固定假延时**。无任何网络请求 | +| M3 | **86** | `resultUrl = selectedStyle.image` | 假装「生成结果」 | **结果就是所选风格卡自己的封面图**——`creationStyles[i].image`,即 `lib/data/demo_data.dart:206-233` 里 4 个硬编码 unsplash URL | +| M4 | **87-90** | 自动填标题/正文 | 假装 AI 生成文案 | 字符串拼接:`'豆豆的${selectedStyle.title}冒险'` / `'豆豆的 AI 萌宠短片'` + 固定正文常量 | +| M5 | **122-129** | `publish()` | 假装发布到社区 | **只弹一条 SnackBar**:`'AI 作品发布随 AI 创作能力上线(M4);发布普通动态请用上方「发布动态」'`。已无任何数据写入(demo `AppState.publishPost` 于 T3-17 退役) | +| M6 | **532-563** | `_GenerationProgress` widget | 四步进度清单 + 线性进度条 | `const labels = ['分析宠物特征','加载风格模型','生成画面细节','高清增强与合成']`(539 行)纯前端文案;`value: step / labels.length`(545)由假 step 驱动 | +| M7 | **421-493** | `_UploadCard` widget | 「选择宠物照片」上传卡 | 486 行副标题写死 `'演示模式会读取豆豆的档案头像'`——自己承认是演示 | +| M8 | **176-188** | 「创作模型」下拉 | 三个模型可选 | 硬编码 `['Patbond-V1', 'Pet-Art Pro', 'Cute Motion']`(179 行),无服务端字典 | +| M9 | **189-204** | 视频时长 / 分辨率 | 参数选择 | 硬编码 `['5 秒','10 秒','15 秒']`(193)、`['720P','1080P','2K']`(201);**选中值只存在于 `setState`,从不发送给任何人** | +| M10 | **205-211** | 「高清增强」开关 | 布尔参数 | `upscaling` 字段(40 行)**声明后除 UI 自身外零消费**——`grep upscaling` 只有 40/210 两处 | +| M11 | **138-157** | `SegmentedButton` AI 图片 / AI 视频 | 两种生成模式 | `CreationMode` 枚举(8 行)。视频路径与图片路径**走同一段假延时、同一张假结果图**(区别仅 87-89 行的标题文案) | +| M12 | **94-120** | `addTag()` 话题弹窗 | 添加话题 | 纯本地 `List tags`(46 行,初值 `['可爱修勾','AI宠物']`)。**契约无话题端点**——`post_analytics.dart:287` 已注明「话题无契约端点,M3 恒 0」 | +| M13 | **369-374** | 位置 `ListTile` | 「北京市 · 朝阳区」 | 写死字符串,`trailing` 有箭头但**无 `onTap`**,点了没反应 | + +### 3.2 状态字段的模拟性质 + +`_CreatePageState`(32-46 行)13 个字段,按 M4 后的去向分类: + +``` +mode (35) → 保留(但 D4-F9 若砍视频则退化为常量) +selectedStyle (36) → 保留,改由服务端字典驱动 +selectedModel (37) → 保留,改由服务端字典驱动 +duration (38) → 视频参数,随 D4-F9 定去留 +resolution (39) → 保留,改由服务端字典驱动 +upscaling (40) → 零消费死字段,删或接真参数 +uploaded (41) → 消亡(改为真实 PickedMediaImage / assetId) +uploading (42) → 消亡(改为 MediaItemPhase) +generating (43) → 消亡(改为 CreationTaskPhase) +generationStep (44) → 消亡(假 step 1..4 → 服务端 progress 或阶段枚举) +resultUrl (45) → 消亡(改为服务端 resultAssetId + 预签名 url) +tags (46) → 消亡(无契约端点) +titleController / contentController (33-34) → 移交 PostComposePage(§7) +``` + +### 3.3 会被保留的部分 + +**唯一真实的部分**是 `_ComposeEntryCard`(394-419 行)——顶部「发布动态」入口, +`onTap: widget.onOpenCompose` → `main_shell_page.dart:190` → `openCompose(PostEntryPoint.createTab)` +→ 真实 `PostComposePage`。这一段 T3-17 已真实化,**M4 不动**。 + +### 3.4 M4 对本文件的处置建议 + +`create_page.dart` 563 行中,约 **470 行属于模拟**(M1-M13), +其中 `_UploadCard`(421-493)、`_ChoiceRow`(495-530)、`_GenerationProgress`(532-563) +三个 private widget 可**改造后迁入** `lib/features/creation/`,其余整段删除。 +建议:**不在原地改,新建 `lib/features/creation/ai_create_page.dart`, +`create_page.dart` 缩为只含 `_ComposeEntryCard` + 新页入口的薄壳**—— +理由是原地改会让 diff 无法审查(470 行删除 + 400 行新增混在一个文件里)。 + +--- + + +## §4 进度反馈机制:轮询 vs SSE vs WebSocket + +### 4.1 先摆事实:两侧的流式基础设施都是零 + +| 事实 | 取证 | +| --- | --- | +| 契约无任何流式端点 | `openapi.yaml` 搜 `sse`/`stream`/`text/event-stream` → **0 命中** | +| 契约 `components.headers` 为空集 | YAML 解析 → `components.headers: []`;全契约 `headers:` 键 0 次出现 | +| 契约全部响应码 | `['200','201','202','400','401','403','404','409','422','423']`——**无 429** | +| 唯一 `202 Accepted` 端点 | `POST /api/v1/events`(埋点批量),**不是**异步任务 | +| 后端无 SSE 实现 | `patbond-api` 搜 `SseEmitter`/`text/event-stream` → **0 命中** | +| 后端无 WebSocket 实现 | 搜 `WebSocket`/`STOMP` → 未见(AI 相关代码整体 0 实现) | +| 客户端无流式依赖 | `pubspec.yaml` 无 `web_socket_channel`、无 `sse_channel`、无 `flutter_client_sse` | +| 客户端从未用过 dio 流式 | `grep -rn "ResponseType\|responseType" lib/` → **0 命中** | +| 无网关(ADR-002) | 分端口直连,`app.dart:159` 注释「三服务分端口直连(ADR-002 无网关)」 | + +**这决定了选项的真实成本**:SSE/WebSocket 不是「客户端选个库」的问题, +而是**两侧在同一个迭代内同时首建长连接基础设施**。 + +### 4.2 三选项对比 + +| 维度 | A. 自适应轮询 | B. SSE | C. WebSocket | +| --- | --- | --- | --- | +| **客户端新依赖** | 0(`dio` 已有) | 0(dio `ResponseType.stream`)但需**手写 `text/event-stream` 帧解析**(dio 不提供) | **需新增** `web_socket_channel` | +| **后端新基础设施** | 一个 `GET /…/{id}` | `SseEmitter` + 心跳 + 超时 + 连接数管理 | WS endpoint + 握手鉴权 + 会话注册表 + 心跳 | +| **能否复用 `ApiClient`** | **能,100%** | **不能**:流不走 `{code,message,data}` 信封,`_unwrap`(`api_client.dart:163-176`)完全不适用 | 不能 | +| **能否复用 401/40101 单飞刷新重放** | **能**(`api_client.dart:114-128`) | **不能**:长连接中途 token 过期无「重放一次」语义,需自建「断线→刷新→按 `Last-Event-ID` 续传」 | 不能,需自建 | +| **中断恢复(杀进程后)** | **天然满足**:重进页面就是一次 GET | 需额外一个「查当前状态」端点兜底——**等于还要做 A** | 同 SSE | +| **Android Doze / 退后台** | 不受影响(无长连接) | 进后台即断,回前台要重连 | 同 SSE | +| **将来加网关** | 无影响 | 反代缓冲会吃掉 SSE 帧,需专门配置(Nginx `proxy_buffering off`),届时返工 | 需 `Upgrade` 头透传配置 | +| **端到端可测性** | **高**:注入假时钟 + 桩仓库,`fake_async` 直测(已有依赖 `fake_async: ^1.3.3`) | 低:需起真 HTTP 流服务器 | 低 | +| **契约表达成本** | 零新机制 | 需给 OpenAPI 加 `text/event-stream` 媒体类型(3.0.3 表达能力有限) | OpenAPI 无法表达,需另开文档 | +| **服务器成本(单机 MVP)** | 图片生成 10~60s,2s 间隔 ≈ 5~30 次轻量 GET/任务 | 每任务一个挂起线程/连接 | 同 SSE | +| **首屏「有反馈」延迟** | ≤ 首个轮询间隔(1s) | 即时 | 即时 | + +### 4.3 推荐:**A. 自适应轮询**(并为 B 预留升级位) + +**推荐理由(按权重排序)**: + +1. **现有网络层与流式模型结构性不兼容**。客户端全部请求走 `ApiClient.request` + → 信封解包(`api_client.dart:163-176`)+ 401/40101 单飞刷新重放一次(114-128)。 + SSE 流两条都用不上,等于**旁路整条已建好的鉴权链,形成第二套鉴权路径**。 + M3.5 的 `/api/v1/me` 接错端口事故根因就是「跨模块调用没有走既有接线纪律」—— + 再开一条旁路只会重演。 +2. **后端零基础设施**。`SseEmitter` 0 命中意味着 M4 若选 SSE, + 后端要在同一迭代内同时做「AI 模型调用 + 任务表 + 配额 + 长连接层」四件新事。 + 这是 M4 最可能失控的地方(见 §14 R1)。 +3. **中断恢复是硬需求,而它天然要求「查状态」端点**。§5 论证:无论选哪种推送, + 都必须有一个「按 taskId 查当前状态」和「列我的进行中任务」的 GET。 + 一旦有了这两个 GET,轮询就已经免费到手;SSE 只是在其上叠一层优化。 + **换言之:A 是 B 的真子集,先做 A 不是走弯路。** +4. **可测性直接决定测试增量能否兑现**。轮询逻辑可用 `now` 注入 + `fake_async` + (`MediaUploader` 已有此范式:`media_uploader.dart:138,142,153`; + `analytics_flush_scheduler_test.dart` 已有 `fake_async` 用法)在纯单测里 + 把「1s→2s→3s 退避」「超时」「代次作废」全测掉,不需要真服务器。 +5. **已有退避语义可照抄**。`analytics_service.dart:46-47`(30s→×2→封顶 5min)、 + `204-209`(退避窗口内跳过定时冲刷、显式触发不受限)、`241-250`(`_scheduleBackoff`/`_resetBackoff`) + 是一套已被 597 测试覆盖的成熟语义。 + +### 4.4 推荐的轮询参数(建议值,待 D4-F1 拍板) + +``` +阶段化间隔(首屏快、后期省): + 第 1~3 次 : 1s → 短任务(缓存命中/失败快返)在 3s 内出终态 + 第 4~8 次 : 2s → 覆盖 10s 档 + 第 9 次起 : 3s → 覆盖 60s 档 + 上限间隔 : 5s + 硬超时 : 5min(可 --dart-define 覆盖)→ 超时置 failed(retryable),不无限轮 + +网络错误(ApiNetworkException)时:叠加指数退避 ×2,封顶 15s; + 连续 5 次网络失败 → 转「连接不稳定」提示 + 手动「重试」钮(不静默死循环) +429(配额/限流)时:读 Retry-After(§6),按其值等待;缺该头回落 30s +页面不可见(Tab 切走 / push 到别的页)时:降频到 10s(不停) +App 退后台时:停轮询(照 SessionTracker 的 onLeaveForeground 触发点) +回前台时:立即轮询一次(照 analytics 的 startPeriodicFlush 幂等模式) +``` + +**关键实现纪律(照抄 `CommunityController._generation`)**: +`community_controller.dart:118, 157, 163, 183, 189` 用「刷新代次」丢弃在途旧代次响应。 +轮询必须有同款守卫——用户切任务/取消/重试后,旧轮询的迟到响应一律作废, +否则会出现「已取消的任务把 UI 推回 running」。 + +### 4.5 若拍板选 B(SSE),客户端改造面清单 + +(备查,非推荐路径) + +1. 新建 `lib/core/network/sse_client.dart`:裸 `Dio` 实例(无 `AuthInterceptor`, + 照 `DioMediaDirectUploadClient` 先例——`media_direct_upload.dart:39-52` 是「独立 Dio 实例」的样板), + `ResponseType.stream` + 手写 `data:`/`event:`/`id:`/`retry:` 帧解析 + `\n\n` 分帧。 +2. Token 过期处理:不能复用 `ApiClient` 的「重放一次」。需自建 + 「流断 → `TokenRefresher.refresh()` → 带 `Last-Event-ID` 重连」,且要防重连风暴。 +3. 必须**同时**实现 A(查状态 GET)作为兜底:退后台断流、Doze、Wi-Fi 切蜂窝都会断。 +4. 契约需新增 `text/event-stream` 响应描述 + `components.headers` 首次引入。 +5. 测试需起真 `HttpServer`(`analytics_service.dart:255-257` 刻意用 + `@protected uploadBatch` 让测试子类替换以**避免**起真服务器——SSE 会把这条纪律破掉)。 + +**估算**:选 B 会让客户端测试增量再 +40~60,且引入一类难以确定性测试的时序 flake。 + +--- + + +## §5 任务中断恢复 + +### 5.1 现有持久化能力盘点 + +| 机制 | 位置 | 适用性 | +| --- | --- | --- | +| `flutter_secure_storage`(token / userId / deviceId) | `lib/features/auth/session_manager.dart:18-28, 43-48` | 仅凭据,不放业务态 | +| `shared_preferences`(AppState demo 宠物 + 天气) | `lib/state/app_state.dart:9-10, 23-45` | demo 家具,不宜扩张 | +| `shared_preferences` 分段队列(埋点,cap 500) | `lib/analytics/analytics_event_store.dart:21-27` | 埋点专用 | +| `shared_preferences` 稳定 `anonymousId` | `lib/analytics/analytics_service.dart:51, 177-190` | 埋点专用 | +| **预签名凭据刻意不持久化** | `media_uploader.dart:124` 注释「预签名凭据只存内存、用完即弃,不持久化(既有纪律)」 | **这是一条现存纪律,M4 必须遵守** | + +**关键观察**:`CommunityController` 是 Tab 级单例但**内存态**, +登出即 `reset()` 清空(`community_controller.dart:246-263`,理由是「避免上一账号数据跨会话泄漏」)。 +全仓**没有任何业务任务态被持久化**。这是刻意的(防跨账号泄漏,M3 第一波「防泄漏」交付物)。 + +### 5.2 三种恢复方案 + +| 方案 | 做法 | 优点 | 缺点 | +| --- | --- | --- | --- | +| **R-A 服务端事实来源** | 新增 `GET /api/v1/creations?status=queued,running`(我的进行中任务列表)+ `GET /api/v1/creations/{id}` | **跨设备一致**;杀进程、换设备、清缓存都能找回;零本地持久化 → 零跨账号泄漏风险;与「服务端是唯一事实来源」纪律一致(`community_controller.dart:20` 注释原文) | 需后端一个列表端点 | +| **R-B 本地持久化 taskId** | `shared_preferences` 存 `pb.creation.activeTaskIds` | 后端只需单个查询端点 | 换设备找不回;清缓存丢失;**引入跨账号泄漏面**(需在 `reset()` 里清,多一处易漏);本地与服务端可能不一致(任务已完成但本地还挂着) | +| **R-C 不做恢复** | 离页即忘 | 零成本 | 图片生成 10~60s,用户切走看别的是常态,「回来发现没了」是致命体验缺陷 | + +### 5.3 推荐:**R-A 服务端事实来源**(+ 极轻量本地提示) + +**理由**: +1. **与既有纪律一致**。`community_controller.dart:20` 明写「服务端是唯一事实来源,内存副本仅作展示缓存」。 + R-B 会开一个「本地也是事实来源」的口子。 +2. **零跨账号泄漏面**。M3 第一波专门做过「防泄漏」(`app.dart:242-249` 登出即 + `petsController.reset()` / `communityController.reset()` / `profileController.reset()`)。 + 持久化任务态就要在这里再加一处清理,且清理失败是静默的(安全事件 2026-09-11 的教训类型)。 +3. **R-A 的端点本来就要做**。§4.3 论证:任何推送方案都需要查状态端点。 + 列表端点只是多一个 query 参数级别的增量。 +4. **换设备场景真实存在**。用户在手机提交生成、去平板看,R-B 完全失效。 + +**建议的极轻量本地补充**(不违反上述纪律): +仅持久化一个 `int`——「上次离开时有 N 个任务在跑」,用于**冷启动首屏立刻显示「创作」Tab 角标** +而不必等首次网络返回。真值仍由 R-A 端点校正。**这个可选,D4-F2 里作为子选项。** + +### 5.4 恢复的三个具体入口(UI 层) + +1. **创作 Tab 角标**:`main_shell_page.dart:277-281` 的 `NavigationDestination`(创作 Tab) + 加 `NavigationDestination(icon: Badge(label: Text('$n'), child: Icon(...)))`。 + 进行中任务数 > 0 时显示。**这是最重要的一个**——它让用户知道「东西还在」。 +2. **AI 任务列表页**(§8 新页 3):进行中置顶 + 历史结果按时间倒序(游标分页复用 `CursorPage`)。 +3. **进创作 Tab 时自动恢复**:`AiCreatePage.initState` 拉一次进行中列表; + 若恰有 1 个在跑,**直接把页面渲染成进行中态**(照 `post_compose_page.dart:132` + `unawaited(_restoreLatestDraft())` + `_restoredBannerVisible` 提示条的先例, + 见 `post_compose_page.dart:194-220, 643-670`)。 + +### 5.5 与 App 生命周期的接线 + +`lib/analytics/session_tracker.dart` 已实现 `WidgetsBindingObserver` +(`app.dart:87-95` 装配,`onLeaveForeground` / `onEnterForeground` 两个回调)。 +`CreationController` 应挂同一套触发点:退后台停轮询、回前台立即轮询一次。 +**注意**:`SessionTracker` 当前只被 `_analytics` 消费(`app.dart:88-93`), +需要把它改成多订阅者,或给 `CreationController` 单独加一个 observer。 +建议后者(不动已被 597 测试覆盖的 `SessionTracker` 语义)。 + +--- + + +## §6 失败、重试与配额(429/Retro-After)的 UI 契约 + +### 6.1 现有三层错误呈现纪律 + +`api_exception.dart:83-85` 注释原文:「页面按类型映射为三层错误呈现 +(字段级 / 表单横幅 / SnackBar,见 12 号组装稿 §4),**服务端原始 message 一律不直接透出给用户**」。 +落地样板见 `community_display.dart`: + +| 函数 | 行号 | 形态 | +| --- | --- | --- | +| `feedLoadErrorMessage` | 30-34 | 首屏 error 态整页 | +| `postPublishErrorMessage`(7 分支,每条语义可辨) | 42-52 | **页内横幅**(`InlineErrorBanner`),刻意不用 SnackBar——「页内停留供对照」(`post_compose_page.dart:103`) | +| `draftSaveErrorMessage` | 55-59 | SnackBar | + +**M4 照抄**:新建 `lib/features/creation/creation_display.dart`,提供 +`creationSubmitErrorMessage` / `creationTaskFailureMessage` / `creationQuotaMessage`。 + +### 6.2 429 / Retry-After:契约与客户端双缺口(**本迭代首次引入**) + +**取证(两侧都空白)**: + +| 事实 | 取证 | +| --- | --- | +| 契约无 429 | 45 个 operation 的响应码全集 `['200','201','202','400','401','403','404','409','422','423']`,**429 不在其中** | +| 契约无 `Retry-After` | `grep -n "429\|Retry-After\|RateLimit\|限流" openapi.yaml` → **0 输出** | +| 契约无任何响应头机制 | `components.headers: []`,全契约 `headers:` 键 0 次出现 | +| 后端无限流实现 | `grep -rniE "429\|TOO_MANY_REQUESTS\|Retry-After\|RateLimit" patbond-*/src/main` → **0 命中** | +| 客户端**已能识别** 429 | `api_client.dart:164-167`:`if (status == 429) throw const ApiRateLimitException();` | +| 客户端**不读** `Retry-After` | 同上——`const` 构造,**丢弃了 `response.headers`** | +| `ApiRateLimitException` 无字段 | `api_exception.dart:111-113`:只有继承的 `message`,无 `retryAfter` | +| 已有 429 话术(但无时长) | `community_display.dart:32, 48, 57`:三处「请求过于频繁,请稍后再试」 | +| 埋点侧 429 的历史处置 | `analytics_service.dart:268-272` 注释原文:「429 按网络错误同路径处理(保段 + 指数退避):**后端限流尚未实现**(iteration-2/09 出入项),**Retry-After 分支待其落地后一并做**」 | + +**结论**:`analytics_service.dart:268-272` 那条「待后端落地后一并做」的历史挂账, +**M4 就是它的兑付时点**——AI 配额是本项目第一个真正需要 429 的场景。 + +### 6.3 429 改造面(客户端,3 处) + +1. **`api_exception.dart:111-113`** — `ApiRateLimitException` 加 `final Duration? retryAfter`: + ```dart + final class ApiRateLimitException extends ApiException { + const ApiRateLimitException([super.message = '请求过于频繁', this.retryAfter]); + final Duration? retryAfter; // 缺该头时为 null,调用方回落缺省等待 + } + ``` + **注意**:这是 `sealed class ApiException` 的子类,加可选位置参数**不破坏** + 现有 `switch (error) { ApiRateLimitException _ => ... }` 的 6 处模式匹配 + (`community_display.dart:32,48,57`、`post_analytics.dart:94`、 + `feed_analytics`/`pet` 域同款)——它们都用 `_` 通配,不解构字段。 +2. **`api_client.dart:163-167`** — `_unwrap` 读头: + `Retry-After` 按 RFC 7231 有**两种格式**(`delta-seconds` 整数 与 HTTP-date), + 必须两种都解析(只解析整数在服务端给日期时会静默回落 null)。 +3. **`creation_display.dart`(新建)** — 配额话术需区分两类: + - **限流**(短期太频繁):「操作太频繁,请 N 秒后再试」+ 倒计时禁用 CTA + - **配额耗尽**(当日/当月额度用尽):「今日免费额度已用完(N/N),明日 0 点重置」 + ——**这两类不能共用一条 429 话术**,否则用户会一直点重试。 + 区分方式待 D4-F6 拍板:建议靠**独立业务错误码**(如 `42901` 限流 / `42902` 配额耗尽), + 而不是靠 429 本身——因为 `ApiCodes` 已有五位码体系(`api_exception.dart:3-81`), + 且 `post_analytics.dart:180-182` 已有 `httpStatus = errorCode ~/ 100` 的推导约定。 + +### 6.4 失败与重试的 UI 契约(建议) + +| 失败类型 | 判定依据 | 可重试 | UI 形态 | 埋点 `failureReason` | +| --- | --- | --- | --- | --- | +| 提交时参数非法 | `ApiCodes.paramError`(40000) | ❌ 终态 | 字段级红字 | `validation_error` | +| 源图 asset 未 ready | `ApiCodes.mediaNotReady`(42203) | ✅ | 页内横幅「图片还没传完」 | `media_upload_incomplete` | +| 源图 asset 不符 | `ApiCodes.mediaNotFound`(40405) | ❌ | 页内横幅 + 重选图 | `not_found` | +| 限流 | 429 + 新码 | ✅ 定时 | 倒计时禁用 CTA | `rate_limited` | +| 配额耗尽 | 429 + 新码 | ❌ 今日 | 配额横幅 + 「了解额度」 | `quota_exceeded`(新) | +| 生成中服务端失败 | 任务终态 `failed` + `failureReason` | ✅ | 结果卡位置显示失败态 + 「重试」 | 服务端给的原因 | +| 内容安全拦截 | 任务终态 + 专有原因 | ❌ 终态 | 「内容不符合规范」不给重试 | `content_rejected`(**注**:`post_analytics.dart:43-44` 已记录此枚举值「待拍板未启用」) | +| 生成超时 | 客户端硬超时 5min | ✅ | 「生成超时」+ 重试 | `timeout`(新) | +| 网络失败 | `ApiNetworkException` | ✅ | SnackBar + 自动退避重试 | `network_error` | +| 会话失效 | `SessionExpiredException` | — | 自动回登录页 | **不上报**(`post_analytics.dart:95` 先例:`SessionExpiredException _ => null`) | + +**重试幂等纪律**:提交生成任务必须带 `Idempotency-Key` 且**调用方持键**—— +照 `post_compose_page.dart:32-34` 注释的纪律:「网络失败重试沿用同键(服务端命中首帖,不重复建帖); +表单一经改动即换新键(避免『同键异 payload』的 40905 常态化)」, +落地在 `post_compose_page.dart:98`(`_idempotencyKey` 字段)+ `:179-182`(`_markDirty()` 置 null) ++ `:267`(`_idempotencyKey ??= _uuid.v4()`)。 +**AI 生成尤其需要这条**——重复提交等于重复消耗配额和 GPU 成本。 + +--- + + +## §7 生成结果 → 社区草稿的复用面(逐项取证) + +### 7.1 逐项判定表 + +| # | 环节 | 现有资产 | 能否直接复用 | 取证与说明 | +| --- | --- | --- | --- | --- | +| 1 | 建草稿 `POST /api/v1/posts {status: draft}` | `createPost(request, {idempotencyKey})` | ✅ **可** | `community_repository.dart:143-155` | +| 2 | 迁移发布 `PATCH {status: published}` | `updatePost` | ✅ **可** | `community_repository.dart:164-171`;两步而非一步的理由见 `post_compose_page.dart:23-30` | +| 3 | 乐观锁冲突自动重提 | `_patchPublish` 捕 `PostVersionConflictException` → `getPost` 取新 version → 重提一次 | ✅ **可** | `post_compose_page.dart:303-328` | +| 4 | 幂等键调用方持键 + 改动换新键 | `_idempotencyKey` / `_markDirty()` | ✅ **可** | `post_compose_page.dart:98, 179-182, 267, 351-354` | +| 5 | 挂接媒体 `PostMediaAttachRequest{assetId, position, isCover, caption}` | 请求模型已定型 | ✅ **可** | `community_models.dart:150-169` | +| 6 | `media` 三态语义(null 缺席不动 / `[]` 清空 / 非空整组替换) | `UpdatePostRequest.media` | ✅ **可** | `community_models.dart:205`(注释)+ `:232` | +| 7 | 草稿恢复(最新一条) | `listMyPosts(limit:1, status: draft)` + 提示条 | ✅ **可** | `post_compose_page.dart:194-220`;提示条 widget `:643-670` | +| 8 | 发布漏斗五事件 + 媒体三段 | `PostAnalytics` | ✅ **可**(需加 AI 归因) | `post_analytics.dart:119-239`;`PostEntryPoint` 需加值,见下 | +| 9 | 发布 gating(正文非空 + 媒体全 ready + 无在途) | `_canPublish` | ⚠️ **需调整** | `post_compose_page.dart:159-163`——AI 结果不经 `_uploader`,`_uploader.isEmpty` 恒 true | +| 10 | 类目 `ai_creation` | 客户端枚举已有 | ⚠️ **读侧有、写侧被封** | 详见 7.2 | +| 11 | 媒体两步上传(选图→压缩→直传→confirm) | `MediaUploader` 全链 | ❌ **不适用** | 详见 7.3 | +| 12 | 服务端既有媒体的只读呈现 | `_draftMedia` + 提示文案 | ⚠️ **形态可借,语义要改** | `post_compose_page.dart:82, 621-636`——当前是「只读呈现,重新选图整组替换」,AI 场景是「结果图就是主体」 | +| 13 | 「保留草稿?」离页三选一 | `_onCancel` + `_ExitChoice` | ✅ **可** | `post_compose_page.dart:447-484, 641` | +| 14 | 「不保留」软删服务端草稿 | `_discardDraft` → `deletePost` + `postDeleted()` | ✅ **可** | `post_compose_page.dart:434-443` | +| 15 | 发布成功后回首页刷 Feed | `openCompose` 返回 `true` → `selectTab(0)` + `refresh()` | ✅ **可** | `main_shell_page.dart:145-163` | +| 16 | 话题 / 位置 | 无 | ❌ **无契约端点** | `post_analytics.dart:287-288` 注明「话题无契约端点,M3 恒 0」;位置 `post_compose_page.dart:612` 是 SnackBar 占位 | + +**复用率结论**:16 项里 **9 项直接可用、4 项需调整、3 项不适用**。 +核心的「建草稿 → 迁移发布 → 幂等 → 乐观锁 → 离页保留」整条骨架**完全可复用**, +这是 M3 留下的最大红利。真正的新工作集中在「AI 结果如何变成一个可引用的 asset」(7.3)。 + +### 7.2 `ai_creation` 类目:读侧已通、写侧被封(**关键取证**) + +| 层 | 状态 | 取证 | +| --- | --- | --- | +| 客户端枚举 | ✅ 三值齐备 | `community_models.dart:19-35`,`aiCreation('ai_creation')` 在 22 行;注释 18 行「M4 预留值,仅读侧出现」 | +| 契约读侧 `Post` | ✅ `[general, help, ai_creation]` | `openapi.yaml:3747`(+ `:3748` description) | +| 契约读侧 `FeedCard` | ✅ 三值 | `openapi.yaml:3826` | +| 契约写侧 `CreatePostRequest` | ❌ **仅 `[general, help]`** | `openapi.yaml:3657`;`:3659` description「`ai_creation` 为 M4 预留值,M3 不开放写入(提交 400/40000)」 | +| 契约写侧 `UpdatePostRequest` | ❌ 仅两值 | `openapi.yaml:3697` | +| 后端 DTO 校验 | ❌ 正则封锁 | `CreatePostRequest.java:26` / `UpdatePostRequest.java:33`:`@Pattern(regexp = "general\|help")` | +| **数据库** | ✅ **已放行三值** | `V5__community_baseline.sql:68`:`ck_posts_category CHECK (category IN ('general','help','ai_creation'))` | +| 后端测试 | 断言当前拒绝 | `PostLifecycleIntegrationTest.java:102-104` | +| 客户端发布页的当前处置 | 读到 `ai_creation` 回落 `general` | `post_compose_page.dart:209-211`:`_category = draft.category == PostCategory.aiCreation ? PostCategory.general : draft.category` | + +**M4 客户端影响**:**零模型改动**——`community_models.dart:193` 的 +`if (category != null) 'category': category!.wire` 已能序列化 `'ai_creation'`。 +只需后端放开两处 `@Pattern` + 契约两处 enum。 +但 **`post_compose_page.dart:209-211` 那个回落必须改**—— +否则 AI 草稿被恢复后类目会被悄悄降级为 `general`,是一个真实的数据损坏路径。 + +**额外发现(后端已预埋)**:`V5__community_baseline.sql:47-48` 已有裸列 `generation_job_id uuid` +(FK 剥离),`:95` 已有 `CREATE INDEX ix_posts_generation_job`,`:17` 注释指向 +`creation.generation_jobs(id) ON DELETE SET NULL`(M4 补回)。 +但**该列未出现在契约任何 schema 中**——`openapi.yaml:136` 与 `:3717` 明确声明 +`generationJob` 整体裁剪不出现。 +→ **客户端要不要知道「这个帖来自哪个生成任务」?** 这是 D4-F5 的一部分。 +建议:**读侧暴露 `generationJobId`**(让 AI 作品在详情页能显示「查看生成参数」), +写侧由服务端从建帖请求推导(客户端传 `generationJobId` 而非自己拼 media)。 + +### 7.3 媒体链路:**两步上传对 AI 结果整段不适用**(推翻 create 页注释的部分表述) + +`create_page.dart:122-124` 现有注释: +> `/// AI 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media` +> `/// asset),走不了两步上传,故不接真实发布链路` + +**核实:前半句成立,但「无 media asset」的表述会误导 M4 设计。** 精确表述应是: + +1. **两步上传确实不适用**。`MediaUploader` 的入口是 `PickedMediaImage` + (`media_uploader.dart:69` `final PickedMediaImage source`), + 管线为「压缩(`_compress` 325-357)→ createUpload(471-482)→ 预签名 PUT 直传(399-408)→ confirm(443)」。 + AI 结果图**在服务端生成,客户端手里没有字节**。让客户端下载再上传是荒谬的(双倍流量 + 双倍存储)。 +2. **但「无 media asset」是错的方向**。正确做法是**服务端在生成完成时直接建 `media.assets` 行**, + 任务结果里返回 `resultAssetId`,客户端**只引用**。 + 证据这条路是通的:`PostMediaAttachRequest` 只需要 `assetId`(`community_models.dart:158`), + 不关心 asset 是怎么来的;`MediaAsset.purpose` 在契约读侧 + (`openapi.yaml:3524-3526`)是**无 enum 约束的自由 string**,读侧向后兼容。 + +**因此 purpose 白名单是唯一的真实卡点**: + +| 事实 | 取证 | +| --- | --- | +| 契约写侧 purpose 三值 | `openapi.yaml:3460`:`enum: [post_image, user_avatar, pet_avatar]` | +| 后端白名单三值(应用层,不可配置外的默认) | `MediaProperties.java:66`;`application.yml:45` | +| DB 层无 CHECK 约束 | `V1__identity_media_baseline.sql:209`:`purpose varchar(32) NOT NULL`——**纯应用层白名单,加值无需迁移** | +| 客户端枚举三值 | `community_models.dart:67-75` | +| 「用途即引用侧类型检查」 | `community_models.dart:64-66` 注释:「引用时服务端校验 purpose 相符,故帖图不能当头像」;不符即 404/40405 | +| **契约内部已有不一致** | `openapi.yaml:1228` 端点 description 仍写「purpose 仅 post_image」,与 `:3460` 三值矛盾——M3.5 追加枚举时漏改。**若 M4 再加值,这处必须一并修** | + +**推荐(D4-F3)**:新增 purpose 值 `ai_image`,并在建帖引用校验里与 `post_image` **同等放行**。 +不复用 `post_image` 的理由:purpose 决定 objectKey 前缀与生命周期策略, +AI 生成图的清理/配额统计口径与用户上传图不同,混在一起将来无法分开。 + +### 7.4 结果 → 草稿的落地方式:复用 `PostComposePage` 还是新建 + +**推荐:复用 `PostComposePage`,扩展一个「预置服务端 asset」入口**(D4-F5)。 + +需要的改动(4 处,均在 `post_compose_page.dart`): + +1. **新增构造参数** `List? presetMedia` + `PostCategory? presetCategory` + + `String? generationJobId`(现有构造 38-46 行)。 +2. **`_canPublish`(159-163)需改**:当前条件 `(_uploader.isEmpty || _uploader.allReady)` + 在预置媒体场景下恒 true,但正文仍必填——这条不变; + 需补「预置媒体存在时不允许 `_uploader` 再加图」或允许混合(待 UI 规范定)。 +3. **`_mediaAttachOrNull()`(249-250)需改**:现在只从 `_uploader` 取, + 需变成 `presetMedia ?? (_uploader.isEmpty ? null : _uploader.buildAttachRequests())`。 +4. **`_restoreLatestDraft()`(194-220)的 `ai_creation` 回落(209-211)必须去掉**(见 7.2)。 + +**不新建 AI 专用发布页的理由**:`PostComposePage` 704 行里承载了 +幂等键管理、乐观锁重提、草稿恢复、离页三选一、发布失败但草稿已存的语义—— +这些都是 T3-17 踩过坑才写对的(`post_compose_page.dart:23-34` 注释记录了取舍)。 +复制一份 AI 版必然漂移。 + +--- + + +## §8 新增页面与 widget 清单 + +### 8.1 新增页面:**6 个**(4 AI + 2 历史遗留搭车) + +| # | 页面 | 建议路径 | 形态 | `AnalyticsPageName` | 说明 | +| --- | --- | --- | --- | --- | --- | +| 1 | **AI 创作页** | `lib/features/creation/ai_create_page.dart` | Tab 内联(替换 `CreatePage` 模拟半边) | `aiCreate('ai_create')` | 源图选择 + 模型/风格/尺寸 + 配额条 + 「开始生成」;有在途任务时渲染进行中态 | +| 2 | **AI 结果页** | `lib/features/creation/ai_result_page.dart` | push 全屏 | `aiResult('ai_result')` | 结果预览(可放大)+ 「重新生成」+ **「发布到社区」→ `PostComposePage`(预置 asset)** | +| 3 | **我的创作** | `lib/features/creation/ai_task_list_page.dart` | push 全屏 | `aiTaskList('ai_task_list')` | 进行中置顶 + 历史结果游标分页;**中断恢复主入口** | +| 4 | **配额/额度说明** | `lib/features/creation/ai_quota_sheet.dart` | `showModalBottomSheet` | 不登记(sheet 非路由) | 已用/剩余/重置时间;429 配额耗尽时的落地页 | +| 5 | **我的收藏** | `lib/features/community/my_bookmarks_page.dart` | push 全屏 | `myBookmarks('my_bookmarks')` | 搭车项;数据层已就绪(`listMyBookmarks`) | +| 6 | **我的草稿** | `lib/features/community/my_drafts_page.dart` | push 全屏 | `myDrafts('my_drafts')` | 搭车项;数据层已就绪(`listMyPosts(status: draft)`) | + +**页面数口径说明**:若 D4-F4 拍板「进行中独立成页」则为 7 个; +本报告推荐**进行中不独立成页**(内联在页 1),理由见 8.4。 + +### 8.2 新增 feature 目录文件(非页面) + +``` +lib/features/creation/ + creation_models.dart # 任务/结果/配额/模型字典/风格字典 请求响应模型 + creation_repository.dart # abstract + ApiCreationRepository(照 community 结构) + creation_exceptions.dart # AI 域错误码 → 类型化异常 + map 函数 + creation_display.dart # 错误话术 + 阶段文案 + 相对时间(照 community_display) + creation_controller.dart # 任务编排 + 轮询 + 代次守卫(照 MediaUploader/CommunityController) + creation_analytics.dart # 强类型埋点封装(照 post_analytics.dart) + ai_create_page.dart + ai_result_page.dart + ai_task_list_page.dart + ai_quota_sheet.dart +``` + +**命名建议 `creation/` 而非 `ai/`**:与后端已预埋的 schema 名一致 +(`V5__community_baseline.sql:17` 注释 `creation.generation_jobs`),跨仓一个词。 + +### 8.3 新增 / 改造 widget 清单 + +**新增(建议 6 个)**: + +| widget | 建议路径 | 用途 | 复用基础 | +| --- | --- | --- | --- | +| `GenerationProgressCard` | `lib/core/widgets/generation_progress_card.dart` | 排队/进行中卡:阶段清单 + 进度条 + 预计剩余 + 取消 | 改造 `create_page.dart:532-563` `_GenerationProgress`(去掉假 step) | +| `QuotaBanner` | `lib/core/widgets/quota_banner.dart` | 「今日剩余 N 次」/ 耗尽态 | 形态照 `InlineErrorBanner` | +| `StylePickerStrip` | `lib/features/creation/…` | 横滑风格卡(选中态描边) | 直接改造 `create_page.dart:218-292`(已是成品,只需换数据源) | +| `AiTaskTile` | `lib/features/creation/…` | 任务列表条目(缩略图 + 状态徽章 + 时间) | 参考 `PostCard` 结构 | +| `CountdownRetryButton` | `lib/core/widgets/…` | 429 倒计时禁用 CTA | 无现成,新写 | +| `AspectRatioChoiceRow` | 复用 `create_page.dart:495-530` `_ChoiceRow` | 尺寸/比例选择 | **已是成品**,提升为公共 widget | + +**改造(4 处)**: + +| 位置 | 改造内容 | 关联 | +| --- | --- | --- | +| `lib/core/widgets/upload_progress_overlay.dart` | 六态映射扩展到 AI 任务态(或新建同构 overlay) | §2.5 | +| `lib/features/main/main_shell_page.dart:277-281` | 创作 Tab 加 `Badge`(进行中任务数) | §5.4 | +| `lib/features/community/post_compose_page.dart` | 4 处改动(见 §7.4) | §7.4 | +| `lib/features/profile/profile_page.dart:235` | 收藏/草稿菜单项 `onTap` 从 `showDemoMessage` 改为真实导航 | §12.1 | + +### 8.4 为什么「进行中」不独立成页 + +1. **独立成页会与中断恢复冲突**。若进行中是一个 push 页,用户返回后这个页就没了, + 必须靠列表页找回——等于两条恢复路径。内联在创作 Tab 则「回到创作 Tab 就看到」。 +2. **`IndexedStack` 保活是免费的**。`main_shell_page.dart:266` + `IndexedStack(index: currentIndex, children: pages)`——Tab 切走时页面**不销毁**, + `State` 保留,轮询可继续(降频)。这是现成的保活机制。 +3. **`CreatePage` 已是 Tab 内联页**(`main_shell_page.dart:187-191`),改造成本最低。 + +**代价**:`AiCreatePage` 会同时承载「参数选择」与「进行中」两种态, +需要清晰的态机切换(见 §9),否则会长成第二个 704 行文件。 +建议把两态拆成两个 private widget,页面本体只做态分发。 + +### 8.5 路由与埋点接线 + +- 页 2/3/5/6 是 push 页,走 `MaterialPageRoute` + `settings: RouteSettings(name: …pageName)` + (照 `main_shell_page.dart:130-140` `openPost` 与 `:146-156` `openCompose` 先例), + `AnalyticsRouteObserver`(`app.dart:109-112, 317`)自动产生 `page_viewed`,**零额外埋点代码**。 +- 页 1 是 Tab 内联,需手动补点——照 `main_shell_page.dart:99-105` `_tabPages` 映射表。 + **但注意**:创作 Tab 当前映射到 `AnalyticsPageName.create`(`:100` 第 2 项)。 + 改造后是否换页名(`create` → `ai_create`)**需与数据侧对齐**—— + 换名会断掉历史趋势,`analytics_page_name.dart:97-98` 有先例记录 + (「档案 Tab 自 T2-12 起页名由 `pet_archive` 改报 `pet_list`」)。建议**保留 `create` 不换**。 +- 新页名必须同时进后端 `EventDictionary` 的 `page_viewed` props 白名单,否则整条 rejected(§10)。 + +--- + + +## §9 状态管理方案 + +### 9.1 沿用现有分层,不引入新框架 + +**现状取证**:全仓无 `provider` / `riverpod` / `bloc` / `get_it` +(`pubspec.yaml` 依赖仅 `shared_preferences` / `dio` / `flutter_secure_storage` / `uuid` / +`package_info_plus` / `image_picker` / `flutter_image_compress` / `flutter_localizations` / `intl`)。 +状态管理是**原生 `ChangeNotifier` + 构造注入 + `ListenableBuilder`/`AnimatedBuilder`**: + +| 层 | 实例 | 装配点 | +| --- | --- | --- | +| Tab 级单例 controller | `PetsController` / `CommunityController` / `ProfileController` | `app.dart:115-132`,构造注入进 `MainShellPage` | +| 页面级状态 | 各 `State`(如 `_PostComposePageState`) | 按页自建 | +| 编排器(页面生命周期) | `MediaUploader`(每次进发布页建一个,退出即 dispose) | `post_compose_page.dart:126-129, 140-148` | +| 抽象注入口(测试) | `App({sessionManager, authRepository, petsRepository, communityRepository, mediaUploaderFactory, avatarUploaderFactory})` | `app.dart:31-40` | + +**纪律原文**(`community_controller.dart:18-20`): +「Tab 级单例:Feed 是游标累积流,且详情页与首页共享同一份帖子内存副本,不做页面级 state。 +评论列表只属详情页,按『页面级状态按页自建』纪律经 repository 自取,不膨胀本控制器。 +服务端是唯一事实来源,内存副本仅作展示缓存。」 + +**M4 判定:不引入任何新状态管理库。** 理由:597 测试全部建立在 +「构造注入假实现 + `pumpWidget`」的模式上;引入 DI 容器要重写测试基建。 + +### 9.2 `CreationController`:Tab 级单例 + +**判定为 Tab 级单例(不是页面级)**,理由: +1. 进行中任务需跨 Tab 存活(用户切去首页刷 Feed,任务要继续轮询 + Tab 角标要更新)。 +2. 创作页、结果页、任务列表页**三页共享同一份任务内存副本**—— + 与 `CommunityController` 让首页 Feed 与详情页共享帖子副本同构(`community_controller.dart:120-121`)。 +3. 轮询定时器需要一个比页面更长的宿主。 + +**装配位置**:`app.dart` 与其他三个 controller 并列(`app.dart:115-132` 附近), +注入 `MainShellPage`(`app.dart:286-302` 参数列表)。 +**必须同时在 `_reportAuthStateChange`(`app.dart:242-249`)加 `creationController.reset()`**—— +否则跨账号泄漏(这是 M3 第一波「防泄漏」的既定纪律,漏一处就是安全缺口)。 + +### 9.3 任务态机 + +照 `MediaItemPhase`(`media_uploader.dart:17-24`)的写法,编译期枚举锁死: + +```dart +/// AI 生成任务阶段。服务端权威,客户端只做映射与呈现。 +/// 生命周期:submitting → queued → running → succeeded | failed | cancelled +enum CreationTaskPhase { + submitting, // 客户端本地态:请求已发出、taskId 未回 + queued, // 服务端已受理,排队中 + running, // 生成中(可带 progress 或阶段标签) + succeeded, // 有 resultAssetId(唯一可交付态) + failed, // 带 failureReason + retryable + cancelled, // 用户主动取消 +} +``` + +**关键不变式(照 `MediaUploadItem` 的构造期断言,`media_uploader.dart:37-40`)**: + +```dart +assert((resultAssetId != null) == (phase == CreationTaskPhase.succeeded), + 'resultAssetId 与 succeeded 态严格绑定(未完成结果不得被引用)'); +``` + +这条断言让「把未完成任务的结果发到社区」在类型层面不可能发生—— +与 `buildAttachRequests()` 非全 ready 即抛 `StateError`(`media_uploader.dart:216-219`)同一思路。 + +**对外快照不可变**:`@immutable class CreationTask`,controller 内部持可变 +`_CreationTaskState`(含 `cancelled` 旗标、`attemptSeq`、`attemptStartedAt`、`pollTimer`)。 + +### 9.4 页面态(`AiCreatePage`) + +``` +配置态(无在途任务) → 参数选择 UI + 「开始生成」CTA +在途态(1 个在途) → GenerationProgressCard + 取消 +在途态(多个在途) → 汇总条 + 「查看全部」→ 任务列表页 +恢复中(首帧拉列表) → FeedSkeleton 骨架(复用) +配额耗尽 → QuotaBanner + CTA 禁用 +``` + +### 9.5 必须实现的三条守卫 + +| 守卫 | 现有先例 | 为什么必须 | +| --- | --- | --- | +| **代次守卫** `_generation` | `community_controller.dart:118, 157, 163, 183, 189` | 取消/重试/切任务后,旧轮询的迟到响应必须作废,否则 UI 被推回错误态 | +| **`cancelled` 旗标** | `media_uploader.dart:78, 309, 317, 371` 等 12 处检查 | 任务被删除后在途请求结果一律丢弃 | +| **`_disposed` 旗标 + `_notify()`** | `community_controller.dart:123, 327-329, 331-335` | 轮询回调在 controller dispose 后触发 `notifyListeners` 会抛异常 | + +### 9.6 时钟与轮询器注入(测试可行性的前提) + +```dart +CreationController({ + required CreationRepository repository, + CreationAnalytics? analytics, + DateTime Function()? now, // 照 media_uploader.dart:138,142 + Duration Function(int attempt)? interval, // 轮询间隔策略,测试可注入常量 +}) +``` + +`fake_async: ^1.3.3` 已在 `dev_dependencies`, +`test/analytics/analytics_flush_scheduler_test.dart` 已有用法样板。 +**没有这两个注入口,轮询就只能用真实 `await Future.delayed` 测, +单测会慢到不可接受且 flake**——这是测试增量能否兑现的技术前提。 + +--- + + +## §10 埋点增量 + +### 10.1 权威白名单是 **41 条**,不是 42(修正) + +| 事实 | 取证 | +| --- | --- | +| 白名单唯一权威来源 | `patbond-api/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java:42`(`WHITELIST = Map.ofEntries(`),条目 43-103,闭合 104 | +| **精确条数 41** | `grep -c "Map.entry(" EventDictionary.java` → **41** | +| **不可配置** | `grep -rn "allowed-events\|allowedEvents\|eventWhitelist"` → **0 命中**——只能改 Java 源码 | +| 分域构成 | 11 auth + 1 page + 3 pet + 7 health_record + 8 post + 2 feed + 8 interactions + 1 experiment = 41 | +| **无任何 AI 事件** | 无 `ai_*` / `generation_*` / `creation_*` | +| 客户端实际发出 | **33 个**(`_track('…')` 28 个 + `auth_repository.dart:78,83,109,113,134` 直调 5 个) | +| 差额 8 个(白名单有、客户端未发) | `auth_register_started`、`auth_token_refresh_succeeded/failed`、`auth_session_restore_started/succeeded/failed`、`health_record_deleted`、`experiment_exposed` | + +**结论:任务书里「现有白名单 42 条」应更正为 41 条。** +`experiment_exposed`(`EventDictionary.java:103`)是 A/B 前置,M4 首用(非 AI 本身)。 + +### 10.2 客户端埋点实现位置 + +| 组件 | 路径 | +| --- | --- | +| 上报客户端(队列 + 冲刷 + 退避 + 隐私红线) | `lib/analytics/analytics_service.dart`(296 行) | +| 分段持久化队列 | `lib/analytics/analytics_event_store.dart` | +| 会话与前后台 | `lib/analytics/session_tracker.dart` | +| 页面曝光 | `lib/analytics/page_view_tracker.dart` + `analytics_route_observer.dart` | +| 页名枚举 | `lib/analytics/analytics_page_name.dart:7-39` | +| 域封装(5 个) | `features/pets/pet_analytics.dart`、`features/pets/health_record_analytics.dart`、`features/community/feed_analytics.dart`、`features/community/community_interaction_analytics.dart`、`features/community/post_analytics.dart` | +| 装配 | `app.dart:97-101`(`apiBaseUrl: patbondUserApiBaseUrl` → **:8082 正确**)、`:139-143` | + +### 10.3 `platform=linux` 桌面批量 400:根因取证(**推翻客户端注释**) + +**客户端注释的说法**(`lib/analytics/analytics_service.dart:99-102`): + +> `/// 平台标识。契约枚举为 android/ios;Web/桌面为开发调试形态,` +> `/// 上报值不在枚举内会被服务端`**`逐条 rejected`**`(不影响客户端),属预期。` + +**核实结论:这条注释是错的。** 实际是**整批 400,全批丢弃**。 + +| 环节 | 取证 | 说明 | +| --- | --- | --- | +| 客户端桌面上报 `'linux'` | `analytics_service.dart:101-112` `_platformName()`:非 web/android/ios 时 `return Platform.operatingSystem` → Linux 桌面得 `'linux'` | | +| 契约枚举两值 | `openapi.yaml:2373-2375`:`enum: [android, ios]`;`platform` 在 required(`:2328`) | | +| 后端**DTO 层** Bean Validation | `TrackEventsRequest.java:56-58`:`@NotNull` + `@Pattern(regexp = "^(android\|ios)$")` on `platform` | **关键** | +| **`@Valid` 级联到列表元素** | `TrackEventsRequest.java:16-19`:`@Valid @NotNull @Size(min=1,max=50) private List events;` | 任一元素校验失败 → `MethodArgumentNotValidException` → **整个请求 400** | +| 逐条 rejected 的逻辑在**更后面** | `AnalyticsService.java:49`(`unknown_event_name`)、`:55`(`identity_mismatch`)、`:65`(`forbidden_field`)、`:83`(`schema_invalid`) | 这四类**才是**逐条 rejected;`platform` 根本走不到这里 | +| 客户端把 4xx 当永久拒绝 | `analytics_service.dart:273-281`:`if (status >= 400 && status < 500) { …dropping ${events.length} events…; return true; }` | **整批静默丢弃** | +| 丢弃即删段 | 同上 `:229` `_store.removeSegments(batch.segmentIds, countAsDropped: rejected)` | 不重试、不保留 | + +**净效果**:Linux 桌面(以及 macOS/Windows/Web)跑 app 时, +**每一批埋点都在 DTO 校验层被整批 400,客户端随即整批删除**。 +桌面上的埋点是 **100% 丢失**,不是「逐条 rejected 的少量损耗」。 + +**M4 影响**:`integration_test/` 4 份真机脚本(本身已无门禁,见 §1)若在 Linux 桌面跑, +埋点断言无法成立。AI 链路的漏斗埋点若只在桌面验证,等于没验证。 + +**修复选项(D4-F8)**: +- **(a) 后端 `@Pattern` 加桌面值**:`^(android|ios|linux|macos|windows|web)$` + 契约 enum 同步 + `EventDictionary` 无关。 + 优点:桌面实测可验埋点。缺点:生产数据里混入开发平台,需在分析侧过滤。 +- **(b) 客户端桌面直接不上报**:`_platformName()` 返回非 android/ios 时, + `trackEvent` 直接 return(一行短路)。优点:数据干净、零后端改动。 + 缺点:桌面永远验不了埋点——**与 AI 长链路的验证需求冲突**。 +- **(c) 后端保留两值,但把 `platform` 校验从 DTO 层下移到逐条校验**: + 即在 `AnalyticsService` 里判 platform 并 `EventResult.rejected(…, "platform_invalid")`。 + 优点:让客户端注释描述的行为**变成真的**(逐条 rejected、不整批 400), + 桌面上其余字段仍走通全链路。缺点:改后端校验层次。 +- **推荐 (c) + (a) 组合**:把 platform 从 DTO `@Pattern` 移到逐条校验(消除整批 400 这个真实缺陷, + 这是**任何平台**的健壮性改进),同时加一个 `--dart-define` 门控的桌面 platform 映射 + (桌面实测时映射为 `android` 以验通全链路)。 + +**无论选哪个,`analytics_service.dart:99-102` 那条注释必须改**——它现在在误导后续所有人。 + +### 10.4 AI 创作链路需要的新事件(建议 9 条) + +命名沿用既有域前缀风格(`post_*` / `feed_*` / `pet_*`),新前缀 `creation_*`。 +**全部需要改 `EventDictionary.java`**(不可配置)。 + +| # | 事件名 | 触发时机 | props(建议) | +| --- | --- | --- | --- | +| 1 | `creation_started` | 进创作页并产生**首次参数改动或选源图**(每次进入记一次,照 `post_create_started` 口径 `post_analytics.dart:129`) | `entryPoint`(`create_tab`/`pet_detail`/`ai_task_list`) | +| 2 | `creation_submitted` | 提交生成**成功受理**(taskId 已回) | `modelId`、`styleId`、`aspectRatio`、`hasSourceImage`(bool)、`attemptSeq` | +| 3 | `creation_submit_failed` | 提交被拒 / 网络失败 / 配额耗尽 | `failureReason`、`attemptSeq`、`errorCode?`、`httpStatus?` | +| 4 | `creation_succeeded` | 任务终态 `succeeded` | `durationMs`(submitted→succeeded)、`modelId`、`styleId`、`queueWaitMsBucket` | +| 5 | `creation_failed` | 任务终态 `failed` / 客户端超时 | `failureReason`、`durationMs`、`modelId`、`attemptSeq`、`errorCode?` | +| 6 | `creation_cancelled` | 用户主动取消在途任务 | `phaseAtCancel`(`queued`/`running`)、`elapsedMsBucket` | +| 7 | `creation_result_viewed` | 结果页曝光(**注**:也可只靠 `page_viewed`,见下) | `modelId`、`styleId` | +| 8 | `creation_published` | AI 结果**成功发到社区** | `durationMs`(succeeded→published)、`fromTaskList`(bool) | +| 9 | `creation_quota_exhausted` | 配额耗尽横幅**首次呈现** | `quotaScope`(`daily`/`monthly`) | + +**可裁剪建议**:#7 与 `page_viewed{pageName: ai_result}` 重复, +建议**删掉 #7,靠 `page_viewed` 覆盖**(`AnalyticsRouteObserver` 自动产生,零代码)。 +→ 净新增 **8 条**,白名单 41 → **49**。 + +### 10.5 隐私红线复核(三条都要守) + +| 红线 | 出处 | AI 链路的落点 | +| --- | --- | --- | +| 内容 ID 不进 props | `post_analytics.dart:11-12`(红线 2:postId/assetId 一律不进 props) | **`taskId` / `resultAssetId` / `generationJobId` 一律不上报** | +| 精确数值只出分桶 | `post_analytics.dart:107-116` `mediaSizeBucketOf` | 排队时长、生成耗时用 `*Bucket`;`durationMs` 沿用 post 域先例(已放行精确毫秒) | +| 文件名/路径/URL 禁止 | `post_analytics.dart:12`(红线 4) | 源图与结果图的任何 URL 不上报 | +| 客户端本地正则拦截 | `analytics_service.dart:288-295`(`password\|token\|secret\|phone\|mobile\|email\|credential\|idfa\|gaid`) | prompt 文本若上报会被这条**放过**(不含敏感词)——**故 prompt 原文必须由纪律禁止,不能靠正则** | + +**新增红线建议**:**用户输入的 prompt 文本一律不上报**(只报长度分桶)。 +理由:prompt 可能含人名、地址、宠物医院名等。这条现有正则拦不住,必须写进 `creation_analytics.dart` 的 doc 注释锁死。 + +### 10.6 `page_viewed` props 白名单同步 + +`EventDictionary.java:59` 的 `page_viewed` 条目有 props 白名单(`pageName`/`referrer` 等)。 +新页名(`ai_create` / `ai_result` / `ai_task_list` / `my_bookmarks` / `my_drafts`) +**是 props 的取值而非键**,取值是否被白名单校验**未取证**—— +缺 `EventDictionary.java:59` 那一行的 props 集合具体内容与 +`AnalyticsService.java:83` `schema_invalid` 的判定范围。 +**需要与后端/数据侧确认**:pageName 取值是否有闭集校验; +若有,新页名必须同批加入,否则 `page_viewed` 整条 rejected。 + +--- + + +## §11 demo/占位盘点与「demo 消亡」清单 + +延续前三个迭代的「demo 消亡」口径(M2 消亡 demo 发布流、M3 消亡详情三页、M3.5 消亡首页问候语硬编码)。 + +### 11.1 M4 会让哪些占位消亡(**确定消亡**) + +| # | 占位 | 位置 | 消亡方式 | +| --- | --- | --- | --- | +| 1 | AI 生成三段假延时(700/650/500ms) | `create_page.dart:57, 78, 82` | 真实任务提交 + 轮询 | +| 2 | 假结果图 = 风格卡自己的封面 | `create_page.dart:86` | 服务端 `resultAssetId` | +| 3 | 假四步进度清单 | `create_page.dart:539` + `_GenerationProgress` 532-563 | 服务端阶段/进度 | +| 4 | 假上传(读 demo 宠物头像) | `create_page.dart:55-64` + `_UploadCard` 421-493(含 486 行「演示模式会读取豆豆的档案头像」) | 真实 `image_picker` + 两步上传 | +| 5 | AI 发布占位 SnackBar | `create_page.dart:122-129` | 真实 → `PostComposePage`(预置 asset) | +| 6 | 自动填标题/正文的字符串拼接 | `create_page.dart:87-90` | 删除(或服务端文案建议) | +| 7 | 硬编码模型列表 | `create_page.dart:179` `['Patbond-V1','Pet-Art Pro','Cute Motion']` | 服务端字典端点 | +| 8 | 硬编码分辨率/时长 | `create_page.dart:193, 201` | 服务端字典端点 | +| 9 | 死字段 `upscaling`(零消费) | `create_page.dart:40, 210` | 删除或接真参数 | +| 10 | 本地 `tags` 列表 + `addTag()` 弹窗 | `create_page.dart:46, 94-120, 348-367` | **删除**(契约无话题端点) | +| 11 | 写死位置「北京市 · 朝阳区」(无 `onTap`) | `create_page.dart:369-374` | 删除 | +| 12 | `creationStyles` demo 常量(4 个 unsplash URL) | `lib/data/demo_data.dart:206-233` | 服务端风格字典 | +| 13 | `CreationStyle` demo 模型类 | `lib/models/models.dart:285-297` | 替换为 `creation_models.dart` 中的契约模型 | +| 14 | 「我的收藏与草稿」演示提示 | `profile_page.dart:235` → `showDemoMessage`(`:81-85`) | 真实导航(**搭车项**,D4-F9) | + +**消亡后 `create_page.dart` 563 行 → 约 40 行**(只剩 `_ComposeEntryCard` + 新页入口)。 +`demo_data.dart` 的 `create` 相关消费点从 4 个减到 3 个 +(`grep -rn "demo_data.dart" lib/`:`home_page.dart:7`、`services_page.dart:3`、`app_state.dart:4`、 +`create_page.dart:3` → 后者消亡)。 + +### 11.2 M4 **不消亡**(刻意保留,已由 ADR-022 决策 D3.5-1 钉死) + +| 占位 | 位置 | 归属里程碑 | +| --- | --- | --- | +| 首页天气与定位(需外部服务 + API key + 配额) | `home_page.dart:583-584, 781`(「当前为演示天气」) | M5 | +| 首页服务商卡 demo 图 | `home_page.dart:684, 689` | M5 | +| 首页「柴犬圈 / 猫咪圈」话题圈 | `home_page.dart:930` | 无契约端点 | +| 首页促销卡「去使用」 | `home_page.dart:1003` | M5 | +| 首页搜索仅过滤已加载缓存(不发检索请求) | `home_page.dart:266` | 需检索端点 | +| 本地服务 Tab 全页 demo | `services_page.dart` + `demo_data.dart:235-245` `serviceCategories` | M5 服务域 | +| 「我的预约订单」「宠物健康卡包」「地址与定位管理」「设置与关于」 | `profile_page.dart:44-45, 51-55` | M5 / 待有可设项 | +| 「恢复演示数据」按钮 | `profile_page.dart:104-129, 247` | 随 `AppState` demo 家具收敛 | +| `AppState.pet`(首页问候卡 / 主壳头像仍消费的 demo 宠物) | `app_state.dart:12-19` | 「随后续工单收敛」(注释原文) | +| 通知按钮「暂无新通知 🐾」 | `main_shell_page.dart:254-259` | 需通知端点 | +| 发布页位置「位置功能即将上线」 | `post_compose_page.dart:612` | 无契约端点 | +| 详情页分享「分享功能即将上线」 | `post_detail_page.dart:446` | 待定 | +| 宠物表单头像本地占位(ADR-010) | `pet_form_page.dart:23, 398` | **注**:M3.5-09 已接宠物头像上传,此注释可能已过时,**未取证** | +| 登录页预留区(不渲染任何占位,ADR-004) | `login_page.dart:221` | 刻意不做 | + +### 11.3 一个需要注意的 demo 依赖 + +`create_page.dart:162` 把 `widget.appState.pet.avatarUrl` 传给 `_UploadCard` 作假图源。 +`AiCreatePage` 若要「用我的宠物照片生成」,**应改从 `PetsController` 取真实宠物头像** +(`pets_controller.dart`,Tab 级单例,`app.dart:115-117` 已装配), +而不是继续依赖 `AppState.pet` 这个 demo 家具。 + +**这是一个真实的产品机会**:宠物档案里已有真实头像(M3.5-09 落地, +`lib/core/widgets/pet_avatar.dart` + `avatar_upload_sheet.dart`), +AI 创作直接「选一只我的宠物」比「从相册选图」体验好得多,且省一次上传。 +→ 建议列入 D4-F4 的子选项:**源图支持「从我的宠物档案选」+「从相册选」双入口**。 +前者复用已 ready 的 `pet_avatar` asset,**零上传**(但需确认 purpose 校验是否放行 +`pet_avatar` 作为 AI 输入——见 §7.3 的 purpose 白名单卡点)。 + +--- + + +## §12 历史遗留搭车判断 + +### 12.1 「我的收藏与草稿」列表页 —— **强烈建议搭车** + +| 项 | 状态 | 取证 | +| --- | --- | --- | +| 后端能力 | ✅ 就绪 | 契约 `/api/v1/me/bookmarks`、`/api/v1/me/posts?status=draft` | +| 客户端仓库方法 | ✅ **已实现** | `community_repository.dart:274-283` `listMyBookmarks`;`:179-193` `listMyPosts({status})` | +| 已有测试覆盖 | ✅ 在 597 内 | `test/features/community/community_repository_test.dart` | +| 分页泛型 | ✅ 复用 | `CursorPage` / `CursorPage` | +| 列表卡片 widget | ✅ 复用 | 收藏页用 `PostCard`(`lib/core/widgets/post_card.dart`,返回 `FeedCard`,形态完全一致) | +| 入口 | ⚠️ 当前是演示提示 | `profile_page.dart:235` `onTap: () => showDemoMessage(context, item.$2)` | +| **缺什么** | **只缺 2 个页面** | 数据层、卡片、分页、错误话术全部现成 | + +**判定:搭车成本最低、价值最高的一项。** 且与 M4 有直接协同—— +AI 结果建成草稿后,用户需要一个地方找到它。**没有草稿列表页,AI 草稿就是黑洞。** +草稿页还需要「继续编辑」入口 → `PostComposePage`(需支持传入指定 draftId, +当前只能恢复「最新一条」,`post_compose_page.dart:194-199` `limit: 1`)。 + +**改造点**:`_restoreLatestDraft()` 需扩展为可选指定 draftId。 + +### 12.2 草稿自动保存 —— **建议不搭车(或仅做最小版)** + +| 项 | 状态 | 取证 | +| --- | --- | --- | +| 当前保存触发 | 仅 2 种:手动「存草稿」+ 离页确认 | `post_analytics.dart:31-41` `DraftSaveTrigger{manual, onExit}` | +| 埋点已预留纪律 | **「自动保存不埋」** | `post_analytics.dart:30` 注释原文「自动保存不埋,防高频」;`:133` 重申 | +| 幂等键管理会冲突 | ⚠️ | `_markDirty()`(`post_compose_page.dart:179-182`)在每次输入时置 `_idempotencyKey = null`。自动保存若在输入过程中触发,会不断换新键 → 每次自动保存建一个新草稿 | +| 乐观锁 version 竞态 | ⚠️ | 自动保存与手动保存并发会撞 40902(`_draftVersion` 单值,`post_compose_page.dart:79`) | + +**判定:风险大于收益。** 幂等键 + 乐观锁两套机制都是按「用户显式提交」设计的, +加自动保存需要重新设计「草稿 upsert」语义(debounce + 单飞 + version 串行化)。 +**若一定要做,最小版**:仅在「已有 `_draftPostId`」时做 debounce 30s 的 PATCH(不建新草稿), +避开建草稿的幂等键问题。**估计 +12~18 测试,且引入一类难测的时序竞态。** + +### 12.3 月份网格选择器 —— **建议搭车(低风险、已有真实误录事故)** + +| 项 | 状态 | 取证 | +| --- | --- | --- | +| 根因已归档 | ✅ 详细 | `lib/core/widgets/app_date_picker.dart:3-13`——原文记录「从 9 月回到 4 月要点 5 次箭头。**已实际导致误录**——用户把当月(2026-09)的就医记录记成了 2026-04-09,进而误判『本月花费 ¥0』是统计坏了」 | +| M3.5 的处置 | ⚠️ **绕开而非解决** | `pickAppDate`(`:54-79`)统一 7 处调用 + 保留手输切换;`AppDateFieldTrailing`(`:86-144`)加「今天」快捷键。注释 `:22-25` 说明「原生 `showDatePicker` 无法注入自定义动作(`builder` 只能包裹整个 Dialog,拿不到内部选中态),所以快捷键放在调用方表单行而非弹窗内」 | +| 收口面 | ✅ 单点 | 7 处调用已全部收口到 `pickAppDate` 一个函数 | +| 主题已定制 | ✅ | `app_theme.dart:215-296` `_datePickerTheme`(含 7 行色对对比度表) | + +**判定:收口已完成,改造面是单个函数,风险低。** +做法:自建一个 `MonthYearGridPicker`(年+月网格,两级), +在 `pickAppDate` 里作为可选入口(或替换 `showDatePicker`)。 +**注意**:完全自建会丢掉 M3.5-01 挂 zh-CN delegate 才拿到的「手输模式 + 格式校验」 +(`app_date_picker.dart:8-13` 记录了这条路「才真正走通」)—— +**必须保留手输**,否则是退步。估计 +15 测试。 + +### 12.4 `SegmentedButton` 粉底主题债 —— **必须搭车(AI 页是重灾区)** + +| 项 | 状态 | 取证 | +| --- | --- | --- | +| 主题**完全未定制** | ✅ 确认 | `grep -n "segmentedButton\|SegmentedButton\|ChoiceChip\|chipTheme" lib/core/theme/app_theme.dart` → **0 命中** | +| 回退路径 | `ColorScheme.fromSeed(seedColor: #FF6F4C)` 派生的 M3 调和色 | `app_theme.dart:88-92`;选中态吃 `secondaryContainer`(珊瑚橙派生出的粉/浅褐调),与品牌色脱节 | +| 使用点 6 处 | | `create_page.dart:138`、`pet_form_page.dart:426, 457`、`home_page.dart:385`、`services_page.dart:102`、`vaccination_form_page.dart:340` | +| `ChoiceChip` 使用点 | | `create_page.dart:519`(`_ChoiceRow`)、`post_compose_page.dart:596` | +| **根因与 M3.5-01 完全同构** | ✅ | `app_theme.dart:200-204` 记录 DatePicker 的同一根因:「此前未定制,`showDatePicker` 完全走 `ColorScheme.fromSeed` 由珊瑚橙 `#FF6F4C` 派生出的 M3 调和色(选中日为暗红棕实底),与全 app 品牌色脱节」 | +| **修复样板现成** | ✅ | `_datePickerTheme`(`app_theme.dart:215-296`)就是样板:复用已审计色对、不新造色值、附对比度表 | + +**判定:必须搭车。** AI 创作页是 `SegmentedButton` + `ChoiceChip` 最密集的页面 +(模型 / 风格 / 尺寸 / 图片-视频四组选择器)。不修就是把新页面直接建在债上。 +**修法**:照 `_datePickerTheme` 的纪律加 `segmentedButtonTheme` + `chipTheme`, +选中态用已审计的 `surfaceTint` 底 + `primaryDark` 字(7.98:1,`app_theme.dart:208` 已记录该色对来源)。 +**成本极低**(一个 theme 块),估计 +6~8 测试。 + +### 12.5 `widthPx`/`heightPx` 恒 null → 单图帖回落 4:3 —— **判定需修正(部分是客户端问题,部分不是)** + +**任务书的表述「widthPx/heightPx 恒 null 导致单图帖一律回落 4:3」不完全准确。** 逐层取证: + +| 层 | 事实 | 取证 | +| --- | --- | --- | +| 契约有字段 | ✅ `PostMediaItem.widthPx/heightPx` 与 `MediaAsset.widthPx/heightPx` 都存在且可空 | `community_models.dart:133-134, 143-144`(PostMediaItem);`:549-550, 563-564`(MediaAsset) | +| **详情页已正确消费** | ✅ **不是 4:3 硬编码** | `post_detail_page.dart:515-521`:`var ratio = 4/3; if (width != null && height != null && width>0 && height>0) { ratio = (width/height).clamp(1/1.33, 1/0.75); }` ——**有维度就用真比例,只在 null 时回落 4:3** | +| **Feed 卡片是硬编码 4:3** | ❌ **客户端缺陷** | `post_card.dart` 单图分支:`AspectRatio(aspectRatio: 4/3, ...)` ——`card.coverImage!.widthPx/heightPx` **可用但被完全忽略** | +| **`PostMediaGrid` 丢掉了维度** | ❌ **客户端缺陷** | `post_media_grid.dart:29` 入参只有 `final List urls`——维度在调用点就被丢弃;`_CollapsedCover`(`:313-367`)`:326-327` 硬编码 `aspectRatio: 4/3` | +| **客户端无法提供维度** | ⚠️ **契约缺口** | `CreateMediaUploadRequest`(`community_models.dart:468-489`)只有 `kind/purpose/mimeType/byteSize/sha256` ——**没有 widthPx/heightPx 字段**。客户端压缩后知道尺寸,但契约不收 | +| 服务端能否自己测量 | **未取证** | 需确认服务端 confirm 时是否读取对象并探测图片尺寸。若不读,则 `widthPx/heightPx` 无来源 → 恒 null | + +**修正后的准确表述**(三段,缺一不可): + +1. **契约缺口**:`CreateMediaUploadRequest` 无维度字段,客户端有维度也传不了。 + → 要么契约加字段(客户端压缩后填),要么服务端 confirm 时探测对象。 +2. **Feed 卡片客户端缺陷**:`post_card.dart` 与 `post_media_grid.dart:326-327` 硬编码 4:3, + **即使服务端将来给了维度也不会生效**。 +3. **详情页客户端已就绪**:`post_detail_page.dart:515-521` 逻辑正确, + 服务端一给维度就自动生效,**无需改动**。 + +**判定:建议搭车做第 2 段(客户端 2 处)**,成本低(+10 测试); +第 1 段是契约/后端决策,需 D4-F9 与后端对齐。 +**注意**:只做第 2 段而后端不给维度,用户感知**零变化**(仍恒 null 回落 4:3)—— +所以这一项必须两侧同批做,否则等于没做。这是本项最容易误判为「已修」的地方。 + +### 12.6 大图下滑关闭手势 —— **建议不搭车** + +| 项 | 状态 | 取证 | +| --- | --- | --- | +| 已明确记录为已知冲突 | ✅ | `post_detail_page.dart:877-878` 原文:「(03 号拍板 E:内置 `InteractiveViewer`,体验不达再升级 `photo_view`)… **下滑关闭与 `InteractiveViewer` 平移手势冲突,留待手势方案升级**」 | +| 当前实现 | `PageView.builder`(`:922`)+ `InteractiveViewer`(`:933`),零依赖 | | +| 冲突本质 | 缩放后的平移与「下滑关闭」抢同一个垂直拖拽 | 需自定义 `GestureRecognizer` 或换 `photo_view` | + +**判定:不搭车。** 这是纯体验优化,与 M4 主线无关; +正确解法是引入 `photo_view`(新依赖)或写自定义手势竞技场——两者都不该在 AI 迭代里做。 +**但 AI 结果预览页会遇到同一个问题**(结果图要能放大看细节)。 +**建议**:结果页**直接复用现有 `_GalleryPage`**(`post_detail_page.dart:877` 起), +承接同样的已知限制,不在 M4 引入第二套图片查看器。 + +### 12.7 搭车总建议 + +| 项 | 建议 | 测试增量 | 理由 | +| --- | --- | --- | --- | +| 12.1 收藏与草稿列表页 | ✅ **搭车** | +36 | 数据层已就绪;AI 草稿的落脚点,M4 强协同 | +| 12.4 SegmentedButton 主题债 | ✅ **搭车** | +7 | AI 页重灾区;成本极低;修法有样板 | +| 12.3 月份网格选择器 | ✅ **搭车**(若排期允许) | +15 | 已致真实误录;收口面单点 | +| 12.5 单图比例(客户端 2 处) | ⚠️ **条件搭车** | +10 | **必须与后端同批**,否则用户零感知 | +| 12.2 草稿自动保存 | ❌ **不搭车** | (+12~18) | 与幂等键/乐观锁语义冲突,需重新设计 | +| 12.6 下滑关闭手势 | ❌ **不搭车** | — | 需新依赖或自定义手势;结果页复用现有查看器即可 | + +--- + + +## §13 测试增量估计 + +### 13.1 历史增量参照(校准基准) + +| 迭代 | 收官测试数 | 增量 | 交付面 | +| --- | --- | --- | --- | +| M2 收官 | 272 | — | 宠物档案 + 健康记录页面族 | +| M3 收官 | 502 | **+230** | 社区五单(数据层 + Feed + 详情 + 互动 + 发布 + 媒体上传) | +| M3.5 收官 | 597 | **+95** | 本地化 + 日期共享层 + 资料页/编辑页 + 双头像 + 问候语 | +| **M4 目标** | **?** | 见下 | AI 创作链路 + 搭车项 | + +**校准结论**:M3 用 230 测试换了 6 个页面 + 1 个编排器 + 1 套数据层。 +M4 的核心 AI 面规模略小于 M3(4 页 vs 6 页,无 Feed/评论/互动), +但**轮询与任务态机的测试密度更高**(时序、退避、代次、恢复各成一组)。 + +### 13.2 核心 AI 链路增量(逐文件) + +| 测试文件 | 估计 | 覆盖要点 | +| --- | --- | --- | +| `test/features/creation/creation_models_test.dart` | 28 | 枚举严格解析(未知值抛 `FormatException`)、`fromJson`/`toJson` 往返、可空字段、边界 | +| `test/features/creation/creation_repository_test.dart` | 32 | 每端点路径/方法/query、`Idempotency-Key` 携带与持键、业务码→类型化异常映射、**端口接线断言** | +| `test/features/creation/creation_controller_test.dart` | **48** | 态机全迁移、轮询间隔阶梯、网络失败退避、硬超时、**代次守卫**(取消后旧响应作废)、`cancelled` 旗标、`_disposed` 守卫、并发上限、恢复(列表→在途态)、`succeeded ⟺ resultAssetId` 断言 | +| `test/features/creation/creation_display_test.dart` | 16 | 各异常→话术映射(含 429 两类区分)、阶段文案、相对时间 | +| `test/features/creation/creation_analytics_test.dart` | 18 | 8 事件的名与 props 逐一断言、分桶、**隐私红线**(taskId/assetId/prompt 不出现) | +| `test/features/creation/ai_create_page_test.dart` | 30 | 配置态/在途态/恢复中/配额耗尽四态渲染、参数选择、CTA gating、提交、取消 | +| `test/features/creation/ai_result_page_test.dart` | 24 | 结果渲染(`SignedNetworkImage`)、重新生成、**「发布到社区」→ 预置 asset 移交** | +| `test/features/creation/ai_task_list_page_test.dart` | 22 | 进行中置顶、游标分页、加载更多失败重试条、空态 | +| `test/core/network/api_client_retry_after_test.dart` | 12 | 429 两种 `Retry-After` 格式(delta-seconds / HTTP-date)、缺该头回落 null | +| `test/features/community/post_compose_preset_test.dart` | 16 | 预置 media 路径、`ai_creation` 类目不再回落、`_canPublish` 新条件 | +| `test/features/main/main_shell_creation_test.dart` | 10 | 创作 Tab 角标、导航接线、`reset()` 清理 | +| **核心小计** | **256** | | + +**保守区间**:部分测试会合并(如 display 与 analytics 可能各少 3~5 条), +`creation_controller_test` 若轮询策略简化可降到 38。 +→ **核心 AI 链路 +205 ~ +256**,取中 **+230**(与 M3 同量级,符合规模判断)。 + +### 13.3 搭车项增量 + +| 搭车项 | 估计 | 备注 | +| --- | --- | --- | +| 12.1 收藏页 + 草稿页(含指定 draftId 恢复) | +36 | 两页各 ~16 + 恢复改造 4 | +| 12.4 SegmentedButton/Chip 主题 | +7 | 主题断言 + 对比度回归 | +| 12.3 月份网格选择器 | +15 | 网格导航 + 手输保留 + 越界钳制 | +| 12.5 单图比例(客户端 2 处) | +10 | `post_card` + `_CollapsedCover` 各 5 | +| 10.3 platform 埋点修复(客户端侧) | +5 | 桌面映射门控 | +| **搭车小计(推荐集)** | **+73** | 不含 12.2 / 12.6 | + +### 13.4 汇总 + +| 方案 | 增量 | **预计总数** | +| --- | --- | --- | +| 仅核心 AI 链路 | +205 ~ +256 | **802 ~ 853** | +| 核心 + 推荐搭车集(12.1/12.4/12.3/12.5/10.3) | +278 ~ +329 | **875 ~ 926** | +| **规划建议值** | **+300** | **约 897** | + +**若 D4-F1 拍板选 SSE**,再 +40~60(流解析 + 重连 + 续传 + 兜底轮询), +且引入时序 flake 风险 → 总数 **约 950**,但**可靠性下降**。 + +### 13.5 手工 E2E 增量 + +现有 42 场景(M1 7 / M2 11 / M3 14 / M3.5 10), +`test_e2e_m35_manual.dart:12-13` 自述「本脚本只覆盖 M3.5 增量面,回归由前三份承担」。 +**M4 需新增 `test_e2e_m4_manual.dart`**,建议覆盖 **10~12 场景**: + +1. 提交生成 → 轮询到 succeeded → 结果 asset 可下载(逐字节校验,照 M3.5 场景 4 口径) +2. 同 `Idempotency-Key` 重放 → 命中首个任务,不重复消耗配额 +3. 同键异 payload → 40905 +4. 配额耗尽 → 429 + `Retry-After` 头存在且可解析 +5. 限流与配额耗尽的**两个错误码可区分** +6. 任务失败 → `failureReason` 在枚举内 + retryable 标记正确 +7. 取消在途任务 → 终态 `cancelled`,不可再转 running +8. 「我的进行中任务」列表:跨会话(新 token)仍能查到 +9. AI 结果建草稿 → `category=ai_creation` 写入成功(**验证写侧枚举已放开**) +10. AI 草稿 → 迁移发布 → Feed 可见且 `category=ai_creation` +11. 引用他人的 / 幽灵的 / 错 purpose 的结果 asset → 404/40405 +12. 引用未完成任务的 asset → 422/42203 + +**发布门禁**:M4 后需五份全跑(42 + 12 = **54 场景**)。 +**注意**:这些是 `dart run` 手动脚本,**不在 `flutter test` 内**, +与 `integration_test/`(无门禁)不同——手工脚本有门禁要求,需在发布清单里明确。 + +--- + + +## §14 风险清单 + +| # | 风险 | 严重度 | 依据 | 缓解 | +| --- | --- | --- | --- | --- | +| **R1** | **后端 AI 面零实现**,M4 需同时新建:模型调用 + 任务表 + `creation` schema + 配额 + 错误码 + 白名单事件。客户端全部工作**阻塞在契约定稿** | 🔴 高 | `patbond-api` 搜 `SseEmitter`/`quota`/`AiTask`/`GenerationTask` → 全 0;`creation` schema 不存在(`CommunityMigrationIntegrationTest.java:16,69,73,79` 断言无 FK 指向 `creation.generation_jobs`) | 契约先行;客户端先做**不依赖 AI 端点**的部分(搭车项 12.1/12.3/12.4、`Retry-After` 改造、demo 剥离) | +| **R2** | **429/`Retry-After` 是契约首次引入响应头机制**(`components.headers: []`,全契约 `headers:` 0 次) | 🟠 中高 | §6.2 取证 | 契约需同时补 `components.headers`;客户端 `ApiRateLimitException` 加字段(不破坏 6 处现有 `switch`) | +| **R3** | **`purpose` 白名单是引用侧的类型检查**,AI 结果图若不加 `ai_image` 则无法被帖子引用(404/40405) | 🟠 中高 | `openapi.yaml:3460` 三值;`community_models.dart:64-66` 注释「用途即引用侧类型检查」 | D4-F3 拍板;DB 层无 CHECK(`V1:209`),加值无需迁移,只改 `MediaProperties.java:66` + `application.yml:45` + 契约 | +| **R4** | **契约已有内部不一致**:`openapi.yaml:1228` 写「purpose 仅 post_image」vs `:3460` 三值枚举 | 🟡 中 | 直接取证 | M4 若加 purpose,这处描述必须一并修(否则第三次漂移) | +| **R5** | **`ai_creation` 写侧被封**,AI 建帖会 400/40000;且 `post_compose_page.dart:209-211` 会把恢复的 AI 草稿**静默降级为 `general`** | 🟠 中高 | `openapi.yaml:3657,3697`;`CreatePostRequest.java:26`;`post_compose_page.dart:209-211` | 后端放开两处 `@Pattern` + 契约两处 enum;客户端去掉回落。**这是一条数据损坏路径,不是纯功能缺失** | +| **R6** | **桌面埋点 100% 丢失**(整批 400),且客户端注释错误描述为「逐条 rejected」 | 🟠 中高 | §10.3 全链取证 | D4-F8;**注释必须改**,否则继续误导 | +| **R7** | **`integration_test/` 4 份真机测试无任何门禁**;AI 长链路(轮询/超时/恢复)恰恰是 widget 测试证明不了的 | 🟠 中高 | `flutter test` 缺省只扫 `test/`;无 CI 配置引用 `integration_test` | 手工 E2E 脚本(§13.5)承担;或给 `integration_test` 建门禁(本迭代范围外,需拍板) | +| **R8** | **轮询的电量/流量成本**在真机未量化;`connectTimeout: 5s` / `receiveTimeout: 10s`(`api_client.dart:45-47`)对轮询偏长,会让失败判定慢 | 🟡 中 | 直接取证 | 轮询用独立 `Dio` 实例(短超时 3s),照 `DioMediaDirectUploadClient` 先例独立配置 | +| **R9** | **`AiCreatePage` 有长成第二个 704 行文件的倾向**(同时承载参数选择 + 进行中 + 配额 + 恢复四态) | 🟡 中 | `post_compose_page.dart` 704 行是前例 | 两态拆 private widget,页面本体只做态分发(§8.4) | +| **R10** | **`sealed class ApiException` 加子类会要求同步全部 `switch`**;若 AI 域新增非 `ApiBusinessException` 的异常类型,会打断 6+ 处已有模式匹配 | 🟡 中 | `api_exception.dart:86` `sealed`;`community_display.dart:30-59`、`post_analytics.dart:91-105` 等 | AI 域异常**一律继承 `ApiBusinessException`**(照 `community_exceptions.dart` 全部 10 个的做法),不动密封层级 | +| **R11** | **无状态管理库 + Tab 级单例增至 4 个**,`app.dart` 的 `initState`(80-147)已 67 行,装配复杂度上升 | 🟢 低中 | 直接取证 | 可接受;但 `_reportAuthStateChange`(242-249)**必须加 `creationController.reset()`**,漏则跨账号泄漏 | +| **R12** | **`prompt` 文本的隐私风险拦不住**:本地正则(`analytics_service.dart:290-293`)不含 prompt 语义词 | 🟡 中 | 直接取证 | 纪律锁死「prompt 原文不上报,只报长度分桶」,写进 `creation_analytics.dart` doc 注释 | +| **R13** | **视频生成若进 M4**:`MediaKind` 只有 `image`(`community_models.dart:56-61`),视频要动契约枚举 + 播放器依赖 + 缩略图 + 时长;`MediaType.video`(`post_analytics.dart:61`)虽已备但未启用 | 🟠 中高 | 直接取证 | D4-F10 建议 **M4 只做图片**,视频 Tab **整段下线**(不留占位——留占位就是新增一个 demo,违背「demo 消亡」口径) | +| **R14** | **模型/风格字典若硬编码在客户端**,改一个风格要发版 | 🟡 中 | `create_page.dart:179` 现状即硬编码 | D4-F7 建议走服务端字典端点,**先例充分**:`pets_repository.dart:146-165` 已有 `listBreeds` / `listVaccineCatalog` 两个字典端点 | +| **R15** | **单图比例修复若只做客户端**,用户零感知(服务端仍不给维度) | 🟡 中 | §12.5 三段取证 | 两侧同批做,或本迭代不做 | + +--- + + +## §15 需要用户拍板的决策(11 项) + +> 编号沿用 `D4-F*`(F = Flutter/前端提出)。每项含**推荐**与**理由**。 +> 标 ⭐ 的三项是**最关键**——它们决定其余决策的形状。 + +### ⭐ D4-F1 长耗时任务的进度反馈机制 + +| 选项 | 说明 | +| --- | --- | +| **A. 自适应轮询**(推荐) | 阶段化间隔 1s×3 → 2s×5 → 3s,上限 5s,硬超时 5min;网络失败叠指数退避 | +| B. SSE | dio `ResponseType.stream` + 手写帧解析 + 断线重连 + 必须另做兜底轮询 | +| C. WebSocket | 新增 `web_socket_channel`,双向能力对单向进度过度设计 | + +**推荐 A。理由**(详见 §4): +1. 现有网络层围绕「信封 + 401 单飞刷新重放」构建(`api_client.dart:97-131, 163-176`), + SSE 两条都用不上 → 形成第二套鉴权旁路。M3.5 的端口事故根因正是「跨模块调用绕开既有接线纪律」。 +2. 两侧流式基础设施均为零(`SseEmitter` 0 命中、客户端 `ResponseType` 0 命中、无流式依赖); + M4 后端已有四件新事(模型调用/任务表/配额/schema),不宜再加长连接层。 +3. **A 是 B 的真子集**:任何推送方案都必须有「查状态」GET 作兜底(退后台/Doze/断网), + 有了它轮询已免费到手。先做 A 不是弯路。 +4. 可测性:`now` 注入 + `fake_async`(已有依赖)可确定性测全部时序;SSE 需起真 HTTP 流服务器, + 会破掉 `analytics_service.dart:255-257` 「用 `@protected` 替换以避免起真服务器」的既有纪律。 +5. 无网关(ADR-002)下 SSE 将来加反代要重做缓冲配置。 + +**若拍板 B**:客户端 +40~60 测试,引入时序 flake(§4.5 有改造面清单)。 + +### ⭐ D4-F2 任务中断恢复的事实来源 + AI 端点的服务归属 + +**两个子决策,必须一起拍**(后者决定客户端接线量)。 + +**(a) 恢复的事实来源** + +| 选项 | 说明 | +| --- | --- | +| **R-A 服务端**(推荐) | `GET /api/v1/creations?status=queued,running` + `GET /api/v1/creations/{id}` | +| R-B 本地持久化 taskId | `shared_preferences` 存活跃 taskId | +| R-C 不做恢复 | 离页即忘 | + +**推荐 R-A。理由**: +1. 与既有纪律一致——`community_controller.dart:20` 明写「服务端是唯一事实来源,内存副本仅作展示缓存」。 +2. 零跨账号泄漏面。全仓**没有任何业务任务态被持久化**(这是 M3 第一波「防泄漏」的刻意结果); + R-B 要在 `app.dart:242-249` 再加一处清理,漏则是安全缺口(参照 2026-09-11 安全事件的教训类型)。 +3. 换设备场景真实存在(手机提交、平板查看),R-B 完全失效。 +4. 该端点本来就要做(见 D4-F1 理由 3)。 + +**可选子项**:仅持久化一个 `int`「上次离开时在途任务数」用于冷启动首屏 Tab 角标,真值由端点校正。 + +**(b) AI 端点落在哪个服务** + +| 选项 | 客户端成本 | +| --- | --- | +| 落 community:8084 | **零接线改动** | +| 落 user:8082 | **零接线改动** | +| **新服务 creation:8085** | 需新增第 5 个 `baseUrl` 常量 + 第 5 条 `ApiClient` 接线(复用 `_sharedRefresher`) | + +**推荐:由后端按内聚性决定,但务必显式写进契约的 `servers` 与 tag description**—— +`openapi.yaml:179`(server 列出 `/api/v1/media/**`)与 `:199`(tag `media` 写明 `patbond-user`) +是 M3.5 端口事故后建立的好实践,AI 端点必须照办。 +**客户端只要求一件事:契约里写清归属,不要让客户端猜。** + +### ⭐ D4-F3 AI 结果图的 `purpose` 与引用侧放行 + +| 选项 | 说明 | +| --- | --- | +| **A. 新增 `ai_image`**(推荐) | 建帖引用校验中与 `post_image` 同等放行 | +| B. 复用 `post_image` | 零契约改动 | +| C. 客户端下载再两步上传 | 荒谬(双倍流量 + 双倍存储),仅列出以排除 | + +**推荐 A。理由**: +1. `purpose` 决定 objectKey 前缀与生命周期策略(`community_models.dart:64-66`); + AI 生成图的清理策略、配额统计、成本归因口径与用户上传图不同,混在一起将来无法分开。 +2. **加值成本极低**:DB 层无 CHECK 约束(`V1__identity_media_baseline.sql:209` 是裸 `varchar(32)`), + **无需迁移**——只改 `MediaProperties.java:66` + `application.yml:45` + 契约 `:3460`。 +3. 读侧天然兼容:`MediaAsset.purpose` 在契约(`openapi.yaml:3524-3526`)是**无 enum 的自由 string**。 +4. 客户端 `MediaPurpose`(`community_models.dart:67-75`)加一个枚举值即可。 + +**附带必做项**:修 `openapi.yaml:1228` 那句过时的「purpose 仅 post_image」(见 R4)。 +**附带子问题**:`pet_avatar` 能否作 AI **输入**源图(§11.3 的产品机会)? +建议放行——它是已 ready 的 asset,零上传体验最好。 + +### D4-F4 AI 创作的入口与页面形态 + +| 选项 | 说明 | +| --- | --- | +| **A. Tab 内联参数区 + 进行中内联 + 结果 push**(推荐) | 进行中不独立成页 | +| B. 全部 push 全屏(照 `PostComposePage` 先例) | 进行中独立成页 | + +**推荐 A。理由**(详见 §8.4): +1. `IndexedStack`(`main_shell_page.dart:266`)Tab 切走**不销毁 State**,是现成的保活机制,轮询可继续。 +2. 进行中若是 push 页,返回后就没了 → 必须靠列表页找回 → 两条恢复路径。 + 内联则「回创作 Tab 就看到」。 +3. `CreatePage` 已是 Tab 内联页(`main_shell_page.dart:187-191`),改造成本最低。 + +**子决策**:源图入口是「相册」单入口还是「我的宠物档案 + 相册」双入口? +**推荐双入口**——宠物头像已 ready(M3.5-09),选它零上传(依赖 D4-F3 子问题)。 + +### D4-F5 结果 → 社区草稿的落地方式 + +| 选项 | 说明 | +| --- | --- | +| **A. 复用 `PostComposePage` + 预置 asset 入口**(推荐) | 加 3 个构造参数,改 4 处 | +| B. 新建 AI 专用发布页 | 复制一份 | + +**推荐 A。理由**:`PostComposePage` 704 行承载了幂等键管理、乐观锁重提、草稿恢复、 +离页三选一、「发布失败但草稿已存」——这些都是 T3-17 踩坑后才写对的 +(取舍记录在 `post_compose_page.dart:23-34`)。复制必然漂移。 +改造面已列在 §7.4(4 处,均有行号)。 + +**子决策**:读侧是否暴露 `generationJobId`?后端 DB 已预埋该列 +(`V5__community_baseline.sql:47-48, 95`)但契约明确裁剪(`openapi.yaml:136, 3717`)。 +**建议读侧暴露**——让 AI 作品详情页能显示「查看生成参数」,是 AI 内容的天然卖点; +写侧建议客户端传 `generationJobId`,由服务端推导 media,而非客户端自拼。 + +### D4-F6 429 语义:限流与配额耗尽如何区分 + +| 选项 | 说明 | +| --- | --- | +| **A. 两个独立业务错误码**(推荐,如 `42901` 限流 / `42902` 配额耗尽)+ 429 + `Retry-After` | 客户端按码分两套 UI | +| B. 只用 429 + `Retry-After`,靠时长长短猜 | 无法可靠区分 | +| C. 配额耗尽用 403 | 与「无权限」语义混淆 | + +**推荐 A。理由**: +1. 两类的 UI 行为**根本不同**——限流给倒计时重试,配额耗尽给「明日重置」且**必须禁用重试**。 + 共用一条话术会让用户一直点(§6.3)。 +2. 项目已有五位业务码体系(`api_exception.dart:3-81`,27 个码), + 且 `post_analytics.dart:180-182` 已有 `httpStatus = errorCode ~/ 100` 的推导约定 —— `429xx` 天然吻合。 +3. `Retry-After` 仍需要(限流用),且是契约首次引入响应头(R2),需同时补 `components.headers`。 + +**客户端配套(必做)**:`ApiRateLimitException` 加 `Duration? retryAfter` +(`api_exception.dart:111-113`),`_unwrap` 读头并解析 **两种 RFC 7231 格式** +(`delta-seconds` 与 HTTP-date;只解析整数会在服务端给日期时静默回落 null)。 +**顺带兑付**:`analytics_service.dart:268-272` 那条「Retry-After 分支待后端落地后一并做」的历史挂账。 + +### D4-F7 模型 / 风格 / 尺寸清单的来源 + +| 选项 | 说明 | +| --- | --- | +| **A. 服务端字典端点**(推荐) | 如 `GET /api/v1/creation/models`、`/creation/styles` | +| B. 客户端硬编码 | 现状(`create_page.dart:179, 193, 201`) | +| C. 远程配置 | 需新基础设施 | + +**推荐 A。理由**: +1. **先例充分**:`pets_repository.dart:146-153` `listBreeds`、`:155-165` `listVaccineCatalog` + 已是两个字典端点(契约内 `/api/v1/breeds`、`/api/v1/vaccine-catalog`), + 数据播种在 `V4__pet_health_dictionary_seed.sql`。照抄这套即可。 +2. 硬编码意味着「上/下线一个风格要发版」——AI 模型迭代频率远高于 app 发版频率。 +3. 风格卡需要封面图,硬编码就是继续挂 unsplash 外链(`demo_data.dart:206-233` 现状)。 + +### D4-F8 桌面埋点整批 400 的修复方式 + +| 选项 | 说明 | +| --- | --- | +| a. 后端 `@Pattern` 加桌面值 + 契约 enum 同步 | 生产数据混入开发平台 | +| b. 客户端桌面直接不上报 | 桌面永远验不了埋点 | +| **c. platform 校验从 DTO 层下移到逐条校验**(推荐) | 消除「整批 400」这个真实缺陷 | +| **c + 门控映射**(推荐组合) | 再加 `--dart-define` 门控把桌面映射为 `android` 以验通全链路 | + +**推荐 c + 门控映射。理由**: +1. **「整批 400」本身是缺陷,与平台无关**——`TrackEventsRequest.java:16` 的 `@Valid` 级联 + 意味着**任何一条事件的任何一个字段**校验失败都会让整批 50 条被拒, + 客户端随即整批删除(`analytics_service.dart:273-281`)。这是一个**毒丸批次放大器**。 + 把逐条可判定的字段下移到 `AnalyticsService` 的逐条校验(那里已有 + `unknown_event_name`/`identity_mismatch`/`forbidden_field`/`schema_invalid` 四类, + `AnalyticsService.java:49,55,65,83`)才是正解。 +2. AI 长链路的漏斗埋点需要能在开发机上验通(R7:真机测试无门禁)。 +3. 数据侧不受污染(生产仍只接受 android/ios)。 + +**必做的附带项**:`analytics_service.dart:99-102` 那条注释**必须改**—— +它现在声称「逐条 rejected(不影响客户端)」,与事实相反,会继续误导后续所有人。 + +### D4-F9 历史遗留搭车范围 + +| 项 | 推荐 | 测试 | +| --- | --- | --- | +| 收藏与草稿列表页 | ✅ 搭车 | +36 | +| SegmentedButton/Chip 主题债 | ✅ 搭车 | +7 | +| 月份网格选择器 | ✅ 搭车(排期允许) | +15 | +| 单图比例(客户端 2 处) | ⚠️ **仅在后端同批给维度时搭车** | +10 | +| 草稿自动保存 | ❌ 不搭车 | — | +| 大图下滑关闭手势 | ❌ 不搭车 | — | + +**理由概要**(详见 §12):前两项数据层/样板已现成、成本低、与 M4 强协同 +(草稿页是 AI 草稿的落脚点;主题债是 AI 页重灾区)。 +月份选择器已致真实误录(`app_date_picker.dart:3-13` 记录了误录事故),收口面单点。 +单图比例**必须两侧同批**否则用户零感知。 +自动保存与幂等键/乐观锁语义冲突(`post_compose_page.dart:179-182` vs 需要 debounce upsert)。 +下滑手势需新依赖或自定义手势竞技场,且结果页可直接复用现有查看器。 + +### D4-F10 AI 视频是否进 M4 + +| 选项 | 说明 | +| --- | --- | +| **A. 只做图片,视频 Tab 整段下线**(推荐) | 不留占位 | +| B. 只做图片,视频 Tab 留「即将上线」占位 | 新增一个 demo | +| C. 图片 + 视频都做 | 需视频链路全栈 | + +**推荐 A。理由**: +1. `MediaKind` 只有 `image`(`community_models.dart:56-61`,注释「M3 仅 image,视频后置」), + 视频要动契约枚举 + 播放器依赖 + 缩略图 + 时长 + 转码 —— 是一个独立里程碑的量。 +2. **不留占位**是关键:本项目每个迭代都以「demo 消亡」为产出口径, + 在消亡 13 处占位的同一个迭代里新增一处占位,是自相矛盾的。 + 现有 `CreationMode.video`(`create_page.dart:8`)与视频时长选择(`:189-197`)应整段删除。 +3. `MediaType.video`(`post_analytics.dart:59-66`)枚举已备,将来启用零成本,不必现在占坑。 + +### D4-F11 AI 长链路的验证门禁 + +| 选项 | 说明 | +| --- | --- | +| **A. 新增 `test_e2e_m4_manual.dart`(10~12 场景),纳入发布门禁**(推荐) | 沿用现有四份的模式 | +| B. 给 `integration_test/` 建 CI 门禁 | 范围外,需新基础设施 | +| C. 只靠 widget 测试 | 轮询/超时/恢复证明不了 | + +**推荐 A。理由**: +1. `integration_test/` 4 份**当前无任何门禁**(§1 证据降级),把它变成门禁是独立工程。 +2. 手工 E2E 脚本已有成熟模式与门禁要求(4 份 / 42 场景),M4 加第五份是最短路径。 +3. AI 链路有若干**只能在真链路暴露**的问题:轮询到终态、幂等键不重复消耗配额、 + `Retry-After` 头真的存在、`ai_creation` 写侧真的放开了、结果 asset 真的能被引用。 + §13.5 已列 12 个候选场景。 + +**附带建议**:把「`integration_test/` 无门禁」这一事实**显式写进 M4 的遗留清单**—— +它是一个长期被当作证据使用的空头承诺,应当或补门禁、或降级为「开发辅助脚本」并改名。 + +--- + + +## §16 我推翻或修正的既有文档结论 + +按「影响规模预估的程度」排序。每条都有原始源码取证。 + +### 16.1 【修正数字】埋点白名单是 **41 条**,不是 42 条 + +- **任务书原文**:「现有白名单 42 条」 +- **实测**:`patbond-api/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java:42-104`, + `grep -c "Map.entry(" ` → **41** +- **构成**:11 auth + 1 page + 3 pet + 7 health_record + 8 post + 2 feed + 8 interactions + 1 experiment = 41 +- **附带发现**:白名单**不可配置**(`grep -rn "allowed-events\|allowedEvents\|eventWhitelist"` → 0 命中), + 只能改 Java 源码。AI 新事件必然需要后端改代码,不能靠配置下发。 +- **另附**:客户端实际只发 **33** 个事件;白名单里有 8 个客户端从未发出 + (`auth_register_started`、`auth_token_refresh_*`、`auth_session_restore_*`、`health_record_deleted`、`experiment_exposed`)。 + +### 16.2 【推翻】「桌面埋点逐条 rejected,不影响客户端」——实际是**整批 400、全批丢弃** + +- **被推翻的原文**(`patbond-flutter/lib/analytics/analytics_service.dart:99-102`): + 「上报值不在枚举内会被服务端**逐条 rejected**(不影响客户端),属预期」 +- **实际机制**(全链取证): + 1. `TrackEventsRequest.java:56-58`:`platform` 上有 `@Pattern(regexp="^(android|ios)$")`, + 这是 **DTO 层 Bean Validation**。 + 2. `TrackEventsRequest.java:16-19`:`@Valid` 级联到 `List` 每个元素 + → 任一元素失败即 `MethodArgumentNotValidException` → **整个请求 400**。 + 3. 逐条 rejected 的逻辑在**更后面**且**永远走不到**: + `AnalyticsService.java:49`(`unknown_event_name`)、`:55`(`identity_mismatch`)、 + `:65`(`forbidden_field`)、`:83`(`schema_invalid`)。 + 4. 客户端 `analytics_service.dart:273-281`:4xx 视为永久拒绝 → `return true` + → `:229` `removeSegments(..., countAsDropped: true)` → **整批删除,不重试**。 +- **净效果**:Linux/macOS/Windows/Web 上跑 app,埋点 **100% 丢失**,不是「少量损耗」。 +- **更严重的衍生问题**:`@Valid` 级联意味着**任何一条事件的任何一个字段**校验失败 + 都会毒死整批 50 条。这是一个**毒丸批次放大器**,与平台无关。 + → 已列为 D4-F8 推荐修法的第一理由。 + +### 16.3 【修正表述】「widthPx/heightPx 恒 null 导致单图帖一律回落 4:3」——三段事实,客户端不全是受害者 + +- **任务书原文**暗示这是单一的后端字段缺失问题。 +- **实测三段**: + 1. **详情页客户端已正确消费**:`post_detail_page.dart:515-521` + `var ratio = 4/3; if (width != null && height != null && ...) ratio = (width/height).clamp(1/1.33, 1/0.75);` + ——有维度就用真比例,服务端一给就自动生效,**无需改动**。 + 2. **Feed 卡片是客户端硬编码缺陷**:`post_card.dart` 单图分支 `AspectRatio(aspectRatio: 4/3, ...)`, + `card.coverImage!.widthPx/heightPx` **可用但被完全忽略**; + `post_media_grid.dart:29` 入参只有 `List urls`(维度在调用点就被丢弃), + `_CollapsedCover` `:326-327` 硬编码 4:3。 + → **即使后端给了维度,Feed 卡片也不会生效。** + 3. **客户端根本无法提供维度**:`CreateMediaUploadRequest`(`community_models.dart:468-489`) + 只有 `kind/purpose/mimeType/byteSize/sha256` ——**契约没有维度字段**。 + 客户端压缩后知道尺寸也传不了。 +- **结论**:这不是「后端漏填」,而是「契约无字段 + Feed 卡片硬编码」双缺口。 + **只修客户端用户零感知;只修后端 Feed 卡片仍是 4:3。必须两侧同批。** +- **未取证部分**:服务端 confirm 时是否读取对象并探测图片尺寸——若不读,则维度无来源。 + +### 16.4 【修正表述】「AI 结果是生成图(无 media asset),走不了两步上传」——后半句会误导设计 + +- **被修正的原文**(`create_page.dart:122-124`): + 「AI 结果是生成图(无本地文件、**无 media asset**),走不了两步上传,故不接真实发布链路」 +- **成立部分**:两步上传确实不适用——`MediaUploader` 入口是 `PickedMediaImage` + (`media_uploader.dart:69`),客户端手里没有 AI 结果的字节。 +- **误导部分**:「无 media asset」会把设计推向「客户端下载再上传」的荒谬方向。 + 正确做法是**服务端生成完成时直接建 asset**,任务结果返回 `resultAssetId`,客户端只引用。 +- **这条路可行的取证**: + - `PostMediaAttachRequest` 只需要 `assetId`(`community_models.dart:158`),不关心 asset 来源; + - `MediaAsset.purpose` 在契约读侧(`openapi.yaml:3524-3526`)是**无 enum 约束的自由 string**,读侧向后兼容; + - `media.assets` 表 `purpose` 无 CHECK 约束(`V1__identity_media_baseline.sql:209`),加值无需迁移。 +- **唯一真实卡点**是写侧 purpose 白名单三值(`openapi.yaml:3460`、`MediaProperties.java:66`)→ D4-F3。 + +### 16.5 【降级证据】`integration_test/` 4 份真机测试**无任何门禁** + +- 受影响的结论(原被当作「链路已验证」): + - `media_uploader.dart:571-577`「桌面真链路只替换选图与压缩两层,其余全为生产实现」 + → 设计意图成立,**「实测通过」无门禁背书**。M4 复用两步上传时应视为「单测充分、端到端未持续验证」。 + - T3-17 发布页真链路(`publish_live_test.dart`)→ 同样降级; + 但其 widget 测试(`test/features/community/post_compose_page_test.dart`)在 597 内,这部分成立。 +- **不降级的一条**:`community_repository.dart:71-74` 称「media 端点归属 user 服务, + T3-13 真链路实测修正」——该结论**另有契约层独立取证** + (`openapi.yaml:199` tag `media` description 写明 `patbond-user`;`:179` server 描述列出 `/api/v1/media/**`), + 不依赖真机脚本,**故不降级**。 + +### 16.6 【发现契约内部不一致】`purpose 仅 post_image` 的过时描述 + +- `openapi.yaml:1228`(`/api/v1/media/uploads` 端点 description)仍写「purpose 仅 post_image」 +- `openapi.yaml:3460`(`CreateMediaUploadRequest.purpose`)已是 `enum: [post_image, user_avatar, pet_avatar]` +- **M3.5 追加两个 purpose 时漏改了端点描述。** M4 若再加 `ai_image`,这处必须一并修,否则第三次漂移。 + +### 16.7 【发现数据损坏路径】AI 草稿恢复会被静默降级为 `general` + +- `post_compose_page.dart:209-211`: + `_category = draft.category == PostCategory.aiCreation ? PostCategory.general : draft.category` +- 当前无害(`ai_creation` 写侧被封,不可能存在 AI 草稿)。 +- **但 M4 放开写侧后,这行会把 AI 草稿的类目静默改成 `general` 并在下次保存时写回服务端。** + 这不是「功能缺失」而是**数据损坏**,必须与放开写侧**同一个提交**改掉。 + +### 16.8 【补充事实】后端已为 AI 预埋 DB 结构,但 `creation` schema 不存在 + +- 已预埋:`V5__community_baseline.sql:47-48` `generation_job_id uuid`(裸列,FK 剥离); + `:95` `CREATE INDEX ix_posts_generation_job`;`:68` `ck_posts_category` **已放行三值**(含 `ai_creation`); + `:17` 注释指向 `creation.generation_jobs(id) ON DELETE SET NULL`(M4 补回)。 +- **不存在**:`creation` schema 与 `generation_jobs` 表从未创建; + 测试显式断言这一点(`CommunityMigrationIntegrationTest.java:16, 69, 73, 79`)。 +- **契约裁剪**:`generationJob` 在契约中明确声明不出现(`openapi.yaml:136, 3717`)。 +- → 意味着 M4 后端要新建 schema/表并补 FK;客户端若要 `generationJobId` 需契约新增(D4-F5 子决策)。 + +### 16.9 【补充事实】契约完全没有 429、`Retry-After`,也没有任何响应头机制 + +- 45 个 operation 的响应码全集:`['200','201','202','400','401','403','404','409','422','423']` —— **无 429** +- `components.headers: []`,全契约 `headers:` 键 **0 次出现** +- 后端 `grep -rniE "429|TOO_MANY_REQUESTS|Retry-After|RateLimit" patbond-*/src/main` → **0 命中** +- 客户端**已能识别** 429(`api_client.dart:164-167`)但**丢弃了响应头**(`const` 构造); + `ApiRateLimitException`(`api_exception.dart:111-113`)无 `retryAfter` 字段 +- **历史挂账**:`analytics_service.dart:268-272` 原文「后端限流尚未实现(iteration-2/09 出入项), + **Retry-After 分支待其落地后一并做**」→ **M4 就是它的兑付时点** + +### 16.10 【补充事实】主题层没有 `segmentedButtonTheme`,也没有 `chipTheme` + +- `grep -n "segmentedButton\|SegmentedButton\|ChoiceChip\|chipTheme" lib/core/theme/app_theme.dart` → **0 命中** +- 6 处 `SegmentedButton` + 2 处 `ChoiceChip` 全部回退到 + `ColorScheme.fromSeed(seedColor: #FF6F4C)`(`app_theme.dart:88-92`)派生的 M3 调和色 +- **与 M3.5-01 修 DatePicker 的根因完全同构**——`app_theme.dart:200-204` 已把这个根因写得很清楚 + (「此前未定制,完全走 `ColorScheme.fromSeed` 由珊瑚橙派生的 M3 调和色,与全 app 品牌色脱节」), + 且 `_datePickerTheme`(`:215-296`,含 7 行色对对比度表)就是现成的修复样板。 +- AI 创作页是这两个组件最密集的页面 → **不修就是把新页建在债上**(D4-F9)。 + +### 16.11 【确认成立】「我的收藏与草稿」后端已就绪、只缺页面 + +- `profile_page.dart:43-46` 注释称「后端能力已就位(`/me/bookmarks`、`/me/posts`),但列表页本单未做」 +- **核实成立**:`community_repository.dart:274-283` `listMyBookmarks`、 + `:179-193` `listMyPosts({status})` 均已实现,且在 597 测试内有覆盖。 +- 缺的**只有 2 个页面**,不缺任何数据层、不缺卡片 widget、不缺分页泛型。 + 这是本次盘点中**投入产出比最高**的搭车项。 + +--- + +## 附录 A:本报告的取证方法与未取证项 + +**已实测执行**:`flutter test --reporter compact`(597 通过 + 2 skipped)、 +`flutter --version`(3.44.6)、各源文件全文阅读、跨仓 grep 取证。 + +**未取证项(明确列出,供后续补齐)**: + +| # | 未取证内容 | 缺什么 | 影响 | +| --- | --- | --- | --- | +| 1 | 服务端 confirm 时是否探测图片尺寸 | 需读 `MediaService` confirm 实现 | 决定 §12.5 的第 1 段是契约问题还是实现问题 | +| 2 | `EventDictionary.java:59` `page_viewed` 的 props 白名单具体取值集合 | 需读该行的 `Set.of(...)` 内容 | 决定新页名是否需同批加入(§10.6) | +| 3 | `AnalyticsService.java:83` `schema_invalid` 的判定范围 | 需读 props 校验实现 | 同上 | +| 4 | `pet_form_page.dart:23, 398` 的「头像本地占位(ADR-010)」注释是否已过时 | M3.5-09 已接宠物头像上传,注释可能未更新 | 仅影响 §11.2 清单准确性 | +| 5 | E2E 断言总数是否为 234 | 未逐一统计(仅确认场景数 42) | 仅影响 §13.5 的门禁描述 | +| 6 | AI 生成的典型耗时分布 | 需模型选型后实测 | 决定 §4.4 轮询间隔阶梯的具体数值 | + +**本报告未修改任何生产代码,未修改 `mkdocs.yml`,未执行任何 git 操作。** + +--- + +**报告人**:Frontend Developer(Flutter) +**完成日期**:2026-09-14 +**基线**:patbond-flutter `dev@fbcd734` / patbond-api `dev@3cd8005` / patbond-doc `main@5cc6361`(三仓均干净) + + + + + + + + + + + + + + + diff --git a/docs/development/iterations/iteration-4/04-reality-check.md b/docs/development/iterations/iteration-4/04-reality-check.md new file mode 100644 index 0000000..69b4932 --- /dev/null +++ b/docs/development/iterations/iteration-4/04-reality-check.md @@ -0,0 +1,687 @@ +# 04 M4 开工现实核查(Reality Check) + +> 作者:Reality Checker +> 日期:2026-09-14 +> 输入:三仓工作区实测(api `3cd8005` / flutter `fbcd734` / doc `5cc6361`,均 tag `v0.4.0`); +> `docs/database/patbond_postgresql.sql` 正典模型;`device-verification.md`;`releases.md`; +> `server-exposure.md`;Gitea API 实时提交状态。 +> 同批 07 号(Evidence Collector)已独立清点基线数字,本报告复用其结论并**不重复清点**; +> 本报告的增值面是 **M4 规划的未证前提狙击**与**跨文档一致性**。 +> +> **总体裁决:NEEDS WORK。** 基线代码质量与契约纪律确实扎实(32/45/75 契约、四模块字节级快照、 +> 381 + 597 双绿、CI 三仓已恢复全绿),但 **M4「AI 创作」的三个核心前提全部无证据支撑**: +> 无任何 AI 服务商 endpoint / 凭证 / 额度(`.env` 与 `init-secrets.sh` 共 4 个密钥,无一条与 AI 相关); +> 服务端**完全不具备写对象存储的能力**(全代码库 S3 调用仅 `createBucket`/`headBucket`/`headObject`/`close`, +> 零 `putObject`、零 `getObject`),故「媒体链路可直接复用」只对读侧与客户端上传侧成立、 +> Worker 产出物落盘是**净新增**;且挂起的真机项已达 **10 项**(非 8 项),M4 若沿现有模式将再批量产出一批。 +> 唯一的好消息是 Worker 队列**不需要** Redis/MQ——正典模型已给出 Postgres `FOR UPDATE SKIP LOCKED` +> 租约队列设计,现有 compose 六容器无需扩容。 + +--- + +## 1. 基线数字逐条核对 + +同批 07 号报告已逐格清点,此处只列**核对结果**与**我侧独立取证的差异项**。 + +| 声称 | 实测 | 裁决 | +| --- | --- | --- | +| 三仓工作区干净、全部停在 `v0.4.0` | `git status --porcelain` 三仓均空输出;`git tag --points-at HEAD` 三仓均返回 `v0.4.0` | ✅ 对 | +| api `dev@3cd8005` | `3cd80055779db2d52cf8cc1af425d06131f7e41d` | ✅ 对 | +| **api 379 测试全绿** | **381**(`3+131+40+100+107`),`BUILD SUCCESS`,Failures 0 / Errors 0 / **Skipped 0** | ❌ **不对,见问题 P4** | +| flutter `dev@fbcd734` | `fbcd73468805e95b8055395654ca1014e0d6c13c` | ✅ 对 | +| flutter 597 测试全绿 | `00:39 +597 ~2: All tests passed!`,exit 0 | ✅ 对(**但含 2 个默认跳过项,见问题 P6**) | +| doc `main@5cc6361` | `5cc63615345fc30cb342a336292d7decdffea98f` | ✅ 对 | +| 契约 v1.4.0:32 路径 / 45 操作 / 75 schema | `info.version=1.4.0`;paths 32、operations 45、schemas 75,**逐格相符** | ✅ 对 | +| 四模块字节级快照锁 | `md5 = a7081fb84f1207eef579ab94025f5801`,doc 正典 + auth/user/pet/community 四份快照**五处完全相同** | ✅ 对 | +| **契约矩阵 181 格零漂移** | 181 这个数字**只存在于报告散文**(`iteration-3.5/04:96`、`releases.md:39`、`feature-checklist.md:242`);代码里唯一被钉住的规模断言是 `MediaContractConformanceTest.java:250` 的 `assertThat(CONTRACT.operations()).hasSize(45)`。无任何测试断言「181」 | ⚠️ **不可机械复核,见问题 P5** | +| Flyway V1~V5 | `patbond-user/src/main/resources/db/migration/` 恰好 V1~V5,无 V6 | ✅ 对 | +| V5 已为 `posts.generation_job_id` 留裸列(无外键) | `V5__community_baseline.sql:48` = `generation_job_id uuid,`(零 `REFERENCES`);`:95` 建 `ix_posts_generation_job`;`:47` 注释明写「FK to creation.generation_jobs stripped(M4 补回)」 | ✅ 对 | +| 六容器部署(含自托管 MinIO) | `docker-compose.yml` services = `postgres, minio, user, auth, pet, community`,恰 6 | ✅ 对 | +| **埋点事件白名单 42 条** | **41 条**(`EventDictionary.java` 的 `Map.ofEntries` 内 `Map.entry` 41 个,去重后仍 41) | ❌ **不对,见问题 P3** | +| ADR-001~022 | `decisions.md` 22 个 `## ADR-0xx` 标题,无缺号无重号 | ✅ 对 | +| E2E 四份脚本 **42 场景** | 脚本自报计数相加**恰好 42**:M1 `[1/7]`~`[7/7]` = 7、M2 `$_passed/11`、M3 `$_passed/14`、M3.5 `$_passed/10` | ✅ 对 | +| E2E **234 断言** | 机械可数:`check(` 计数 M2 42 + M3 84 + M3.5 84 = **210**;M1 无 `check(`、用 16 处 `✗` 卫语句 → 合计 **226**。差 8 处无法机械复现(疑为 `assertMeShape` 等辅助函数内部断言另计) | ⚠️ 数量级可信、精确值未取证 | +| mkdocs 可构建、导航无死链 | `mkdocs build --strict` **exit 0**,`Documentation built in 1.71 seconds` | ✅ 对 | +| main 分支保护禁直推 + 两个状态检查上下文 | 仅有 `releases.md:110-114` 表格作为证据(称经 Gitea API 核实);我侧 `GET /branch_protections` 返回 **401**(无 token,未取证)。**且该上下文的选型本身有洞,见问题 P2** | ⚠️ 配置未复核 / 设计有洞 | + +### 1.1 我侧独立取证:CI 状态比声称的**更好**(唯一正向偏差) + +`iteration-3.5/04:8` 与 `§7.3` 记录「⚠️ Gitea CI 仍红——runner 级故障,最后一次 CI 绿是 M3 末的 `8089c06`」, +且 `08-release-e2e-regression.md` 全文**零处**提及 CI/runner/Gitea。若照抄这条结论,会以为 M4 开工时 CI 仍死。 + +实测(Gitea API `GET /repos/{owner}/{repo}/commits/{sha}/status`,2026-09-14): + +| 仓 | state | 上下文 | 结果 | 上报时间 | +| --- | --- | --- | --- | --- | +| patbond-api | `success` | `CI / backend-test (push)` | Successful in 5m31s | 2026-09-14T09:44:05+08:00 | +| patbond-api | `success` | `CI / backend-test (pull_request)` | Successful in 6m42s | 2026-09-14T09:54:37+08:00 | +| patbond-flutter | `success` | `CI / flutter-gates (push)` | Successful in 3m46s | 2026-09-14T09:58:23+08:00 | +| patbond-flutter | `success` | `CI / flutter-gates (pull_request)` | Successful in 3m44s | 2026-09-14T10:02:08+08:00 | +| patbond-doc | `success` | `CI / docs-build (push)` | Successful in 1m22s | 2026-09-14T11:20:44+08:00 | + +**结论:runner 已修复,三仓 HEAD 全绿,`(push)` 与 `(pull_request)` 双上下文均真实跑过。** +`iteration-3.5/04 §7.3` 的「CI 仍红」是**已过期的历史状态**,M4 开工不必把它当风险。 +(此项 07 号列为「未取证」,本报告补齐。) + +## 2. 遗留与挂起项的当前真实状态 + +**纪律**:本节每一项都从源码/迁移文件直接取证,**不采信任何报告结论**。 + +### 2.1 真机挂起项:实际 10 项,且「转述污染」链条已可完整还原 + +`device-truth`:打开 `docs/development/device-verification.md` 逐节清点顶层验证项: + +| 迭代 | 节标题 | 顶层项数 | 明细 | 执行记录 | +| --- | --- | --- | --- | --- | +| M2 | 「M2 挂起项(2026-09-08 登记,待执行)」 | **2** | 验证一 Android 事件真实落库;验证二 SessionTracker 30 分钟后台换会话 | `_(待真机到位后填写…)_` → **未做** | +| M3 | 「M3 预登记(社区)」 | **4** | 媒体上传弱网;乐观更新真机手感;Feed 图片加载;社区事件落库 | `_(待补)_` → **未做** | +| M3.5 | 「M3.5 预登记(用户资料与头像)」 | **4** | 头像上传弱网;头像缓存;caregiver 改宠物头像;获赞数对账 | `_(待补)_` → **未做** | +| | **合计** | **10** | | **0 项已执行** | + +M3.5 节的正文自证是四项:「下列**四项**是桌面替代不了的部分」。 + +**污染链条**(这正是 M3.5 教训的同型复发,值得单独记录): + +1. **源头**:`releases.md:121` 写「**真机验证四项挂起**(媒体上传弱网、乐观更新手感、Feed 图片加载、社区事件落库);另有 M2 两项」= 登记 **6 项**,**整段漏掉 M3.5 的四项**。 +2. 我的开工任务书据此写「真机**四项**验证」(只剩 M3 的 4)。 +3. 协调者更正为「**8 项** = M2 两项 + M3 四项 + M3.5 **两项**」——方向对了,但 M3.5 又少计 2。 +4. **实测 = 10 项**。 + +即:同一事实经过三次转述,出现 4 → 6 → 8 → 10 四个不同数字,**每一次转述都在丢项**。 +`device-verification.md` 是唯一正确的原始文件,任何规划都必须直接读它。 + +### 2.2 M2 两项的 2026-09-21 时限:可行,且「等真机」是个伪阻塞 + +`device-verification.md` 的 M2 节挂着时限提醒「建议在 **2026-09-21**(北极星首次出数日)前完成」。今天 09-14,剩 7 天。 + +关键取证——**该清单明写「Android 真机(推荐)或 Android 模拟器」,而本机模拟器链路完整可用**: + +``` +$ ls ~/.android/avd/ → Pixel_7.avd Pixel_7.ini +$ ~/Android/Sdk/emulator/emulator -list-avds → Pixel_7 +$ ls -la /dev/kvm → crw-rw-rw- 1 root kvm (全员可读写,无需加组) +$ ls ~/Android/Sdk/system-images → android-36 +$ ls ~/Android/Sdk → build-tools cmake cmdline-tools emulator ndk platforms platform-tools skins … +``` + +`flutter devices` 当前只看到 Linux/Chrome,原因是 `ANDROID_HOME`/`ANDROID_SDK_ROOT` **未设置**且 +`adb`/`emulator` 不在 `PATH`(实测 `which adb` → not found),**不是缺硬件**。 + +**裁决:时限可行(CERTIFIED-可行)。** M2 两项的净工作量是「验证一 ~10 分钟 + 验证二 ~45 分钟(其中 35 分钟是纯等待)」, +合计约 1 小时挂钟时间,其中人工操作不足 15 分钟。前置只差三条命令(导出 `ANDROID_HOME`、 +`PATH` 加 `platform-tools`/`emulator`、起 `Pixel_7`)。**7 天时限绰绰有余;把它记作「待真机到位」已经拖了 6 天,属登记口径错误而非资源不足。** + +**解除条件**:起 `Pixel_7` 模拟器 + compose 六容器,跑完 M2 两项,填 `device-verification.md` 的「M2 项执行记录」, +并把 `feature-checklist.md` 第 9 节由 🟡 改 ✅。 + +**但需同时纠正一处过度乐观**:10 项里只有一部分模拟器可替代。按各项通过标准的物理依赖分类: + +| 可用模拟器完成(6 项) | 必须真实硬件(4 项) | +| --- | --- | +| M2 两项(`platform=android` 落库、SessionTracker 30min) | M3-1 媒体上传弱网(需蜂窝/飞行模式掐断、中端机压缩耗时 ≤2s) | +| M3-4 社区事件落库(只要 platform 枚举命中即可) | M3-2 乐观更新手感(需中低端机真实帧率判「无可见掉帧」) | +| M3.5-3 caregiver 改宠物头像(纯权限档位) | M3-3 Feed 图片加载(含「蜂窝 vs Wi-Fi 可达性差异」) | +| M3.5-4 获赞数对账(纯数字口径) | M3.5-1 头像上传弱网(含 **iPhone HEIC** —— 模拟器与 Android 都造不出) | +| M3.5-2 头像缓存(跨页命中可在模拟器观察) | | + +**另注(07 号已取证,此处只标注影响)**:M3.5-3 caregiver 项的前置写着「按 `pet_health` 的协作表直接造数据,**收口时补 SQL**」, +该 SQL 从未补 → **此项当前不可执行**,即便有设备也跑不了。解除条件是先补造数 SQL。 + +### 2.3 `widthPx` / `heightPx` 仍恒 null —— 结构性,且 M4 会被它直接绊到 + +**取证(不看文档,看写路径)**: + +- 声明侧存在:`V1__identity_media_baseline.sql:217-218` 有 `width_px integer, height_px integer`; + `MediaAssetResponse.java:18-19`、`PostMediaItemResponse.java:15-16` 都外露这两个字段。 +- **写路径不存在**:`MediaAssetRepository.insertUploading()` 的 INSERT 列清单是 + `(id, owner_user_id, kind, purpose, storage_type, bucket, object_key, mime_type, byte_size, sha256, status)` + —— **不含 width_px / height_px**。 +- **更新路径也不存在**:全库 `grep width_px` 命中的 `UPDATE` 语句 **0 条**;`media.assets` 上仅两条 UPDATE, + 即 `markReady`(`SET status='ready', ready_at=now(), updated_at=now()`)与 `markFailed`(`SET status='failed'`), + **都不碰尺寸列**。 + +**结论:生产链路上这两列永久为 NULL,没有任何代码能写入。** 裁决 **NEEDS WORK(确认遗留)**。 + +⚠️ **陷阱提示**:`PostMediaAttachIntegrationTest.java:44-45` 断言 `widthPx==640 / heightPx==480` **是绿的**, +因为 `CommunityTestData.java:55` 在测试夹具里直接 INSERT 了这两列。 +**测试绿 ≠ 生产有值** —— 这与 M3.5「无 nickname 字段」同型:断言测的是夹具,不是产品。任何规划不得据此认为该字段可用。 + +### 2.4 `eventVersion` 口径仍未定型 —— 且服务端根本不校验 + +- 契约要求必填:`openapi.yaml` `TrackedEvent.required` 含 `eventVersion`。 +- 契约描述**已过期**:`openapi.yaml:2341-2343` 写 `description: 事件 schema 版本(**字典 v1 全部为 1**)`, + 而 `EventDictionary.java` 类注释开头即 `Event dictionary **v3**`。 +- **服务端零校验**:`grep -rn "eventVersion" --include=*.java` 在 `EventDictionary.java` 与 + `AnalyticsService.java` 中命中 **0 次**。唯一处理是 `TrackEventsRequest.java:38` 的 `@NotNull` + 与 `AnalyticsRepository.java:26/48` 的原样落库。 +- 库侧只有宽约束:`V2__create_platform_product_events.sql:11` `event_version smallint NOT NULL DEFAULT 1`, + `:22` `CHECK (event_version > 0)`。 + +**结论**:客户端可对任意事件上报任意正整数版本号并被静默接受;字典已到 v3 而契约仍宣称「全部为 1」。 +裁决 **NEEDS WORK(确认遗留)**。M4 若新增事件,必须先定这个口径,否则新事件版本号写什么都「对」。 + +### 2.5 429 限流仍缺 —— 后端与契约**双零命中** + +``` +$ grep -rn "429|RateLimit|rateLimit|TOO_MANY" patbond-api --include=*.java --include=*.yml → 0 行 +$ grep -n "429" patbond-doc/docs/api/openapi.yaml → 0 行 +``` + +**结论**:不仅未实现,**契约里连 429 这个响应码都没声明**,即系统在任何路径上都不可能返回 429。 +裁决 **NEEDS WORK(确认遗留)**。 + +连带影响:`iteration-3/03:151` 规划过「429 按 `Retry-After` 退避……Retry-After 留接线点」。 +客户端那条分支**永远走不到**,属当前**无法被任何测试覆盖的死分支**。 +M4 上 AI 生成必然要限流(见 §3.5),届时这条分支才第一次有意义。 + +### 2.6 「我的收藏与草稿」页仍缺 —— 后端就绪、数据层就绪、**UI 零** + +| 层 | 状态 | 证据 | +| --- | --- | --- | +| 契约 | ✅ 有 | `openapi.yaml` 含 `/api/v1/me/bookmarks [GET]` 与 `/api/v1/me/posts [GET]` | +| Flutter 数据层 | ✅ 有 | `community_repository.dart:185` `listMyPosts`(带 `status` 过滤)、`:279` `listMyBookmarks` | +| Flutter UI | ❌ **无** | `find lib -iname "*bookmark*" -o -iname "*draft*" -o -iname "*favorite*"` → **零结果**;`grep -rn "bookmark|draft|favorite" lib/app/` → **零结果**(无路由) | +| 入口 | 假入口 | `profile_page.dart:49` 菜单项「我的收藏与草稿」仍走演示提示;`:45-46` 注释自承「后端能力已就位(`/me/bookmarks`、`/me/posts`),但列表页本单未做」 | + +**死代码取证**:`listMyBookmarks` 的生产调用方 **0 个**(`grep` 仅命中 `community_repository.dart` 自身与 3 个测试文件)。 +`listMyPosts` 有 1 个生产调用方:`post_compose_page.dart:196` 的 `_restoreLatestDraft()`, +其注释自承「**草稿恢复(最小实现:最新一条)**」,`limit: 1, status: PostStatus.draft`。 + +**结论**:裁决 **NEEDS WORK(确认遗留)**。收藏列表完全无 UI 且数据层是死代码;草稿只有「恢复最新一条」,无列表、无自动保存。 +这一条对 M4 直接相关 —— 见 §3.4。 + +## 3. M4 规划未证前提狙击 + +本节是本报告的重心。每条前提给出**它需要什么证据**与**实测到的证据**。 + +### 3.1 【最危险】真实 AI 模型服务:endpoint / 凭证 / 额度**三者全无** + +需要的证据:一个可调用的模型服务地址、一份可用凭证、一个已确认的额度或计费口径。 + +实测: + +``` +$ grep -rniE "openai|anthropic|stability|replicate|dashscope|volcengine|comfyui|sdxl|api_key|apiKey" \ + patbond-api --include=*.java --include=*.yml --include=*.yaml --include=*.sample +→ 零命中(排除 gen_random / generated / generation_job_id 等同形词后) + +$ grep -oE "^[A-Z_]+" patbond-api/.env +→ PATBOND_DB_PASSWORD / PATBOND_INTERNAL_TOKEN / PATBOND_MINIO_ROOT_USER / PATBOND_MINIO_ROOT_PASSWORD + +$ grep -oE "PATBOND_[A-Z_]+" patbond-api/deploy/init-secrets.sh | sort -u +→ 同上 4 个,无第五个 +``` + +**即:整个项目的密钥面共 4 项(DB 口令、服务间 token、MinIO 用户/口令),无一条与任何 AI 服务商相关; +没有任何 HTTP 客户端、SDK 依赖、配置占位符指向任何模型服务。** + +更关键的一条反证 —— **正典模型自己就只设想了 fixture 提供方**: + +`patbond_postgresql.sql:1495-1499` 的种子数据: + +```sql +INSERT INTO creation.generation_models + (id, code, display_name, provider_code, provider_model_name, media_kind, sort_order) +VALUES + (…, 'patbond-v1', 'Patbond-V1', 'fixture', 'patbond-image-v1', 'image', 10), + (…, 'patbond-v1', 'Patbond-V1', 'fixture', 'patbond-video-v1', 'video', 10); +``` + +`provider_code = 'fixture'`;`:1510` 的样例 job 亦为 `provider_code_snapshot='fixture'`、 +`provider_request_id='fixture-provider-job-1'`。**设计者从未假设 M4 接真模型。** + +同时注意 `generation_jobs` 有三个 **NOT NULL** 的快照列: +`provider_code_snapshot`、`provider_model_snapshot`、`model_version_snapshot`(`:620-622`)。 +任何一次入队都必须填出这三个值 —— 接 fixture 也要填,这是**契约级强制**,不能含糊。 + +**裁决:BLOCKED。** + +**这直接击穿「本迭代端到端可验证」的说法。** 两条路,必须现在拍板选一条,不能含混: + +- **路 A(推荐,可 CERTIFIED)**:M4 明确只做 **fixture/stub provider**,与正典种子一致。 + 「端到端可验证」重新定义为「入队 → 租约领取 → 状态机流转 → 产物落 MinIO → 挂帖」全链路可验, + **产物是 stub 图**(例如把输入图做一次确定性变换)。这条路的每一环都能被自动化测试覆盖,无外部依赖、无额度风险、CI 可跑。 +- **路 B(需先解阻塞)**:接真模型。**开工前必须先有**:服务商选定 + endpoint + 凭证注入方案 + (`init-secrets.sh` 增第 5 项 + compose 环境变量 + ADR)+ 额度/计费上限 + 失败与超时口径 + + ADR 记录「凭证不入库」如何保证。这些**一件都还没有**。 + +⚠️ 若规划文本同时写「fixture 兜底」又写「端到端接通真实模型」,那是自相矛盾, +按本报告纪律判 **NEEDS WORK**,必须二选一并写进 ADR。 + +### 3.2 【前提是伪命题】Worker 队列**不需要** Redis / MQ + +这条前提我给出的是**否证**:任务书假设「Worker 队列需要 Redis 或 MQ」,实测该假设本身不成立。 + +- 现有 compose 六服务 = `postgres, minio, user, auth, pet, community`。**无 Redis、无 RabbitMQ/Kafka、无任何 broker。** +- 但正典模型**已经给出了完整的 Postgres 租约队列设计**,不需要 broker: + - `generation_jobs` 具备队列所需全部列:`status`(`queued/running/succeeded/failed/cancelled`)、 + `priority`、`attempt_count`/`max_attempts`、`next_attempt_at`、`lease_owner`、`lease_expires_at`、`progress`、`version`。 + - 两个专用偏索引:`ix_generation_jobs_queue ON (priority DESC, next_attempt_at, created_at, id) WHERE status='queued'` + 与 `ix_generation_jobs_running ON (lease_expires_at, id) WHERE status='running'`(`:712-715`)。 + - `patbond_postgresql.sql:1896-1917` 直接给出了**多 Worker 并发领取的标准写法**,注释即 + 「AI worker claim pattern for multiple concurrent workers」: + + ```sql + WITH picked AS ( + SELECT id FROM creation.generation_jobs + WHERE status='queued' AND attempt_count < max_attempts AND next_attempt_at <= now() + ORDER BY priority DESC, next_attempt_at, created_at, id + FOR UPDATE SKIP LOCKED LIMIT 1 + ) + UPDATE creation.generation_jobs j + SET status='running', started_at=now(), progress=1, attempt_count=attempt_count+1, + lease_owner=:worker_id, lease_expires_at=now()+interval '2 minutes', version=version+1 + FROM picked WHERE j.id=picked.id RETURNING j.*; + ``` + - `ck_generation_jobs_state` 是一条**五分支状态机 CHECK**,把每个 status 允许的字段组合钉死 + (如 `running` 必须 `lease_owner IS NOT NULL`、`succeeded` 必须 `progress=100 AND output_asset_id IS NOT NULL`)。 + 这是很强的资产:**状态机由数据库强制,Worker 写错就报错**。 +- 调度侧也有现成先例:`UserApplication.java:7` 已有 `@EnableScheduling`, + `SessionCleanupJob.java:32` 已有一个 `@Scheduled` 任务在生产运行。轮询式 Worker 与它同构。 + +**裁决:CERTIFIED(基础设施无需扩容)。** 这是 M4 少有的**真·已就位**项。 +**明确建议不要引入 Redis/MQ** —— 会凭空增加一个容器、一套运维面、一份 ADR,而正典设计已否决其必要性。 + +⚠️ 但两个**未证的衍生点**必须写进规划: +1. **`lease_expires_at` 的回收者不存在。** 设计给了 `ix_generation_jobs_running` 索引, + 却没有任何代码回收超租约的僵尸 job。这与已登记的「`uploading` 超时清理任务」是**同型缺口**, + 而那一项至今未做(`releases.md:123` 已登记)。M4 若不写回收器,`running` 的 job 崩了就永久卡死。 +2. **Worker 放哪个模块未定。** `@EnableScheduling` 只在 `patbond-user`。新建 `patbond-creation` + 模块意味着第 7 个容器(compose 从 6 → 7),需 ADR。若塞进现有模块,则 AI 长任务会与在线请求争线程池。 + +### 3.3 【半真半假,最易误判】「媒体链路 M3 已就位可直接复用」 + +这句话必须**按方向拆开**验,因为读侧成立、客户端写侧成立、**服务端写侧完全不存在**。 + +**取证 —— 全代码库 S3 调用面**: + +``` +$ grep -rhoE "\b(s3|client|s3Client)\.[a-zA-Z]+\(" patbond-user/src/main/java/com/patbond/patbond/user/ +→ client.close( client.createBucket( client.headBucket( client.headObject( +``` + +即整个后端对对象存储的能力只有:建桶、探桶、探对象元数据、关闭客户端。 +**零 `putObject`、零 `getObject`。** `patbond-pet` 与 `patbond-community` 侧则只有 `S3Presigner` +做本地 SigV4 签名(`MediaUrlSigner.java`),连网络调用都没有。 + +| 复用面 | 结论 | 证据 | +| --- | --- | --- | +| 预签名 GET 读取(帖图/头像展示) | ✅ **真可复用** | `MediaUrlSigner.java` 在 pet/community 两处已复制运行 | +| 客户端上传三段(createUpload → 直传 → complete) | ✅ **真可复用** | `MediaService.java` + `/media/uploads` 两端点已在契约内 | +| 桶初始化 | ✅ 可复用 | `S3ObjectStorage.ensureBucket():66` | +| **服务端写对象(Worker 产出物落盘)** | ❌ **净新增,零基础** | 无 `putObject` | +| **服务端读对象字节(把输入图喂给模型)** | ❌ **净新增,零基础** | 无 `getObject`(`headObject` 只取元数据) | +| `purpose` 白名单 | ⚠️ 需扩展 | `application.yml.sample:59` = `post_image,user_avatar,pet_avatar`;`MediaProperties.java:66` 同值。无 AI 输入/输出用途 | +| 产物尺寸写入 `media.assets` | ❌ 不可复用 | 见 §2.3,`insertUploading` 无尺寸列、无 UPDATE 路径 | + +**裁决:NEEDS WORK(该表述必须在规划里改写)。** + +准确表述应为:「媒体的**读链路与客户端上传链路**可直接复用;**服务端读写对象字节的能力为零,是 M4 的净新增工作**。」 + +好消息是 `purpose` 扩展很便宜 —— `MediaProperties.java:61` 注释明写该白名单是 +「configuration + contract-enum change, **never a migration**」,即改配置 + 改契约枚举即可,不需要迁移。 + +⚠️ 一个**具体的下游矛盾**:`generation_jobs` 自己存了 `width_px`/`height_px`(`:616-617`, +带 `CHECK … BETWEEN 64 AND 8192`),也就是 AI 产物的尺寸在 `creation` schema 里**是已知的**; +但 `media.assets` 的同名列永远为 NULL(§2.3)。若 Worker 不顺手把尺寸写进 `media.assets`, +客户端渲染 AI 产物会**继续回落 4:3 占位**(这正是已登记的「单图帖回落 4:3」遗留)。 +M4 有一次几乎零成本修掉它的机会(Worker 本来就知道尺寸),**建议顺带修掉,不要再滚一轮**。 + +### 3.4 「社区草稿已就位可复用」—— 只有一半 + +- ✅ 库侧就位:`V5__community_baseline.sql:53` `status varchar(16) NOT NULL DEFAULT 'draft'`。 +- ✅ 契约就位:`/api/v1/me/posts` 支持 `status` 过滤;`Post.category` 枚举已含 `ai_creation`。 +- ✅ **M4 的读侧钩子已预留**:契约明写 `ai_creation 为 M4 预留值,M3 不开放写入(提交 400/40000)`, + 且有测试钉住 —— `PostLifecycleIntegrationTest.java:102-104` 断言 M3 提交 `category: ai_creation` 被拒。 + **M4 要做的是把这个拒绝改成放行**,需同步改契约、快照、该测试。 +- ❌ **草稿 UI 只有「恢复最新一条」**:见 §2.6。无草稿列表、无自动保存(`releases.md:123` 已登记为遗留)。 + +**裁决:NEEDS WORK。** 「AI 创作产物存草稿再发布」这条产品路径依赖草稿列表,而列表不存在。 +规划若假设「用户可以把多个 AI 产物存成草稿再挑一个发」,那是**未证前提** —— 当前只能恢复最新一条,多草稿会互相覆盖。 + +### 3.5 【高危】长耗时异步任务在桌面端**基本无法验证**,M4 极可能再产出一批挂起项 + +这是我对 M4 最强的风险判断,有三条硬证据。 + +**证据一:埋点在桌面端结构性不可用。** +`openapi.yaml:2373-2375`: + +```yaml +platform: + type: string + enum: [android, ios] +``` + +枚举只有两个值。Linux 桌面上报 `platform=linux` → 整批 400。这不是缺陷而是契约内行为, +`device-verification.md` 通用前置已写明「桌面/Web 不可用……platform 值不在契约枚举内会被服务端整批拒绝」。 +**推论:M4 新增的任何创作漏斗事件(生成发起/成功/失败/耗时分桶),其「落库」验证只能在 Android 上做 → 又是一批真机挂起项。** + +**证据二:选图与压缩在 Linux 桌面无原生实现,桌面实测走的是替身。** +`image_picker` 与 `flutter_image_compress` **确实已实现**(`media_picking.dart:28` `SystemMediaImagePicker`、 +`media_compression.dart:42` `FlutterImageCompress.compressWithList`)—— +但 `media_uploader.dart:570-571` 与 `app.dart:53` 都注明「Linux 桌面既无 `image_picker` 也无 +`flutter_image_compress` 的原生实现,桌面真链路**只替换选图与压缩两层**」。 +(顺带纠正我方任务书的一处错误表述:并非「无 image_picker/compress 实现」, +而是**实现有、Linux 平台支持无**。这是原始文件与转述的又一处偏差。) +**推论:AI 创作必然以「选一张宠物照」开头 → 该入口在桌面永远是替身 → 首步就无法真实验证。** + +**证据三:现有 E2E 与 integration_test 都不在门禁里。** +四份 `test_e2e_*_manual.dart` 是手工脚本,需 compose 全栈在位,**不在 `flutter test` 内、CI 不会跑** +(07 号亦独立取证 `integration_test/` 下 4 个真机测试完全在 `flutter test` 之外)。 +`flutter test` 的 2 个 skip 也正是需要后端的 smoke(`PATBOND_MEDIA_SMOKE=1` / `PATBOND_DETAIL_SMOKE=1`)。 +**推论:长耗时异步任务(入队→轮询→完成,正典租约 2 分钟)如果照现有模式验证, +只会再产出一份「手工脚本 + 真机清单」,自动化门禁覆盖率为零。** + +**裁决:NEEDS WORK,且这是 M4 最可能失控的一面。** + +**可解除的具体做法**(这些都能在桌面/CI 内做,不必等真机): + +1. **Worker 状态机做纯后端集成测试**(Testcontainers + fixture provider)—— + 入队、SKIP LOCKED 并发领取、租约过期回收、重试退避、`max_attempts` 耗尽转 failed、幂等键去重。 + 这些**全部不需要客户端、不需要真模型、不需要真机**,可 100% 进 `mvnw test` 门禁。这是 M4 最该先建的护栏。 +2. **`platform` 枚举扩容拍板**:若想让桌面参与埋点验证,就在契约 `enum` 加 `linux`(或加通用 `desktop`)。 + 这是一行契约改动 + 快照同步,能一次性解掉 M2/M3/M3.5 遗留下来的「事件落库只能真机验」死结, + **收益跨三个迭代**。若决定不加,则必须承认 M4 的埋点验证同样挂起,并写进清单。 +3. **入队/轮询/取消的契约面进 E2E 脚本**(第五份),并同步更新回归清单(见 §4.1)。 + +### 3.6 其余未证前提(一次列清) + +| 前提 | 实测 | 裁决 | +| --- | --- | --- | +| 「V5 已留裸列,M4 补外键很轻」 | 裸列确实在(`V5:48`)。但 V6 需 `CREATE SCHEMA creation` + 3 张表 + ~12 索引 + 3 触发器 + 补 1 个外键,**不是轻量迁移** | NEEDS WORK(工作量被低估) | +| 「跨 schema 外键照正典补回即可」 | `V5:20-30` 注释确立的纪律是「未迁移 schema 的跨库外键一律裁剪」。`generation_jobs` 自身引用 `identity.users`/`pet_health.pets`/`media.assets`(均已存在,可保留),但它还被 `marketplace` 之外的 `platform.regions` 牵连口径 —— **需逐条确认哪些保留哪些裁剪**,无现成结论 | 未取证,需 API/DBA 角色出定型表 | +| 「A/B 实验前置已就位」 | 服务端字典**有** `experiment_exposed`(`EventDictionary.java:103`,注释自承「dictionary ahead of its M4 first use」)。但 **Flutter 侧零引用**:`grep -rn "experiment_exposed\|experimentKey" lib/ test/` → **零命中**。即无任何客户端能发这个事件 | NEEDS WORK(仅服务端半就位) | +| 「北极星 2026-09-21 首次出数」 | 依赖 M2 两项真机验证(§2.2)。技术上可行,但**至今 0 项执行**,且 `platform=linux` 死结未解 | NEEDS WORK(可行但已拖期) | +| 「零迁移」惯例可延续 | M3.5 做到零迁移是因为所需列 V1/V3/V5 已存在。**M4 必然需要 V6**(`creation` schema 完全不存在),零迁移惯例**在 M4 必然中断** | 需明确写进规划,别延续错误预期 | + +## 4. 发布流程与服务器侧登记项(协调者追加三项) + +按分工,此处**不重复** Evidence Collector 对 checklist 的逐步清点,只判「规划是否站得住」。 + +### 4.1 发布流程:文档内部自相矛盾,且状态检查上下文的选型有洞 + +**(a)同一份 `releases.md` 自己打自己(回归清单份数)** + +| 位置 | 表述 | +| --- | --- | +| `releases.md:36` | 「**E2E 回归(四份,同环境串行)**……M1 7/7 + M2 11/11 + M3 14/14 + M3.5 10/10 = 42/42 场景」 | +| `releases.md:56` | 「**回归清单由两份改为四份**:M1/M2/M3/M3.5」 | +| **`releases.md:131`** | 「1. 完成 checklist 第 1~2 步(三仓 CI 绿 + **E2E 双份回归 PASS**)」 | + +`:131` 位于「**发布后生效的纪律**」一节 —— 也就是**给下一次(即 M4)发布看的那段前瞻指令,写的是「双份」**。 +`:36`/`:56` 是本次回顾记录,写的是「四份」。**前瞻指令与本次结论矛盾,且错在前瞻侧。** +照 `:131` 执行 M4 发布,会只跑 2 份、漏掉 M3/M3.5 两份共 24 个场景。 + +叠加 Evidence Collector 独立取证的「发布 checklist 正文(`iteration-3/08:84`)仍写着 M2+M3 两份、 +8 步里 4 步过期、标题至今是『草案』从未固化进 `git-workflow.md`」—— +**即『四份』这个正确结论只活在回顾表格里,两处可执行入口(checklist 正文 + 纪律段)都还是旧的。** + +裁决 **NEEDS WORK**。解除条件:把 `releases.md:131` 的「双份」改「四份」, +同步 `iteration-3/08` checklist 正文,并把 checklist 从「草案」固化进 `git-workflow.md`。 + +**(b)状态检查上下文选了 `(push)`,与「等 PR 检查转绿」的指令不是一回事** + +`releases.md:110-114` 登记的必需上下文是: + +| 仓库 | protected | 状态检查上下文 | +| --- | --- | --- | +| patbond-api | `true` | `CI / backend-test **(push)**` | +| patbond-flutter | `true` | `CI / flutter-gates **(push)**` | +| patbond-doc | 未启用 | —— | + +而 `releases.md:133` 的指令是「3. 等 **PR 的** CI 状态检查转绿(即上表的 `status_check_contexts`)」。 + +**这两者不等价。** 我实测(§1.1)同一个 commit 上 `(push)` 与 `(pull_request)` 是**两个独立上下文**: + +``` +CI / backend-test (push) success 5m31s 09:44:05 +CI / backend-test (pull_request) success 6m42s 09:54:37 +``` + +api 工作流是 `on: push: branches: [dev]`。因此 `dev → main` 的 PR 场景下, +`(push)` 状态是**推 dev 时就已经写好的**,PR 开出来之前它就是绿的。 +**结论:这个门禁实际由「推 dev」满足,而不是由 PR 满足。** +若某次 PR 的 `(pull_request)` 检查失败、而先前推 dev 的 `(push)` 是绿的,**合并仍会被放行** —— 门禁形同虚设。 + +`releases.md:116` 自承选择显式写死上下文是为了消除「留空 = 空集为真反而放行」的歧义, +方向正确,但**挑错了上下文**:要真正在 PR 时把关,必需上下文应是 `(pull_request)`(或两者都要求)。 + +裁决 **NEEDS WORK(真实的门禁漏洞)**。解除条件:把必需上下文改为 +`CI / backend-test (pull_request)` / `CI / flutter-gates (pull_request)`,或两个上下文都列为必需, +并重新用 Gitea API 核实后更新 `releases.md` 表格。 +(注:我侧 `GET /branch_protections` 返回 **401**,无 token 故无法复核当前实际配置, +上述判断基于文档登记值 + 我实测到的上下文命名事实。**修正前必须先用有权限的 token 核一遍实际配置。**) + +**(c)doc 仓的口径需要说清楚,避免误读** + +`releases.md:128` 的「`main` 已禁止直接推送——影响 `main` 的一切变更一律走 PR」是**全局语气**, +但 `:114`/`:116` 明确 doc 仓 main **未启用保护**、「即日常分支、不参与发布分支语义,按规划不设保护」。 +两处并存容易被后续 agent 误读成「doc 也要走 PR」。且 doc 工作流是 `on: push: branches: [main]`, +**若哪天真给 doc main 加上保护并要求 `(push)` 上下文,会直接死锁**(推 main 被禁 → `(push)` 永不产生 → PR 永不可合)。 +建议在 `:128` 加一句限定「(api 与 flutter 两仓;doc 仓 main 保持直推)」。裁决 **NEEDS WORK(表述风险)**。 + +### 4.2 服务器侧:文档站公开可访问,登记项仍悬空 + +`server-exposure.md` §2 常驻服务表中该行原文: + +> **文档站(patbond-doc)** | 经 nginx 443 | mkdocs 构建产物,含架构/部署/迭代全部文档 | —— | +> ✅ 运行;⚠️ **公开可访问,待评估是否加 basic auth 或 IP 白名单**(无凭证内容,但暴露内部架构细节) + +**现状核实**: + +- 该项**仍是「待评估」,无结论、无归属决策**(「归属决策」列为空 `——`)、无 ADR 记录。 +- `https://git.patbond.cn/` 实测返回 **200**(nginx 在服;文档站按 `sites-enabled` 另一域名分流,我未探测其域名, + **未取证**:文档站实际 URL 与是否真的无鉴权)。 +- §1 端口表「最后确认」全部停在 **2026-09-11**。而 `server-exposure.md` 自订纪律 ② + 是「**每次迭代收官核对一遍,更新『最后确认』**」,M3.5 收官与 v0.4.0 发布均发生在 **09-14**, + 该列**未更新**。按同文档纪律 ⑤「本页与实际不符即为缺陷」,这本身是一处待补。 + +**我的评估与建议(只取证与建议,不动服务器)**: + +风险等级判**中**,倾向「应当加访问控制」,理由是三条**已发生**的事实叠加: + +1. 文档站内容包含 `server-exposure.md` 本身 —— 即**一份完整的对外端口清单与常驻服务清单**, + 还包含 `ci-runner-setup.md`(CI 拓扑)、`decisions.md`(全部 22 条架构决策)、 + 数据库正典 SQL(全表结构与约束)。这是一份**给攻击者的现成侦察报告**。 +2. 本项目**刚刚发生过**一次真实入侵尝试(`iteration-3.5/07`,Gitea gitconfig 注入), + `server-exposure.md` 的缘起就是「Nacos 在 ADR-002 移除后仍暴露公网近两个月」—— + **说明本环境的暴露面治理确实曾经失守过**,不是理论风险。 +3 该站与 Gitea **同一台机器、同一个 nginx**(`sites-enabled/` 同级分流)。文档站的任何 nginx 配置失误 + 与代码托管共享爆炸半径。 + +**建议**:加 IP 白名单或 basic auth(二者皆可,basic auth 更省事),并把结论写成 ADR 或在 +`server-exposure.md` 的「归属决策」列填上,把状态从「待评估」改成终态。 +成本约十分钟 nginx 配置,**不应再挂第四个迭代**。 + +⚠️ 但需注意一条**执行顺序约束**:若加 basic auth,`mkdocs build` 的产物是静态站,不受影响; +但若有任何自动化在拉取文档站 URL 做校验,会被 401 打断 —— 我**未取证**是否存在这类消费方, +落地前应先 grep 三仓有无对文档站 URL 的自动访问。 + +裁决 **NEEDS WORK(登记项悬空,建议本迭代内闭环)**。 + +## 5. 真实问题清单(按严重度排序) + +> 编号 P1~P12。「新发现」= 本报告首次登记;「已登记」= 文档已知但状态需纠正。 + +### P1 —— 阻断级:M4 无任何 AI 服务商 endpoint / 凭证 / 额度(新发现) + +密钥面共 4 项,无一与 AI 相关;代码库零 provider SDK;正典种子 `provider_code='fixture'`。 +**影响**:「AI 创作端到端可验证」当前是无证据的口号。**必须在开工前二选一**(fixture 路 / 真模型路,见 §3.1)。 +`generation_jobs` 三个 NOT NULL 快照列迫使这个选择必须显式。 + +### P2 —— 阻断级:服务端零对象写能力,「媒体链路可复用」被高估(新发现) + +全库 S3 调用仅 `createBucket`/`headBucket`/`headObject`/`close`。Worker 落产物需 `putObject`、 +喂输入需 `getObject`,**两者皆为净新增**。 +**影响**:M4 媒体侧工作量被系统性低估;规划中「直接复用」的措辞必须改写(§3.3)。 + +### P3 —— 高:埋点白名单实为 41 条,四份文档写成 42,且无测试锁定(已登记数字错误) + +实测 `Map.entry` 41 个。差异来源已定位到 **`feature-checklist.md:218` 的算术错误**: +它写「community 域 **19** + experiment_exposed」= +20 → 22+20=42; +而 `EventDictionary.java` 类注释自己的拆分是 post **8** + feed **2** + interactions **8** = **18**, +18 + `experiment_exposed` 1 = 19 增量,22+19 = **41**。即「community 域 19」把 `experiment_exposed` 重复计了一次。 +**影响**:中等(数字失真,不影响运行),但**无任何测试断言该数量**(唯一规模断言是 `operations()==45`), +所以这个数字会继续漂。**建议加一条 `WHITELIST.size()` 断言把它钉住**,成本一行。 + +### P4 —— 高:v0.4.0 发布记录把 381 测试写成 379(已登记数字错误) + +实测 `3cd8005` = **381**。差异来源已定位:`iteration-3.5/04:150-159` 明确记录契约冻结 +「测试数 **379 → 381(+2)**」,而 `3cd8005` **正是那次契约冻结的提交**。 +`releases.md:17`、`:35`、`feature-checklist.md:5` 三处写 379,都是照抄了冻结**前**的数字。 +**影响**:`releases.md` 把 hash `3cd8005` 与 379 绑在一行,是内部不自洽的发布记录; +后续任何「测试数应为 379」的回归判断都会误判。 + +### P5 —— 高:门禁漏洞——必需状态检查选了 `(push)` 而非 `(pull_request)`(新发现) + +见 §4.1(b)。`(push)` 在 PR 开出前就已绿,门禁实际由推 dev 满足;PR 检查红也能合并。 +**影响**:`main` 分支保护的实际强度**低于文档宣称**。这是本次发现的唯一安全/流程类真实漏洞。 + +### P6 —— 中高:M4 极可能再批量产出「只能真机验」的挂起项(新发现) + +三条硬约束叠加:`platform` enum 仅 `[android, ios]`(桌面埋点整批 400); +Linux 桌面无选图/压缩原生实现(走替身);四份 E2E + `integration_test/` 4 个测试**全在 CI 之外**, +`flutter test` 的 2 个 skip 也是需后端的 smoke。 +**影响**:若不先建后端侧 Worker 状态机自动化测试并拍板 `platform` 枚举,M4 收官时挂起项会从 10 项继续往上加。 + +### P7 —— 中高:真机挂起项实为 10 项,`releases.md` 只登记 6 项(已登记但漏项) + +`releases.md:121` 漏掉整个 M3.5 四项。转述链 4→6→8→10 每一跳都在丢项(§2.1)。 +其中 **M3.5-3 caregiver 项因前置造数 SQL 从未补,当前不可执行**。 +**影响**:M4 规划若照 `releases.md` 估算收尾工作量,会低估 4 项。 + +### P8 —— 中:发布纪律段仍写「E2E 双份回归」,前瞻指令错误(新发现) + +`releases.md:131` 与同文件 `:36`/`:56` 矛盾,且错在给 M4 用的前瞻侧; +叠加 checklist 正文(`iteration-3/08:84`)仍写两份、8 步中 4 步过期、至今是「草案」未固化。 +**影响**:M4 发布会漏跑 24 个 E2E 场景。 + +### P9 —— 中:`429` 限流在后端与契约**双零命中**,客户端退避分支是死代码(已登记) + +契约里连 429 响应码都未声明。M4 上 AI 生成必然需要配额限流,届时这条分支才第一次有意义(§2.5)。 + +### P10 —— 中:`widthPx`/`heightPx` 永久 NULL,且有一条「测试绿但生产空」的陷阱(已登记 + 新发现陷阱) + +写路径与更新路径均不存在;`PostMediaAttachIntegrationTest:44-45` 断言 640/480 靠的是 +`CommunityTestData:55` 的夹具直插。**这是 M3.5「nickname」教训的同型复发。** +M4 的 Worker 天然知道产物尺寸,**有一次近乎零成本修掉的机会**(§3.3 末)。 + +### P11 —— 中:`eventVersion` 服务端零校验,契约描述已过期(已登记) + +契约称「字典 v1 全部为 1」,实际字典 v3;服务端仅 `@NotNull`,任意正整数均被接受(§2.4)。 +M4 新增事件前必须定口径。 + +### P12 —— 低:`patbond-doc` 无 `.gitignore`,`site/` 仅靠**全局** gitignore 屏蔽(Evidence Collector 取证,我侧补充成因) + +我侧补充证据:`git check-ignore -v site` 返回 `/home/lx/.gitignore_global:183:/site`, +即屏蔽规则来自**本机用户级全局配置**,不在仓库内。 +**影响**:任何新机器/新克隆/CI 容器里跑 `mkdocs build` 都会让 `site/`(数百文件)变成未跟踪, +极易被误 commit。修复成本:仓库根加一行 `site/` 的 `.gitignore`。 + +### 另记:一处**正向**偏差(不是问题,但必须纠正认知) + +CI 并非「仍红」。三仓 HEAD 全绿、`(push)` 与 `(pull_request)` 双上下文均已真实跑过(§1.1)。 +`iteration-3.5/04 §7.3` 的「CI 仍红」是 09-11 的历史快照,**M4 开工不应把它当风险项继承**。 + +## 6. 逐项裁决与解除条件 + +### 6.1 基线资产(M4 可以放心站上去的部分) + +| 项 | 裁决 | 依据 / 解除条件 | +| --- | --- | --- | +| 契约 v1.4.0 规模与四模块字节级快照锁 | ✅ **CERTIFIED** | 32/45/75 逐格相符;md5 五处一致。无条件可用 | +| Flyway V1~V5 链 + `generation_job_id` 裸列 | ✅ **CERTIFIED** | `V5:48` 零 `REFERENCES`,`V5:95` 索引在,无 V6 | +| 六容器编排 | ✅ **CERTIFIED** | 6 services 实测;**且 M4 无需扩容**(§3.2) | +| Worker 队列基础设施 | ✅ **CERTIFIED(无需 Redis/MQ)** | 正典 SKIP LOCKED 租约设计 + 五分支状态机 CHECK + 两个偏索引 + 已有 `@EnableScheduling` 先例 | +| ADR-001~022 连续无缺号 | ✅ **CERTIFIED** | 22 个标题实测 | +| mkdocs `--strict` 可构建 | ✅ **CERTIFIED** | exit 0,1.71s | +| CI 三仓门禁 | ✅ **CERTIFIED** | 五个上下文全 `success`(09-14)。**注:门禁强度另见 P5** | +| flutter 597 测试 | ✅ **CERTIFIED(带注)** | 597 passed;注:2 个 env-gated smoke 默认跳过 | +| api 测试全绿 | ✅ **CERTIFIED(数字须改 381)** | BUILD SUCCESS、0 失败 0 跳过;**解除条件**:把三处 379 改 381 | + +### 6.2 M4 开工前必须拍板/解阻塞(BLOCKED 与高危 NEEDS WORK) + +| 项 | 裁决 | 解除条件 | +| --- | --- | --- | +| AI 模型服务来源 | 🔴 **BLOCKED** | 二选一并写进 ADR:**路 A** 只做 fixture provider(推荐,全链路可自动化验证);**路 B** 接真模型,则须先备齐 endpoint + 凭证注入方案(`init-secrets.sh` 第 5 项 + compose 环境变量)+ 额度上限 + 超时/失败口径。**在此之前不得声称「端到端可验证」** | +| 「端到端可验证」的定义 | 🔴 **BLOCKED** | 随上一条同时定义。若走路 A,须显式写明「产物为 stub,不验证生成质量」 | +| 服务端对象读写能力 | 🟠 **NEEDS WORK** | 承认为净新增工作项并排期:`putObject`(产物落盘)+ `getObject`/预签名读(输入喂模型)+ `purpose` 白名单扩 AI 用途(改配置 + 契约枚举,无需迁移) | +| 「媒体链路可直接复用」表述 | 🟠 **NEEDS WORK** | 规划文本改写为「读链路与客户端上传链路可复用;服务端读写对象为净新增」 | +| Worker 归属模块与容器数 | 🟠 **NEEDS WORK** | 出 ADR:新建 `patbond-creation`(compose 6→7)还是并入现有模块(需评估线程池隔离)。当前无结论 | +| 租约超时回收器 | 🟠 **NEEDS WORK** | 正典给了 `ix_generation_jobs_running` 索引但无回收代码。M4 必须实现,否则 `running` job 崩溃即永久卡死。与既有「`uploading` 超时清理」同型缺口,建议一并做 | +| M4 是否再产出真机挂起项 | 🟠 **NEEDS WORK** | 三条并行解除:① 后端 Worker 状态机进 `mvnw test` 门禁(不需真机/真模型,**M4 第一优先**);② 拍板 `platform` enum 是否加 `linux`/`desktop`;③ 新增第五份 E2E 脚本覆盖入队/轮询/取消契约面 | +| 「零迁移」预期 | 🟠 **NEEDS WORK** | 明确写进规划:**M4 必然需要 V6**(`creation` schema 完全不存在),零迁移惯例在此中断 | +| V6 工作量 | 🟠 **NEEDS WORK** | 按 `CREATE SCHEMA` + 3 表 + ~12 索引 + 3 触发器 + 补 1 外键估,不是轻量迁移。需 DBA/API 角色出跨 schema 外键保留/裁剪定型表 | + +### 6.3 遗留项裁决(M4 需明确「修」还是「继续挂」) + +| 项 | 裁决 | 解除条件 / 建议 | +| --- | --- | --- | +| 真机挂起 10 项 | 🟠 **NEEDS WORK** | 先修**登记口径**(`releases.md:121` 补 M3.5 四项、`feature-checklist.md` 补跟踪 2 项);再按 §2.2 分类推进 | +| M2 两项 @ 09-21 时限 | 🟢 **可行(判 CERTIFIED-可行)** | 本机 `Pixel_7` AVD + `/dev/kvm` 齐备,约 1 小时挂钟即可完成。只差导出 `ANDROID_HOME` / `PATH`。**「等真机」是伪阻塞** | +| M3.5-3 caregiver 项 | 🔴 **BLOCKED** | 前置造数 SQL 从未补,有设备也跑不了。解除条件:先补 `pet_health` 协作表造数 SQL | +| `widthPx`/`heightPx` 恒 NULL | 🟠 **NEEDS WORK(建议 M4 顺带修)** | Worker 已知产物尺寸,写入近乎零成本;不修则 AI 产物继续回落 4:3 | +| `eventVersion` 口径 | 🟠 **NEEDS WORK(M4 前必须定)** | 定「版本号随字典版本」还是「随单事件 schema」;同步改契约描述(现称「字典 v1 全部为 1」);补服务端校验 | +| 429 限流 | 🟠 **NEEDS WORK(M4 强相关)** | AI 生成需配额限流。落地时须同时在契约声明 429 + `Retry-After`,客户端死分支才能激活并被测到 | +| 「我的收藏与草稿」页 | 🟠 **NEEDS WORK** | 若 M4 产品路径含「多个 AI 产物存草稿再挑发」,则草稿列表是**前置依赖**,当前只能恢复最新一条、多草稿互相覆盖 | +| `experiment_exposed` 客户端缺失 | 🟠 **NEEDS WORK** | 服务端字典已有,Flutter 零引用。若 M4 要做 A/B,需补客户端发射点 | +| 白名单数量无测试锁定 | 🟠 **NEEDS WORK** | 加一行 `WHITELIST.size()` 断言,防数字继续漂 | + +### 6.4 流程与服务器侧 + +| 项 | 裁决 | 解除条件 | +| --- | --- | --- | +| 必需状态检查上下文选型 | 🟠 **NEEDS WORK(真实漏洞)** | 改为 `(pull_request)` 或双上下文皆必需;用有权限 token 复核实际配置后更新 `releases.md` 表格。**我侧 401 未能复核当前配置** | +| E2E 回归份数(前瞻指令) | 🟠 **NEEDS WORK** | `releases.md:131` 双份→四份;同步 checklist 正文;从「草案」固化进 `git-workflow.md` | +| doc 仓 main 口径表述 | 🟠 **NEEDS WORK(低)** | `releases.md:128` 加限定「api 与 flutter 两仓;doc 保持直推」。**警告**:若日后给 doc main 加保护并要求 `(push)`,因工作流是 `on: push: branches:[main]` 会**死锁** | +| 文档站访问控制 | 🟠 **NEEDS WORK(建议本迭代闭环)** | 加 basic auth 或 IP 白名单;把 `server-exposure.md` 该行状态从「待评估」改终态并填「归属决策」列 | +| `server-exposure.md` 最后确认日期 | 🟠 **NEEDS WORK(低)** | 全表停在 09-11,但 v0.4.0 发布在 09-14。按该文档纪律 ②/⑤ 应更新 | +| `patbond-doc` 缺 `.gitignore` | 🟠 **NEEDS WORK(低)** | 仓库根加 `.gitignore` 含 `site/`;当前仅靠 `~/.gitignore_global:183` 屏蔽 | + +## 7. 给 M4 的最小可信化建议 + +不改变 M4 的产品目标,只让它**可被证明**。按优先级: + +1. **先拍 AI provider 的板(路 A / 路 B),并写进 ADR。** 这是唯一的阻断项,其他一切排期都依赖它。 + 若一周内拿不到真模型凭证,就走路 A —— **fixture 路完全可以交付一个诚实的、全自动验证的 M4**。 +2. **第一波先建后端 Worker 的自动化护栏,不碰客户端。** 用 Testcontainers 覆盖:入队幂等、 + SKIP LOCKED 并发领取、租约过期回收、重试退避、`max_attempts` 耗尽、五状态机流转。 + 这些**零外部依赖、零真机、可进 CI**,是 M4 唯一能靠自动化门禁守住的部分,应当先做厚。 +3. **顺手修两个近乎零成本的历史遗留**:`media.assets` 尺寸写入(Worker 本来就知道), + 以及白名单数量断言。都是一次改动换一个永久防漂。 +4. **拍板 `platform` 枚举。** 加 `linux`/`desktop` 能一次解掉横跨 M2/M3/M3.5/M4 的 + 「事件落库只能真机验」死结,收益跨四个迭代,成本是一行契约 + 快照同步。 +5. **本迭代内清掉 M2 两项真机验证**(09-21 时限,模拟器 1 小时即可),别让北极星首批读数标「未验收」。 +6. **修掉发布流程的两处(P5 门禁上下文、P8 双份/四份)**,否则 M4 发布会重复本次的漏洞。 +7. **不要引入 Redis/MQ。** 正典已否决其必要性;引入等于凭空多一个容器、一套运维面、一份 ADR。 + +--- + +## 附:本报告的取证边界 + +**已亲自取证**(命令 + 输出 / 文件 + 行号,均在正文内):三仓 HEAD 与 tag 与洁净度、 +`mvnw clean test` 全量实跑(381)、`flutter test` 全量实跑(597 + 2 skip)、 +openapi 32/45/75 与五处 md5、Flyway V1~V5 与 `V5:48`、compose 6 服务、 +`EventDictionary` 41 条、ADR 22 条、`mkdocs build --strict`、 +**Gitea 五个 CI 上下文实时状态**、`platform` enum、`eventVersion` 全链路、 +429 双零命中、`widthPx` 写路径缺失、S3 调用面全集、`.env`/`init-secrets.sh` 密钥面、 +`purpose` 白名单、收藏/草稿 UI 缺失与死代码、E2E 场景自报计数、 +Android SDK/AVD/KVM 可用性、`device-verification.md` 10 项、`releases.md` 内部矛盾、 +`server-exposure.md` 登记项、`patbond-doc` 无 `.gitignore` 的成因。 + +**未取证(明确列出缺什么)**: + +| 项 | 缺什么 | +| --- | --- | +| E2E **234 断言**精确值 | 机械可数 226(210 `check(` + 16 `✗`)。差 8 处需人工核对 `assertMeShape` 类辅助函数的内部断言计法 | +| E2E 四份**零失败重跑** | 需 compose 六容器起栈 + 串行跑约数十分钟。本次**未重跑**,仅采信 `08` 号报告 | +| **契约矩阵 181 格** | 无机械可数来源;代码内唯一规模断言是 `operations()==45`。需人工按 `iteration-3.5/04 §3` 表逐格复核 | +| **零迁移在存量库上实证** | 需起 compose 于既有 `pgdata` volume 观察 Flyway 日志。未执行 | +| `check-secrets.sh --all` | 未执行(三仓) | +| **`main` 分支保护实际配置** | `GET /branch_protections` 返回 **401**,无 token。文档登记值未能复核 —— **P5 修正前必须先用有权限 token 核实** | +| 服务器侧实际暴露面 | 未登录服务器执行 `ss -tlnp`;文档站实际域名与是否真无鉴权未探测(仅确认 `git.patbond.cn` 返回 200) | +| 跨 schema 外键保留/裁剪定型 | 需 API/DBA 角色出定型表,非本报告职责 | + +**未修改任何生产代码**;未改 `mkdocs.yml`;未执行任何 `git commit` / `push`。 +`mkdocs build --strict` 重建了被全局 gitignore 屏蔽的 `patbond-doc/site/`,三仓 `git status` 仍为空。 + diff --git a/docs/development/iterations/iteration-4/05-ai-create-ui-spec.md b/docs/development/iterations/iteration-4/05-ai-create-ui-spec.md new file mode 100644 index 0000000..608a7ec --- /dev/null +++ b/docs/development/iterations/iteration-4/05-ai-create-ui-spec.md @@ -0,0 +1,1117 @@ +# 05 · 第四迭代 AI 创作 UI 设计规范 + +> 作者:UI Designer +> 日期:2026-09-14 +> 迭代:Iteration 4「M4 AI 创作」 +> 性质:开工前设计规范;**只定规格,不改代码**(本次未触碰 `patbond-flutter` 任何文件、未改 `mkdocs.yml`、未 commit) +> 素材来源(全部已开源码/原文核实,行号见各节): +> - 品牌正典 `AI宠物_iOS_UI设计稿.html`(ADR-005),**其第三画框正是「AI 创作」**(:317-356) +> - 落地 token `patbond-flutter/lib/core/theme/app_theme.dart` +> - 前序规范 `iteration-1/04`、`iteration-2/05`、`iteration-3/05`、`iteration-3.5/05`(本规范延续其写法与颗粒度) +> - 冻结契约 `patbond-doc/docs/api/openapi.yaml` v1.4.0 +> - `creation` schema 设计稿 `patbond-doc/docs/database/patbond_postgresql.sql:556-716`(**未进 Flyway、未实现,但状态机已定型**) +> - 现状 demo `patbond-flutter/lib/features/create/create_page.dart`(563 行,M4 的替换目标) + +## 结论先行 + +1. **界面 8 个单元**:6 页(A1 创作首页 / A2 参数页 / A3 等待页 / A4 结果页 / A5 我的 AI 作品 / A6 全屏查看)+ 1 sheet(S1 配额说明)+ 1 全局层(G1 进行中指示位)。 +2. **等待态推荐方案**:提交后自动进 A3,**明确允许离开**;进度用「阶段文字 + 不确定进度环」为主形态,`progress>0` 时才叠确定型数字;**不展示预计剩余时间,改展示已用时**;回来的路三条(G1 全局条 → Tab 角标 → A5 列表)。理由见 §2.3。 +3. **组件账:新建 8,复用 16(其中 6 需小改)**。全局角标 / 全局悬浮层 / 跨页任务状态层在客户端**零先例**,是本迭代最大的一块新建(§4)。 +4. **`widthPx`/`heightPx` 债在 M4 由隐性升为显性**,严重度**中高**,但修复成本极低(AI 输出路径**不需要图片解码**)。判断与三处修法见 §8.3。 +5. **待拍板决策 18 项**(§9)。最关键三项:D1 等待态形态与「不承诺推送」的文案纪律、D2 参考图必填与否(**任务描述的「可选参考图」与设计稿 `input_asset_id NOT NULL` 冲突**)、D3 配额契约(429 与两个新错误码尚不存在)。 +6. **两处必须先纠正的事实误解**,否则整条链路会照错误前提施工:见 §0.6。 + +--- + +## 0. 前置核实 + +> 纪律说明:M3.5 有过「一份报告的转述结论被全链路采信」的教训。本节所有「有 XX / 没有 XX」的判断都开过源码,注明文件与行号。凡未取证的,明写「未取证」。 + +### 0.1 设计 token 的唯一出处 —— `lib/core/theme/app_theme.dart` + +本规范**不发明任何新色值、新圆角值**。全部取自该文件: + +| token 类 | 定义位置 | 取值 | +| --- | --- | --- | +| `AppColors` | `app_theme.dart:5-67` | 17 个语义色 + `brandGradient`(:62-66,`primary → accent` topLeft→bottomRight) | +| `AppRadius` | `app_theme.dart:70-85` | `sm 12` / `md 16` / `lg 18` / `xl 24` / `pill 999` | +| 字级 | `app_theme.dart:105-123` | `headlineSmall 22/w800`、`titleLarge 18/w800`、`titleMedium 15/w700`、`bodyMedium 14/h1.5`、`bodySmall 12/h1.4`(**默认色 `muted`,DEBT-2 重灾区**) | +| 卡片 | `app_theme.dart:124-132` | 白底 + `border` 1px + 圆角 `xl` 24 + elevation 0 + margin 0 | +| 主按钮 | `app_theme.dart:133-145` | `primaryStrong` 底 + 白字 + 最小 64×52 + 圆角 `md` 16;禁用 `ink` 12% 底 + 38% 字 | +| 输入框 | `app_theme.dart:146-172` | 白底 + `border` 描边 + 圆角 `lg` 18 + padding 16/14;聚焦 `primary` 1.5px | +| 底栏 | `app_theme.dart:180-195` | `surface` 底 + `surfaceTint` 指示器 + height 72 + label 11 | + +**间距没有 token 类**(已核实:`app_theme.dart` 中无 `AppSpacing`/`AppGaps` 之类)。全仓 padding 是散落硬编码(14 / 16 / 18 居多)。本规范沿用前三份规范的口头刻度 **4 / 8 / 12 / 16 / 24 / 32**,页面内边距 `EdgeInsets.fromLTRB(16, 12, 16, 28)`(与 `create_page.dart:134`、`post_compose_page.dart:540-541`、`home_page.dart:357-358` 一致)。 + +**正典色值与落地 token 逐一对齐**(`AI宠物_iOS_UI设计稿.html:12-21`):`peach #FFE8D6`=`surfaceTint`、`coral #FF6F4C`=`primary`、`coral-dark #7A2E12`=`primaryDark`、`amber #FFB648`=`accent`、`amber-dark #7A4B0A`=`accentDark`、`ink #3E2A1F`=`ink`、`muted #9C8977`=`muted`、`border #F0DCC8`=`border`、`sage #7FA88A`=`success`、`sage-bg #E8F0E8`=`successSurface`。零漂移。 + +### 0.2 服务端能给什么、给不了什么(决定设计边界) + +**确定不存在(v1.4.0 冻结态)**: + +| 事项 | 证据 | +| --- | --- | +| 任何 AI 创作端点、模型/风格目录端点 | `openapi.yaml` 32 条 path 全清单中零命中;`patbond-api/pom.xml:16-20` 只有 5 个模块,无 creation | +| 任何异步任务状态查询/轮询端点 | 同上;唯一定时器是 `SessionCleanupJob.java:32-35`(内部清理,无对外状态) | +| 429 / 配额 / 限流错误码 | `ErrorCode.java:10-38` 共 27 个业务码,无 429/`TOO_MANY`/`RATE_LIMIT`/`QUOTA`;`openapi.yaml:32-62` 错误码表同;`feature-checklist.md:194` 把 429 记为 ⬜ 未做 | +| AI 提供方决策 | 22 条 ADR(`decisions.md:8-195`)无 AI 供应方;`development-plan.md:358` 明写「AI 提供方…**尚未确定**」 | +| `creation` schema 实际建表 | Flyway 止于 `V5__community_baseline.sql`;`decisions.md:192`「下一个版本号 V6 留给后续」 | +| 视频能力 | `openapi.yaml:3456-3457` `kind` enum 只有 `image`(ADR-018 视频后置,`decisions.md:159-161`) | +| WebSocket / SSE / 推送 | 无依赖、无端点 | + +**确定存在、可直接复用**:两步上传闭环(`openapi.yaml:1223`、`:1256`);`media` schema 可承载 AI 输入输出(`development-plan.md:64`);`purpose` 白名单是**配置项而非 DB CHECK**(`decisions.md:192`),新增 `ai_input`/`ai_output` **零迁移**;`posts.category='ai_creation'` 与 `posts.generation_job_id` 裸列已在库(`V5__community_baseline.sql:48`、`:68`);游标分页正典 `{items, nextCursor, hasMore}`(`openapi.yaml:133-134`)。 + +**⚠ 写侧当前拒收 `ai_creation`**:`CreatePostRequest.java:26` 的 `@Pattern(regexp = "general|help")`,提交即 400/40000。M4 必须放开(§10 诉求 R5)。 + +### 0.3 `creation.generation_jobs` 状态机(本规范等待态设计的唯一依据) + +设计稿 `patbond_postgresql.sql:605-716`。**未实现、未冻结进契约**,但状态-字段联动是 CHECK 级铁律(`:667-691`),UI 可安全依赖: + +| status | progress | 其他字段铁律 | UI 含义 | +| --- | --- | --- | --- | +| `queued` | **恒 0** | `started_at` / `completed_at` / `output_asset_id` / `error_code` 全 NULL | 排队中 | +| `running` | **0..100,无下限约束** | `started_at` 非空;`output_asset_id` / `error_code` NULL | 生成中 | +| `succeeded` | **恒 100** | `output_asset_id` **非空**、`error_code` NULL、`completed_at` 非空 | 已完成 | +| `failed` | 无约束 | `error_code` **非空**、`output_asset_id` NULL、`completed_at` 非空 | 失败 | +| `cancelled` | 无约束 | `completed_at` 非空;output/error 均无约束 | 已取消 | + +其余可用字段:`attempt_count` / `max_attempts DEFAULT 3`(`:628-629`)、`error_code varchar(64)` / `error_message varchar(1000)`(`:631-632`)、`prompt varchar(10000)` / `negative_prompt varchar(3000)`(`:614-615`)、`width_px`/`height_px` 各 64..8192(`:616-617`、`:649-653`)、`upscale`(`:619`)、`pet_id` nullable(`:611`)、`input_asset_id` **NOT NULL**(`:612`)、`output_asset_id` **单列 nullable**(`:613`)。 + +**从这张表直接推出的三条设计约束**(详论见 §2.3): + +1. `queued` 时 progress 恒 0 → **排队态绝不能画确定型进度条**,否则用户看到 0% 不动会判定卡死。 +2. `running` 时 progress **无下限**(提供方可能不上报,一直是 0)→ UI 必须能画不确定型,**确定型只作为可选增强**。 +3. `output_asset_id` 是**单列** → **M4 结果恒单图**。多图需要 job→N assets 的关系表,设计稿没有。A4 按单图设计,布局为多图预留但不实现(§2.4)。 + +### 0.4 正典「AI 创作」画框已给出的语言(本规范全部延续) + +`AI宠物_iOS_UI设计稿.html:317-356` + CSS `:150-173`: + +| 正典元素 | 描述(CSS 行号) | 本规范处置 | +| --- | --- | --- | +| header「AI 创作」+ 右上 `🕘` icon-btn | :323-326 | **保留**,🕘 即历史入口 → A5「我的 AI 作品」(正典早已给出这个入口位,不是新增提案) | +| `.ai-hero` | :151-156,`coral→amber` 135° 渐变、圆角 20、padding 18、**白字**、h3 16、p 11 opacity .92 | 形态保留,**承字色必须改**(白字于该渐变仅 2.22:1,§7.2 D9) | +| `.upload-btn` | :157-161,白 92% 底 + `coral-dark` 字 12/w600、圆角 12、padding 9 | **保留**(primaryDark 于白 92% 叠渐变最不利端 8.69:1,达标) | +| `.section-title` | :162,13/w600 `ink` | 收敛为既有 `titleLarge` 18/w800(与全 app 分区标题一致,正典 13 偏小) | +| `.style-grid` / `.style-card` | :163-173,**3 列网格** gap 10、圆角 14、`aspect-ratio 1/1.1`、`peach` 底、border 1px、底部 `rgba(62,42,31,.75)` 渐变 + 白字 9/w600 居中 | **采纳 3 列网格**(现 `create_page.dart:218-292` 是横滑 125 宽列表,与正典不符,§2.1 修订);圆角 14 收敛为 `sm` 12;scrim 75% 提到 80%(§7.2) | +| 「热门风格」+「AI 视频」两个分组 | :332-346 | **数据驱动**:目录端点返回哪些 `media_kind` 就渲染哪些分组;M4 只有 image 时**不渲染 kind 切换器**(§2.1、D6) | +| 「我的」页菜单「🖼 我的 AI 作品」 | :497 | A5 的第二个入口 | +| 会员卡「AI 会员 Plus / 无限 AI 生图」+ PRO 徽章 | :489-493 | **M4 不渲染**(无支付能力,点了是死路,§2.7 / D12) | + +### 0.5 客户端零先例的三件事(本迭代最大的新建量) + +逐项 grep 已核实: + +1. **全局角标 / 全局悬浮层 / 全局 banner —— 零先例。** `Badge`(Material)全 lib 零使用(唯一 `Badge` 相关命中是 `pet_avatar.dart` 的 `showEditBadge`/`_EditBadge`,那是头像编辑徽标不是计数角标);`FloatingActionButton` 零使用;`Overlay`/`OverlayEntry` 零使用;`MaterialBanner` 零使用(仅两个自绘**页内**横幅 `InlineErrorBanner`、`_RestoredDraftBanner`)。G1 要在 `MainShellPage.build` 的 Scaffold 层新开插槽。 +2. **跨页 / 跨进程的任务状态层 —— 零先例。** 全 lib 只有 3 个 Timer,无一是业务轮询(`analytics_service.dart:195` 埋点 flush、`feed_exposure.dart:48` 曝光停留、`splash_page.dart:49` 转圈延迟);`poll` 零业务命中。持久化只有 `SharedPreferences`(宠物 + 地区天气 + 埋点队列)与 `FlutterSecureStorage`(仅 token);无 Hive/sqflite/drift。`MediaUploader` 的纪律相反:凭据只存内存、离页 `reset()+dispose()`、杀进程即全丢。 +3. **月份网格选择器 —— 零实现**(§8.2)。 + +### 0.6 ⚠ 两处必须先纠正的事实误解 + +**(a)「可选参考图」与设计稿冲突。** 工单描述写「可选参考图(复用 M3 媒体上传)」,但 `patbond_postgresql.sql:612` 是 `input_asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT` —— **参考图在设计稿里是必填**。正典也一致:`AI宠物_iOS_UI设计稿.html:329`「上传一张照片,AI 帮你生成专属宠物写真」、`:330` CTA「+ 上传宠物照片」。即产品定位是**图生图(宠物写真)而非通用文生图**。这决定 A1 hero 的 CTA 语义、A2 的表单 gating、以及"能不能纯靠提示词生成"。**必须拍板(D2)**,本规范默认按「必填」写,并在 §2.2 给出「若改为可选」的降级形态。 + +**(b)M3.5 的「中文本地化」不是 i18n 框架。** 核实:`l10n.yaml` 不存在、`lib/l10n/` 不存在、`AppLocalizations` 全 lib 零引用、`pubspec.yaml` 无 `generate: true`。实际接入仅是 `lib/app/app_localization.dart` 挂了 Material/Cupertino/Widgets 三件套 delegate(:20-25)+ 锁 `Locale('zh','CN')` 单语言(:29、:38),消费点 `app.dart:314-315`。**业务文案全是源码内硬编码中文字面量。** → §5 文案表**不给 i18n key**,直接给中文原文 + 落点,与现仓一致。唯一的既有纪律是 `app_date_picker.dart:75-76`:Material 组件自带文案交给 delegate,不在业务代码重复硬编码。 + +### 0.7 证据强度声明(按协作方要求降级) + +- Flutter 测试基线以协作方实测为准:**597 通过 + 2 skipped**。本规范不引用测试数作为设计结论的依据。 +- `integration_test/` 下的真机/桌面实测脚本**完全在 `flutter test` 之外、无任何门禁会跑**。因此 `iteration-3.5/05` 报告 §4 的「桌面逐步实测截图」在本规范中**仅作为设计意图的参考,不作为「该 UI 链路已验证」的证据**。凡本规范引用既有组件行为,一律以源码行号为准,不以实测报告为准。 +- 未取证项:AI 提供方的真实生成耗时量级、是否上报进度、是否支持取消 —— 三者均取决于尚未产生的选型 ADR。本规范因此把「进度是否可用」做成可降级设计(§2.3),不押注任何一种。 + +--- + +## 1. 页面族总览与通用排版 token + +```text +创作 Tab(底栏 index 1,✨ auto_awesome,AppBar 标题「创作中心」) + └─ A1 创作首页(hero + 配额行 + 风格目录 3 列网格 + 右上历史入口) + ├─ A2 生成参数页(参考图 + 提示词 + 两级渐进披露) + │ └─ A3 任务等待页(提交后自动进;可离开) + │ ├─ A4 结果预览页(单图原比例 + 重新生成 / 暂不发布 / 发布到社区) + │ │ ├─ A6 全屏结果查看(复用提取) + │ │ └─ → PostComposePage(预填草稿,§2.6) + │ └─ S1 配额说明 sheet(429 出口) + └─ A5 我的 AI 作品与任务列表(网格 + 状态角标;第二入口在「我的」页菜单) + +G1 全局「进行中」指示位(跨 Tab;首页与创作 Tab 顶部条 + 底栏创作 Tab 角标) +``` + +**入口三处**(均已有正典依据,非新增提案):底栏创作 Tab(`main_shell_page.dart:277-281`)→ A1;A1 右上 `history` 钮(正典 `🕘`,:325)→ A5;「我的」页菜单「我的 AI 作品」(正典 :497)→ A5。 + +**通用排版 token**(延续前三份规范,不新造): + +- 页面内边距 `EdgeInsets.fromLTRB(16, 12, 16, 28)`;间距刻度 4 / 8 / 12 / 16 / 24 / 32;卡间距 16,分区间距 24 +- 圆角:卡片 `xl` 24(Card 主题默认)、输入框 `lg` 18、网格单格与内嵌块 `sm` 12、chip/胶囊 `pill` +- 字级:分区标题 `titleLarge` 18/w800;卡内标题 `titleMedium` 15/w700;正文 `bodyMedium` 14/h1.5;次级 12 **一律显式 `inkSoft`**(不用 `muted`,DEBT-2) +- Bottom sheet 沿用既有骨架(`showDragHandle` + `useSafeArea` + `isScrollControlled`,`avatar_upload_sheet.dart:45-48`) +- 错误三层模型沿用:字段 `errorText` / 区块 `InlineErrorBanner` + 重试 / 瞬态 SnackBar +- 触控目标 ≥44×44;不足时须在源码显式记录妥协(既有惯例 `comment_tile.dart:114-115`、`post_media_grid.dart:226`) +- `muted` 仅限:输入占位符、禁用态、纯装饰图标 + +## 2. 页面规范 + +### 2.1 A1 创作首页(模型 / 风格目录) + +替换 `create_page.dart` 的上半部。`_ComposeEntryCard`(:394-419,T3-17 落地的**真实**发布入口)**保留不动**——它是本页唯一已经真实的东西。 + +```text +AppBar(主壳既有:leading 头像+Patbond / 标题「创作中心」/ 通知钮) + ↑ 主壳 AppBar 不动;A1 自身的「历史」钮放在页面内容首行右侧 + (不塞进主壳 actions —— 那是全 5 个 Tab 共用的,塞进去会在其他 Tab 露出) +┌ G1 进行中条(仅有在途任务时出现,§2.7)────────┐ ← 页面第一位 +└──────────────────────────────────────────────┘ + ↓ 12 +┌ 发布动态入口(既有 _ComposeEntryCard,原样保留)┐ +└──────────────────────────────────────────────┘ + ↓ 16 +┌ AiHeroCard(正典 .ai-hero)────────────────────┐ +│ 变成毛孩子的写真 ink 18/w800│ ← 承字改 ink,非白字(§7.2) +│ 上传一张照片,AI 帮你生成专属宠物写真 ink 12/w500│ +│ ┌────────────────────────────────┐ │ +│ │ + 上传宠物照片 │ 白92%底 │ 正典 .upload-btn +│ └────────────────────────────────┘ primaryDark│ +│ ─────────────────────────────────────────────│ 白 40% 分隔线 +│ ✨ 今日还可生成 3 次 QuotaMeter │ +└──────────────────────────────────────────────┘ + ↓ 24 +热门风格 titleLarge 18/w800 + ↓ 12 +┌──────┐┌──────┐┌──────┐ StyleCard 3 列网格 +│ ││ ││ │ crossAxisSpacing 10 / mainAxisSpacing 10 +│ 吉卜力││ 迪士尼││ 漫画风│ childAspectRatio 1/1.1(正典 :166) +└──────┘└──────┘└──────┘ 格内圆角 sm12 +┌──────┐┌──────┐┌──────┐ +│ 水彩 ││电影海报││拟人化 │ +└──────┘└──────┘└──────┘ + ↓ 24 +(若目录含 video 类)AI 视频 同款 3 列网格,M4 无 video 则整段不渲染 +``` + +**「历史」钮落位**(正典 `🕘`,:325):内容首行右对齐一枚 `IconButton(Icons.history, tooltip: '我的 AI 作品')`,44×44,`inkSoft` 图标。**不放进 `MainShellPage` 的 AppBar actions**(`main_shell_page.dart:251-262` 是 5 个 Tab 共用的通知钮位,塞进去会在首页/档案/服务/我的四个 Tab 上错误露出)。 + +**`AiHeroCard` 规格**(新建):`brandGradient`(`app_theme.dart:62-66`)+ 圆角 `xl` 24(正典 20 收敛到 token)+ padding 18。承字 `ink`:标题 18/w800(最不利渐变端 4.90:1)、副标 12/w500(**不用透明度降权,靠字号字重区分**——白 92% 在此渐变上仅 2.0x:1)。CTA 白 92% 底 + `primaryDark` 12/w700 + 圆角 `sm` 12 + 高 44(正典 padding 9 撑不到 44)。分隔线白 40% 1px。 + +**`StyleCard` 规格**(新建,正典 `.style-card`): + +| 态 | 视觉 | +| --- | --- | +| 默认 | `surfaceTint` 底(预览图未到手时的占位色,正典 `peach`)+ `border` 1px + 圆角 `sm` 12 + `RemoteImage(fit: cover)` 预览图;底部 `transparent → ink 80%` 竖向渐变 + 白字标题 12/w700 居中 + 副标 10(有则渲染),底 padding 10/6 | +| 选中 | 描边转 `primaryStrong` **2px**(**不用 `primary`**:`primary/canvas` 仅 2.59:1 不达非文字 3:1,`primaryStrong/canvas` 4.23:1;现 `create_page.dart:237` 正是 `primary`,§8.5 修订)**+ 右上角 20 圆 `primaryStrong` 实底 + 白 `check` 图标 13**(双通道,不靠颜色单通道传达选中) | +| 按下 | `InkWell` ripple 圆角随格 | +| 不可用 | **M4 不出现**(目录端点只返回 `enabled=true` 的行——服务端已有 partial index `WHERE enabled`,`patbond_postgresql.sql:601-603`)。若拍板决定返回全量(D7),灰态形态为:整格 `Opacity 0.5` + 移除 `onTap` + 底部字条改「暂不可用」+ 不渲染选中角标 | +| 预览图缺失 | `preview_asset_id` 是 nullable(`patbond_postgresql.sql:586`)→ 无预览图时**不留空白格**:`surfaceTint` 底 + 居中 `auto_awesome` 28 `primary`(纯装饰,与 `EmptyState` 的 `muted` 图标同类,不受对比度约束)+ 底部字条照常 | + +**风格预览图从哪来**:`creation.generation_styles.preview_asset_id → media.assets`(`patbond_postgresql.sql:586`),即**走既有两步上传 + 预签名 GET 的同一条路**,客户端拿到的是时效性签名 URL,`RemoteImage` 直接消费(缓存 key 已剥签名参数,`common.dart:25-28`)。**不得持久化 URL**(纪律 R2)。运营侧如何把图灌进去(后台?种子数据?)**未取证** —— 缺一个「风格目录内容管理」的答案,属后端/运营范畴,记入 §10 R7。 + +**模型(model)怎么呈现 —— 与风格分开**:`generation_models` 与 `generation_styles` 是两张表(`:556` / `:580`)。设计判断:**A1 只呈现风格,不呈现模型**。模型是实现细节(`provider_model_name`),普通用户不需要在首屏做这个选择;默认取目录 `sort_order` 首个。多模型的选择下沉到 A2 的二级披露(§2.2)。现 `create_page.dart:176-188` 把「创作模型」做成首屏 `DropdownButtonFormField`(列出 `Patbond-V1 / Pet-Art Pro / Cute Motion` 三个假名),**这个层级是错的**,M4 修正。 + +**`media_kind` 切换器**:现 `create_page.dart:138-157` 的 `SegmentedButton`(AI 图片 / AI 视频)。M4 处置 = **数据驱动**:按目录返回的 `media_kind` 去重后决定;只有一种时**整个控件不渲染**(不给灰态占位——ADR-018 视频后置,契约 `kind` enum 只有 `image`,渲染一个永远点不动的「AI 视频」段是在承诺不存在的能力)。契约扩到 video 时 UI 自动长出第二段。**副作用:M4 因此绕开了这枚粉底控件的这一个使用点,但另 5 处仍在,主题债照旧要偿(§8.1)。** + +**状态矩阵**: + +| 态 | 呈现 | +| --- | --- | +| 首载 | hero 骨架(`surfaceTint` 圆角 `xl` 块)+ 6 格 `surfaceTint` 方块;呼吸动效沿 `feed_skeleton.dart:26-46` 同款(1200ms、`disableAnimations` 时静止在 1.0) | +| 空(目录 0 条) | `EmptyStateIllustration(icon: auto_awesome, title: '暂时没有可用的风格', description: '我们正在准备新的风格,稍后再来看看')`,**不给 CTA**(无处可去);hero 的上传 CTA 同时禁用(选不了风格就发不了任务) | +| 加载失败 | `InlineErrorBanner` + 其下「重试」`TextButton`;hero 保留可见但 CTA 禁用 | +| 进行中 | G1 条在页首(§2.7);hero 与目录**照常可用**(允许排多个任务,上限由配额约束) | +| 成功(就绪) | 如上线框 | +| 无权限 | **不作为页面态存在**。全部端点 401,而 App 登录后才进主壳(`splash_page.dart` → login),未登录到不了 A1;在途 401 由既有 `TokenRefresher` 兜底 | +| 配额耗尽 | 不打断浏览:`QuotaMeter` 转耗尽态(§2.7),hero CTA **仍可点**,到 A2 提交时才拦(**不在 A1 就禁用**——用户可能只是想逛风格,提前禁用会让人以为功能坏了) | + + +### 2.2 A2 生成参数页 + +push 全屏页(不用 sheet:有图片选择 + 长文本输入 + 两级披露,sheet 高度不够且键盘弹起时披露区会被压没)。 + +```text +AppBar:← 返回 / 「生成设置」/ (无右侧 action) +┌ 参考图区 ─────────────────────────────────┐ +│ ┌──────┐┌ + ┐ │ PostMediaEditGrid(复用) +│ │ 缩略图││虚线│ ← 上限 1 张,已选后+格隐藏 │ maxImages: 1 +│ └──────┘└────┘ │ 每格叠 UploadProgressOverlay +└──────────────────────────────────────────┘ + 已选风格:#吉卜力 ← TagPill(primary),右侧「更换」TextButton → 返回 A1 + ↓ 16 +┌ 提示词 ───────────────────────────────────┐ +│ 想让它变成什么样?(选填) │ 裸 TextField +│ minLines 3 / maxLines null │ hint「例如:坐在樱花树下,暖阳,胶片质感」 +│ 0/500 12 inkSoft│ 软上限 500(硬上限契约 10000,D8) +└──────────────────────────────────────────┘ + ↓ 12 +┌ 这是哪只毛孩子?(选填)───────────────────┐ +│ ( 豆豆 ) ( 花卷 ) ( 不指定 ) │ PetAvatar md44 横排 + 名字 11 +└──────────────────────────────────────────┘ (沿 iteration-2/05 §141 切换器先例) + ↓ 16 +▸ 更多设置 ← 一级披露(默认收起) + 画面比例 ( 1:1 )( 3:4 )( 4:3 )( 9:16 )( 16:9 ) ChoiceChip 组 + 高清增强 [ ●——] 提升毛发与眼睛细节 SwitchListTile.adaptive + ↓ 12 +▸ 高级 ← 二级披露(默认收起) + 不想出现的元素(选填) minLines 2 / 0/200 + 创作模型 ( Patbond-V1 )( … ) 仅目录 >1 个模型时渲染 + ↓ 24 +┌ 底部固定条(surface 底 + 顶部 border 1px)──┐ +│ 今日还可生成 3 次 QuotaMeter 12 inkSoft│ +│ ┌──────────────────────────────────────┐ │ +│ │ ✨ 开始生成(消耗 1 次) │ │ FilledButton 全宽 52 +│ └──────────────────────────────────────┘ │ +└──────────────────────────────────────────┘ +``` + +**渐进披露的分层依据**(三层,不是随意分的): + +| 层 | 项 | 为什么在这层 | +| --- | --- | --- | +| 常显 | 参考图、已选风格、提示词、宠物 | 参考图是必填(`input_asset_id NOT NULL`);风格是用户从 A1 带过来的,必须可见可改;提示词是本页的主要创作动作 | +| 一级「更多设置」 | 画面比例、高清增强 | 会改变**结果的样子**,用户可能想调,但有合理默认(1:1 + upscale 关) | +| 二级「高级」 | 负面提示词、模型 | 「负面提示词」是专业概念(普通用户不知道要填什么);模型是实现细节。放二级 = 承认它们存在但不打扰 90% 的人 | + +**披露控件不用 `ExpansionTile`。** 理由:`ExpansionTile` 的展开图标色、文字色、`collapsedBackgroundColor` 全走 `ColorScheme` 派生值,而 `app_theme.dart` **没有 `expansionTileTheme`**(已核实:`buildAppTheme()` 只定制了 8 个子主题,:105/124/133/146/173/179/180)。直接用它会**复制 `SegmentedButton` 粉底那笔债的成因**(§8.1)。改为自绘披露行 `GenerationParamSection`(新建):`Row`(`titleMedium` 标题 + `Spacer` + `expand_more`/`expand_less` 20 `inkSoft`)整行高 48 可点,展开区 `AnimatedSize` 200ms `Curves.easeOut`。 + +**画面比例给枚举,不给像素输入。** `width_px`/`height_px` 在设计稿是 64..8192 的自由整数(`patbond_postgresql.sql:616-617`、`:649-653`),但让用户填像素是灾难。UI 给 5 档比例,客户端按「模型基准边长 × 比例」换算成像素提交。**这需要目录端点给出每个模型支持的比例白名单与基准边长**,否则客户端会算出提供方不支持的尺寸 → §10 诉求 R2、拍板 D5。 + +**表单 gating(可提交条件)**: + +- 按 D2 默认(参考图必填):`参考图 ready`(`MediaUploader.allReady` 且 `readyCount == 1`)**且** `已选风格非空` **且** 无在途提交。提示词**不**参与 gating(选填)。 +- **若 D2 改为「参考图可选」**:gating 降级为 `已选风格非空 && (参考图为空 || 参考图 ready) && (提示词非空 || 参考图非空)` —— 即至少要有「一张图」或「一句话」,两者全空不能提交。同时 A1 hero 的 CTA 文案须从「+ 上传宠物照片」改为「开始创作」(正典文案绑定了「上传照片」这个前提,`AI宠物_iOS_UI设计稿.html:330`)。 +- 不满足时按钮走主题禁用态(`ink` 12% 底 + 38% 字,`app_theme.dart:137-138`),**并在按钮上方给一行 12 `inkSoft` 的原因**(「先选一张宠物照片」/「先选一个风格」)—— 只置灰不说原因是既有页面的通病,M4 不复制。 + +**参考图上传:复用而非重写。** 走既有 `MediaUploader`(`media_uploader.dart:127-143`)+ `PostMediaEditGrid`(`post_media_grid.dart:128`)+ `UploadProgressOverlay`(`upload_progress_overlay.dart:17`),参数 `maxImages: 1, maxConcurrentUploads: 1, purpose: MediaPurpose.aiInput`。六态(`queued/compressing/uploading/confirming/ready/failed`,`media_uploader.dart:17-24`)的视觉已在 `UploadProgressOverlay` 里定型,**一格都不用重画**。 + +**新增 `MediaPurpose` 枚举值**:现有三值 `postImage/userAvatar/petAvatar`(`community_models.dart:67-75`)→ 加 `aiInput('ai_input')`、`aiOutput('ai_output')`。**零 Flyway 迁移**(`purpose` 无 DB CHECK,白名单是配置项 `MediaProperties.allowedPurposes`,ADR-022 决策 5,`decisions.md:192`);但引用侧会校验用途相符,给错是 404/40405(`ErrorCode.java:28`)。 + +**不用 `AvatarUploadSheet`**(`avatar_upload_sheet.dart:38`):它是「弹 sheet → 选 → 确认 → 关闭 → 返回 assetId」的一次性交互,适合头像;A2 的参考图要**与其他表单项同屏共存**(用户会边看图边改提示词),塞进 sheet 就看不见了。改为内嵌 `PostMediaEditGrid`。 + +**`AppTextField` 用不了。** 已核实它**没有 `maxLines`/`minLines`/`maxLength` 参数**(`app_text_field.dart:9-24`)。两条路:(a) 给它加这三个参数(一次改好,`post_compose_page.dart:575-585` 的裸 `TextField` 也能顺手收敛);(b) 照发布页写裸 `TextField`。**推荐 (a)**:M4 有两个长文本框(提示词 + 负面提示词),加上发布页正文共三处,散着写三份 decoration 必然漂移。 + +**提示词计数器软上限 500**:契约硬上限 10000(`patbond_postgresql.sql:614`)。给用户看 `0/10000` 会鼓励写小作文,而多数提供方对超长 prompt 的效果反而更差。计数器显示 `N/500`,到 500 时**软拦**(提示「再长也不会更准,试试精简一点」+ 仍允许继续输入到硬上限)——不硬截断,避免用户粘贴长文时内容被吃掉。拍板 D8。 + +**状态矩阵**: + +| 态 | 呈现 | +| --- | --- | +| 空(初始) | 参考图区只有「+」虚线格;提示词空 + hint;披露区收起;提交钮禁用 + 原因行 | +| 加载 | **本页无首载请求**(风格/模型从 A1 带入)。宠物列表若未预取则该区显示 3 个 44 圆 `surfaceTint` 占位 | +| 进行中(参考图上传) | 该格叠 `UploadProgressOverlay` 四视觉态;提交钮禁用 + 原因行「照片还在上传」 | +| 成功(就绪) | 提交钮可用,文案带「(消耗 1 次)」 | +| 提交中 | 钮内 18 白转圈 + 文案「正在提交…」,整页输入 `AbsorbPointer`(防重复提交产生两个 job) | +| 失败(参考图) | 格内失败态 + 可重试则给「重试」通栏(`upload_progress_overlay.dart:71-104`);压缩后仍超 10 MiB 时 `retryable:false`,**只给「重新选择」**(既有纪律,`media_uploader.dart:350-356`) | +| 失败(提交) | 顶部 `InlineErrorBanner` + 分码文案(§5);**不清空任何已填内容**;参考图的 assetId 保留(不必重传) | +| 配额耗尽(429) | 提交被拒 → 弹 S1 sheet(§2.7),页面内容全保留;`QuotaMeter` 就地转耗尽态 | +| 无权限 | 同 A1,不作为页面态 | + +### 2.3 A3 任务等待页 ★ 本迭代最关键的设计问题 + +**形态裁决:提交成功后 `pushReplacement` 进 A3(替换掉 A2),A3 明确允许离开。** + +比较过的三条路: + +| 方案 | 形态 | 判定 | +| --- | --- | --- | +| 甲 阻塞式 | 全屏遮罩不可返回,等到出结果 | **弃用**。分钟级任务把用户锁在一个页面上是不可接受的;且杀进程即丢,用户会以为额度白扣了 | +| 乙 提交即散 | 提交后直接回 A1,只留 G1 条 | **弃用**。点了「开始生成」却立刻回到原页面,用户拿不到「事情真的开始了」的确认,第一反应是再点一次(→ 重复扣额度) | +| 丙 进等待页 + 可离开 | 自动进 A3 看到进度;页面显式说明可以离开;离开后靠 G1 回来 | **采纳**。既给出即刻确认,又不绑住用户 | + +`pushReplacement` 而非 `push`:否则用户从 A3 返回会回到 A2(一个已经提交过的表单),再点一次「开始生成」就是第二个 job。替换掉 A2 后,返回直接回 A1。 + +```text +AppBar:← 返回 / 「正在生成」/ 右侧「取消」TextButton(inkSoft) +┌──────────────────────────────────────────┐ +│ │ +│ ◜◝ │ GenerationProgressPanel +│ ◟ ◞ ← 不确定进度环 56 │ primaryStrong 值条 +│ surfaceTint 轨道 │ 轨道 3.80:1(非文字达标) +│ │ +│ 正在生成… titleLarge│ ← 阶段文字为主 +│ 已经等了 42 秒 12 inkSoft │ ← 已用时,不是预计剩余 +│ │ +│ ● 已排队 ✓ │ 阶段步进(三段) +│ ● 生成中 ← 当前,primaryStrong 加粗 │ +│ ○ 收尾 muted │ +│ │ +│ ───────────────────────────────────── │ +│ 可以先去做别的,回到 App 就能看到结果 │ 12 inkSoft,居中 +│ (不承诺推送,见下) │ +└──────────────────────────────────────────┘ + ↓ 16 +参考图缩略(96 圆角 sm12)+ #吉卜力 TagPill + 提示词首行截断 + ← 让用户在等待中确认「我提交的是这个」 +``` + +**进度表达的四条裁决**(依据 §0.3 的 CHECK 铁律): + +1. **主形态是「阶段文字 + 不确定进度环」,不是百分比。** 因为 `queued` 时 `progress` 恒 0(硬约束),`running` 时 `progress` **无下限约束**(提供方可能全程不上报)。若把百分比当主形态,最常见的用户观感就是「0% 卡了半天」——这比没有百分比糟得多。 +2. **`progress > 0` 时才叠数字**,形态从不确定环切换为确定环 + 环心 `42%` 15/w800 `ink`。切换时不做重置动画(环角度从当前位置续上)。 +3. **绝不展示预计剩余时间。** ETA 需要提供方支持 + 队列深度模型,M4 两者都没有(AI 提供方 ADR 尚未产生)。倒计时走完还没完成是最伤信任的反模式。 +4. **改展示「已用时」。** 已用时是客观事实、客户端自己就能算(提交时刻起,或服务端 `started_at`),不构成任何承诺。文案「已经等了 42 秒」→ 超 2 分钟改「已经等了 2 分 15 秒」(不显示毫秒/不显示小时)。 + +**排队位次**:若服务端能给 `queuePosition`(离散事实,可信),`queued` 态文案升级为「前面还有 3 个任务」;给不出就只说「已排队,正在等待空闲的算力」。**不自己编排位**。→ §10 诉求 R3。 + +**阶段步进只做三段,不做四段。** 现 `create_page.dart:539` 的假阶梯是四段(`分析宠物特征 / 加载风格模型 / 生成画面细节 / 高清增强与合成`)——那是**编造的**内部步骤,服务端的 status 只有 `queued/running/succeeded/...`,映不出四段。M4 三段严格对应:`queued`→「已排队」、`running`→「生成中」、`running && progress>=95`(或 `succeeded` 前的过渡)→「收尾」。**不编造服务端没有的阶段。** + +- 「收尾」这一段是否成立取决于 progress 是否可信。若拍板决定「progress 不保证」(D4 推荐),则**只做两段**(已排队 / 生成中),第三段不渲染。宁可少一段真的,不要多一段假的。 + +**用户能否离开 —— 能,而且这件事必须写在屏幕上。** 底部说明行是本页的必需元素,不是装饰。 + +**⚠ 文案纪律:不能说「完成后通知你」。** 已核实客户端**没有任何推送能力**(无 WebSocket、无 SSE、无 FCM/APNs 依赖、无相关端点)。「通知你」会让用户锁屏等推送,然后什么也不来。正确文案:**「可以先去做别的,回到 App 就能看到结果」**。这是本节最容易写错、且错了以后用户会明确认为产品骗人的一处。 + +**离开后如何回来 —— 三条路,按显著性排序**: + +| # | 路径 | 位置 | +| --- | --- | --- | +| 1 | G1 全局进行中条 | 首页与创作 Tab 的页面第一位(§2.7) | +| 2 | 底栏创作 Tab 图标角标 | `main_shell_page.dart:277-281` 的 icon 外包 Material `Badge`(无数字小圆点) | +| 3 | A5 我的 AI 作品列表 | A1 右上 `history` 钮 / 「我的」页菜单 | + +**轮询策略(UI 侧诉求,实现属客户端任务层)**:无推送 → 只能轮询。 + +- 仅在**前台可见**时轮询;`AppLifecycleState.paused` 停止;回前台立即补一次(不等下一个 tick) +- 退避阶梯:0–30 s 每 3 s;30 s–2 min 每 5 s;2 min–10 min 每 10 s;超 10 min 每 30 s +- 杀进程恢复:**只持久化 jobId 列表**(`SharedPreferences`),重启后拉一次状态。**不持久化任何 URL**(纪律 R2:预签名 URL TTL 1 h,持久化必得 403/404) +- 这一层客户端**零先例**(§0.5 第 2 条),是本迭代最大的新建块 + +**第六个 UI 态:僵死 / 超时。** 服务端只有 5 个 status,但 `running` 可能长时间无进展(提供方挂了、lease 过期重试中)。UI 必须有第六态,且**它不是错误态**: + +- 触发:`running` 且已用时 > 10 min(阈值待拍板 D10) +- 呈现:进度环转 `accent` 色系 + 文案「还在生成,比平时久一些」+ 副行「你可以继续等,或者取消这次生成」+ 「取消」钮从 AppBar 提升为页内 `OutlinedButton`(提高可见性) +- **不报错、不自动取消**(自动取消会丢掉一个可能马上就成功的任务) + +**取消**:`development-plan.md:249` 明确 M4 要做「查询和取消」。 + +- 入口:AppBar 右侧「取消」TextButton(僵死态时提升为页内钮) +- 二次确认 `AlertDialog`:「取消这次生成?」+ 内容按 D11 拍板结果二选一 ——「已消耗的额度不会退还」或「额度会退还」。**这句话必须说准**,说错方向用户会觉得被坑 +- `cancelled` 后 A3 转终态:`EmptyStateIllustration(icon: cancel_outlined, title:'已取消这次生成', ctaLabel:'再试一次')` → 回 A1 +- **`queued` 与 `running` 都允许取消**(设计稿的 `cancelled` 态对 output/error 无约束,两处都能进) + +**状态矩阵**: + +| 态 | 服务端 status | 呈现 | +| --- | --- | --- | +| 加载(首次查询前) | — | 进度环不确定 + 「正在提交…」;**不显示已用时**(还没有起点) | +| 排队中 | `queued` | 不确定环 + 「已排队」+ 已用时 +(有 queuePosition 则)「前面还有 N 个」 | +| 生成中(无进度) | `running`,`progress==0` | 不确定环 + 「正在生成…」+ 已用时 | +| 生成中(有进度) | `running`,`progress>0` | 确定环 + 环心百分比 + 「正在生成…」+ 已用时 | +| 僵死 / 超时 | `running` 且已用时 > 阈值 | 见上第六态(`accent` 环 + 提升的取消钮)。**非错误态** | +| 成功 | `succeeded` | 环补满 100% → 200 ms 停顿 → **自动 `pushReplacement` 进 A4**(不让用户再点一次「查看结果」;结果就是他等的东西) | +| 失败 | `failed` | 见下失败态设计 | +| 已取消 | `cancelled` | `EmptyStateIllustration` 终态 + 「再试一次」 | +| 轮询请求本身失败 | — | **不打断等待**:静默重试下一个 tick;连续 3 次失败才在页面底部加一行 12 `inkSoft`「网络不太稳,正在重连…」。任务在服务端照常跑,把网络抖动升级成错误页是过度反应 | +| 空 / 无权限 | — | 不存在(本页必然有一个 jobId 才能到达;jobId 查不到 → 404 走失败态) | + +**失败态设计**(`failed`,`error_code` 非空是硬约束): + +```text +┌──────────────────────────────────────────┐ +│ ⊘ 56 errorDark │ 非 EmptyStateIllustration +│ 这次没有生成成功 titleLarge │ (那个是"空"语义,不是"失败") +│ 参考图里没有识别到宠物,换一张试试 bodyMedium inkSoft +│ │ +│ ┌───────────────┐ ┌─────────────────┐ │ +│ │ 换个参考图 │ │ 用同样设置重试 │ │ +│ └───────────────┘ └─────────────────┘ │ +│ OutlinedButton FilledButton │ +│ 这次失败不消耗额度 ← 若 D11 定为不扣 │ +└──────────────────────────────────────────┘ +``` + +- 文案按 `error_code` 分层(§5 文案表),**兜底文案不能是「未知错误」**:给「这次生成没成功,换个参考图或稍后再试」+ 折叠区可展开看 `error_message`(给愿意看的人,`varchar(1000)`) +- `attempt_count` / `max_attempts`(`patbond_postgresql.sql:628-629`):服务端已自动重试过 N 次。UI **不暴露内部重试次数**(用户不关心 worker 重试了 3 遍),但若 `attempt_count == max_attempts` 则「用同样设置重试」按钮改为次要态并在下方加一行「同样的设置已经试过几次了,建议换张照片」 +- 失败**是否退额度**是 D11 的一部分,必须与文案一致 + + +### 2.4 A4 结果预览页 + +由 A3 成功后 `pushReplacement` 进入(返回键回 A1,不回 A3——那是个已终结的等待页)。 + +```text +AppBar:← 返回 / 「创作完成」/ 右侧 share_outlined(系统分享,可选,D14) +┌──────────────────────────────────────────┐ +│ │ +│ 结果图 —— 按真实比例渲染 │ ★ 不是 4:3 +│ 通栏出血,圆角 0(贴边) │ 点击 → A6 全屏 +│ 比例来自 job 的 width/height │ +│ │ +└──────────────────────────────────────────┘ + ↓ 16(页面水平 padding 16 从此处起) +#吉卜力 · 1:1 · 高清增强 TagPill + 12 inkSoft +提示词:坐在樱花树下,暖阳,胶片质感 12 inkSoft,≤2 行截断 +「全文」 + ↓ 24 +┌──────────────────────────────────────┐ +│ 发布到社区 │ FilledButton 全宽 52 +└──────────────────────────────────────┘ primaryStrong 底白字 4.49:1 + ↓ 12 +┌──────────────┐ ┌──────────────────┐ +│ 重新生成 │ │ 暂不发布 │ OutlinedButton × 2 等分,高 48 +└──────────────┘ └──────────────────┘ + 「重新生成」下方:将消耗 1 次额度 11 inkSoft + ↓ 16 +作品已存在「我的 AI 作品」里,之后也能发布 12 inkSoft 居中 +``` + +**单图 / 多图:M4 恒单图。** 依据 `patbond_postgresql.sql:613` `output_asset_id uuid` 是**单列**,一个 job 对应一个输出资产;多图需要 job→N assets 的关系表,设计稿没有。因此: + +- A4 按单图设计。**不用 `PostMediaGrid`** —— 它单图走 `_CollapsedCover` 且硬编码 `AspectRatio(4/3)`(`post_media_grid.dart:326-327`),正是本页要避免的东西。 +- 多图预留(不实现):若将来一个 job 出 N 张,形态为 `PageView` 横滑 + 底部页码指示 + 右上「2/4」`ink` 胶囊白字(13.50:1),沿 `iteration-3/05 §2.2` 详情页多图的既定语言。 + +**结果图的比例从哪来 —— 这里不受 widthPx 债影响。** A4 的比例取自**本次 job 的 `width_px`/`height_px`**(`patbond_postgresql.sql:616-617`),那是**客户端自己刚刚提交的参数**,一定知道。所以 A4 能画出完整正确的构图。**债在发布之后才显现**(Feed 读的是 `media.assets` 的尺寸,恒 null)—— 这个前后对照正是 §8.3 判定「M4 由隐性升为显性」的核心论据。 + +- 保护性钳制:即使比例已知,也钳到 `[9/16, 16/9]`(0.5625–1.778)防异常值撑爆页面;超出时 `BoxFit.contain` + `canvas` 底衬(**不裁切**——用户刚花额度生成的图,宁可留白不可裁)。 + +**三个动作的语义与文案,逐个都有讲究**: + +| 动作 | 形态 | 设计判断 | +| --- | --- | --- | +| 发布到社区 | 主 CTA,`FilledButton` 全宽 | 这是产品希望发生的事,给最高视觉权重 | +| 重新生成 | 次要,`OutlinedButton` | **必须在按钮上写明「将消耗 1 次额度」**。这是会花钱的操作,把代价藏在点击之后是暗黑模式。同参数直接重提(不回 A2),但**换新的 `idempotency_key`**(否则命中 `UNIQUE(user_id, idempotency_key)` 会返回同一个旧 job,用户看到「什么都没发生」) | +| 暂不发布 | 次要,`OutlinedButton` | **不叫「丢弃」/「删除」。** job 已在服务端存在、图也在 `media.assets` 里,点这个只是「现在不发帖」。叫「丢弃」会让用户以为作品被销毁,从而不敢点。配一行说明「作品已存在「我的 AI 作品」里,之后也能发布」。真要删是 A5 的删除动作 | + +**放大查看 → A6。** 复用既有 `_MediaGalleryPage`(`post_detail_page.dart:880-985`):黑底 + `PageView` + `InteractiveViewer(maxScale:4)` + 双击 2.5× 缩放(:904-914)+ 右上「n/N」`ink` 胶囊 + 关闭钮 `tooltip:'关闭'`(:953-957)。它现在是 **`post_detail_page.dart` 的私有类**,需**提为共享 `MediaGalleryPage`**(`lib/core/widgets/media_gallery_page.dart`)。单图时不渲染页码胶囊(既有逻辑 `:959` 已判 `urls.length > 1`),零改造。 + +- 已知遗留(不在 M4 修):下滑关闭手势与 `InteractiveViewer` 平移冲突,`post_detail_page.dart:878-879` 已登记「留待手势方案升级(photo_view 复评条件:体验不达标)」。M4 沿用现状,只提取不改行为。 + +**状态矩阵**: + +| 态 | 呈现 | +| --- | --- | +| 加载(图片下载中) | `RemoteImage` 自带 loading:`surfaceTint` 底 + 18 转圈(`common.dart:32-44`)。**外层不再叠一层骨架**(会双重转圈) | +| 图片下载失败 | `RemoteImage` 自带兜底:`surfaceTint` + `Icons.pets 34 muted`(`common.dart:45-56`)+ 其下一行「图片加载失败,下拉重试」+ 三个动作钮**照常可用**(图没显示不等于作品不存在) | +| 成功 | 如上线框 | +| 进行中 | 点「发布到社区」后:钮内转圈 + 另两钮禁用(防并发建草稿);点「重新生成」后:`pushReplacement` 回 A3 | +| 失败(建草稿失败) | `InlineErrorBanner` 在图下方 + 重试;**停在本页不跳转**(跳到发布页会得到一个没有图的空草稿) | +| 配额耗尽 | 只影响「重新生成」:该钮转禁用 + 下方文案改「今日额度已用完,明天再来」;「发布到社区」**不受影响**(发帖不消耗生成额度) | +| 空 / 无权限 | 不存在(有 `succeeded` job 才能到达本页) | + +### 2.5 A5 我的 AI 作品与任务列表 + +一个列表承载**全部五种 status**(不做「作品」与「任务」两个分开的列表——用户心智里它们是同一串东西的不同阶段)。 + +```text +AppBar:← 返回 / 「我的 AI 作品」 +┌──────┐┌──────┐┌──────┐ 3 列网格(与 A1 风格网格同栅格) +│ 图 ││ ◜◝ ││ ⊘ │ childAspectRatio 1:1 +│ ││ 生成中 ││ 失败 │ 格间距 4(沿 PostMediaGrid 的 _spacing) +└──────┘└──────┘└──────┘ 圆角 sm12 +┌──────┐┌──────┐ +│ 图 📣 ││ 图 │ ← 📣 = 已发布到社区的角标 +└──────┘└──────┘ + 触底加载更多 / 「没有更多了」12 inkSoft 居中 +``` + +**`GenerationJobCard`(网格单格)五态**(新建): + +| status | 格内视觉 | 点击去向 | +| --- | --- | --- | +| `succeeded` | 结果图 `BoxFit.cover`;已发帖的加右上 `campaign` 14 白图标衬 `ink` 80% 圆(7.10:1) | A4(结果页,动作行按已发布状态调整) | +| `queued` | `surfaceTint` 底 + 居中 `schedule` 24 `inkSoft` + 底部字条「排队中」11 白字衬 `ink` 80% | A3 | +| `running` | `surfaceTint` 底 + 居中 20 不确定转圈 `primaryStrong` + 底部字条「生成中」 | A3 | +| `failed` | `error` 12% 底 + 居中 `error_outline` 24 `errorDark`(6.50:1)+ 底部 `errorDark` 实底白字「失败」 | A3 的失败态 | +| `cancelled` | `canvas` 底 + 居中 `cancel_outlined` 24 `muted`(禁用语义,`muted` 合法用途)+ 底部字条「已取消」 | A3 的取消终态 | + +- 长按 → 操作 sheet:「保存到相册 / 删除」(删除需二次确认;**这里才是真的删**,与 A4 的「暂不发布」区分) +- 「保存到相册」需新依赖(`gal` / `image_gallery_saver` 之类)—— **未取证**该依赖是否可接受,记入 D13 + +**这里需要月份筛选吗 —— 不需要。** 若产品要「按月看作品」,才会撞上 §8.2 的月份网格缺失。本规范**建议 M4 不做时间筛选**(作品量级在 M4 不足以需要筛选,游标分页够用),从而**不触发那笔债**。 + +**状态矩阵**: + +| 态 | 呈现 | +| --- | --- | +| 首载 | `AiWorkGridSkeleton`:9 格 `surfaceTint` 方块 + 呼吸动效(沿 `feed_skeleton.dart` 同参数)。**不能直接用 `FeedSkeleton`** —— 它是单图卡形态(头像行 + 4:3 块 + 两条文本条,`feed_skeleton.dart:60-95`),与网格完全不同 | +| 空 | `EmptyStateIllustration(icon: auto_awesome, title:'还没有 AI 作品', description:'上传一张宠物照片,试试把它变成写真', ctaLabel:'开始创作')` → A1 | +| 加载失败 | `InlineErrorBanner` + 重试 | +| 翻页失败 | 列表尾部一行「加载失败,点击重试」12 `primaryStrong`(白底 4.49:1);**已加载的格子保留** | +| 进行中 | 混在列表里(`queued`/`running` 格),同时 G1 条在页首 | +| 成功 | 如上线框 | +| 无权限 | 不存在(`/me/...` 语义,只看自己的) | + +### 2.6 A4 →「一键建社区草稿」的过渡 + +**推荐路线:服务端建草稿,客户端只带 draftPostId 进发布页。** + +```text +A4 点「发布到社区」 + ↓ POST /posts { status:'draft', category:'ai_creation', + │ generationJobId: , + │ media:[{ assetId: , position:0, isCover:true }], + │ content:'' } ← 正文空,见下 + ↓ 拿到 { id: draftPostId, version } + ↓ push PostComposePage(initialDraftPostId: draftPostId) +发布页:拉这一条草稿 → 渲染图 + 锁定分类 + 正文空待填 +``` + +比较过的两条路: + +| 路线 | 做法 | 判定 | +| --- | --- | --- | +| 甲 服务端建草稿 | 如上;发布页从「拉最新草稿」改为「拉指定草稿」 | **采纳**。`ai_creation` 分类与 `generation_job_id` 的挂接在服务端一次完成;且**复用了发布页已有的草稿恢复通路**(`post_compose_page.dart:194-220` 已经会恢复 `draft.media`),改动面最小 | +| 乙 客户端预填 | 发布页加 `initialContent`/`initialCategory`/`initialAssetIds`,首次存草稿时才带 jobId | 弃用。要新增 3 个参数 + 发布页要能提交 `generationJobId`(写侧 DTO 也得改),改动面更大,且「AI 帖的元数据挂接」这件事被摊到客户端 | + +**预填了什么 —— 逐项**: + +| 项 | 预填 | 理由 | +| --- | --- | --- | +| 媒体 | ✅ AI 输出图 1 张(服务端已挂在草稿上) | 这是发帖的全部内容 | +| 分类 | ✅ `ai_creation`,且**锁定不可改** | 改成 general 会让 `generation_job_id` 挂在一个非 AI 帖上,语义漂移。呈现为**静态 `TagPill`** 而不是可选 `ChoiceChip` | +| 正文 | ❌ **不预填,只给 hint** | 见下,这是本节最关键的判断 | +| 话题 | ❌ | 话题在客户端**完全不存在**(`post_compose_page.dart:288-289` `topicCount: 0` 硬编码,注释「话题无契约端点,M3 恒 0」;ADR-018 已把话题剪出)。M4 不引入 | +| 位置 | ❌ | 现为假入口(`post_compose_page.dart:607-613`),M5 服务域 | +| 提示词 | ❌ 默认不公开,见 D9 | prompt 可能含隐私(他人姓名、地点)。给一个「附上创作提示词」开关,**默认关** | + +**为什么正文不预填 —— 与 M3.5 的既有纪律同源。** 现 demo 会预填「刚刚用 Patbond 创作了新作品,快来看看豆豆的新造型吧!✨」(`create_page.dart:90`)。这与 M3.5 定下的「编辑页不预填 username」是**同一类错误**(`iteration-3.5/05 §2.1`):**一个纯展示用的机器文案一旦预填进输入框,用户按一下发布就把它固化成真实数据**。后果具体且可预期 —— Feed 会被同一句话灌满,AI 帖立刻变成可识别的垃圾内容。 + +替代方案(既不留空白页,也不代替用户说话): + +- 正文 `hint`:「说说这张图的灵感…」(hint 用 `muted` 合法) +- 正文框下一行**可点的建议标签**:`( #吉卜力风 )( 提示词 )( 我家豆豆 )` —— 用户**主动点**才把对应内容插进正文。把「省事」和「代写」区分开 +- 发布 gating 沿用既有 `_canPublish`(`post_compose_page.dart:159-163`):`content` 非空是后端必填,所以**用户必须自己写一句话才能发**。这不是障碍,是防灌水的最后一道闸 + +**发布页必须改的两处**(否则这条链路是坏的): + +1. **`initialDraftPostId` 参数 + `_restoreLatestDraft` 改造。** 现在它无条件拉「最新一条 draft」(`post_compose_page.dart:196-199`)并**无条件覆盖正文**(:203)。若用户本来就有一条旧草稿,从 A4 进来会恢复错的那条。需改为:给了 `initialDraftPostId` 就按 id 拉,否则保持现行为。 +2. **⚠ 服务端草稿的图现在只显示计数文字,不渲染缩略图。** 已核实 `post_compose_page.dart:620-638`:`_uploader.isEmpty && _draftMedia.isNotEmpty` 时只输出一行文字「草稿已含 N 张图片(发布时保留;重新选图将整组替换)」。对 M3 的旧草稿场景勉强够用;**对 M4 是明确缺陷** —— 用户刚在 A4 看过完整的图,进发布页却只看到一行字,会以为图丢了。修法:把 `_draftMedia` 用 `PostMediaGrid`(展示态,`urls` 取 `_draftMedia[i].url`)渲染出来。**零新建组件。** + - 注意:`PostMediaGrid` 单图会回落 4:3(`post_media_grid.dart:326-327`),此处**可接受**(发布页是编辑上下文,不是最终呈现),但若一并按 §8.3 修好 `PostMediaGrid` 读比例,这里也顺带正确。 +3. `PostEntryPoint` 枚举加值:现有 `createTab/feed/topicDetail/petDetail`(`post_analytics.dart:19-28`),加 `aiResult`(入口归因,埋点侧需同步 → 交由埋点规格)。 + +### 2.7 G1 全局「进行中」指示位 + S1 配额说明 + +**要不要一个持久的任务入口 —— 要,但要最轻的那种。** 理由:无推送能力(§2.3),用户离开 A3 后**唯一**能知道任务状态的途径就是自己回来看。没有全局提示位,「离开」这件事就等于「失联」。 + +**G1 由两个部件组成**(都是客户端首例,§0.5): + +| 部件 | 形态 | 位置 | +| --- | --- | --- | +| `ActiveJobBar` 进行中条 | 高 52 的 `surfaceTint` 底圆角 `sm` 12 横条:左 20 不确定转圈 `primaryStrong` → 10 → 文字(`primaryDark`,7.98:1)→ `Spacer` → `chevron_right` 20 `primaryDark`。整条可点 → A3 | **页面第一位**(首页 ListView children 首位、创作 Tab 首位) | +| Tab 角标 | Material `Badge`(**内置组件,客户端首次使用**)包住底栏创作 Tab 的 `auto_awesome_outlined` 图标。**小圆点无数字**(`Badge` 的 `smallSize` 形态),`primaryStrong` 底(白底 4.49:1 达非文字 3:1) | `main_shell_page.dart:277-281` | + +**为什么条放在「页面第一位」而不是别处**: + +- **不能悬浮**(`FloatingActionButton`/`Overlay` 全 lib 零先例,凭空引入一层悬浮层要处理键盘、sheet、滚动遮挡,成本远超收益) +- **不能塞进主壳 AppBar**(那是 5 个 Tab 共用的,会在档案/服务/我的页错误露出) +- **首页放在天气条之上不与 ADR-022 冲突**:ADR-022 决策 D3.5-1 钉死的是「天气条 / 圈子 / 促销卡三项刻意保留、不得顺手清理」(`decisions.md:188`),且 `home_greeting_test.dart` 有反向用例钉住「保留项仍在」。G1 是**在其上方插入一个状态条**,不移动、不删除、不改动任何 demo 占位 → 那条反向测试不会挂。这是唯一零冲突的插入位。 +- 只在**有在途任务时**渲染(`queued`/`running` 存在),无任务时整条不占位(不留空 `SizedBox` 高度) + +**三种条文案**(对应三种状态): + +| 情形 | 文案 | 视觉差异 | +| --- | --- | --- | +| 1 个在途 | `正在生成你的作品…` | 转圈 + `primaryDark` 字 | +| N 个在途 | `2 个作品正在生成…` | 同上 | +| 刚完成、用户还没看 | `作品生成好了,来看看 →` | 转圈换 `check_circle` 20 `successInk`(7.90:1;**不用 `success`**,白/淡底上仅 2.29–2.67:1 不达非文字 3:1,§7.2 D10);底色转 `successSurface`,字转 `successInk`(6.79:1) | + +- 「已完成」态在**用户点进去看过之后**消失(不是超时消失)。角标同步。这是唯一让用户不会漏掉结果的规则。 +- 完成时若 App 在前台且轮询到 `succeeded`:额外弹一条 SnackBar「作品生成好了」+ action「查看」。**不弹对话框**(用户可能正在打字/浏览,抢焦点是敌意行为)。 + +**S1 配额说明 sheet**(429 的出口): + +```text +┌ drag handle ─────────────────────────────┐ +│ 今日额度已用完 titleLarge │ +│ │ +│ 每天可以免费生成 5 次,明天 0 点重置 │ bodyMedium inkSoft +│ ▓▓▓▓▓▓▓▓▓▓ 5 / 5 QuotaMeter │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ 看看我的作品 │ │ FilledButton → A5 +│ └──────────────────────────────────┘ │ +│ 去社区逛逛 TextButton → 首页 │ +└──────────────────────────────────────────┘ +``` + +**配额耗尽时不给「升级会员」按钮。** 正典有会员卡「AI 会员 Plus / 无限 AI 生图」(`AI宠物_iOS_UI设计稿.html:489-493`),但 M4 **没有任何支付能力**(`development-plan.md` 把优惠/订单列在 M5)。给一个点了通向死路的升级按钮,比不给更伤——用户会认为产品在钓鱼。M4 只给两个**真的能去**的出口。拍板 D12。 + +**两类 429 必须分开**(契约层诉求 R4): + +| 类别 | 文案 | 出口 | 为什么不能合并 | +| --- | --- | --- | --- | +| 配额耗尽 | 「今日额度已用完」+ 重置时间 | S1 sheet(看作品 / 逛社区) | 等的是**明天**,用户该走开 | +| 频率过快 | 「操作太频繁,请 12 秒后再试」+ 倒计时 | **SnackBar,不弹 sheet** | 等的是**几秒**,用户该原地待一下。弹 sheet 是过度反应 | + +**`QuotaMeter` 规格**(新建,三处共用:A1 hero、A2 底条、S1):横向细条 `LinearProgressIndicator` `minHeight 6` + 圆角 `pill`,**值条 `primaryStrong` / 轨道 `surfaceTint`**(3.80:1 达非文字 3:1;**不能用 `primary` 值条** —— `primary/surfaceTint` 仅 2.33:1)+ 右侧 `3/5` 12/w600 `inkSoft`。耗尽态值条转 `errorDark`(6.50:1)+ 文字转「已用完」`errorDark`。**紧凑形态**(hero 内)只给一行文字不给条。 + + +## 3. 状态矩阵总表 + +各页详细矩阵见 §2 对应小节,此处为交叉核对表(`—` = 该态在该页不存在,且是**定型而非遗漏**)。 + +| 界面 | 空 | 加载 | 进行中 | 成功 | 失败 | 无权限 | +| --- | --- | --- | --- | --- | --- | --- | +| A1 创作首页 | `EmptyStateIllustration` 无 CTA + hero CTA 禁用 | hero 块 + 6 格骨架 | G1 条在页首,目录照常可用 | 线框态 | `InlineErrorBanner` + 重试 | — | +| A2 参数页 | 初始态(+格 / 空提示词 / 钮禁用 + 原因行) | 无首载;宠物区 3 圆占位 | 参考图 `UploadProgressOverlay` 四态 | 钮可用带「消耗 1 次」 | 上传格内失败 + 重试 / 提交失败顶部横幅**不清内容** | — | +| A3 等待页 | — | 不确定环 +「正在提交…」无已用时 | **本页主态**(排队 / 生成中 / 僵死三分支) | 环补满 → 自动进 A4 | 独立失败版式(非 EmptyState)+ 两个出口 | — | +| A4 结果页 | — | `RemoteImage` 自带(不叠外层骨架) | 建草稿中:主钮转圈 + 另两钮禁用 | 线框态 | 建草稿失败横幅,**停在本页** | — | +| A5 作品列表 | `EmptyStateIllustration` + CTA「开始创作」 | 9 格 `AiWorkGridSkeleton` | 混在列表里的 queued/running 格 | 网格 | 首载横幅 + 重试 / 翻页失败保留已加载 | — | +| A6 全屏查看 | — | `RemoteImage` 自带 | — | 黑底 + 缩放 | 图失败兜底(`Icons.pets`) | — | +| S1 配额 sheet | — | — | — | 线框态 | — | — | +| G1 全局位 | 无在途任务 → **整条不渲染不占位** | — | 三种条文案 | 「生成好了 →」态(点过才消失) | 轮询连挂 3 次 → 条内加「网络不太稳」副行 | — | + +**「无权限」整列为 `—` 是定型**:所有 AI 端点都需认证(401),而 App 登录后才进主壳(`splash_page.dart` → login),未登录到不了任何一页;在途 401 由既有 `TokenRefresher` 兜底,不构成页面态。这与 `iteration-3.5/05 §3.2`「资料页没有独立空态是定型而非遗漏」是同类的显式声明。 + +**A3/A4/A6 的「空」态为 `—` 也是定型**:到达它们的前提就是「有一个 job / 有一个 succeeded job / 有一张图」,空态在逻辑上不可达。jobId 查不到 → 404 → 走失败态,不是空态。 + +## 4. 组件清单(复用 vs 新建) + +**账目:新建 8,复用 16(其中 6 需小改)。** + +### 4.1 直接复用,零改动(10) + +| # | 组件 | 位置 | M4 用在哪 | +| --- | --- | --- | --- | +| 1 | `RemoteImage` | `lib/widgets/common.dart:5-60` | 风格预览、结果图、A5 网格;三态(loading `surfaceTint`+转圈 / 失败 `Icons.pets` / 成功)全部够用 | +| 2 | `SectionCard` | `common.dart:62-77` | A2 各表单分组 | +| 3 | `TagPill` | `common.dart:86-132` | 「已选风格」标签、发布页锁定的 `ai_creation` 分类标签;用其既有深变体映射 `_defaultInkFor`(:103-109) | +| 4 | `EmptyStateIllustration` | `empty_state_illustration.dart:10-78` | A1 空态、A5 空态、A3 取消终态 | +| 5 | `InlineErrorBanner` | `inline_error_banner.dart:8-41` | A1/A2/A4/A5 的区块错误 | +| 6 | `PostMediaGrid` 展示态 | `post_media_grid.dart:21-110` | 发布页渲染草稿图(§2.6 修法);**A4 结果图不用它**(4:3 回落) | +| 7 | `PostMediaEditGrid` | `post_media_grid.dart:128-182` | A2 参考图(`maxImages:1`,「+」虚线格与删除角标全现成) | +| 8 | `UploadProgressOverlay` | `upload_progress_overlay.dart:17-131` | A2 参考图六态 → 四视觉态,一格不用重画 | +| 9 | `MediaUploader` | `media_uploader.dart:127-565` | A2 参考图编排:状态机、并发槽、凭据 30 s 余量换新(:170、:377-397)、压缩阶梯 `[80,60]`(:173)、超限 `retryable:false`(:350-356)全部沿用 | +| 10 | `PetAvatar` + `PrimaryButton` | `pet_avatar.dart` / `primary_button.dart:8-47` | A2 宠物选择器用 `PetAvatarSize.md` 44(恰为最小触控目标);全宽按钮沿用 | + +(`EmptyState`,`common.dart:134-156`:M4 不用 —— 已核实它**无 CTA 能力**,M4 一律用 `EmptyStateIllustration`。列此以说明该判断是核实过的,不是漏看。) + +### 4.2 复用但需小改(6) + +| # | 组件 / 枚举 | 位置 | 要改什么 | 规模 | +| --- | --- | --- | --- | --- | +| 11 | `AppTextField` | `app_text_field.dart:9-24` | 加 `maxLines` / `minLines` / `maxLength` 三个参数(现在没有,已核实)。顺带可收敛 `post_compose_page.dart:575-585` 的裸 `TextField` | S | +| 12 | `_MediaGalleryPage` → `MediaGalleryPage` | `post_detail_page.dart:880-985` | **提为共享**(移到 `lib/core/widgets/media_gallery_page.dart` 并公开),行为零改 | S | +| 13 | `PostComposePage` | `post_compose_page.dart:39-46` | 加 `initialDraftPostId`;`_restoreLatestDraft`(:194-220)改为「有 id 按 id 拉」;**`_draftMedia` 从计数文字(:620-638)改为 `PostMediaGrid` 渲染**;分类区支持 `ai_creation` 锁定态(现 `ChoiceChip×2`,:589-605) | M | +| 14 | `MediaPurpose` | `community_models.dart:67-75` | 加 `aiInput('ai_input')` / `aiOutput('ai_output')`(零 Flyway,白名单是配置项) | S | +| 15 | `PostEntryPoint` | `post_analytics.dart:19-28` | 加 `aiResult`(埋点归因,口径交由埋点规格) | S | +| 16 | 呼吸动效逻辑 | `feed_skeleton.dart:26-46` | **不改 `FeedSkeleton` 本体**,但 `AiWorkGridSkeleton` 须复用同一套参数(1200 ms、0.6↔1.0、`disableAnimations` 静止在 1.0)。建议提为共享 `BreathingOpacity`,两处共用一份 | S | + +### 4.3 必须新建(8) + +| # | 组件 | 建议位置 | 用途 | 规模 | +| --- | --- | --- | --- | --- | +| 17 | `AiHeroCard` | `features/create/` | A1 渐变 hero + 上传 CTA + 紧凑配额行 | S | +| 18 | `StyleCard` | `lib/core/widgets/style_card.dart` | 风格/模型卡,5 态(默认/选中/按下/不可用/无预览图) | M | +| 19 | `GenerationParamSection` | `features/create/` | 自绘渐进披露分组(**不用 `ExpansionTile`**,避免复制 `SegmentedButton` 粉底那笔债的成因) | S | +| 20 | `GenerationProgressPanel` | `features/create/` | A3 核心:不确定/确定环切换 + 阶段步进 + 已用时 + 僵死态 | **L** | +| 21 | `GenerationJobCard` | `lib/core/widgets/` | A5 网格单格,五 status 态 + 已发帖角标 | M | +| 22 | `QuotaMeter` | `lib/core/widgets/quota_meter.dart` | 三处共用(hero 紧凑行 / A2 底条 / S1 完整条),含耗尽态 | S | +| 23 | `ActiveJobBar` | `lib/core/widgets/active_job_bar.dart` | G1 全局条,三种文案态;**客户端首个跨页状态条** | M | +| 24 | `AiWorkGridSkeleton` | `lib/core/widgets/` | A5 首载 9 格骨架(`FeedSkeleton` 是单图卡形态,形状完全不同,不能直接用) | S | + +### 4.4 不是组件、但必须一并落的三件事 + +| 事项 | 说明 | 规模 | +| --- | --- | --- | +| 客户端任务状态层 | 轮询(前台限定 + 退避阶梯)+ jobId 列表持久化 + 跨 Tab 单例 + 杀进程恢复。**客户端零先例**(§0.5 第 2 条),是本迭代最大的一块新建 | **L** | +| `Badge` 首次引入 | Material 内置组件,全 lib 零使用(已核实)。用在底栏创作 Tab 图标上(`main_shell_page.dart:277-281`) | S | +| `segmentedButtonTheme` | 偿 §8.1 的债。不做也不阻塞 M4(M4 的分段器数据驱动后不渲染),但另 5 处仍在 | S | + +### 4.5 现状 demo 的消亡清单(`create_page.dart`,563 行) + +| 现状 | 行号 | M4 处置 | +| --- | --- | --- | +| `_ComposeEntryCard` 真实发布入口 | :394-419 | **保留不动**(T3-17 落地的真东西) | +| `SegmentedButton` | :138-157 | 改数据驱动;M4 无 video 时不渲染 | +| `_UploadCard`(读 demo 宠物头像当"上传") | :421-493 | **消亡** → A2 的 `PostMediaEditGrid` 真上传 | +| 「生成设置」卡(模型下拉 / 时长 / 分辨率 / 高清增强) | :170-214 | 部分继承:模型下沉二级披露;分辨率→画面比例;高清增强保留;`_ChoiceRow`(:495-530)可就地重写为比例 chip 组 | +| 横滑风格列表(`SizedBox(height:150)` + 125 宽卡) | :218-292 | **消亡** → 正典 3 列网格 + `StyleCard`(正典 `.style-grid` :163-173;现状与正典不符) | +| `_GenerationProgress` 四步假阶梯 | :532-563 | **消亡** → `GenerationProgressPanel` 三段(或两段)真状态 | +| `generate()` / `simulateUpload()` 假延时 | :55-92 | **消亡** | +| 结果卡(标题/正文/话题 `InputChip`/位置/发布钮) | :312-386 | **消亡** → A4 三动作 + 发布走 `PostComposePage` | +| `publish()` 占位 SnackBar | :122-129 | **消亡** | +| `CreationStyle` 模型 + `creationStyles` 常量 | `models.dart:285-297`、`demo_data.dart:206-233` | **消亡** → 服务端目录 | +| `tags` 本地话题数组 + `addTag()` AlertDialog | :46、:94-120 | **消亡**(话题在客户端不存在,ADR-018 已剪出) | + +## 5. 中文文案表 + +**不给 i18n key**(§0.6b 已核实:仓库无 ARB / gen-l10n / `AppLocalizations`,业务文案是源码内硬编码中文)。Material 组件自带文案(对话框按钮、日期选择器等)交给三件套 delegate,**不在业务代码重复硬编码**(既有纪律 `app_date_picker.dart:75-76`)。 + +### 5.1 界面文案 + +| 落点 | 中文原文 | 备注 | +| --- | --- | --- | +| A1 hero 标题 | `变成毛孩子的写真` | 正典是`如果我的猫变成人?`(:328)——那是一句具体的玩法标题,不适合做常驻 hero 标题(每次进来都问同一个问题会腻)。改为能力陈述 | +| A1 hero 副标 | `上传一张照片,AI 帮你生成专属宠物写真` | 正典原文(:329)沿用 | +| A1 hero CTA | `+ 上传宠物照片` | 正典原文(:330)。**若 D2 改为参考图可选,须改为**`开始创作` | +| A1 分区标题 | `热门风格` / `AI 视频` | 正典原文(:332、:341) | +| A1 历史钮 tooltip | `我的 AI 作品` | | +| A1 空态 | `暂时没有可用的风格` / `我们正在准备新的风格,稍后再来看看` | 无 CTA | +| A2 标题 | `生成设置` | | +| A2 参考图区标题 | `宠物照片` | | +| A2 已选风格行 | `已选风格:` + `#吉卜力` / `更换` | | +| A2 提示词标题 | `想让它变成什么样?(选填)` | | +| A2 提示词 hint | `例如:坐在樱花树下,暖阳,胶片质感` | 给具体例子,不写`请输入提示词` | +| A2 提示词软上限提示 | `再长也不会更准,试试精简一点` | 到 500 字时出现,不硬截断 | +| A2 宠物区标题 | `这是哪只毛孩子?(选填)` / `不指定` | | +| A2 一级披露 | `更多设置` | | +| A2 比例标题 | `画面比例` | 值:`1:1` `3:4` `4:3` `9:16` `16:9` | +| A2 高清增强 | `高清增强` / `提升毛发与眼睛细节` | 副标沿用现状 `create_page.dart:208` | +| A2 二级披露 | `高级` | | +| A2 负面提示词 | `不想出现的元素(选填)` | **不叫「负面提示词」**(专业术语,普通用户读不懂) | +| A2 模型标题 | `创作模型` | 仅目录 >1 个模型时渲染 | +| A2 提交钮 | `✨ 开始生成(消耗 1 次)` | 代价写在按钮上 | +| A2 提交中 | `正在提交…` | | +| A2 gating 原因行 | `先选一张宠物照片` / `先选一个风格` / `照片还在上传` | 只置灰不说原因是既有通病,M4 不复制 | +| A3 标题 | `正在生成` | | +| A3 排队 | `已排队` / `正在等待空闲的算力` | 有 `queuePosition` 时副行改 `前面还有 3 个任务` | +| A3 生成中 | `正在生成…` | | +| A3 已用时 | `已经等了 42 秒` / `已经等了 2 分 15 秒` | **不是**预计剩余时间 | +| A3 阶段步进 | `已排队` / `生成中` / `收尾` | 第三段仅在 progress 可信时渲染(D4) | +| A3 可离开说明 | `可以先去做别的,回到 App 就能看到结果` | ⚠ **绝不能写「完成后通知你」**(无推送能力,§2.3) | +| A3 僵死态 | `还在生成,比平时久一些` / `你可以继续等,或者取消这次生成` | **不是错误文案** | +| A3 轮询抖动 | `网络不太稳,正在重连…` | 连续 3 次失败才出现 | +| A3 取消钮 / 确认 | `取消` / `取消这次生成?` | 对话框内容按 D11:`已消耗的额度不会退还` 或 `额度会退还给你` | +| A3 已取消终态 | `已取消这次生成` / CTA `再试一次` | | +| A4 标题 | `创作完成` | | +| A4 主 CTA | `发布到社区` | | +| A4 次要钮 | `重新生成` / `暂不发布` | **不叫「丢弃」/「删除」**(§2.4) | +| A4 重新生成代价 | `将消耗 1 次额度` | | +| A4 底部说明 | `作品已存在「我的 AI 作品」里,之后也能发布` | | +| A4 图加载失败 | `图片加载失败,下拉重试` | 三个动作钮仍可用 | +| A5 标题 | `我的 AI 作品` | 与正典菜单项一致(:497) | +| A5 格内字条 | `排队中` / `生成中` / `失败` / `已取消` | | +| A5 空态 | `还没有 AI 作品` / `上传一张宠物照片,试试把它变成写真` / CTA `开始创作` | | +| A5 长按 sheet | `保存到相册` / `删除` / `删除这个作品?` | 删除是真删(与 A4「暂不发布」区分) | +| A5 翻页失败 | `加载失败,点击重试` / `没有更多了` | | +| G1 条 | `正在生成你的作品…` / `2 个作品正在生成…` / `作品生成好了,来看看 →` | | +| G1 完成 SnackBar | `作品生成好了` + action `查看` | 不弹对话框 | +| S1 sheet | `今日额度已用完` / `每天可以免费生成 5 次,明天 0 点重置` | 次数与窗口须与服务端配额一致,不写死 | +| S1 出口 | `看看我的作品` / `去社区逛逛` | **不给「升级会员」**(M4 无支付,D12) | +| 发布页分类锁定标签 | `AI 创作` | 静态 `TagPill`,不可改 | +| 发布页正文 hint | `说说这张图的灵感…` | | +| 发布页建议标签 | `#吉卜力风` / `提示词` / `我家豆豆` | 用户点了才插入正文,**不预填** | +| 发布页提示词开关 | `附上创作提示词` | 默认关(D9) | + +### 5.2 错误文案分层 + +**⚠ 前提:以下错误码除 40405 / 42203 / 40902 外,其余全部尚不存在**,需在 M4 契约新开(§10 R4)。号段是**建议**,须与后端一起定号(纪律:业务码永不复用或改号,`openapi.yaml:28`)。 + +| 情形 | 建议码 | 用户可见文案 | 出口 | +| --- | --- | --- | --- | +| 配额耗尽 | 429 / `42900` | `今日额度已用完,明天 0 点重置` | S1 sheet | +| 频率过快 | 429 / `42901` | `操作太频繁,请 12 秒后再试` | SnackBar + 倒计时,**不弹 sheet** | +| 参考图不存在 / 非本人 / 用途不符 | 404 / `40405`(**已存在**) | `这张照片已失效,请重新选择` | 回 A2 参考图区 | +| 参考图非 ready | 422 / `42203`(**已存在**) | `照片还没处理完,稍等一下再试` | 停在 A2 | +| 风格 / 模型已下架 | 404 / 新码 | `这个风格暂时下架了,换一个试试` | 回 A1 | +| 提示词违规 | 422 / 新码 | `提示词里有不能生成的内容,改一改再试` | 停在 A2,聚焦提示词框 | +| 生成失败:识别不到宠物 | `error_code` 分支 | `参考图里没有识别到宠物,换一张试试` | A3 失败态「换个参考图」 | +| 生成失败:提供方不可用 | 503 / `50300`(**已存在**)或 `error_code` | `生成服务暂时不可用,稍后再试` | A3 失败态「用同样设置重试」 | +| 生成失败:兜底 | 任意未识别 `error_code` | `这次生成没成功,换个参考图或稍后再试` | **绝不写「未知错误」**;折叠区可展开看 `error_message` | +| 重试次数已尽 | `attempt_count == max_attempts` | `同样的设置已经试过几次了,建议换张照片` | 「用同样设置重试」降为次要态 | +| 建草稿失败:写侧拒收分类 | 400 / `40000` | `发布准备失败,请重试` | 停在 A4(这是 §10 R5 未做时的表现,属实现缺陷不该到用户面前) | +| 草稿乐观锁冲突 | 409 / `40902`(**已存在**) | `草稿已被更新,请重新操作` | 沿用 M3.5 既有文案(`iteration-3.5/05 §1.2`),不另造 | +| 网络不可达 | — | `网络好像不太好,检查一下再试` | 沿既有网络层文案惯例 | + + +## 6. 动效与反馈约定 + +### 6.1 动效清单 + +| 场景 | 动效 | 时长 / 曲线 | reduce-motion 降级 | +| --- | --- | --- | --- | +| 骨架呼吸(A1/A5) | 不透明度 0.6 ↔ 1.0 循环 | 1200 ms 往复 | **静止在 1.0**(沿 `feed_skeleton.dart:40-43`) | +| 渐进披露展开 | `AnimatedSize` | 200 ms `easeOut` | 瞬变(`duration: Duration.zero`) | +| 风格卡选中 | 描边色 + 勾选角标淡入 | 150 ms | 瞬变 | +| 进度环(不确定) | Material 内置旋转 | 内置 | **不停转**(这是状态指示不是装饰,停了会让人以为卡死);改用 `LinearProgressIndicator` 不确定态亦可 | +| 进度环(不确定 → 确定切换) | 从当前角度续上,**不回零** | 300 ms `easeOut` | 直接跳到当前值 | +| 进度环补满 → 进 A4 | 补到 100% → 停 200 ms → `pushReplacement` | 合计 ≈500 ms | 直接跳转 | +| G1 条出现 / 消失 | 高度 + 不透明度 `AnimatedSize` | 200 ms `easeOut` | 瞬现 / 瞬隐 | +| G1 条「进行中 → 已完成」 | 底色与图标交叉淡入 | 250 ms | 瞬变 | +| Tab 角标出现 | `Badge` 内置 scale | 内置 | 瞬现 | +| 上传格进度层 | 沿既有 `UploadProgressOverlay`(成功态 150 ms 淡出,`upload_progress_overlay.dart:64-70`) | 150 ms | 沿既有 | +| 页面转场 | 系统默认 `MaterialPageRoute` | 内置 | 系统处理 | + +**reduce-motion 检测口径**:用 `MediaQuery.maybeOf(context)?.disableAnimations ?? false`(**`maybeOf` + 兜底**,沿 `like_button.dart:104-105` 的稳妥写法,而不是 `feed_skeleton.dart:40` 的 `MediaQuery.of`)。 + +**唯一不降级的动效是进度环的旋转**,理由已写在表里。这是一个刻意的偏离,须在源码注释里记明(否则下一个人会"顺手统一"掉)。 + +### 6.2 反馈约定 + +| 反馈层 | 用在哪 | 不用在哪 | +| --- | --- | --- | +| 字段级 `errorText` | 提示词违规、软上限提示 | 不用于网络错误 | +| 区块 `InlineErrorBanner` + 重试 | A1 目录失败、A2 提交失败、A4 建草稿失败、A5 首载失败 | 不用于轮询抖动(§2.3:抖动不升级为错误) | +| 瞬态 `SnackBar` | 频率过快(带倒计时)、G1 完成通知、删除成功 | **不用于配额耗尽**(那要给出口,用 S1 sheet) | +| Bottom sheet | S1 配额说明、A5 长按操作 | 不用于 A2 表单(要与其他项同屏) | +| `AlertDialog` | 取消生成确认、删除作品确认 | **不用于「作品生成好了」**(抢焦点,用户可能正在打字) | +| 页内终态版式 | A3 失败态、A3 取消终态 | — | + +**提交类操作的防重复三道闸**(AI 生成是花钱的,比点赞严格得多): + +1. 点击后立即 `AbsorbPointer` 整页 + 钮内转圈(视觉与交互双锁) +2. 幂等键:沿既有纪律(`post_compose_page.dart:98`、`:179-182` 表单一改即换新键),`generation_jobs` 有 `UNIQUE(user_id, idempotency_key)`(设计稿 `:643`) +3. **A4「重新生成」必须换新幂等键**,否则会命中同一个旧 job,用户看到「点了没反应」 + +**乐观更新在 M4 不适用**。M3 的点赞/收藏乐观更新(`iteration-3/05 §4`)是因为结果可预测;AI 生成的结果**不可预测**,不存在可乐观呈现的终态。M4 的所有写操作都是「等服务端确认」。这条要写明,避免有人照搬 M3 的策略。 + +## 7. 无障碍要点与色彩自查 + +### 7.1 无障碍要点 + +**既有惯例(已核实,M4 沿用不新造)**: + +- `IconButton` 一律给中文 `tooltip`(全 lib 13 处,如 `main_shell_page.dart:253`、`create_page.dart:463`、`post_detail_page.dart:954`) +- 裸 `InkWell` / `GestureDetector` 给 `Semantics(label:, button:)`,且 `button:` 随可用性变化(`like_button.dart:159-161` 的 `button: widget.onPressed != null` 是最规范的写法) +- 触控目标 ≥44×44;不足时在源码显式记录妥协(`comment_tile.dart:114-115`、`post_media_grid.dart:226`) +- 共享组件把语义 label 做成参数(`LikeButton.semanticLabel`),由调用方给业务语义 + +**M4 必须补的语义标注**(这些是纯图形/纯状态元素,不标注则屏幕阅读器读不出): + +| 元素 | 要求 | +| --- | --- | +| `StyleCard` | `Semantics(label: '<风格名>风格', selected: <是否选中>, button: true)`。**必须给 `selected`** —— 选中态是描边 + 角标(纯视觉),不给 `selected` 屏幕阅读器无法区分 | +| `GenerationProgressPanel` | 整块 `Semantics(label:)` 动态描述当前状态(如`正在生成,已经等了 42 秒`)+ `liveRegion: true`(状态变化时主动播报)。**这是全仓第一处 `liveRegion` 用法**,需在注释里说明理由 | +| 进度环 | `ExcludeSemantics`(数值已由外层 label 承载,避免重复播报"进度条")。注:`ExcludeSemantics` 全 lib 零使用,M4 首例 | +| `GenerationJobCard` | `Semantics(label: '<状态>的作品,<发布状态>', button: true)`;纯图形态(`queued`/`running`/`failed`/`cancelled`)尤其必要 | +| `ActiveJobBar` | `Semantics(label: <条文案>, button: true, liveRegion: true)`(完成时播报) | +| Tab 角标 | Material `Badge` 需给 `Badge(label:)` 或外层 `Semantics` 说明`有正在生成的作品`;**纯小圆点无数字,屏幕阅读器读不到任何东西** | +| `QuotaMeter` | `Semantics(label: '今日还可生成 3 次,共 5 次')`;进度条本身 `ExcludeSemantics` | +| A4 结果图 | `Semantics(image: true, label: '<风格名>风格的生成结果,双击查看大图')` | + +**其他要点**: + +- **字体放大适配**:`textScaler` / `textScaleFactor` 全 lib **零处理**(已核实)。M4 的 `StyleCard` 底部字条(12/w700 在 `1/1.1` 比例的格内)与 `GenerationJobCard` 字条是最容易在 200% 缩放下溢出的两处 → 底部字条用 `maxLines: 1` + `TextOverflow.ellipsis`,且格高随字号增长(不用固定高度)。这是设计侧能做的防御;系统性的字体放大适配不在 M4 范围,记入遗留。 +- **键盘 / 焦点**:A2 有 5+ 个可聚焦元素 + 两级披露。披露区展开后须把焦点送进新出现的第一个控件;收起时焦点回披露行本身(否则焦点会落在已隐藏的控件上)。 +- **不靠颜色单通道传达状态**:`StyleCard` 选中 = 描边 + 勾选角标(双通道);`GenerationJobCard` 状态 = 图标 + 文字字条(双通道);G1 完成态 = 底色 + 图标 + 文案(三通道)。 + +### 7.2 色彩无障碍自查(程序精算) + +计算方法沿 `iteration-3/05 §5`:WCAG 2.x 相对亮度公式;8% 淡底按 `withAlpha(20)`(7.84%)与白底合成;scrim 合成按**最不利底(纯白图)**计算。阈值:正文 4.5:1,大字 3:1,非文字 3:1。 + +| 组合(用途) | 对比度 | 判定 | +| --- | --- | --- | +| `ink` / `surface`(正文、页码胶囊白字于 ink 实底同值 13.50) | 13.50 | 达标 | +| `inkSoft` / `surface` / `canvas` / `surfaceTint`(全部次级信息文字) | 6.59 / 6.21 / 5.58 | 达标 | +| 白字 / `ink` 80% scrim 合成白图(格内字条、+N 角标、已发帖角标) | 7.10 | 达标 | +| 白字 / `ink` 75% scrim(**正典 `.style-card` label 原值**,:171) | 6.11 | 达标,但**统一提到 80%** 与 M3 规则一致 | +| 白字 / `ink` 60% scrim | 3.88 | **不达标,禁用** | +| **白字 / `brandGradient` 中点** | **2.22** | **❌ 不达标 →** 见 D9 | +| **白字 / `brandGradient` accent 端(最不利)** | **1.74** | **❌ 严重不达标** | +| **`ink` 字 / `brandGradient` primary 端(最不利)** | **4.90** | **✅ 采纳方案**(中点 6.08,accent 端 7.74) | +| `primaryDark` / 白 92% 叠渐变(正典 `.upload-btn`,最不利端) | 8.69 | 达标(正典这条是对的) | +| 白字 / `primaryStrong`(A4 主 CTA、发布钮、勾选角标底) | 4.49 | 达标(一迭代已裁决按 ≈4.5 采纳) | +| `primaryStrong` / `surface`(白卡内链接字、A5 翻页重试) | 4.49 | 达标 | +| `primaryStrong` / `canvas` | 4.23 | **贴线不过作正文**;仅可作**非文字**(描边/值条,≥3)。canvas 底文字一律 `primaryDark`(8.88) | +| `primaryDark` / `surfaceTint`(G1 条文字、tonal 元素) | 7.98 | 达标 | +| `primaryDark` / primary 8% 底(`TagPill(primary)`) | 8.74 | 达标 | +| **`primaryStrong` 值条 / `surfaceTint` 轨道**(`QuotaMeter`、进度环) | **3.80** | 达标(非文字 ≥3) | +| **`primary` 值条 / `surfaceTint` 轨道** | **2.33** | **❌ 禁用** —— 值条必须 `primaryStrong` | +| **`primary` 2px 描边 / `canvas`**(现 `create_page.dart:237` 选中风格卡) | **2.59** | **❌ 不达非文字 3:1 →** D10 修订为 `primaryStrong`(4.23) | +| **`success` 图标 / `surface`**(现 `create_page.dart:320`、`:553`) | **2.67** | **❌ 不达非文字 3:1 →** D10 修订为 `successInk`(7.90) | +| **`success` / `successSurface`** | **2.29** | **❌ 同上禁用** | +| `successInk` / `surface` / `successSurface`(G1 完成态图标与文字) | 7.90 / 6.79 | 达标 | +| **`warning` / `surface`** | **2.15** | **❌ `warning` 不可作图标或文字色**(任何底上都不行:canvas 2.02)。僵死态用 `accentDark`(7.40)或 `accent` 仅作**装饰底** | +| `accentDark` / `surface` / `canvas` / accent 8% 底 | 7.40 / 6.97 / 7.07 | 达标 | +| `accentDark` / `accent` 实底(正典 PRO 徽章语言) | 4.24 | 达标(正文 ≈4.5 贴线;仅用于 ≥14/w700 的短标签) | +| `errorDark` / `surface` / error 8% 底(失败图标、字条、耗尽态) | 6.50 / 5.78 | 达标 | +| `error` / `surface`(失败图标,非文字) | 4.99 | 达标 | +| 白字 / `errorDark` 实底(失败通栏「重试」) | 6.50 | 达标 | +| `muted` / `surface`(hint、禁用、`cancelled` 图标) | 3.36 | **仅限占位/禁用/纯装饰**(DEBT-2 允许的用途) | +| `surfaceTint` / `surface`(骨架块、格底) | 1.18 | 装饰性占位,不受约束 | +| **现状:SegmentedButton 选中 `onSecondaryContainer` #5D4038 / `secondaryContainer` #FFDAD2** | **7.19** | **对比度达标** —— 这笔债是**品牌一致性**问题不是无障碍问题(§8.1) | + +**新增裁决点**: + +| # | 事项 | 处置 | +| --- | --- | --- | +| 1 | `brandGradient` 承载文字 | **一律用 `ink`,禁用白字**(白字最不利端 1.74:1)。M4 首次在渐变上放文字,此前 `brandGradient` 只用于装饰(头像环、`BrandMark` 方块)——所以这不是修旧债,是**立新规**:`brandGradient` 上的文字色恒为 `ink`;需要"反白"观感时改用 `primaryStrong → accentDark` 渐变(白字最差端 4.49:1),但那是另一套色,需单独拍板 | +| 2 | 次级信息不用透明度降权 | hero 副标不用「白/`ink` 92%」,靠字号 + 字重(12/w500 vs 18/w800)区分层级。透明度降权是 M3 已踩过的坑(`Colors.white70` 于 ink 80% scrim 实测仅 4.46:1 贴线不过,现 `create_page.dart:280` 正是此写法) | +| 3 | 进度值条与轨道 | 值条恒 `primaryStrong`,轨道恒 `surfaceTint`(3.80)。`primary` 值条禁用 | +| 4 | 语义色的「图标可用性」 | `success`(2.67)与 `warning`(2.15)**都不达非文字 3:1**,二者**只能作淡底**,图标/文字必须用 `successInk`(7.90)/ `accentDark`(7.40)。这条应作为全 app 规则登记(不止 M4) | + + +## 8. 设计债处置意见 + +### 8.1 SegmentedButton 粉底(M2 遗留) + +**核实结论**(两路独立复核): + +- `app_theme.dart` **没有 `segmentedButtonTheme`**(`buildAppTheme()` 只定制 8 个子主题::105/124/133/146/173/179/180);全 `lib/` grep `segmentedButtonTheme|SegmentedButtonThemeData` **零命中** +- Flutter SDK(本机 3.44.6)默认:选中容器取 `colorScheme.secondaryContainer`、前景取 `onSecondaryContainer`(`flutter/packages/flutter/lib/src/material/segmented_button.dart:1214`、`:1224-1232`) +- `app_theme.dart:88-92` 的 `fromSeed` 只 override 了 `surface`/`error`/`onError`,**没 override `secondaryContainer`** +- **精确色值**(本机 `material_color_utilities 0.13.0` + `SchemeTonalSpot(#FF6F4C)` 实算):`secondaryContainer` = **`#FFDAD2`**、`onSecondaryContainer` = **`#5D4038`**、未选中描边 `outline` = `#85736F` +- **对比度 7.19:1,达标** + +**处置意见:偿,但明确它是 P2 品牌一致性债,不是 P1 无障碍债。** + +这一点很重要,因为它改变了优先级判断。`#FFDAD2` 是一枚淡**粉**(与色板的 `surfaceTint #FFE8D6` 桃色仅差 8 点蓝通道,肉眼是"一个偏粉一个偏橙"),它不在色板里,但**它不伤可读性**。所以: + +- **不应该**为它拖延 M4 的功能范围 +- **应该**在 M4 期间顺手补 `segmentedButtonTheme`:选中 `surfaceTint` 底 + `primaryDark` 字/w700(7.98:1,与筛选 chip 同款,`iteration-2/05 §D7` 已裁决过的色对);未选中 `surface` 底 + `border` 描边 + `inkSoft` 字(6.59:1);描边从 `outline #85736F` 改 `border #F0DCC8` +- **收益面**:一处主题修好 **6 个使用点**(全部已核实无局部 `style:` 覆盖)—— `home_page.dart:385`、`create_page.dart:138`、`services_page.dart:102`、`pet_form_page.dart:426`、`pet_form_page.dart:457`、`vaccination_form_page.dart:340` +- **M4 自身的关系**:`create_page.dart:138` 那一枚在 M4 会因"数据驱动 + 只有 image 类不渲染"而消失(§2.1),所以**M4 不修也不会有新债**;但另 5 处照旧。规模 S,建议并入 M4 的主题收敛小工单。 + +**连带诉求:同时补 `expansionTileTheme` 或干脆不用 `ExpansionTile`。** 本规范选后者(§2.2)—— `ExpansionTile` 的色值同样走 `ColorScheme` 派生,直接用会**在 M4 里现制一笔一模一样的新债**。这是这笔旧债给出的最有价值的教训:**凡 Material 组件的默认色未在 `app_theme.dart` 里被显式 override,就不要在新页面首次引入它。** + +### 8.2 月份网格选择器缺失(M3.5 遗留) + +**核实结论**:`app_date_picker.dart` 全文 144 行,只提供 `dateOnly` / `today()` / `isDateSelectable` / `pickAppDate`(内部 `showDatePicker`,:54-79)/ `AppDateFieldTrailing`(:86-144)。**没有任何「只选年月」能力**。全 lib grep `showMonthPicker|MonthPicker|monthGrid|月份网格|selectMonth|YearMonth` 唯一命中是 `app_date_picker.dart:5` 那句注释本身。M3.5 的登记原文:`iteration-3.5/02-client-ux-fixes.md:282-283`「未做:月份网格选择器(需自绘/引包)。若第二批有余量可评估,但当前年份网格 + 手输 +「今天」已覆盖实测暴露的全部痛点」。 + +**处置意见:M4 不偿,且 M4 应主动设计成不触发它。** + +- M4 唯一可能撞上它的地方是 A5 的「按月筛选我的作品」。**本规范建议 M4 不做时间筛选**(§2.5):M4 的作品量级不足以需要筛选,游标分页够用。 +- 因此这笔债在 M4 **既不偿也不恶化**。 +- 若产品坚持要时间筛选(拍板 D15),成本从"零"跳到"自绘一个月份网格(M)",且它会成为第一个进 `lib/core/widgets/` 的日期类新组件 —— 那时应作为独立工单,不要挂在 AI 创作的工单里做。 + +### 8.3 ★ widthPx / heightPx 恒 null —— 在 M4 会不会变成显性 bug + +**核实到的事实链(三层,逐层带证据)**: + +| 层 | 事实 | +| --- | --- | +| DB | `media.assets` 建表**有列**:`V1__identity_media_baseline.sql:217-218` `width_px integer` / `height_px integer`;CHECK **明确允许 NULL**(:241-245) | +| 服务端 | main 代码对 `media.assets` 的三条写 SQL **全都不含这两列**:`MediaAssetRepository.java:35-41`(insertUploading)、`:82-86`(markReady)、`:94-97`(markFailed)。`MediaService.completeUpload`(:80-119)只做 `storage.stat()` HEAD 校验,**无任何图片解码**;全仓 `ImageIO`/`BufferedImage` grep = 0。`toResponse`(:121-128)原样透传库里的 NULL | +| 契约 | `openapi.yaml:3533-3539` `MediaAsset.widthPx` 的 description 写着「**complete 后回填,可空**」—— **与实现矛盾**(实际从不回填);`PostMediaItem.widthPx/heightPx`(:3605-3610)连 description 都没有。两处均 `nullable: true`,均不在 `required` 里 | +| 客户端 | **详情页读比例**:`post_detail_page.dart:513-521`,null 时回落 `4/3`,非 null 时钳制 `(width/height).clamp(1/1.33, 1/0.75)` = `[0.752, 1.333]`。**Feed 卡根本不读**:`post_card.dart:70-73` 硬编码 `AspectRatio(4/3)`。`PostMediaGrid` 单图折叠同样硬编码 4:3(`post_media_grid.dart:326-327`) | +| 数据通路 | `FeedCard.coverImage` 是 `PostMediaItem?`(`community_models.dart:383` 附近字段清单),**本来就带 widthPx/heightPx** —— 通路是通的,是卡片自己忽略了 | + +**严重度判断:中高(P1),M4 应作为必做项,理由不是"更严重了"而是"从看不见变成看得见"。** + +M3 的裁切用户容忍度高,因为那是**用户自己拍的照片**——他知道原图长什么样,也知道 App 只是裁了个封面。M4 完全不同: + +1. **AI 生成图的比例是用户自己在 A2 里选的**(1:1 / 3:4 / 9:16 …),他对"我要的是竖图"有明确预期 +2. **他刚在 A4 看过完整构图**(A4 用 job 的 width/height,比例正确,§2.4) +3. **然后同一张图在 Feed 里被裁成 4:3** —— 这是**同一次会话内的前后直接对照**。用户的结论不会是"Feed 封面裁切策略",而是"发布把我的图裁坏了" + +具体损失量(按 A2 提供的 5 档比例算): + +| 用户选的比例 | Feed 卡(恒 4:3 ≈1.333) | 详情页(widthPx null → 也回落 4:3) | +| --- | --- | --- | +| 1:1 (1.0) | 上下各裁 ~12.5%,主体居中时可接受 | 同 | +| 4:3 (1.333) | 正好 | 正好 | +| 3:4 (0.75) | **裁掉约 44%** | 同(钳制下界 0.752,几乎正好,但前提是有值) | +| 16:9 (1.778) | 左右各裁 ~12.5% | 同 | +| **9:16 (0.5625)** | **裁掉约 58%,显性 bug 级** | **即使有值也被钳到 0.752,仍裁掉约 25%** | + +**这里有一个几乎零成本的修法,是本节最重要的结论:AI 输出路径不需要图片解码。** + +`generation_jobs.width_px` / `height_px`(`patbond_postgresql.sql:616-617`)就是**生成尺寸本身**——worker 在写 output asset 时这两个数就在手里。所以: + +- **用户上传路径**要回填尺寸,确实需要引入图片解码(`ImageIO` 或探测库),那是有成本的(也是 `iteration-3/28-e2e-smoke-report.md:661-662` 建议的两个选项之一) +- **AI 输出路径**只需要 worker 在 insert output asset 时把 job 的两个数一并写进去。**一行赋值,零新依赖。** + +**设计侧的三项诉求(缺一不可,只做第一项修不好)**: + +| # | 诉求 | 落点 | 规模 | +| --- | --- | --- | --- | +| R1a | **worker 写 AI 输出 asset 时回填 `width_px`/`height_px`**(取 job 的生成尺寸,无需解码) | 后端 creation worker | S | +| R1b | **`post_card.dart:70-73` 单图改读 `coverImage.widthPx/heightPx`**,null 时才回落 4:3。数据通路本来就通(`FeedCard.coverImage` 是 `PostMediaItem`) | 客户端 | S | +| R1c | **详情页钳制下界从 `1/1.33`(0.752)放宽到 `9/16`(0.5625)**,`post_detail_page.dart:520` | 客户端 | S | + +R1c 的理由:M3 定这个钳制是为了防"用户上传的超长截图撑爆列表"(合理)。但 **AI 生成图的比例是枚举可控的**(用户只能从 5 档里选),放宽到 9:16 仍能防住无限长图,同时让竖版 AI 图正确显示。若担心影响普通帖,可按 `category == 'ai_creation'` 分档钳制 —— 但更简单的做法是统一放宽到 9:16(普通用户上传的手机竖拍照片是 3:4,本来就在范围内;真正超长的截图仍被钳住)。 + +**若三项一个都不做的后果**:AI 创作是 M4 的招牌功能,而它产出的作品在社区里**一律显示为被裁坏的 4:3**。这是我在本规范里唯一标记为"会直接损害功能可信度"的债。 + +**另一个应当顺手做的事**:`openapi.yaml:3533-3536` 那句「complete 后回填,可空」是**错的描述**(实现从不回填)。要么随 R1a 让它变成真的(AI 路径变真了,用户上传路径仍假),要么改成「预留字段;AI 输出回填,用户上传暂不回填」。**留着一句和实现矛盾的 description 是最坏的选项** —— 下一个读契约的人会照它写代码。 + +### 8.4 首页 demo 占位与 AI 入口的共存 + +**核实结论**:首页 `ListView` children 顺序与 demo 登记(`home_page.dart:23-33` 是 ADR-022 钉死的登记表):天气条(:361-365,demo)→ 问候卡(:367-375,问候名真实、右侧大图与建议仍 demo)→ 搜索框(:377-384,demo)→ `SegmentedButton`(:385-401,真)→ `_StoryRow`(:404,圈子是 demo,环内「发布」是真入口)→ `_PromoCard`(:406,demo)→ Feed(:408,真)。且 `home_greeting_test.dart` 有**反向用例钉住「保留项仍在」**。 + +**处置意见:M4 不给首页加任何新的 AI 入口卡片。** + +- 底栏创作 Tab(`main_shell_page.dart:277-281`,✨ `auto_awesome`)已是正典给的**一级入口**(`AI宠物_iOS_UI设计稿.html:350` tabbar 就是这么画的)。首页再加一张"去 AI 创作"卡是重复导航。 +- 更实际的顾虑:首页已经有一张促销形态的大卡(`_PromoCard`)。再放一张渐变 hero 卡在它附近,**两张争视觉重量的卡上下相邻**是最糟的布局结果,而且 `_PromoCard` 是刻意保留的 demo(不能移走它来腾位)。 +- **唯一放进首页的东西是 G1 状态条,位置在页面第一位(天气条之上)。** 它不与 ADR-022 冲突:ADR-022 钉的是「三项 demo 刻意保留、不得顺手清理」(`decisions.md:188`),G1 是**在其上方插入**,不移动、不删除、不改动任何 demo → `home_greeting_test.dart` 的反向用例不会挂。且它只在有在途任务时渲染,无任务时零占位。 +- **若产品坚持要首页曝光 AI 创作**(拍板 D16):唯一不冲突的插入位是**问候卡与搜索框之间**(`home_page.dart:375` 之后、`:377` 之前),形态必须是**窄条 pill 入口**(高 ≤52,一行文字 + `chevron_right`)而不是大卡。理由同上。 + +### 8.5 顺手核出的三处现状缺陷(`create_page.dart`,M4 替换时一并纠正) + +这三处都在 M4 的替换范围内,不是额外工作量,但值得写明避免照抄: + +| # | 位置 | 问题 | 修法 | +| --- | --- | --- | --- | +| 1 | `create_page.dart:237` | 选中风格卡描边用 `AppColors.primary`,于 `canvas` 底 **2.59:1**,不达非文字 3:1 | 改 `primaryStrong`(4.23)+ 加勾选角标(双通道) | +| 2 | `create_page.dart:320`、`:552-554` | `Icons.check_circle` / 阶段勾选用 `AppColors.success`,于白卡 **2.67:1**,不达非文字 3:1 | 改 `successInk`(7.90)。**这条应升为全 app 规则**:`success` 与 `warning` 只能作淡底,不能作图标/文字(§7.2 裁决点 4) | +| 3 | `create_page.dart:280` | 风格卡副标用 `Colors.white70`,于 `ink` 80% scrim 实测 **4.46:1**,贴线不过 | 改纯白(7.10);层级靠字号 10 vs 12/w700 区分,不靠透明度 | + +另两处**不是缺陷但偏离色板**,M4 替换时一并收敛:`create_page.dart:298` 主 CTA 用 `backgroundColor: AppColors.ink`(白/ink 13.50:1 达标,但主按钮主题本是 `primaryStrong`,`app_theme.dart:135`);`create_page.dart:545` `LinearProgressIndicator` 未指定色,走 `colorScheme.primary` 的 seed 派生值(实算 `#904B3A`)而非色板成员。 + + +## 9. 待拍板决策清单(18 项) + +标 ★ 的三项是最关键的(不定这三项,A2/A3 无法开工)。 + +| # | 事项 | 推荐 | 理由 | +| --- | --- | --- | --- | +| **★ D1** | **长耗时等待态的整体形态** | 提交后 `pushReplacement` 进 A3;A3 **明确允许离开**;进度用「阶段文字 + 不确定环」为主,`progress>0` 才叠确定型数字;**不展示 ETA,改展示已用时**;回来的路三条(G1 条 → Tab 角标 → A5);**文案不得承诺推送** | 三条方案里只有这条既给出「事情真的开始了」的即时确认,又不把用户锁死。ETA 需要提供方支持 + 队列深度模型,M4 两者都无(AI 提供方 ADR 尚未产生),倒计时走完还没完成是最伤信任的反模式。「通知你」在无推送能力(已核实无 WS/SSE/FCM)下是直接的失信 | +| **★ D2** | **参考图必填还是可选** | **必填** | 工单描述写「可选参考图」,但设计稿 `patbond_postgresql.sql:612` 是 `input_asset_id uuid NOT NULL`,正典也是「上传一张照片,AI 帮你生成专属宠物写真」(:329)——产品定位是**图生图(宠物写真)**而非通用文生图。若改可选,A1 hero CTA 文案、A2 gating、以及"纯提示词能不能生成"三处都要改(§2.2 已备降级形态) | +| **★ D3** | **配额契约(429 + 两个新错误码)** | 新开 429,并**拆成两个码**:`42900` 配额耗尽 / `42901` 频率过快 | 429 与配额码**当前完全不存在**(`ErrorCode.java:10-38` 27 个码里没有;`feature-checklist.md:194` 记为 ⬜)。两类必须分开:配额耗尽等的是**明天**(该给出口,弹 S1 sheet),频率过快等的是**几秒**(该原地待着,弹 SnackBar),共用一个码就必然有一半场景的文案和出口是错的。号段须与后端一起定(业务码永不改号,`openapi.yaml:28`) | +| D4 | `running` 时 `progress` 是否保证 >0 且单调递增 | **不保证** → UI 恒以不确定环为主形态,阶段步进**只做两段**(已排队 / 生成中),第三段「收尾」不渲染 | 设计稿 `running` 态对 progress **无下限约束**(`:673-677`);真实进度取决于尚未选定的提供方。押注"会有进度"的后果是最常见观感变成「0% 卡住」 | +| D5 | 画面比例:枚举 5 档,还是自由像素 | **枚举 5 档**(1:1 / 3:4 / 4:3 / 9:16 / 16:9),客户端按「模型基准边长 × 比例」换算像素 | `width_px`/`height_px` 是 64..8192 自由整数,但让用户填像素是灾难。**连带诉求**:目录端点须给每个模型的支持比例白名单 + 基准边长(§10 R2),否则客户端会算出提供方不支持的尺寸 | +| D6 | AI 视频(`media_kind = video`)在 M4 的呈现 | **数据驱动,M4 不渲染 kind 切换器**(正典的「AI 视频」分组整段不出现) | ADR-018 视频后置,契约 `kind` enum 只有 `image`(`openapi.yaml:3456-3457`)。渲染一个永远点不动的「AI 视频」段是在承诺不存在的能力。数据驱动后契约扩到 video 时 UI 自动长出来 | +| D7 | 目录端点是否返回 `enabled=false` 的风格/模型 | **只返回 enabled 的**(服务端已有 partial index `WHERE enabled`,`:601-603`) | 返回全量会让 UI 多维护一个灰态,而"暂不可用的风格"对用户没有信息价值 | +| D8 | 提示词长度:UI 是否设软上限 | **软上限 500**(计数器 `N/500`,到顶给提示但不硬截断;硬上限仍是契约 10000) | 给用户看 `0/10000` 会鼓励写小作文,多数提供方对超长 prompt 效果反而更差。不硬截断是为了不吃掉用户粘贴的长文 | +| D9 | 发帖时是否公开创作提示词 | **默认不公开**,给一个「附上创作提示词」开关(默认关) | prompt 可能含隐私(他人姓名、地点、私人梗)。默认公开等于替用户做了一个不可撤回的隐私决定 | +| D10 | `brandGradient` 上的文字色(**新规则**) | **恒 `ink`,禁用白字** | 白字于该渐变最不利端 **1.74:1**、中点 **2.22:1**,严重不达标;`ink` 最不利端 4.90:1 达标。此前 `brandGradient` 只用于装饰(头像环、`BrandMark`),M4 是首次在其上放文字,所以这是**立新规**不是修旧债。需要"反白"观感时改用 `primaryStrong → accentDark` 渐变(白字最差 4.49:1),但那是另一套色,须单独拍板 | +| D11 | 生成失败 / 用户取消,额度是否退还 | **失败退还,用户主动取消不退还** | 失败是服务方的责任,扣额度会被视为坑钱;主动取消是用户的选择,且 worker 可能已经消耗了算力。**这条必须与文案严格一致**(§5.1 的 A3 取消对话框与失败态说明都依赖它),说反方向会直接被判为欺骗 | +| D12 | 配额耗尽时是否给「升级会员」入口 | **不给** | 正典有会员卡「AI 会员 Plus / 无限 AI 生图」(:489-493),但 M4 **无任何支付能力**(优惠/订单在 M5)。给一个通向死路的升级按钮比不给更伤。M4 只给两个真能去的出口(看作品 / 逛社区) | +| D13 | A5 是否做「保存到相册」 | 建议做,但**依赖需批** | 需引入 `gal` / `image_gallery_saver` 类新依赖 + Android/iOS 权限声明。**未取证**该依赖是否可接受。若不批,A5 长按 sheet 只留「删除」 | +| D14 | A4 是否给系统分享(分享到微信等) | 建议 M4 **不做** | 需 `share_plus` 依赖 + 先把签名 URL 的图落到临时文件(预签名 URL 直接分享出去会在 1 h 后失效,接收方点开是 403)。收益不如成本,留 backlog | +| D15 | A5 是否做时间/月份筛选 | **不做** | 做了就触发 §8.2 的月份网格缺失(成本从零跳到 M)。M4 作品量级不足以需要筛选,游标分页够用 | +| D16 | 首页是否加 AI 创作入口 | **不加**(只放 G1 状态条在页面第一位) | 底栏创作 Tab 已是正典给的一级入口,首页再加是重复导航;且首页已有 `_PromoCard` 这张促销形态大卡(刻意保留的 demo,不能移走腾位),两张争视觉重量的卡相邻是最糟布局。若坚持要,唯一不冲突的位置与形态见 §8.4 | +| D17 | 一键建草稿走服务端还是客户端预填 | **服务端建草稿**,客户端只带 `draftPostId` 进发布页 | 复用了发布页已有的草稿恢复通路(`post_compose_page.dart:194-220` 本来就会恢复 `draft.media`),改动面最小;且把「AI 帖元数据挂接」收在服务端一处。**连带必做**:修 `post_compose_page.dart:620-638`(草稿图现在只显示计数文字不渲染缩略图,对 M4 是明确缺陷) | +| D18 | 是否允许同时排多个生成任务 | **允许**,上限由配额自然约束 | A1 在有在途任务时仍可提交新任务。若要限制"同时只能一个",A1 的 hero CTA 需在有在途任务时禁用,且要给出原因文案——那是额外的一套状态,收益不明 | + +**跨角色需确认的三项**(不是纯 UI 决策,但会改变界面): + +| # | 事项 | 需谁确认 | +| --- | --- | --- | +| X1 | `queuePosition`(队列排位)能否下发 | 后端。给不出则 A3 排队态只能说「已排队」(§10 R3) | +| X2 | 僵死态阈值(本规范暂取 10 min) | 后端 + 产品。需要真实生成耗时量级才能定,而那取决于未产生的提供方 ADR | +| X3 | G1 完成态「点过才消失」的埋点口径 | 埋点侧。涉及「结果已被查看」这个客户端状态是否要上报 | + + +## 10. 给后端 / 契约的设计侧诉求 + +按「界面画不出来的程度」排序。R1 是唯一标记为「不做会直接损害功能可信度」的。 + +| # | 诉求 | 为什么界面需要它 | 规模 | +| --- | --- | --- | --- | +| **R1** | **AI 输出 asset 回填 `width_px`/`height_px`**(取 job 的生成尺寸,**无需图片解码**);配套客户端 R1b(`post_card.dart:70-73` 单图读比例)+ R1c(详情页钳制下界放宽到 9/16) | 否则 M4 的招牌功能产出的作品在社区里一律显示为被裁坏的 4:3,而用户刚在 A4 看过完整构图 —— 同一次会话内的直接对照(§8.3 全文) | S(三处各 S) | +| **R2** | **目录端点给出每个模型的支持比例白名单 + 基准边长** | A2 的画面比例是枚举 chip,客户端要按「基准边长 × 比例」换算像素。没有白名单,客户端会算出提供方不支持的尺寸,提交必被拒(D5) | S | +| **R3** | **任务状态响应带 `queuePosition`**(`queued` 时) | A3 排队态的唯一可信信息。给不出就只能说「已排队」——用户无法判断是 3 秒还是 3 分钟。**排位是离散事实,不是预测**,与 ETA 性质不同,可以安全展示(X1) | S | +| **R4** | **429 + 两个新错误码(配额耗尽 / 频率过快),且响应体带 `limit` / `remaining` / `window` / `resetAt`(或 `retryAfterSeconds`)** | `QuotaMeter` 要在**提交前**显示剩余次数(预防优于事后报错);S1 要说出准确的重置时间;「频率过快」要显示倒计时。四个字段少一个都得靠客户端猜(D3) | M | +| **R5** | **放开写侧 `ai_creation` 分类**:`CreatePostRequest.java:26` 的 `@Pattern(regexp = "general|help")` | 现在提交 `ai_creation` 直接 400/40000,「一键建草稿」整条链路走不通。DB CHECK 早已允许(`V5__community_baseline.sql:68`),只是写侧 DTO 挡着 | S | +| **R6** | **`POST /posts` 接受 `generationJobId`**;`Post` / `FeedCard` 响应可选带回 | A5 要显示「这个作品已发过帖」的角标(需要 job→post 的关联);A4 复访时要知道是否已发布 | S | +| **R7** | **风格目录的内容从哪来**(运营后台?种子数据?) | `generation_styles.preview_asset_id` 是 nullable(`:586`),没有预览图的风格卡只能显示图标占位。若上线时目录是空的或全无预览图,A1 就是一屏灰格子。**本项未取证**,需要一个答案 | ? | +| R8 | 任务列表端点沿用游标分页正典 `{items, nextCursor, hasMore}` | A5 的触底加载与「没有更多了」直接复用 M3 的既有骨架(`openapi.yaml:133-134`) | S | +| R9 | **修 `openapi.yaml:3533-3536` 那句与实现矛盾的 description**(「complete 后回填,可空」实际从不回填) | 留着一句和实现矛盾的契约描述是最坏选项——下一个人会照它写代码。随 R1a 改为「AI 输出回填;用户上传暂不回填」 | S | +| R10 | 取消端点须在 `queued` 与 `running` 两态都可用 | A3 的取消入口在两态都渲染。设计稿的 `cancelled` 态对 output/error 无约束(`:687-691`),两处都能进 | S | + +**明确不需要后端做的事**(避免过度设计): + +- **不需要推送 / WebSocket / SSE。** 轮询 + G1 + 角标已经能覆盖「离开再回来」的全部路径(§2.3)。为 M4 引入推送基础设施的收益远低于成本。 +- **不需要 ETA 字段。** 本规范主动不展示预计剩余时间(D1),所以不要为此建模。 +- **不需要 job → N assets 的关系表。** M4 恒单图(`output_asset_id` 单列已够,§2.4)。 + +## 11. 交付验收对照(供开发 / QA) + +- [ ] 8 个新建组件落位(`AiHeroCard` / `StyleCard` / `GenerationParamSection` / `GenerationProgressPanel` / `GenerationJobCard` / `QuotaMeter` / `ActiveJobBar` / `AiWorkGridSkeleton`);6 个改造项按 §4.2 完成 +- [ ] A1–A6 + S1 + G1 共 8 个界面单元,**每一个都具备 §3 表里为它列出的全部态**;`—` 的格子须在源码注释里写明「定型而非遗漏」及理由(沿 `iteration-3.5/05 §3.2` 的先例) +- [ ] **A3 的三条硬纪律**:(a) 屏幕上明写可以离开;(b) 文案**不出现「通知你」/「推送」**任何字样;(c) **不出现任何预计剩余时间/倒计时**(除「频率过快」的秒级倒计时,那是服务端给的确定值) +- [ ] `queued` 态**不使用确定型进度条**;`progress==0` 的 `running` 态同 +- [ ] 僵死态(`running` 超阈值)**不呈现为错误**,且取消钮在该态可见度提升 +- [ ] 轮询:仅前台、退避阶梯按 §2.3、连续 3 次失败才提示、**不因抖动打断等待**;jobId 列表持久化但**任何 URL 都不落盘**(纪律 R2) +- [ ] 三道防重复提交闸齐备(`AbsorbPointer` + 幂等键 + A4「重新生成」换新键) +- [ ] A4 结果图按**真实比例**渲染(取 job 的 width/height),不走 `PostMediaGrid` 的 4:3 回落;钳制 `[9/16, 16/9]` 且超界用 `contain` 不裁切 +- [ ] 「暂不发布」不叫「丢弃」;A5 的删除才是真删,且有二次确认 +- [ ] 发布页:`initialDraftPostId` 生效且不误恢复旧草稿;**草稿图渲染为缩略图而非计数文字**;`ai_creation` 分类锁定为静态标签;**正文不预填任何机器文案** +- [ ] widthPx 三项(R1a/R1b/R1c)落地;`post_card.dart` 与 `post_detail_page.dart` 的单图比例逻辑**一致**(现在不一致) +- [ ] 色彩:§7.2 表内全部组合达 AA;**`brandGradient` 上无白字**;`primary` 不作 2px 选中描边、不作进度值条;`success`/`warning` 不作图标或文字色;`Colors.white70` 于 scrim 零残留 +- [ ] 次级信息文字全部显式 `inkSoft`,`muted` 仅出现在占位 / 禁用 / 纯装饰(DEBT-2 不新增欠账) +- [ ] 语义标注:`StyleCard` 带 `selected`;`GenerationProgressPanel` 与 `ActiveJobBar` 带 `liveRegion`;Tab 角标可被读出;进度环 `ExcludeSemantics` +- [ ] reduce-motion(`MediaQuery.maybeOf(...)?.disableAnimations`)下全部动效降级,**唯一例外是进度环旋转**,且该例外在源码注释里写明理由 +- [ ] 触控目标全数 ≥44×44;不足处在源码显式记录妥协 +- [ ] 200% 字体缩放下 `StyleCard` / `GenerationJobCard` 的底部字条不溢出(`maxLines:1` + ellipsis + 格高随字号) +- [ ] `create_page.dart` 的消亡清单(§4.5)逐项清除,`_ComposeEntryCard` 保留;`CreationStyle` / `creationStyles` 从 `models.dart` / `demo_data.dart` 移除 +- [ ] 首页:**未新增任何 AI 入口卡**;G1 条插在页面第一位;`home_greeting_test.dart` 的「demo 占位仍在」反向用例仍绿 +- [ ] 若顺手偿了 §8.1:`segmentedButtonTheme` 落地,另 5 个使用点视觉回归无布局变化 + +--- + +## 附:本规范的自我限界 + +- **未取证项**:AI 提供方的真实生成耗时量级 / 是否上报进度 / 是否支持取消(取决于尚未产生的选型 ADR);风格目录的内容管理途径(R7);「保存到相册」依赖是否可批(D13)。这三处本规范给的是**可降级设计**而非确定形态。 +- **本规范不引用测试数作为设计结论依据**。Flutter 基线以协作方实测为准:597 通过 + 2 skipped。`integration_test/` 下的真机/桌面脚本**在 `flutter test` 之外、无门禁会跑**,因此既有报告中的「桌面逐步实测截图」在本规范中仅作设计意图参考,**不作为「该 UI 链路已验证」的证据**;凡引用既有组件行为一律以源码行号为准。 +- **本次未触碰任何生产代码、未改 `app_theme.dart`、未改 `mkdocs.yml`、未执行任何 git 提交。** 导航入档随波末统一处理。 + +--- +**UI Designer** · 2026-09-14 + diff --git a/docs/development/iterations/iteration-4/06-experiment-tracking-plan.md b/docs/development/iterations/iteration-4/06-experiment-tracking-plan.md new file mode 100644 index 0000000..a277c12 --- /dev/null +++ b/docs/development/iterations/iteration-4/06-experiment-tracking-plan.md @@ -0,0 +1,762 @@ +# 第四迭代埋点与实验规划(AI 创作) + +> 角色:Experiment Tracker +> 日期:2026-09-14 +> 前序:`iteration-2/06`(字典 v2、北极星定义式、H1~H4、A/B 八项前置)、`iteration-2/24`(白名单 v2)、`iteration-3/06`(字典 v3、聚合曝光裁定、A/B 前置推进)、`iteration-3/22`(白名单 v3)、`iteration-3/10`(队列硬化)、`iteration-3/28` §7 观察项 2(eventVersion 歧义登记) +> 依据:`patbond-api` dev@3cd8005 `EventDictionary.java` / `TrackEventsRequest.java` / `AnalyticsService.java` / `V2__create_platform_product_events.sql` 现行实现;`patbond-flutter` dev@fbcd734 `lib/analytics/` 与 `lib/features/*/‌*_analytics.dart` 现行挂接;契约 v1.4.0;`development-plan.md` §M4;`device-verification.md` +> 范围:M4 AI 创作纵切(模型/风格目录、生成任务创建/查询/取消、Worker 队列执行、产物入媒体表、一键建社区草稿)的埋点与实验;本地服务(M5)不在本轮定义 +> 性质:纯规划文档,供 M4 开发工单直接引用;本报告未改动任何生产代码或配置 + +**速览(七个核心结论)**: + +1. **北极星 09-21 首次出数不可行,且不是「来不及」而是「窗口已关闭」**。09-21 是 W37 队列(首记 09-07~09-13)+8 天的成熟日,入队窗口已于 **09-13 结束**——今日起做任何事都无法为 W37 补进一个用户。给出三档降级方案,推荐「触发条件替代日期承诺」+「基建就绪读数」,见 §0。 +2. **decisive finding:阻塞 M2 两项真机验证的「待设备到位」已经不成立**。本机 `~/Android/Sdk` 已装 android-36 x86_64 系统镜像与 `platform-tools/adb`,`flutter emulators` 列出一个可直接启动的 **Pixel_7** AVD;而 `device-verification.md:9` 明文接受「Android 真机(推荐)或 Android 模拟器」。两项合计约 55 分钟,**今天即可执行**。这把最早可得的北极星读数从「无限期」拉到 **2026-09-28(W38 队列)**,见 §0.3。 +3. **白名单实际 41 条,不是 42**,根因是 `experiment_exposed` 被重复计数(community 域实为 18 条,加 platform 域 1 条共 19 条新增;文档记作「19 + experiment_exposed」= 20)。**无任何测试断言字典条数**,故漂移四份文档一路不可见。M4 前置项:补一个条数断言,见 §1.1、§7。 +4. **eventVersion 定型为「每个事件自身的 props schema 版本」**(否决「字典世代」读法):客户端现行硬编码 1 在此读法下**本就正确**,零客户端改动、零历史回填;反之则全部历史行皆错且不可修复。配套给出 bump 规则与三层校验对齐方案,见 §2。 +5. **`platform=linux` 整批 400 是契约明文行为,枚举排除 linux 是三层锁死的设计意图;缺陷在客户端**——`analytics_service.dart:99-100` 注释称「逐条 rejected(不影响客户端)」与实现相反:实际整批 400 且被 `uploadBatch` 判为永久拒绝**整批丢弃**,桌面端 100% 静默丢事件。处置为客户端本地短路 + 调试覆盖开关,不动枚举,见 §3。 +6. **服务端生成侧不建第二条埋点管道**:`platform.product_events` 的 `anonymous_id`/`session_id` NOT NULL 与 `platform IN ('android','ios')` CHECK 使 Worker 事件无法入表;裁定以 `creation.generation_tasks` 事实表为服务端指标权威,经 `creationTaskId` 与客户端事件拼接,见 §4.2。 +7. **M4 首个「实验」应是 A/A 基建验证空跑,真实 A/B 冻结设计待流量**。A/B 前置为 **5 绿 3 半**——分流哈希、Flutter 曝光封装、feature flag **三者代码零实现**(本角色逐项 grep 取证),必须作为前置工单而非既有资产,见 §6。 + +**事件增量**:新增 **8** 条(全部客户端侧,`creation` 域),既有事件补属性 **2** 处,废弃 **0** 条;字典 **41 → 49**。 + +--- + +## 0. 北极星 09-21 时限:风险评估与行动建议 + +> 本节按任务要求置于全文最前(惯例的 §0「基线现状」顺延为 §1)。 + +### 0.1 北极星是否已定型:**是,无待办** + +定型载体 **ADR-012**(`architecture/decisions.md:128`,原文): + +> 北极星 = **7 日回访记录率**(分母:当 ISO 周产生生命周期首条 `health_record_create_succeeded` 的去重用户;分子:其中在首记日之后第 1–7 个 UTC 自然日内再次创建成功者;不含首记当日;首记日 +8 天出数)。 + +ADR-020(`decisions.md:172`)确认 M3 保持不变,复评点 = M3 收官 + H7 读数。完整定义式、七项口径与出数 SQL 在 `iteration-2/06` §2.1。**指标定义侧零缺口**: + +- 唯一数据源 `health_record_create_succeeded` 在白名单内(`EventDictionary.java:67`),且客户端**确实在发**(`lib/features/pets/health_record_analytics.dart` 挂接,本角色 grep 取证于 §1.2 的 33 条实发清单)。 +- 口径刻意不依赖 `sessionId`(只用 `userId` + `server_ts`),故队列硬化与会话缺陷都不污染读数。 +- 统计纪律已定:Wilson 95% CI 发布、<50 人周合并或 4 周滚动、未成熟队列不发布。 + +**但出数缺一个可执行载体**:`iteration-2/06` §2.1 的 SQL 只存在于报告正文,仓库内无脚本、无视图、无定时任务,没有任何一处「跑一下就出数」的入口。这与 PM 报告的判断一致,列为 §8 拍板项与 §附 工单。 + +### 0.2 09-21 为何不可行:窗口已关闭(算术判定,非进度判断) + +09-21 这个日期的来源是 `iteration-3/06:312`(原文): + +> | 北极星首个成熟周队列 | W37 队列(09-07~09-13 首记)+8 天成熟 | **2026-09-21(周一)首次出数**,此后每周一滚动 | 数据侧 | 本角色发布(Wilson 95% CI,<50 人周合并) | + +对齐 ISO 周实测(`date -d`):09-07 = 周一 / ISO 周 37 第 1 天,09-13 = 周日 / W37 第 7 天,09-14 = 周一 / **W38 第 1 天**。因此: + +| 事实 | 结论 | +| --- | --- | +| W37 的**入队窗口** = 首记日落在 09-07~09-13 | 该窗口已于 **2026-09-13(昨天)结束** | +| 分母 = 首记落在 W37 的去重 userId | 今日(09-14)起产生的首记一律归入 **W38**,永远进不了 W37 | +| 09-21 = W37 最后一名成员(09-13 首记)的 +8 天成熟日 | 09-21 只能出 W37 这一个队列的读数 | + +**判定:不可行,且无任何补救能改变。** 这不是「一周内做不完」的问题——即使今天就把 10 项真机验证全部做完、立刻拉来一批真实用户,他们全部落在 W38,W37 的分母不会增加一个人。 + +**W37 分母的现实取值:极可能为 0**,证据链: + +1. `device-verification.md` 三处执行记录(L106 / L168 / L221)**全部空白**——10 项真机验证一项未执行。 +2. 无 android/ios 包分发:`releases.md` v0.4.0(09-14 发布)只登记三仓 tag 与 CI 门禁,无商店/内测分发;`server-exposure.md` 对公网只开 22/80/443(Gitea + 文档站),**无 API 端点对外**,因此不存在真实用户可达的后端。 +3. 桌面端 100% 丢弃(§3 取证):唯一实际跑过的客户端形态(Linux 桌面)产生的事件全被整批 400 后永久丢弃。 +4. 库中可能存在的 `product_events` 行只有 curl 人工注入的合成 payload(`iteration-3/25` §5c、`iteration-3/26` §5c,均以 `platform=android` 伪值造),且位于可被 `docker compose down -v` 清掉的本地卷 `patbond_pgdata`。 + +未取证项:本角色**未查询数据库**(compose 未运行,且 8 个 agent 并发期间不宜起容器占端口)。W37 分母的实测值需执行: + +```bash +cd <你的工作区>/patbond-api && docker compose up -d postgres +docker exec patbond-postgres-1 psql -U patbond -d patbond -c \ + "SELECT platform, count(*) FILTER (WHERE event_name='health_record_create_succeeded') AS first_rec_src, + count(*) AS all_events, min(server_ts), max(server_ts) + FROM platform.product_events GROUP BY platform;" +``` + +### 0.3 一周内真正可行的事(decisive finding) + +**「待设备到位」这个阻塞理由已经不成立。** 本角色实测本机环境: + +| 取证 | 结果 | +| --- | --- | +| `ls ~/Android/Sdk/system-images/*/*/*` | `android-36/google_apis_ps16k/x86_64`、`android-36/google_apis/x86_64` | +| `ls ~/.android/avd/` | `Pixel_7.avd`、`Pixel_7.ini` | +| `flutter emulators` | `1 available emulator: Pixel_7 • Pixel 7 • Google • android` | +| `ls ~/Android/Sdk/platform-tools/adb` | 存在 | +| `flutter devices` | 当前仅 linux + chrome(模拟器未启动,**不是不存在**) | + +而 `device-verification.md:9` 明文把模拟器列为一等路径: + +> **设备**:Android 真机(推荐)或 Android 模拟器。桌面/Web 不可用——没有真实的移动端后台生命周期(`paused` 不触发),且 platform 值不在契约枚举内会被服务端整批拒绝。 + +M2 两项的步骤完整、通过标准明确、合计约 **55 分钟**(验证一 ~10 分钟:注册→切 Tab/建宠物/记体重→退后台 5 秒→psql 查 `platform='android'`;验证二 ~45 分钟:同一次登录不杀进程,5 分钟不换会话 / 35 分钟换会话,期望恰好 2 个 `session_id`)。唯一需要补的是 `ANDROID_HOME`/PATH 与 `flutter emulators --launch Pixel_7`,以及模拟器场景下宿主机地址用 `10.0.2.2`(文档 L20-31 已给命令)。 + +**这把最早可得的北极星读数从「无限期」拉到 2026-09-28**:W38 = 09-14~09-20,最晚首记 09-20 → +8 天 = **09-28(周一)**。前提是本周内(09-14~09-20)确实产生首记,且**同一 userId 在首记之后的第 1~7 个 UTC 自然日中的另一天**再创建一条成功记录(分子不含首记当日,故单次模拟器会话无法产生分子,需跨日两次操作)。 + +诚实标注:这样得到的 W38 读数**是测试账号自播种的,不是产品读数**。它的价值是**管道自证**(证明 SQL、口径、落库、去重全链路可跑通并能出一个非零数),必须与真实产品读数分账登记,绝不可用于任何产品结论或 A/B 基线。 + +### 0.4 降级方案(三档,推荐 A+B) + +| 档 | 方案 | 代价 | 本角色意见 | +| --- | --- | --- | --- | +| **A** | **把首次出数从「日期承诺」改为「事件驱动触发条件」**:定义 T0 = 首个满足「≥1 名真实移动端用户产生生命周期首条 `health_record_create_succeeded`」的 ISO 周;首次出数 = T0 + 8 天后的首个周一,此后每周一滚动。同时把 `device-verification.md:40` 与 `iteration-3/29:52` 的「09-21 时限提醒」改写为「已失效 + 指向本节」 | 常设文档两处改写 | **推荐**。真机与真实流量都不在项目控制范围内,继续挂日期只会持续生产假红线——本次就是第一例 | +| **B** | **09-21 仍按期出一次「基建就绪读数」**:不报北极星数值,只报「北极星 SQL 已可执行 + W37 分母 = N(实测)+ 链路证据」,作为管道验收而非指标读数 | 一条 SQL + 一段登记 | **推荐并行**。保住「每周一滚动」的节奏纪律不断线,且顺带把 §0.1 的「无 SQL 载体」缺口一并补上 | +| **C** | 用桌面 override(§3)或 curl 造数凑出「首批读数」 | —— | **明确否决**。数据造假,且会污染 W37 之后所有队列基线与 A/B 的历史对照 | + +### 0.5 本周行动建议(按优先级,含责任侧) + +| # | 行动 | 责任侧 | 工时 | 产出 | +| --- | --- | --- | --- | --- | +| 1 | 启动 `Pixel_7` 模拟器,执行 M2 两项真机验证,填 `device-verification.md` L106 执行记录 | 真机执行人(PM 已排为第一波首日工单) | 1 小时 | `platform='android'` 事件实证;A/B 前置 #1 的拦路项解除 | +| 2 | 把 `iteration-2/06` §2.1 的北极星 SQL 落成仓库内可执行载体(脚本或数据库视图),跑出 W37 实测分母 | 数据侧 | 0.5 天 | §0.4-B 的基建就绪读数;消除「无 SQL 载体」缺口 | +| 3 | 拍板 §0.4 的 A + B,改写两处常设文档的时限表述 | 用户 / PM | 0.5 小时 | 假红线清除 | +| 4 | 本周内跨两天用模拟器自播种 W38 队列(首记 + 隔日回访记录各一条),09-28 出管道自证读数 | 真机执行人 | 2 × 15 分钟 | 首个非零读数(标「自播种,非产品数据」) | +| 5 | 按 §3 修客户端桌面短路 + 调试覆盖开关,让桌面 E2E 也能验端上完整链路 | Flutter | 0.5 天 | 「埋点落库」类验证从「只能真机」降级为「桌面可验 + 真机只验移动生命周期」 | + +**关于其余 8 项真机挂起**(真机挂起实为 **10 项** = M2 两项 + M3 四项 + M3.5 四项;`feature-checklist.md` 只跟踪了 M3.5 的两项,漏登记 caregiver 改宠物头像与获赞数对账两项):与北极星出数**无关**,不进本周关键路径。其中 **M3.5-3(caregiver 改宠物头像)当前不可执行**——`device-verification.md:202` 承诺的造数 SQL「收口时补」至今未补,且关系授予入口未开放,需先补 SQL 才能排期。 + +## 1. 基线现状取证(含三处文档口径纠正) + +本节所有判断均以打开原始源码/配置为准,逐条注明文件与行号。**不采信任何文档转述**(M3.5 教训:一份报告写「无 nickname 字段」实指「契约未暴露」,被误读后规模预估高估一整档)。 + +### 1.1 纠正一:白名单实际 **41 条**,不是 42 + +| 项 | 实测 | 证据 | +| --- | --- | --- | +| 白名单定义位置 | **唯一一处** `WHITELIST` map | `patbond-api/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java:42-104` | +| `Map.entry(` 条数 | **41** | `grep -c 'Map.entry(' EventDictionary.java` → 41;逐条枚举见下 | +| 是否存在第二处白名单 | **否** | grep `experiment_exposed` / `user_unfollowed` 全仓:命中仅该文件、其测试、文档与客户端发送点,无第二份配置 | +| 是否有测试断言条数 | **否** | `grep -n "hasSize\|size()\|42\|41\|40\|count" EventDictionaryTest.java` → **零命中** | + +分域实测计数:auth **11**(`:43-57`)+ `page_viewed` **1**(`:59`)+ pet **3**(`:61-64`)+ health_record **7**(`:66-74`)+ post **8**(`:76-86`)+ feed **2**(`:88-91`)+ 互动 **8**(`:93-101`)+ `experiment_exposed` **1**(`:103`)= **41**。 + +**根因(已定位)**:`experiment_exposed` 被重复计数。community 域实为 **18** 条(post 8 + feed 2 + 互动 8),`experiment_exposed` 属 platform 域,v3 新增合计 **19** 条,22 + 19 = **41**。但 `iteration-3/22:13` 写作「新增事件 **20**(06 号 §1.5 的 19 个 + experiment_exposed 已含其中,编号 22~40)」——括号内「已含其中」自我否证了「20」这个数,而下游文档取了 20:`iteration-3/29-m3-summary.md:20`「22 事件 → **42 事件**(+19 community 域 + experiment_exposed)」、`feature-checklist.md:218`「EventDictionary 22→**42**」。该类自身 Javadoc(`:11-16` 的 8+2+8 + experiment_exposed)反而是对的,等于 41。 + +**为何漂移四份文档一路不可见**:`EventDictionaryTest.java` 逐事件断言 props 集合,但**从不断言字典总条数**——加一条、少一条、数错一条,测试全绿。这正是「无门禁的口径」必然漂移的样本。 + +**M4 前置项(本报告列为硬要求)**:在 `EventDictionaryTest` 增一个条数断言 + 一份全事件名快照断言,把「字典条数」变成受测契约。见 §7、§附 工单 1。 + +### 1.2 字典 41 条 vs 客户端实发 33 条:**8 条字典空转** + +客户端实发事件名(`grep -rhoP "(trackEvent|_track)\(\s*'\K[a-z_]+" patbond-flutter/lib/ | sort -u` → **33** 条)与字典逐条比对,以下 **8** 条在 `patbond-flutter/lib/` **零引用**(逐条 raw grep 复核,非按差集推断): + +| # | 事件名 | 字典位置 | 空转原因(取证) | M4 处置建议 | +| --- | --- | --- | --- | --- | +| 1 | `auth_register_started` | `:43` | 未挂接。注意字典**也没有** `auth_login_started` | **建议 M4 顺带补挂**:注册漏斗起点缺失 ⇒ 注册转化率无分母 | +| 2 | `auth_token_refresh_succeeded` | `:50` | 未挂接 | 保留,M6 可观测性迭代补 | +| 3 | `auth_token_refresh_failed` | `:51` | 未挂接 | 同上 | +| 4 | `auth_session_restore_started` | `:54` | 未挂接 | 同上 | +| 5 | `auth_session_restore_succeeded` | `:55` | 未挂接 | 同上 | +| 6 | `auth_session_restore_failed` | `:56` | 未挂接 | 同上 | +| 7 | `health_record_deleted` | `:74` | `health_record_analytics.dart:8` 注释:「因 M2 契约无删除端点暂无挂接点,留待删除交互落地」 | 保留(等删除 UI) | +| 8 | `experiment_exposed` | `:103` | **`lib/` 零引用**——A/B 前置 #5 只交付了字典半边,Flutter 强类型封装未落地 | **M4 必做**,见 §6.2 | + +**废弃建议:0 条。** 上述 8 条均有明确的将来挂接点,按 ADR-013 的判例(「废弃是零成本的」以客户端零引用为前提)反向适用:这些是「字典先行」而非「设计废弃」,删掉只会在挂接时再加回来。改为**登记为「字典空转清单」纳入离线巡检**,防止其无声长期存在。 + +### 1.3 纠正二:真机验证挂起实为 **10 项**,不是 8 项 + +`device-verification.md` 全文实登记 **10** 项 = M2 **2** + M3 **4** + M3.5 **4**。汇总文档口径分叉:`feature-checklist.md:221` 与 `releases.md:120`、`iteration-3/29:52` 只统计「M2 两项 + M3 四项」= 6;`feature-checklist.md:244` 只登记 M3.5 的两项(头像上传弱网、头像缓存),**漏登记 M3.5-3(caregiver 改宠物头像)与 M3.5-4(获赞数与帖子点赞数对账)**。 + +与埋点相关的只有两项:**M2-验证一**(Android 事件真实落库)与 **M3-4**(社区事件落库)。三处执行记录(L106/L168/L221)全空。 + +### 1.4 纠正三:A/B 前置为 **5 绿 3 半**,不是「6 绿 1 半」 + +`iteration-3/06:351` 原文结论:「**结论:M3 末 6 项全绿 + #7 部分绿,M4 初补齐监控即可启动首实验。**」本角色对代码侧逐项 grep 取证,与 PM 报告结论一致:**分流哈希、Flutter 曝光封装、feature flag 三者代码零实现**。 + +| # | 前置条件 | M3 宣称 | 代码/文档取证 | 对齐后 | +| --- | --- | --- | --- | --- | +| 1 | 数据质量验收 | 绿(注「拦路项是真机」) | 真机 0/10 执行;巡检无数据可跑 | **绿(条件性)**——§0.5 行动 1 执行后成立 | +| 2 | 指标基线 | 绿(「09-21 起自然达成」) | 前瞻式绿;无数据,且 §0 已判定 09-21 不成立 | **绿(条件性)**——依赖 §0.4 重定 T0 | +| 3 | 样本量规则成文 | 绿(M3 中交付) | `docs/` 无独立文档;方法学示例存在于 `iteration-2/06:317`(基线率/MDE/α/功效/每组样本量/8 周止损线) | **半**:方法已示范,规则未成文、未按实测基线重算(无基线可代入) | +| 4 | 稳定分流组件 | 绿 | `anonymousId` 持久化**已落**(`analytics_service.dart:177-190`,key `pb.analytics.anonymousId`);`hash(userId, experimentSalt) % buckets` **零实现**——grep `experimentSalt\|assignVariant\|分流` 全 api/flutter 主源码,`bucket` 命中全是 MinIO 对象存储桶 | **半** | +| 5 | 曝光事件 | 绿(「Flutter 强类型封装同批出」) | 字典半边已落(`EventDictionary.java:103`);Flutter 封装**零实现**(§1.2 第 8 条) | **半** | +| 6 | 实验设计模板与评审流程 | 绿(与 #3 同文档) | 同 #3,无独立文档 | 并入 #3 计 | +| 7 | 护栏监控与回滚 | **部分绿**(「回滚随社区发布开关顺带落地」) | feature flag **零实现**:grep `featureflag/toggle/ConditionalOnProperty` 于 api 主源码与 `application*.yml`、`docker-compose*.yml`、`.env*`、flutter `lib/` **全部零命中**。即「回滚绿」这半也不成立 | **半(偏红)** | +| 8 | 隐私合规复核 | 常态 | 每实验一次 | **常态**,M4 首实验时执行 | + +**结论:三项零代码前置(#4 分流哈希、#5 Flutter 曝光封装、#7 feature flag)必须作为 M4 前置工单排期,不得假定已就绪。** 这直接决定 §6 的实验形态选择。 + +### 1.5 接收链路现状(M4 设计的硬约束) + +| 项 | 现状 | 证据 | +| --- | --- | --- | +| 端点 | `POST /api/v1/events`,匿名可报(唯一允许匿名的写端点),单批 1–50 | `AnalyticsController.java:31-37`;契约 `openapi-v1.4.0.yaml:440-495` | +| 逐条拒绝原因 | `unknown_event_name` / `identity_mismatch` / `forbidden_field` / `schema_invalid` | `AnalyticsService.java:46-88` | +| 整批 400 的触发面 | JSON 不可解析、条数越界、**任一条 DTO 字段校验失败** | 契约 `:481`;`TrackEventsRequest` 的 `@NotNull`/`@Pattern`/`@Size` 全是请求级校验 | +| props 处置 | 白名单外的键**剥离后入库**(事件保留,计 warning) | `AnalyticsService.java:90-108` | +| 幂等 | `event_id` PRIMARY KEY + `ON CONFLICT DO NOTHING` | `AnalyticsRepository.java:44`;`V2:9` | +| 统计权威时间 | `server_ts timestamptz NOT NULL DEFAULT now()`,客户端不发 | `V2:16` | +| 事实表 | `platform.product_events`,props `jsonb`,`user_id` **刻意不设外键** | `V2:8-31` | +| 高频事件索引 | `(event_name, server_ts)`、`(user_id, server_ts) WHERE NOT NULL`、`(anonymous_id, server_ts)` | `V2:37-45` | +| 未实现项 | 64KB 请求体上限、429 限流 | 契约据实未写(`iteration-3/06` §0) | + +**对 M4 最关键的三条硬约束**(决定 §4.2 的裁定): + +1. `anonymous_id uuid NOT NULL`、`session_id uuid NOT NULL`(`V2:12,14`)——服务端 Worker 二者皆无。 +2. `CONSTRAINT ck_product_events_platform CHECK (platform IN ('android','ios'))`(`V2:23`)——**平台枚举在 DB 层也锁死**,加值需 Flyway 迁移。 +3. `CONSTRAINT ck_product_events_client_ts CHECK (client_ts >= server_ts - interval '30 days' AND client_ts <= server_ts + interval '1 day')`(`V2:27-30`)——离线积压超 30 天的事件**落库即失败**,被吞成 `schema_invalid`。 + +### 1.6 命名与公共属性口径(M4 沿用,不改) + +- 事件名:`<域>_<动作>_<结果>` snake_case,结果后缀仅 `_succeeded`/`_failed`;**单义事件不设结果后缀**(`health_record_viewed`/`post_deleted` 先例)。DTO 正则 `^[a-z][a-z0-9_]{1,63}$`(`TrackEventsRequest.java:35`),DB CHECK 同式(`V2:21`)。 +- 属性名 camelCase;公共属性十项全带(`iteration-1/13` §4.0),`userId` 是唯一可空项,`serverTs` 服务端补写、客户端不发。 +- **禁止多路复用事件**(ADR-013 判例):不设 `creation_action(actionType)` 式事件,任何一个动作的枚举扩充不得污染其他指标口径的分母。 +- 去重:dedupe key = `eventId`(客户端 UUIDv7,实测 `analytics_service.dart:137` 已为 `Uuid().v7()`,v2 登记的 v4 偏差已修);指标层主体去重一律 `userId`;漏斗归因窗 24 小时。 +- 质量阈值:丢失率 <5%、对账偏差 <5%、去重命中 <10%、`serverTs` 覆盖 100%、无红线泄漏。 + +## 2. 历史包袱一:eventVersion 口径定型 + +### 2.1 歧义取证(三层各说各话) + +登记于 `iteration-3/28` §7 观察项 2,本角色逐层复核确认: + +| 层 | 现状 | 证据 | +| --- | --- | --- | +| 契约 | 描述「事件 schema 版本(**字典 v1 全部为 1**)」——可读作「每个事件自身的 schema 版本」,也可读作「事件字典版本」;`type: integer`,**无 `minimum`** | `openapi-v1.4.0.yaml:2341-2344` | +| 服务端 DTO | `@NotNull Integer eventVersion`,**无范围/枚举校验** | `TrackEventsRequest.java:38-39` | +| 服务端逻辑 | 原样传给落库,**全程不校验、不参与任何判断** | `AnalyticsService.java:77`、`AnalyticsRepository.java:26,48` | +| 数据库 | `event_version smallint NOT NULL DEFAULT 1` + `CHECK (event_version > 0)` | `V2:11,22` | +| 客户端 | 对**所有**事件硬编码 `'eventVersion': 1` | `analytics_service.dart:139` | +| E2E 脚本 | M2 版发 **2**、M3 版发 **3**(按「字典世代」读法) | `iteration-3/28` §7 | + +**两处附带缺陷(本报告新增取证)**: + +1. **三层校验不一致导致错误被降级**:`eventVersion=0` 或负数可通过 DTO 校验(只有 `@NotNull`),到落库时被 DB CHECK 拒绝抛异常,在 `AnalyticsService.java:81-84` 被 catch 后返回 `schema_invalid`。即客户端的版本号 bug 不会得到清晰的 400,而是伪装成「落库失败」逐条静默拒绝。契约「据实不写 minimum」(`iteration-2/09` §出入 4)的选择反过来固化了这个不一致。 +2. **契约描述本身已过期**:「字典 v1 全部为 1」写于字典 v1 时期,字典已走到 v3 仍未更新——这句话的存在本身就是该字段长期无人管理的证据。 + +### 2.2 定型裁定:**每个事件自身的 props schema 版本**(否决「字典世代」读法) + +四条理由,按决定性排序: + +1. **历史数据的可修复性是不对称的**。按「每事件 schema 版本」读,客户端现行硬编码 1 **本就是正确的**——41 条事件中无一条曾变更过 props 语义,全部理应为 1。零客户端改动、零回填、零历史重解释。按「字典世代」读,则**所有历史行全错**:既有行该是 1/2/3 三种值却全是 1,而 `server_ts` 无法反推事件当初属于哪一代字典(同一事件名跨代持续上报),**不可修复**。 +2. **「字典世代」读法承载零信息量**。字典世代可由 `event_name` 唯一确定(每个事件名只在一代中入册,`iteration-3/22` §1 的编号表即映射)。一个可被现有列完全推导的版本列是死重量,还会诱导下游写出 `WHERE event_version = 3` 这种在客户端口径下永远为空的查询——`iteration-3/28` §7 记录的「后果」正是此。 +3. **「每事件 schema 版本」才是该字段的设计意图**。`iteration-1/13` §4 原文「schema 变更须递增事件版本并在本字典追加条目,禁止原地改语义」——「**追加条目**」只有在「同名事件的不同版本并列为两条字典条目」时才讲得通,这是每事件版本的语义。规划文档的历次用法也一律如此:`iteration-2/06` §1.4「若编辑放弃率成为问题再以 eventVersion=2 增补」、`iteration-3/06` §1.4「届时以 eventVersion=2 增补 `_failed`」。 +4. **错的是脚本,不是客户端**。两版 E2E 脚本发 2/3 是沿 M2 先例的误读,`iteration-3/28` §3.13 落库的 `event_version=3` 已明确「**不是客户端真实取值**」。修脚本是两行改动。 + +### 2.3 bump 规则(定型的核心,此前完全缺失) + +歧义的真正代价不是取值错,而是**没人知道什么时候该 +1**。定型如下: + +| 变更类型 | 是否 bump | 理由 | +| --- | --- | --- | +| 新增**可选**属性,且不改变既有属性语义 | **不 bump** | 下游按键取值,多一个键不破坏任何既有解析;这是最高频的变更,若 bump 则版本号会因无害增补而通胀 | +| 删除属性 | **必须 bump** | 下游解析会 KeyError / 静默变 NULL | +| 改属性语义、单位或口径(如 `durationMs` 的起点变了) | **必须 bump** | 最危险的一类:不 bump 则新旧数据混在同一列,指标断层不可见 | +| 改既有枚举值的含义(新增枚举值**不**算) | **必须 bump** | 同上 | +| 改触发时机(如 `_succeeded` 从「收到响应」改为「UI 渲染完成」) | **必须 bump** | 时间序列会出现无解释的漂移 | +| 改事件名 | 不 bump,**按新事件处理** | 新名即新条目,旧名进「故意不进字典」锁死清单 | + +配套纪律: + +- bump 后**新旧版本在字典中并列为两条条目**(同名、不同 version、各自的 props 白名单),旧版本标注冻结日期;下游一律按 `(event_name, event_version)` 二元组取口径,**禁止只按 event_name 聚合跨版本数据**。 +- 字典条目一旦发布,其 `(name, version)` 的 props 白名单**只可增不可减**(减即 bump)。 +- 客户端不得硬编码字面量 `1`:改为按事件查表取版本(见 §2.4)。 + +### 2.4 落地方案(M4 内完成,两档) + +**推荐档(严校验,把口径变成受测契约)**: + +1. `EventDictionary` 的 WHITELIST key 从 `String eventName` 改为 `(eventName, version)` 二元组(Java 可用 `record EventKey(String name, int version)`),`isKnownEvent`/`allowedProps` 同步改签名;41 条现有条目全部登记为 version **1**。 +2. `AnalyticsService.processEvent` 在「未知事件名」之后增一道校验:`(name, version)` 不在字典 ⇒ 逐条 `rejected`,新增 reason **`unknown_event_version`**。 + - 关键:**放在逐条拒绝路径,不放 DTO 校验**。若做成 DTO 的 `@Min/@Max`,一条脏事件会整批 400 连坐——这正是 §3 里 `platform` 犯过的错,不重犯。 +3. 契约同步:`eventVersion` 描述改为无歧义表述 + 补 `minimum: 1`(与 DB CHECK 对齐,消除 §2.1 缺陷 1);`EventResult.reason` 枚举追加 `unknown_event_version`。**属纯增量**(新增枚举值 + 新增校验说明),v1.4.0 客户端无需改动。 +4. 客户端 `analytics_service.dart:139` 的字面量 `1` 改为从事件定义查表取值(与强类型封装同层,编译期锁死)。 +5. 两份 E2E 脚本的 `eventVersion` 由 2/3 改 **1**。 + +契约描述建议措辞(可直接抄): + +> `eventVersion`:**该事件自身 props schema 的版本**,起始 1。新增可选属性不递增;删除属性、改属性语义/单位、改既有枚举值含义、改触发时机必须递增,并在事件字典中与旧版本并列成两条条目。**不是**事件字典的世代号——字典世代由 `eventName` 唯一确定。当前全部 41 条事件均为版本 1。 + +**最小档(若不愿动校验逻辑)**:只做上述第 3 步的 `minimum: 1` + DTO `@Min(1)` + 第 4、5 步。代价:口径靠文档纪律维持,无门禁——鉴于 §1.1 刚证明「无门禁的口径必然漂移」,本角色**不推荐**最小档。 + +**为何必须在 M4 新增事件之前定型**:M4 一次进 8 条新事件,若口径未定,这 8 条会各自带上一个含义不明的版本号,债务从 41 条规模翻到 49 条规模,且新条目还会成为「按字典世代读」的新证据(有人会想给它们标 4)。定型的边际成本此刻最低。 + +## 3. 历史包袱二:platform=linux 桌面端埋点整批 400 的处置 + +### 3.1 是设计意图还是缺陷:**两者都有,但缺陷不在服务端** + +**枚举排除 linux = 设计意图,且是三层锁死的**(改动成本远高于文档暗示): + +| 层 | 约束 | 位置 | +| --- | --- | --- | +| 契约 | `platform: {type: string, enum: [android, ios]}` | `openapi-v1.4.0.yaml:2373-2376`(× 5 份副本) | +| 服务端 DTO | `@Pattern(regexp = "^(android|ios)$", message = "platform 必须为 android 或 ios")` | `TrackEventsRequest.java:56-58` | +| 数据库 | `CONSTRAINT ck_product_events_platform CHECK (platform IN ('android','ios'))` | `V2:23` | + +**整批 400 = 契约明文行为,不是缺陷**。契约 `:481` 原文:「**整批拒绝**——JSON 不可解析、events 为空或超过 50 条、**单条事件字段校验失败**(code 40000)」。`platform` 是 DTO 字段级 `@Pattern`,属请求级校验,故一条 linux 事件否掉整批,与设计一致。 + +**缺陷在客户端**,两处: + +1. **注释与实现相反**。`analytics_service.dart:99-100` 原文: + + > `/// 平台标识。契约枚举为 android/ios;Web/桌面为开发调试形态,` + > `/// 上报值不在枚举内会被服务端逐条 rejected(不影响客户端),属预期。` + + 实际不是「逐条 rejected」而是**整批 400**。逐条 rejected 只发生在 `AnalyticsService.processEvent` 的四种原因里(`unknown_event_name`/`identity_mismatch`/`forbidden_field`/`schema_invalid`),`platform` 根本到不了那一层。 + +2. **「不影响客户端」也是错的——影响是 100% 静默丢弃**。`_platformName()`(`:101-112`)在桌面走 `return Platform.operatingSystem` ⇒ `"linux"`;随后 `uploadBatch`(`:273-281`)把 4xx 判为**永久拒绝**: + + > `// 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功;` + > `// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。` + + `_flush()`(`:229`)据此 `removeSegments(..., countAsDropped: true)`。**结果:桌面端每一批事件都被丢弃,端上采集/队列/冲刷全部正常运转但落库恒为零**,只在 debug 日志留一行 `Analytics batch permanently rejected`。 + +这个错误注释已被转述进至少 5 处文档(`releases.md:120`、`device-verification.md:144`、`feature-checklist.md:221`、`iteration-3/27:34`、`iteration-3/28:56`),措辞多为「属契约内行为」「既有预期」——**「契约内」是对的,「预期」掩盖了「桌面端埋点能力为零」这个事实**。这与 §1.1 的 41/42 是同一类问题:一句不准确的表述被当作结论反复引用。 + +### 3.2 处置建议:客户端本地短路 + 显式调试覆盖开关(不动枚举) + +**否决「把 linux/server 加进枚举」**:需 Flyway V6 改 CHECK(M4 本就要建 `creation` schema,但混进埋点平台枚举会让这次迁移横跨两个无关关注点)+ 5 份契约副本 + DTO 正则 + 4 个模块的契约一致性矩阵重跑;且会让 `platform` 维度混入非产品流量,污染所有按平台切分的指标。收益仅为「桌面调试方便」,不成比例。 + +**建议做三件事(均在 Flutter 侧,零契约、零迁移)**: + +1. **本地短路**:`_platformName()` 返回值不在 `{android, ios}` 内时,`trackEvent` **直接不入队**并 `debugPrint` 一条明确的「桌面端埋点已本地禁用(platform=$p 不在契约枚举内)」。 + - 收益不只是省流量:当前桌面上**所有**事件被同一个 400 连坐丢弃,若将来同一队列里混入合法事件(例如覆盖开关只对部分事件生效),毒丸批次会把它们一起带走。短路把这个风险从「隐性」变为「不存在」。 +2. **显式调试覆盖开关**:新增 `--dart-define=PATBOND_ANALYTICS_PLATFORM_OVERRIDE=android`,**默认关**。开启后桌面上报 `platform=android`,使桌面 E2E 能真验端上完整链路(采集 → 分段持久化队列 → 冲刷 → 202 → 逐条 `accepted`、`rejected=0`)。 + - **这是解 §0 困局的技术杠杆**:可把「埋点落库」这一类验证从「只能真机」降级为「桌面可验链路 + 真机只验移动生命周期」。真机仍不可替代的部分是 `paused` 生命周期与 30 分钟后台换会话(`device-verification.md:9` 明确「桌面/Web 不可用——没有真实的移动端后台生命周期(`paused` 不触发)」),即 M2-验证二的核心。 + - **数据完整性护栏(必须一并写入纪律)**:覆盖开关只允许对**本地 compose 后端**使用,产生的行 `platform` 维度是伪值,禁止用于任何产品读数、北极星队列或 A/B 基线;建议同时把 `appVersion` 打上可识别后缀(如 `0.4.0+desktop-e2e`)使这类行在 SQL 层可被一条 `WHERE app_version NOT LIKE '%desktop-e2e%'` 干净剔除。**这是本建议能否被采纳的前提条件**——没有这条护栏,覆盖开关就是 §0.4 档 C(造数据)的后门。 +3. **修注释与文档**:把 `analytics_service.dart:99-100` 改为陈述实际行为(整批 400 + 永久丢弃 + 桌面采集能力为零),并在 §7 列出的 5 处文档转述点同步纠正。 + +### 3.3 顺带发现(不属本节范围,登记备查) + +`ck_product_events_client_ts`(`V2:27-30`)要求 `client_ts >= server_ts - interval '30 days'`。客户端持久化队列容量 500 条、宣称「离线积压约两周容量」(`analytics_service.dart:159-161`),但若设备离线超 30 天后回连,积压事件**落库即触发 CHECK 失败**,被 `AnalyticsService.java:81-84` 吞成 `schema_invalid` 逐条拒绝,客户端收 202 后删段——数据静默丢失且原因不可辨(与真正的 schema 问题同一个 reason)。建议 M4 顺手给这类拒绝一个独立 reason(如 `client_ts_out_of_range`),或在客户端冲刷前按 30 天窗口本地淘汰。**未取证**:无线上数据可证明该路径是否真实发生过。 + +## 4. 事件字典 v4 增量(AI 创作) + +### 4.1 沿用原则与域划分 + +命名 `<域>_<动作>_<结果>` snake_case、结果编码进事件名(`_succeeded`/`_failed`)、单义事件不设结果后缀、`eventVersion` 起始 1(口径按 §2 定型)、公共属性十项全带、属性 camelCase。 + +**新增域前缀:`creation`**(与 `development-plan.md` §3 的 schema 名 `creation` 对齐,域按实体归属划——实体是「生成任务」)。服务端 Worker 侧的执行事实**不进事件字典**,理由见 §4.2。 + +**设计纪律(沿 ADR-013 判例)**:不设 `creation_action(actionType)` 式多路复用事件。提交/结果/失败/取消/建草稿是五个语义独立的动作,各自独立成名;任何一个的枚举扩充不污染其他指标口径的分母。 + +### 4.2 核心裁定:服务端生成侧**不建第二条埋点管道** + +M4 的生成执行发生在 Worker(队列消费、租约、重试、超时),这些事实客户端观测不到。三个候选方案: + +| 方案 | 做法 | 判定 | +| --- | --- | --- | +| A | Worker 走 `POST /api/v1/events`,`platform`/`anonymousId`/`sessionId` 填伪值 | **否决**。`platform` 只能填 android/ios(`V2:23`),会把服务端流量混进平台维度,**污染所有既有按平台切分的指标**;`session_id NOT NULL` 也只能造假 | +| B | 扩 `product_events`:platform 加 `server` 值、`anonymous_id`/`session_id` 改可空 | **否决**。需 Flyway 迁移改 CHECK + 放宽两个 NOT NULL + 5 份契约;放宽 NOT NULL 是**收紧不可逆**的反向操作,且此后每个下游查询都要处理 NULL 会话 | +| C | 新建 `platform.server_events` 表,Worker 进程内直写(不走 HTTP) | 可行但**多余**,见下 | +| **D** | **以 `creation.generation_tasks` 事实表为服务端指标权威**,经 `creationTaskId` 与客户端事件拼接 | **采纳** | + +**采纳 D 的理由**: + +1. **所需事实 100% 已在任务表里**。M4 的验收标准(`development-plan.md:252-256`)本就要求「任务状态流转合法、Worker 重启不丢任务、相同幂等请求不重复生成、失败原因可追踪」——这逼出的表结构天然包含时间戳、状态、尝试次数、失败原因。§5 的服务端侧指标(成功率、P50/P95 生成耗时、排队时长、重试率、超时率)**全部可由该表直接聚合**,事件层加不了任何信息。 +2. **避免双写不一致**。若同一事实既有任务表行又有事件行,两者必然在某些边界(Worker 崩溃、事务回滚)分叉,届时「哪个是真的」无解。任务表是状态机的 system of record,事件只能是它的影子。 +3. **零迁移增量**(相对方案 B/C):M4 本就要为 `creation` schema 建 V6 迁移,指标所需列并入即可,不额外建表、不改埋点管道。 +4. 客户端事件继续只承载**用户可见行为**(看到了什么、点了什么、等了多久),这是它的比较优势且不可被服务端替代(用户是否真的看到结果,服务端不知道)。 + +**因此这是对后端工单的硬要求**(本报告的指标口径依赖它,请在契约冻结时一并确认):`creation.generation_tasks` 须持久化以下列,且**终态行保留 ≥90 天不得物理删除**(软终态): + +`id`(= `creationTaskId`)、`user_id`、`model_key`、`style_key`、`status`、`created_at`、`queued_at`、`first_attempt_at`、`last_attempt_at`、`finished_at`、`attempt_count`、`failure_reason`、`input_media_count`、`output_asset_count`、`idempotency_key`/`request_hash`。 + +若上述任一列缺失,对应指标不可计算——`first_attempt_at` 缺失 ⇒ 排队时长与纯执行耗时无法分离(只能得到端到端耗时);`attempt_count` 缺失 ⇒ 重试率不可得。 + +### 4.3 隐私红线增量与一处修订申请 + +沿用现行红线:props 键命中 `password|token|secret|phone|mobile|email|credential|idfa|gaid` ⇒ 整条 rejected(`EventDictionary.java:38-39`;客户端同款本地拦截 `analytics_service.dart:288-295`)。社区红线(`EventDictionary.java:22-25`):无自由文本、无内容或对方主体 ID、无话题名、无文件名/路径/URL,只有行为计数与分桶。 + +**AI 创作域的红线增量(新增三条)**: + +1. **生成 prompt 文本绝不入 props**,任何长度、任何截断、任何哈希都不行——哈希可被字典攻击还原短 prompt。只允许 `promptLengthBucket`(分桶)。 +2. **模型/风格标识只许上报目录的稳定 key**(`modelKey`/`styleKey`),不上报展示名、不上报版本串、不上报供应商名。展示名可能被产品运营改成含营销文案的自由文本。 +3. **产物不入 props**:无 assetId、无 objectKey、无签名 URL、无缩略图,只有 `resultCount`/`outputAssetCount` 计数(沿 M3 媒体域先例)。 + +**修订申请(§8 拍板项 6)**:现行红线写作「无内容或对方主体 ID(postId/commentId/topicId/target userId)」,字面上会连带禁止 `creationTaskId`。但 §5 的漏斗**必须**有一个客户端与服务端共享的拼接键,否则「提交 → 排队 → 完成 → 建草稿 → 发帖」跨越两个数据源无法连成一条。 + +建议把红线措辞**收敛为其原本意图**: + +> 禁止**他人主体与内容主体** ID(postId / commentId / topicId / 对方 userId);**允许上报调用者自有资源的 ID 作为漏斗拼接键**,当前仅 `creationTaskId` 一例,新增需本报告同级评审。 + +理由:原红线的隐私意图是「防止从事件流重建「谁对谁的内容做了什么」的社交图谱」(`iteration-3/06` §1.3)。`creationTaskId` 指向的是**上报者自己**的任务,不涉及第二主体;它不泄露 prompt 或产物(那些在 `creation` 表里,与 props 同一数据库同一信任域,不构成跨边界泄漏);且它是 UUIDv7,除时间戳外无内在信息。 + +**否决的替代方案**:把拼接键做成第 11 个**公共属性**(顶层字段而非 props)。虽然架构上更干净(props 红线完全不动),但要改 5 份契约副本的 `TrackedEvent`、改 DTO、加 DB 列(Flyway 迁移)、且要为一个只有 `creation` 域用得上的字段污染全部 41 条既有事件的公共属性集——**十项公共属性是跨迭代冻结面,为单域需求撬动它不成比例**。走 props 白名单则是零迁移(props 是 jsonb)。 + +### 4.4 新事件清单(`creation` 域,8 条,全部客户端侧) + +| 事件名 | 触发时机 | 专有属性 | +| --- | --- | --- | +| `creation_started` | 进入 AI 创作流程并产生**首次有效交互**(选定模型或风格、或首次输入 prompt),每次进入记一次;仅浏览不交互不记 | `entryPoint`(`create_tab` / `home` / `pet_detail` / `post_form`,待 UI 定稿收敛) | +| `creation_model_selected` | 模型或风格**选定动作完成**时(每次变更各记一次,不去重——变更次数本身是选择摩擦的度量) | `modelKey`、`styleKey`、`selectSeq`(本次创作内第几次选择,从 1 起) | +| `creation_submit_succeeded` | 生成任务创建接口成功响应后(拿到 taskId,**非生成完成**)(**漏斗事件**,M4 全部转化率指标的分母源) | `creationTaskId`、`durationMs`(started→submit)、`modelKey`、`styleKey`、`inputMediaCount`、`promptLengthBucket`、`regenerateFrom` | +| `creation_submit_failed` | 生成任务创建接口失败(含配额拦截、内容审核前置拒绝) | `modelKey`、`failureReason`、`errorCode`、`httpStatus`、`attemptSeq` | +| `creation_result_viewed` | 成功生成的结果**首次渲染可见**(不是任务完成,是用户真的看到了) | `creationTaskId`、`waitedMs`(submit→首次可见,用户感知等待)、`resultCount` | +| `creation_failure_viewed` | 失败态**首次渲染**给用户(与 `creation_submit_failed` 区分:后者是提交就没成,此者是排队/执行后才失败) | `creationTaskId`、`failureReason`、`waitedMs` | +| `creation_cancelled` | 用户主动取消且取消接口成功响应后 | `creationTaskId`、`waitedMs`、`stage`(`queued` / `running`) | +| `creation_draft_created` | 「一键创建社区草稿」成功响应后 | `creationTaskId`、`selectedCount`(选入草稿的产物数) | + +**枚举值定义**(客户端编译期锁死,离线巡检;ingest 只校验键不校验值——`EventDictionary.java:26-33` 的既有设计): + +| 属性 | 枚举 / 口径 | +| --- | --- | +| `regenerateFrom` | `none`(首次生成)/ `failure`(对失败任务重试)/ `dissatisfaction`(对已成功结果不满意再生成)——三值必须分开,否则「重新生成率」会把「系统不可靠」与「产品不满意」两个完全不同的问题混成一个数 | +| `failureReason` | 沿用既有族 + 新增 `quota_exceeded`、`model_unavailable`、`content_rejected`(内容安全拒绝)、`generation_timeout`、`cancelled` | +| `promptLengthBucket` | 沿 `textLengthBucket` 先例分桶(如 `0` / `1-20` / `21-60` / `61-200` / `200+`),**绝不上报原文与长度精确值** | +| `stage` | `queued`(尚未被 Worker 取走)/ `running`(已租约执行中) | +| `modelKey` / `styleKey` | 服务端目录接口的稳定 key,与目录契约同批冻结;**非展示名** | +| `inputMediaCount` / `resultCount` / `selectedCount` | int 计数,无输入为 0 | + +**去重口径**: + +- `creation_started`:按「进入一次创作流程」记一次,同一 `sessionId` 内反复切页不重复记(客户端持有本次流程状态,离开流程即重置)。 +- `creation_result_viewed` / `creation_failure_viewed`:**每个 `creationTaskId` 至多一条**(「首次可见」语义)。客户端需按 taskId 记忆已上报集合,避免用户来回切页刷出多条——否则「结果触达率」分子会 >1。 +- `creation_model_selected`:**刻意不去重**(见上表 `selectSeq`)。 +- 其余事件:一次成功业务动作一条。 +- 服务端幂等仍以 `eventId` 为唯一保证点(客户端 at-least-once 投递)。 + +### 4.5 v4 增量总览(编号沿跨迭代连续编号) + +字典现有 41 条(§1.1 取证),编号沿 `iteration-3/22` 的 22~40 续接。**注意:既有编号最大为 40 而实际条数为 41**(`page_viewed` 在 v2 转正时记作 `—` 未占号),本报告不回改历史编号,新增从 **41** 起编,并在 §7 要求把「编号」与「条数」两个口径在测试里各自锁死。 + +| # | 事件名 | 版本 | 性质 | +| --- | --- | --- | --- | +| 41 | `creation_started` | 1 | 新增 | +| 42 | `creation_model_selected` | 1 | 新增 | +| 43 | `creation_submit_succeeded` | 1 | 新增(漏斗事件) | +| 44 | `creation_submit_failed` | 1 | 新增 | +| 45 | `creation_result_viewed` | 1 | 新增(漏斗事件,结果触达) | +| 46 | `creation_failure_viewed` | 1 | 新增 | +| 47 | `creation_cancelled` | 1 | 新增 | +| 48 | `creation_draft_created` | 1 | 新增(漏斗事件,转化前置) | +| — | `post_publish_succeeded` | 1 | **属性增量**(+`creationTaskId`,不 bump——§2.3 规则:新增可选属性不递增) | +| — | `post_draft_saved` | 1 | **属性增量**(+`creationTaskId`,不 bump) | +| — | `experiment_exposed` | 1 | **启用**(字典 M3 先行,M4 首次实际上报;无属性变更) | + +**字典条数:41 → 49。废弃 0 条。** + +**后端 `EventDictionary` 白名单增量(工单可直接抄)**: + +```java +// v4 增量 creation 域(iteration-4 报告 06 §4.4) +Map.entry("creation_started", Set.of("entryPoint")), +Map.entry("creation_model_selected", Set.of("modelKey", "styleKey", "selectSeq")), +Map.entry("creation_submit_succeeded", + Set.of("creationTaskId", "durationMs", "modelKey", "styleKey", + "inputMediaCount", "promptLengthBucket", "regenerateFrom")), +Map.entry("creation_submit_failed", + Set.of("modelKey", "failureReason", "errorCode", "httpStatus", "attemptSeq")), +Map.entry("creation_result_viewed", Set.of("creationTaskId", "waitedMs", "resultCount")), +Map.entry("creation_failure_viewed", Set.of("creationTaskId", "failureReason", "waitedMs")), +Map.entry("creation_cancelled", Set.of("creationTaskId", "waitedMs", "stage")), +Map.entry("creation_draft_created", Set.of("creationTaskId", "selectedCount")), +``` + +**既有条目改写两处**(在原位加属性,不新增条目): + +```java +// 原:Set.of("durationMs", "mediaCount", "topicCount", "textLengthBucket", "fromDraft") +Map.entry("post_publish_succeeded", + Set.of("durationMs", "mediaCount", "topicCount", "textLengthBucket", + "fromDraft", "creationTaskId")), +// 原:Set.of("trigger", "mediaCount") +Map.entry("post_draft_saved", Set.of("trigger", "mediaCount", "creationTaskId")), +``` + +**类注释红线措辞同步修订**(`EventDictionary.java:22-25`):按 §4.3 把「no content or counterpart IDs」改为「no **counterpart-subject or content** IDs; the caller's own `creationTaskId` is permitted as the funnel join key」。 + +### 4.6 漏斗定义、闭环与缺口复核 + +**AI 创作全漏斗(六段,跨两个数据源,单键 `creationTaskId` 拼接)**: + +| 段 | 事件 / 事实 | 数据源 | 主体去重 | +| --- | --- | --- | --- | +| 1 进入创作 | `creation_started` | 客户端 | userId | +| 2 选定模型 | `creation_model_selected`(末次) | 客户端 | userId | +| 3 提交生成 | `creation_submit_succeeded` / `creation_submit_failed` | 客户端 | userId(任务量用 creationTaskId) | +| 4 排队与执行 | `generation_tasks`:`queued_at` → `first_attempt_at` → `finished_at`、`status`、`attempt_count` | **服务端事实表** | creationTaskId | +| 5 结果触达 | `creation_result_viewed` / `creation_failure_viewed` / `creation_cancelled` | 客户端 | creationTaskId | +| 6 建草稿 → 发帖 | `creation_draft_created` → `post_publish_succeeded`(`creationTaskId` 非空) | 客户端 | userId | + +**join key 定型**:`creationTaskId`(UUIDv7,由服务端在创建任务时生成并在响应中返回,客户端原样上报)。 + +- 客户端事件侧位置:`props->>'creationTaskId'`。 +- 服务端侧:`creation.generation_tasks.id`。 +- 拼接 SQL 惯例(漏斗第 3~5 段): + ```sql + SELECT t.id, t.status, t.attempt_count, + t.first_attempt_at - t.queued_at AS queue_wait, + t.finished_at - t.first_attempt_at AS exec_time, + v.props->>'waitedMs' AS perceived_wait_ms + FROM creation.generation_tasks t + LEFT JOIN platform.product_events v + ON v.event_name = 'creation_result_viewed' + AND (v.props->>'creationTaskId')::uuid = t.id + WHERE t.created_at >= :from; + ``` +- **为何不用 (userId, 时间窗) 拼接**:用户可并发提交多个任务(重试 + 新建),时间窗拼接在并发场景下会把 A 任务的耗时配给 B 任务的结果,且无法察觉——P95 耗时这类尾部指标对错配极其敏感。此外未登录不可创作(生成消耗配额),故 userId 恒非空,但仍不足以消歧。 + +**闭环成立**:六段无断点,第 4 段的服务端事实与前后客户端事件均可经 `creationTaskId` 连上。 + +**缺口 1(接受不埋):客户端 `creation_queued`。** 排队进入/结束的时点以服务端为准(`queued_at`/`first_attempt_at`),客户端只能靠轮询观测,粒度取决于轮询周期,会系统性高估排队时长。客户端侧的「等待」由 `waitedMs` 一个用户感知量表达即可,不再补一个精度更差的重复事件。 + +**缺口 2(接受不埋):`creation_retried` 独立事件。** 重试即一次新提交,已由 `creation_submit_succeeded.regenerateFrom ∈ {failure, dissatisfaction}` 表达。独立事件会与提交事件双计,且让「提交总数」这个分母出现两种口径。 + +**缺口 3(接受不埋):`creation_quota_blocked` 独立事件。** 已由 `creation_submit_failed(failureReason='quota_exceeded')` 覆盖;独立成名违反「结果编码进事件名」惯例,且分裂提交失败的分母。 + +**缺口 4(登记待拍板,缺口 4 = §8 拍板 9):结果内单产物的选择/滑动行为**(多产物时用户看了第几张、选了第几张)。首版不埋——UI 未定稿,且 `resultIndex` 类属性容易被误当作「用户偏好某模型输出」的产品结论。若成为问题,届时按 §2.3 以新增可选属性(不 bump)补 `selectedIndexBucket`。 + +**修订 1(实质缺口,本版已修)**:`post_publish_succeeded` 与 `post_draft_saved` 的现行白名单**无任何标识帖子来源的属性**(分别为 `durationMs`/`mediaCount`/`topicCount`/`textLengthBucket`/`fromDraft` 与 `trigger`/`mediaCount`,见 `EventDictionary.java:78-79,77`),因此「AI 生成 → 发帖」这条 M4 最核心的转化**在现行字典下根本不可计算**。这是本报告发现的最实质字典缺口,靠 §4.5 的两处属性增量修复。 + +**维度够用性**:`modelKey`/`styleKey` 支持按模型切分全部指标;`regenerateFrom` 区分系统不可靠与产品不满意;`promptLengthBucket`/`inputMediaCount` 支持输入复杂度 × 耗时/成功率相关分析;`entryPoint` 支持入口效率对比;平台与版本维度由公共属性提供。**一处不够用但无需加属性**:「是否首次使用 AI 创作」可由用户级 `min(server_ts)` of `creation_submit_succeeded` 推导。 + +### 4.7 pageName 枚举增量 + +`page_viewed.pageName` 由客户端编译期枚举锁死,**ingest 只校验键不校验值**(`EventDictionary.java:30-33` 明确「pageName growth needs no code change here」),故后端零改动。 + +现行客户端枚举实测 **14** 个(`analytics_page_name.dart:9-25`):`login`/`register`/`home`/`profile`/`pet_list`/`pet_detail`/`pet_form`/`record_form`/`record_detail`/`create`/`pet_archive`/`services`/`post_detail`/`post_form`。 + +**M4 增量:新增 1 个** —— `creation_result`(生成结果页)。AI 创作 Tab 复用既有 `create`;模型/风格选择若为同页 sheet 则不单独计页。 + +**顺带登记的偏差(无功能影响)**:`EventDictionary.java:28-31` 的注释声称 v3 pageName 家族含 `topic_list`/`topic_detail`/`user_profile`/`follower_list`/`following_list`/`favorite_list`/`draft_list`,但客户端枚举中**这 7 个都不存在**(对应页面属 ADR-018 剪出或尚未落地)。因 ingest 不校验值,无功能影响;但这是「文档先于实现」的又一例,建议随本次修订据实收敛。 + +## 5. M4 指标口径定义 + +统计口径沿用 v1/v2:`server_ts` 划 UTC 日界;主体去重一律 `userId`;漏斗归因窗 24 小时。**任务级指标按 `creationTaskId` 去重,用户级指标按 `userId` 去重**——两者不可混用(一个用户可提交 N 个任务,混用会让重度用户支配读数)。 + +| 指标 | 分子 | 分母 | 数据源 | 口径要点 | +| --- | --- | --- | --- | --- | +| **生成成功率** | `status='succeeded'` 的任务数 | `status IN ('succeeded','failed')` 的任务数 | 服务端事实表 | **排除 `cancelled`**(用户主动取消不是系统失败,计入会让「用户不耐烦」伪装成「系统不可靠」);**排除未终态**(`queued`/`running`);按 task 去重非按 user | +| **P50 / P95 生成耗时** | —— | —— | 服务端事实表 | `finished_at - first_attempt_at`(**纯执行耗时,不含排队**);只取 `status='succeeded'`(失败任务的耗时是超时值,混入会污染尾部);P95 用 `percentile_cont(0.95)`;按模型(`model_key`)分组发布,**不发布跨模型合并的 P95**(不同模型耗时分布差一个量级,合并值无意义) | +| **排队时长** | —— | —— | 服务端事实表 | `first_attempt_at - queued_at`;P50/P95 同上;**重试任务只取首次尝试**,否则重试等待会被计成排队 | +| **端到端感知等待**(辅助) | —— | —— | 客户端 | `creation_result_viewed.waitedMs` 的 P50/P95。与「排队 + 执行」之差即客户端轮询/渲染开销——**这个差值是客户端性能债的直接读数** | +| **结果触达率** | `creation_result_viewed` 的去重 `creationTaskId` | `status='succeeded'` 的任务数 | 双源 | 衡量「生成完了但用户没看到」。**这是 AI 创作特有的浪费指标**:算力已花但价值未交付。触达率低 ⇒ 需要完成通知 | +| **生成 → 发帖转化率**(M4 核心) | 24h 内 `post_publish_succeeded` 且 `props->>'creationTaskId'` 非空 的去重 `userId` | `creation_result_viewed` 的去重 `userId` | 客户端 | 分母用**结果触达**而非提交或成功——没看到结果的人不可能发帖,用提交做分母会把系统故障算进产品转化的账 | +| **草稿 → 发帖转化率**(辅助) | 同上分子 | `creation_draft_created` 去重 `userId` | 客户端 | 拆解上一指标:区分「不想发」与「建了草稿卡在发布环节」 | +| **重新生成率** | `creation_submit_succeeded` 且 `regenerateFrom <> 'none'` | 全部 `creation_submit_succeeded` | 客户端 | **必须按 `regenerateFrom` 值分开发布两个数**:`failure` 档升高 = 系统不可靠(应归因到生成成功率);`dissatisfaction` 档升高 = 产品质量问题。合并成一个数会让两种截然不同的病症互相掩盖 | +| **配额触顶率** | `creation_submit_failed` 且 `failureReason='quota_exceeded'` 的去重 `userId` | `creation_started` 的去重 `userId` | 客户端 | 分母用「进入创作」而非「提交」——被配额拦住的用户其提交是失败的,用提交做分母会自我循环。**同时须按用户分层看**(触顶用户占比 vs 触顶次数分布),均值会掩盖「少数重度用户天天触顶」 | +| **创作漏斗整体转化** | `creation_submit_succeeded` 去重 `userId` | `creation_started` 去重 `userId` | 客户端 | 提交前流失(选模型放弃、prompt 写不出来) | + +**护栏指标(M4 全程周报,不只 A/B 期间)**: + +| 护栏 | 口径 | 告警线 | +| --- | --- | --- | +| 生成成功率 | 见上 | 周环比相对下降 >5% ⇒ P1 | +| P95 生成耗时 | 见上,按模型 | 周环比 +50% ⇒ P1 | +| 内容审核拒绝率 | `failureReason='content_rejected'` / 提交总数 | 绝对值 >5% ⇒ 复核审核阈值是否过严 | +| 埋点质量四项 | 丢失率 <5%、对账偏差 <5%、去重命中 <10%、`serverTs` 覆盖 100% | 任一破线 ⇒ 当周读数标「数据未验收」 | +| **北极星不回退** | 7 日回访记录率(ADR-012) | AI 创作是新场景,**必须确认它没有把用户从「记录」这个核心场景吸走**——这是 M4 最重要的护栏 | +| 帖子删除率 | `post_deleted` / `post_publish_succeeded`,按 `creationTaskId` 有无分两群 | AI 帖删除率显著高于自发帖 ⇒ 一键发布在诱导用户发不想发的内容 | + +**对账 SQL 增量(入周一巡检,沿 `iteration-2/06` §6 惯例)**: + +```sql +-- 对账 1:客户端提交事件数 vs 服务端任务行数(期望偏差 <5%) +SELECT + (SELECT count(DISTINCT props->>'creationTaskId') FROM platform.product_events + WHERE event_name = 'creation_submit_succeeded' AND server_ts >= :from) AS client_submits, + (SELECT count(*) FROM creation.generation_tasks WHERE created_at >= :from) AS server_tasks; + +-- 对账 2:孤儿 taskId——客户端上报了但服务端无此任务(期望恒为空集,命中即 P1) +SELECT DISTINCT e.props->>'creationTaskId' AS orphan_task +FROM platform.product_events e +LEFT JOIN creation.generation_tasks t ON (e.props->>'creationTaskId')::uuid = t.id +WHERE e.event_name LIKE 'creation_%' AND e.props ? 'creationTaskId' AND t.id IS NULL; + +-- 对账 3:AI 来源帖的 creationTaskId 必须指向本人任务(期望恒为空集,命中即越权或口径错) +SELECT e.event_id +FROM platform.product_events e +JOIN creation.generation_tasks t ON (e.props->>'creationTaskId')::uuid = t.id +WHERE e.event_name = 'post_publish_succeeded' AND e.user_id IS NOT NULL + AND t.user_id <> e.user_id; + +-- 对账 4:红线巡检——creation 域事件不得出现 prompt/URL 形态的值(期望恒为空集) +SELECT event_id, event_name, props +FROM platform.product_events +WHERE event_name LIKE 'creation_%' + AND (props::text ~* '(https?://|/[a-z0-9_-]+\.(jpg|jpeg|png|webp|heic))' + OR length(props::text) > 512); -- 512 字节以上说明混入了自由文本 + +-- 对账 5:字典空转巡检(§1.2)——列出白名单内但本周零上报的事件 +-- (期望只剩已知的 8 条空转项;出现新成员即挂接回归) +SELECT e.name FROM (VALUES ('creation_started'),('creation_submit_succeeded'), + ('creation_result_viewed'),('creation_draft_created'),('experiment_exposed')) AS e(name) +WHERE NOT EXISTS (SELECT 1 FROM platform.product_events p + WHERE p.event_name = e.name AND p.server_ts >= :from); +``` + +**未取证**:以上 SQL 未在真实数据上执行过(无数据,§0.2),且 `creation.generation_tasks` 的列名以 §4.2 的要求为准、尚未由后端契约确认;后端定稿后需回改列名并试跑一次。 + +## 6. A/B 实验设计 + +### 6.1 形态裁定:M4 首个「实验」应是 A/A 基建验证,真实 A/B 冻结设计待流量 + +`iteration-3/29:68` 的路线是「M4 可启动首个实验」。本角色按 §1.4 取证后**修订这个判断**,两条理由: + +1. **前置三项代码零实现**(分流哈希、Flutter 曝光封装、feature flag)。这不是「补监控即可」的距离,是从零写三个组件。 +2. **样本量不可达**。§6.3 的真实 A/B 需约 **1,560** 名到达生成结果的用户;当前真实用户数为 **0**(§0.2 取证)。按 M2 §4.3 的止损纪律「若按届时 DAU 换算实验需运行超过 8 周,判定该实验不可行」,该实验现在就判不可行。 + +**因此 M4 的实验交付物拆成两件**: + +- **6.2 A/A 基建验证空跑**——M4 内可执行、零产品风险、极小样本即有价值。 +- **6.3 首个真实 A/B 设计书**——设计冻结、判定线预登记(防 HARKing),**放行条件挂在流量上**,不挂日期。 + +### 6.2 A/A 基建验证实验(M4 交付,非产品实验) + +**目的**:在没有产品风险的前提下验证实验基建本身。A/A 的全部价值在于「**当两组体验完全相同时,指标差异应当不显著**」——若显著,说明基建有缺陷(分流不均、曝光漏报、指标管道错配),而这类缺陷在真实 A/B 中会被误读成产品结论。 + +| 项 | 设计 | +| --- | --- | +| 假设 | 无产品假设。原假设即「两组无差异」,期望**不拒绝** | +| 变体 | Control 与 Variant **完全相同的现有体验**(代码路径一致,只走一遍分流与曝光上报) | +| 触点 | AI 创作结果页(`creation_result` 页曝光时上报 `experiment_exposed`) | +| 分流单位 | `userId`(AI 创作需登录消耗配额,故无需 anonymousId 分流——**这顺带让前置 #4 的「登录前分流归并规则」不成为本实验的前置**) | +| 分流实现 | `hash(userId + experimentSalt) % 100`,`experimentKey='creation_result_cta_aa'`,50/50 | +| 检查项 | ① **SRM(样本比例失配)**:两组曝光用户数的卡方检验,α=0.001,超出即分流有偏;② 曝光事件与分配的一致性:`experiment_exposed` 去重用户数 ≈ 实际到达结果页用户数(漏报率 <2%);③ 每个 userId 的 `variant` **跨会话、跨设备恒定**(同一 userId 出现两个 variant ⇒ P1);④ §5 全部指标在两组间的差异均不显著(p>0.05);⑤ 停止规则与回滚开关各演练一次 | +| 样本量 | 检测粗差不需要功效计算:**≥200 曝光用户合计**即可暴露 >60/40 的分流失衡与曝光漏报;恒定性检查(③)在几十个用户上就能发现 bug。**测试账号可参与**(无产品结论,不存在污染产品读数的问题,但须与产品读数分账) | +| 运行周期 | ≥3 天(跨一次冷启动与一次 token 刷新,验证 variant 在会话重建后不漂移) | +| 停止规则 | 检查项 ①③ 任一失败 ⇒ 立即停、修基建、重跑;不设「提前成功」——A/A 没有成功可提前 | +| 产品风险 | **零**(两组体验相同) | + +**这一步的产出是「基建可信」这个前提本身**。跳过它直接跑真实 A/B,等于把三个从未运行过的新组件与一个产品结论绑在一起,出问题时无法归因。 + +### 6.3 首个真实 A/B 设计书(设计冻结,放行条件挂流量) + +**候选选择说明**:`iteration-3/06:351` 的候选池按 H3/H4/H5/H7 判定结果动态排序,但 H1~H8 **全部无读数**(无数据),数据驱动的排序此刻不可用。故按「M4 新建面 + 效应量可期 + 与 M4 核心指标直接挂钩」选定下述实验,并明确它**不占用**候选池中那四个记录域实验的席位。 + +```markdown +# 实验:生成结果页「发布到社区」引导强度 + +## 假设 +**问题陈述**:AI 生成的产物默认停留在创作页,用户需自行找到入口才能发到社区; +「生成 → 发帖」这条 M4 最核心的价值链路很可能在最后一步大量流失。 +**假设**:把生成结果页的「发布到社区」从次级入口提升为主按钮,并预填一段可编辑的 +默认文案,将提升「生成 → 发帖转化率」。 +**主指标**:生成 → 发帖转化率(§5 定义:24h 内 post_publish_succeeded 且 +creationTaskId 非空的去重 userId / creation_result_viewed 去重 userId) +**成功阈值**:绝对提升 ≥ +6pp 且 95% CI 下界 > 0 +**次要指标**:草稿 → 发帖转化率、创作漏斗整体转化、重新生成率(dissatisfaction 档) +**护栏指标**:生成成功率、P95 生成耗时(按模型)、**帖子删除率(AI 帖 vs 自发帖)**、 +次日回访、**北极星 7 日回访记录率不得下降** + +## 实验设计 +**类型**:双臂 A/B(feature flag 控制,服务端下发变体) +**总体**:全部到达生成结果页的登录用户;无地域/机型限制;新老用户均入组(并预登记 +按「是否首次使用 AI 创作」的分层分析,**该分层是预登记的、非事后挖掘**) +**分流单位**:userId(`hash(userId + experimentSalt) % 100`),50/50 +**样本量**:每组 ~780、合计 ~1,560 名**曝光**(= 到达结果页)用户 + 推导:p₁=0.20(基线假设,**无实测基线,见下方风险**)、p₂=0.26(MDE +6pp)、 + 双侧 α=0.05、功效 80%: + n = (1.96·√(2·0.23·0.77) + 0.8416·√(0.20·0.80 + 0.26·0.74))² / 0.06² ≈ 772 → 取 780 +**最短运行周期**:**≥14 天**且不早于样本量达标。14 天的三个来源:① 覆盖两个完整周内 + 节律(周末创作行为与工作日不同);② 主指标含 24h 归因窗;③ 北极星护栏需 +8 天成熟期 +**变体**: +- Control:现状——结果页主操作为「保存」,发布社区为次级入口 +- Variant A:结果页主按钮改为「发布到社区」,预填默认文案(可编辑),「保存」降为次级 + +## 风险评估 +**潜在风险**: +1. 诱导发布低质内容 → 社区 Feed 质量下降、用户后悔删帖 +2. 预填文案若千篇一律 → Feed 同质化 +3. 「保存」降级可能损害只想留存产物的用户 +**缓解**:帖子删除率为一级护栏(AI 帖 vs 自发帖分群对比);预填文案不得含固定营销语; +「保存」保持一次点击可达;feature flag 支持 5 分钟内全量回滚 +**成功/失败判定**: +- 主指标达阈值 且 全部护栏未破 → **Go**,全量 +- 主指标达阈值 但 帖子删除率显著升高 → **不全量**,改做「发布前预览确认」再测 +- 主指标未达阈值(CI 含 0)→ **No-Go**,保持现状,转而排查结果触达率 + +## 统计纪律(预登记,不得事后调整) +- **固定视界,不许偷看**:样本量达标且满 14 天前不做任何显著性判定。中途看板只 + 允许查看护栏与数据质量,**不得查看主指标的 p 值** +- **单一主指标**,故无需多重比较校正;次要指标一律标注为探索性,**不可用于翻盘 + No-Go 结论** +- 护栏采用单侧监控(α=0.005),其触发只用于**提前停止**,不用于宣告成功 +- **提前停止仅在护栏破线时**:生成成功率相对下降 >5%,或 P95 生成耗时 +50%, + 或帖子删除率相对升高 >30% +- 判定线先于数据存在(防 HARKing);分层分析只有上面预登记的一项 +- 隐私合规复核(A/B 前置 #8)在 T0 前执行一次 + +## 放行条件(全部满足才能启动,**不挂日期**) +1. §6.2 的 A/A 验证通过(含 SRM 与 variant 恒定性) +2. A/B 前置 #4 分流哈希、#5 Flutter 曝光封装、#7 feature flag **代码落地并有测试** +3. #3/#6 样本量规则与实验设计模板成文 +4. 埋点质量四项达标,且 §5 对账 1~4 连续两周无破线 +5. **实测基线**:`creation_result_viewed → 发帖` 转化率连续 ≥2 周稳定产出(当前用的 + p₁=0.20 是**假设值**,达标后必须用实测值重算样本量——若实测基线是 5% 而非 20%, + 所需样本量会变成数千每组,实验可行性结论随之翻转) +6. 按实测 DAU 换算,预计运行 **≤8 周**(>8 周即判不可行,退回观察性分析,止损线预登记) +``` + +**当前放行条件状态:0/6 满足。** 条件 5 与 6 依赖真实流量,不在工程可控范围内——这是本设计书**必须挂条件而非挂日期**的原因,也是 §0.4 给北极星提的同一条纪律(用触发条件替代日期承诺)。 + +### 6.4 `experiment_exposed` 的上报纪律(M4 首次启用) + +字典条目 M3 已先行(`EventDictionary.java:103`,props = `experimentKey`、`variant`),M4 首次实际上报。三条纪律: + +1. **在用户实际到达实验触点、变体 UI 已渲染时上报**,不在分配时上报。分配时上报会把「被分到但从未看到」的用户算进分母,系统性稀释效应量(`iteration-3/06:145` 已定此口径)。 +2. **每个 (userId, experimentKey) 每会话至多一条**,跨会话可重复(用于验证 variant 恒定性)。分析时按 userId 去重取首次曝光。 +3. **`experimentKey` 取自实验注册表枚举**,不接受自由字符串;`variant` 取 `control` / `variant_a` 等固定枚举。注册表是 §附 工单的交付物之一。 + +## 7. 白名单变更清单与同步点 + +### 7.1 变更汇总 + +| 类别 | 数量 | 明细 | +| --- | --- | --- | +| **新增事件** | **8** | `creation_started`、`creation_model_selected`、`creation_submit_succeeded`、`creation_submit_failed`、`creation_result_viewed`、`creation_failure_viewed`、`creation_cancelled`、`creation_draft_created` | +| **既有事件加属性** | **2** | `post_publish_succeeded` +`creationTaskId`;`post_draft_saved` +`creationTaskId` | +| **启用既有事件** | **1** | `experiment_exposed`(字典已在,客户端封装 M4 补) | +| **废弃事件** | **0** | 见 §1.2:8 条空转事件均有将来挂接点,改为纳入巡检而非废弃 | +| **eventVersion bump** | **0** | 新增可选属性按 §2.3 规则不递增 | +| **pageName 新增** | **1** | `creation_result`(后端零改动) | +| **字典条数** | **41 → 49** | 基数 41 已取证(§1.1),非文档记载的 42 | +| **Flyway 迁移** | 埋点侧 **0** | `creation` schema 的 V6 迁移属后端范围;`product_events` 不动(props 是 jsonb,加属性零迁移) | + +### 7.2 同步点(契约/配置/测试,逐项可勾) + +| # | 同步点 | 文件 | 改动 | +| --- | --- | --- | --- | +| 1 | 后端白名单 | `patbond-api/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java` | +8 条目(§4.5 代码块可直抄);改写 2 条既有条目;类注释红线措辞按 §4.3 修订;若采纳 §2.4 推荐档则 WHITELIST key 改 `(name, version)` 二元组 | +| 2 | 后端服务 | `.../analytics/AnalyticsService.java` | 仅 §2.4 推荐档需改:增 `unknown_event_version` 逐条拒绝分支(**放逐条路径,不放 DTO**) | +| 3 | 后端 DTO | `.../analytics/TrackEventsRequest.java` | `eventVersion` 补 `@Min(1)`(与 DB CHECK 对齐,消除 §2.1 缺陷 1)。**`platform` 正则不动** | +| 4 | **字典条数门禁** | `.../analytics/EventDictionaryTest.java` | **本次必做**:① 断言白名单条数 = 49;② 断言全事件名快照集合;③ 新增 8 条各自的 props 断言;④ 2 条改写事件的 props 断言更新。**理由见 §1.1——没有这个断言,41/42 这类漂移永久不可见** | +| 5 | 契约(5 份副本,md5 现为同一值) | `patbond-api/patbond-{auth,user,pet,community}/src/test/resources/contract/openapi-v1.4.0.yaml` + `patbond-doc/docs/api/openapi.yaml` | `eventVersion` 描述改无歧义表述 + 补 `minimum: 1`;`EventResult.reason` 枚举 +`unknown_event_version`(推荐档)。**均为纯增量**,v1.4.0 客户端无需改动。(`patbond-doc/site/` 下同名文件是 mkdocs 构建产物,不手改) | +| 6 | 客户端埋点核心 | `patbond-flutter/lib/analytics/analytics_service.dart` | ① `:139` 的 `'eventVersion': 1` 字面量改查表取值;② `:99-100` 注释按 §3.1 据实改写;③ `_platformName()` 非枚举值本地短路(§3.2);④ 新增 `PATBOND_ANALYTICS_PLATFORM_OVERRIDE` 调试覆盖 + `appVersion` 后缀护栏 | +| 7 | 客户端强类型封装(新建) | `patbond-flutter/lib/features/creation/creation_analytics.dart` | 8 条事件的编译期封装 + 枚举(沿 `pet_analytics.dart`/`post_analytics.dart`/`community_interaction_analytics.dart` 先例:枚举锁死,业务代码禁止手拼事件名与属性) | +| 8 | 客户端曝光封装(新建) | `patbond-flutter/lib/analytics/experiment_exposure.dart`(建议路径) | `experiment_exposed` 强类型封装 + 实验注册表枚举(A/B 前置 #5 的缺失半边,§1.4) | +| 9 | pageName 枚举 | `patbond-flutter/lib/analytics/analytics_page_name.dart` | +`creationResult('creation_result')`;顺带据实收敛 §4.7 登记的 7 个不存在项的注释 | +| 10 | 既有帖子埋点 | `patbond-flutter/lib/features/community/post_analytics.dart` | 发布/存草稿两处透传 `creationTaskId`(仅 AI 来源时携带) | +| 11 | E2E 脚本 | `patbond-flutter/test_e2e_m2_manual.dart`、`test_e2e_m3_manual.dart` | `eventVersion` 由 2/3 改 **1**(§2.2 理由 4) | +| 12 | 常设文档条数纠正 | `patbond-doc/docs/development/feature-checklist.md:218` | 「EventDictionary 22→42」改为据实的 41,并记 M4 后 49 | +| 13 | 常设文档 platform 表述 | `releases.md:120`、`device-verification.md:144`、`feature-checklist.md:221` | 把「逐条 rejected / 不影响客户端」改为「整批 400 + 永久丢弃,桌面采集能力为零」(§3.1) | +| 14 | 常设文档北极星时限 | `device-verification.md:40`、`iteration-3/29:52` | 按 §0.4 档 A 改为触发条件表述(迭代报告存档按「迭代报告豁免」惯例不回改,只改常设页与被引用的时限提醒) | +| 15 | ADR | `patbond-doc/docs/architecture/decisions.md` | 新增「ADR-023 M4 埋点与实验决策」:eventVersion 定型 + bump 规则、服务端不建第二管道、红线为 `creationTaskId` 开例外、北极星时限改触发条件、A/B 前置状态按取证重置为 5 绿 3 半、M4 首个实验为 A/A | + +### 7.3 巡检增量 + +- §5 的对账 SQL 1~5 入周一巡检。 +- 「字典空转清单」(§1.2 的 8 条 + M4 后新增项)纳入月度巡检,防止字典先行条目无声长期空转。 +- SRM 检查(§6.2 检查项 ①)在任何实验运行期间每日跑一次。 + +## 8. 待拍板清单(汇总) + +| # | 事项 | 选项 | 本角色裁定 / 建议 | +| --- | --- | --- | --- | +| **1** | **北极星 09-21 首次出数时限的处置** | ① 维持日期承诺 ② 改为事件驱动触发条件(档 A)+ 09-21 出基建就绪读数(档 B) ③ 造数凑读数(档 C) | **建议 ② = A+B**。09-21 已被算术判定为不可能(W37 入队窗口 09-13 已关闭,§0.2),维持日期只会持续生产假红线;档 C **明确否决**(数据造假且污染后续所有队列基线)。同时把 `iteration-2/06` §2.1 的北极星 SQL 落成仓库内可执行载体——它目前只存在于报告正文 | +| **2** | **eventVersion 口径定型** | ① 每个事件自身的 props schema 版本 ② 事件字典世代号 | **建议 ①**(§2.2 四条理由)。决定性理由是可修复性不对称:读法 ① 下客户端现行硬编码 1 **本就正确**,零改动零回填;读法 ② 下全部历史行皆错且**不可修复**(`server_ts` 无法反推事件当初属于哪代字典)。必须在 M4 新增 8 条事件**之前**定型,否则债务从 41 条规模翻到 49 条 | +| **3** | **eventVersion 是否上服务端校验** | ① 推荐档:字典 key 改 `(name, version)`,未知版本逐条 `unknown_event_version` ② 最小档:仅补契约 `minimum: 1` + DTO `@Min(1)` | **建议 ①**。§1.1 刚证明「无门禁的口径必然漂移四份文档而不可见」,纯文档纪律不足。注意实现须放**逐条拒绝路径**,不可放 DTO 校验——否则重犯 `platform` 的整批连坐错误 | +| **4** | **platform=linux 处置** | ① 把 linux/server 加进枚举 ② 客户端本地短路 + 默认关闭的调试覆盖开关 | **建议 ②**。枚举是三层锁死(契约 × 5 份 + DTO 正则 + DB CHECK `V2:23`),加值需 Flyway 迁移且会让非产品流量污染 platform 维度;收益仅「桌面调试方便」,不成比例。**采纳 ② 的前提条件**:覆盖开关必须配 `appVersion` 可识别后缀护栏(§3.2),否则它就是档 C 造数据的后门 | +| **5** | **服务端生成侧指标来源** | ① 新建 `server_events` 埋点管道 ② 扩 `product_events` 容纳服务端事件 ③ 以 `creation.generation_tasks` 事实表为权威 | **建议 ③**(§4.2)。所需事实 100% 已在任务表(M4 验收标准本就逼出这些列),事件层加不了信息还会引入双写不一致;② 需放宽两个 NOT NULL(不可逆的反向操作)+ 改 DB CHECK。**代价**:后端须承诺 §4.2 列出的列齐备且终态行保留 ≥90 天,否则对应指标不可算 | +| **6** | **隐私红线是否为 `creationTaskId` 开例外** | ① 维持「无内容或对方主体 ID」的字面禁止,漏斗改用 (userId, 时间窗) 拼接 ② 收敛措辞为「禁他人/内容主体 ID」,允许上报者自有资源 ID 作拼接键 | **建议 ②**。原红线意图是防止重建「谁对谁的内容做了什么」的社交图谱,`creationTaskId` 不涉第二主体、不泄露 prompt 与产物;① 的时间窗拼接在并发提交下会把 A 任务耗时配给 B 任务结果且不可察觉,对 P95 这类尾部指标是致命的。**否决**「做成第 11 个公共属性」的替代方案——为单域需求撬动跨迭代冻结的十项公共属性面不成比例 | +| **7** | **M4 首个「实验」的形态** | ① 直接跑真实 A/B ② A/A 基建验证先行,真实 A/B 冻结设计待流量 | **建议 ②**(§6.1)。真实 A/B 需 ~1,560 名到达结果页用户,当前真实用户 0,按 M2 §4.3 止损纪律现在就判不可行;且三项前置为零代码。A/A 零产品风险、极小样本即可暴露分流失衡与曝光漏报——**跳过它等于把三个从未运行过的组件与一个产品结论绑在一起** | +| **8** | **A/B 前置状态是否按取证结果重置** | ① 维持 `iteration-3/29` 的「6 绿 1 半」 ② 重置为 **5 绿 3 半**并写入 ADR | **建议 ②**。#4 分流哈希、#5 Flutter 曝光封装、#7 feature flag 三者代码零实现(逐项 grep 取证,§1.4),且 #1/#2 的绿是**前瞻式**的(挂在「09-21 起自然达成」上,该前提已随 §0 失效)。这直接决定 M4 排期——**若按「6 绿 1 半」排,会在实验启动日才发现要先写三个组件** | +| **9** | 结果内单产物选择/滑动行为是否首版就埋(§4.6 缺口 4) | ① 首版补 `selectedIndexBucket` ② 不埋,成为问题时按 §2.3 新增可选属性 | **建议 ②**。UI 未定稿;且 `resultIndex` 类数据容易被误读成「用户偏好某模型输出」的产品结论。复活条件已写明 | +| **10** | 白名单条数口径纠正(42 → 41,M4 后 49)的回改范围 | ① 全量回改含历史迭代报告 ② 只改常设文档(`feature-checklist.md`),迭代报告存档不动 | **建议 ②**,沿既有「迭代报告豁免」惯例:迭代报告是时点存档,回改会破坏其与当时代码状态的对应关系。但**必须**同时落地 §7.2 同步点 4 的条数断言,否则纠正一次仍会漂移第二次 | + +**最关键三项**:**#1**(北极星时限——唯一有外部日期承诺、且已失效)、**#8**(A/B 前置重置——直接改变 M4 排期与工单量)、**#5**(服务端指标来源——决定 M4 后端表结构,定晚了要返工迁移)。 + +## 附:M4 埋点工单拆分建议(按依赖排序) + +1. **真机执行人(首日,1 小时,无依赖,最高优先)**:`export ANDROID_HOME=~/Android/Sdk` + `flutter emulators --launch Pixel_7`,执行 M2 两项真机验证(步骤 `device-verification.md:42-92`,模拟器场景宿主机地址用 `10.0.2.2`),填 L106 执行记录。产出 `platform='android'` 事件实证,解除 A/B 前置 #1 的拦路项。**这是 §0.3 的 decisive finding 的直接落地,且不依赖任何其他工单**。 +2. **用户 / PM(首日)**:拍板 §8 的 #1、#2、#5、#8(前两项决定后续工单形态,#5 决定后端表结构,#8 决定排期)。 +3. **数据侧(0.5 天,依赖 1)**:把 `iteration-2/06` §2.1 北极星 SQL 落成仓库内可执行载体,跑出 W37 实测分母,按 §0.4 档 B 出「基建就绪读数」。 +4. **后端(依赖 2 的 #5)**:`creation` schema 的 V6 迁移中确保 §4.2 要求的列齐备(`queued_at`/`first_attempt_at`/`attempt_count`/`failure_reason` 等)+ 终态行保留 ≥90 天纪律;与生成任务契约同批冻结 `modelKey`/`styleKey` 枚举。 +5. **后端(依赖 2 的 #2#3)**:`EventDictionary` v4 增量 8 条 + 2 条改写(§4.5 代码块可直抄);eventVersion 校验按推荐档;**同批补字典条数与全名快照断言**(§7.2 同步点 4)。可与 4 并行。 +6. **后端(依赖 5)**:契约 5 份副本同步 `eventVersion` 描述 + `minimum: 1` + `reason` 枚举增量;跑契约一致性矩阵。 +7. **Flutter(依赖 2 的 #4,可与 4/5 并行)**:`analytics_service.dart` 四项改动(eventVersion 查表、注释据实、桌面短路、调试覆盖 + appVersion 护栏);两份 E2E 脚本 `eventVersion` 改 1。 +8. **Flutter(依赖 5 的字典)**:新建 `creation_analytics.dart` 强类型封装 8 事件 + 枚举;`analytics_page_name.dart` +`creationResult`;`post_analytics.dart` 透传 `creationTaskId`。 +9. **后端 + Flutter(A/B 前置补齐,依赖 2 的 #8)**:① 分流组件 `hash(userId + experimentSalt) % 100` + 实验注册表;② `experiment_exposure.dart` 曝光强类型封装;③ feature flag 开关机制(含 5 分钟内回滚能力)。**三者均为零代码起步,不得按「已就绪」排期**。 +10. **本角色(依赖 9)**:样本量规则 + 实验设计模板成文(A/B 前置 #3/#6,此前两迭代标绿但无文档);A/A 基建验证实验的检查项清单与 SRM 巡检脚本。 +11. **本角色(M4 全程)**:每周一北极星/护栏读数复核;§5 对账 SQL 1~5 入巡检;字典空转清单月度巡检。 +12. **文档(收官前)**:ADR-023 落地;§7.2 同步点 12~14 的三处常设文档纠正。 diff --git a/docs/development/iterations/iteration-4/07-evidence-baseline-audit.md b/docs/development/iterations/iteration-4/07-evidence-baseline-audit.md new file mode 100644 index 0000000..0618d23 --- /dev/null +++ b/docs/development/iterations/iteration-4/07-evidence-baseline-audit.md @@ -0,0 +1,550 @@ +# M4 开工基线证据审计(07 号报告) + +> 任务:M4「AI 创作」开工前对全部声称基线**逐条亲自取证**。每条结论附实际命令输出或 `文件:行号`。 +> 执行人:Evidence Collector | 取证日期:**2026-09-14**(工作机本地检出,三仓均停在 `v0.4.0`) +> 纪律:只读取证 + 只写本报告。未修改任何生产代码、`mkdocs.yml`、服务器配置;未执行 `git commit` / `push`。 + +> **结论先行**:10 条基线中 **6 条完全对上**、**3 条对不上**、**1 条无法静态复核**。 +> 三个专项均发现实质问题:真机挂起项**实际登记 10 项而非 8 项**(差的 2 项是 M3.5 的 +> caregiver 改宠物头像与获赞数对账,登记在常设清单里但未进功能清单跟踪); +> 发布 checklist **仍写着「M2+M3 两份」E2E,从未改成四份**;文档站待评估项只以 +> 表格单元格内的一句 `⚠️` 存在,无归属人、无时限、无 ADR。 +> 埋点白名单实为 **41 条**(非 42),且**没有任何测试锁住这个数**。 +> api 测试实为 **381**(非 379)——`v0.4.0` 发布门禁表里的数字是旧的。 + +--- + +## 0. 取证环境事实 + +| 项 | 值 | 证据 | +| --- | --- | --- | +| 取证时刻 | `2026-09-14 14:09:28 +0800` | `date '+%Y-%m-%d %H:%M:%S'` | +| 默认 JDK | `openjdk 26.0.2.1 (2026-08-18)` | `java -version` | +| 可用 JVM | `java-11-openjdk` / `java-17-openjdk` / `java-26-openjdk` | `ls /usr/lib/jvm/` | +| `JAVA_HOME` | **未设置** | `echo $JAVA_HOME` → `(unset)` | +| 后端容器 | **全部未运行** | `docker compose ps` 输出为空 | + +> 说明:`git-workflow.md` 与 `device-verification.md` 都写 `JAVA_HOME=/usr/lib/jvm/java-17-openjdk`, +> 该路径**确实存在**(`ls -d` 成功),故文档前置有效;但本次基线测试在**默认 JDK 26** 下跑, +> 仍 381/381 全绿(surefire 日志内 `using Java 26.0.2.1`)。两条通路都可用,非缺陷,仅记录口径差异。 +> +> **后端容器未运行**是本次唯一的取证硬约束——它导致 E2E 断言数无法运行时复核(见基线 9)。 + +--- + +## 1. 基线逐条对账表 + +| # | 基线 | 声称值 | 实测值 | 证据(命令 / 文件:行号) | 裁决 | +| --- | --- | --- | --- | --- | --- | +| 1a | 三仓工作区干净 | 全部干净 | 全部干净 | `git -C <仓> status --porcelain` 三仓均**空输出** | ✅ 对上 | +| 1b | 三仓停在 `v0.4.0` | 是 | 是 | `git tag --points-at HEAD` 三仓均返回 `v0.4.0` | ✅ 对上 | +| 1c | api HEAD | `dev@3cd8005` | `dev@3cd8005` | `3cd80055779db2d52cf8cc1af425d06131f7e41d`,`HEAD -> dev, tag: v0.4.0, origin/main, origin/dev` | ✅ 对上 | +| 1d | flutter HEAD | `dev@fbcd734` | `dev@fbcd734` | `fbcd73468805e95b8055395654ca1014e0d6c13c`,同点位含 `origin/main` | ✅ 对上 | +| 1e | doc HEAD | `main@5cc6361` | `main@5cc6361` | `5cc63615345fc30cb342a336292d7decdffea98f` | ✅ 对上 | +| 2a | api 测试数 | **379** | **381** | `./mvnw -B test` exit 0;surefire XML 聚合:auth 40 + common 3 + community 107 + pet 100 + user 131 = **381**,failures=0 errors=0 skipped=0 | ❌ **对不上(+2)** | +| 2b | flutter 测试数 | **597** | **597**(+2 skipped) | `flutter test` → `01:03 +597 ~2: All tests passed!` exit 0 | ⚠️ 数字对上,**但 2 项被跳过未披露** | +| 3a | 契约版本 | `v1.4.0` | `1.4.0` | `openapi.yaml:4` → `version: 1.4.0` | ✅ 对上 | +| 3b | 契约规模 | 32 路径 / 45 操作 / 75 schema | **32 / 45 / 75** | Python + PyYAML 机械计数(逐路径列出,见 §2.1) | ✅ 对上 | +| 3c | 四模块字节级快照锁 | 四模块一致 | **5 份全同**,md5 `a7081fb84f1207eef579ab94025f5801` | `md5sum` 于 doc 正典 + auth/community/pet/user 四份 `openapi-v1.4.0.yaml` | ✅ 对上(与 04 号报告的 `a7081f…5801` 吻合) | +| 4 | 契约矩阵 181 格零漂移 | 181 格 | 表列合计 **24+83+66+8 = 181**;4 测试类 40 个 `@Test` 全绿 | `iteration-3.5/04-contract-freeze-v140.md:96` 合计行;4 个 `*ContractConformanceTest.java` 全在 381 内 0 失败 | ⚠️ **口径自洽,但「181」不可机械计数**(见 §2.2) | +| 5a | Flyway V1~V5 | V1~V5 | 恰好 V1~V5,**无 V6** | `find -name "V*.sql"` → 5 个源文件;测试日志 `Successfully applied 5 migrations ... now at version v5` | ✅ 对上 | +| 5b | `posts.generation_job_id` 裸列无 FK | 裸列、无外键 | **列存在且确无 FK** | `V5__community_baseline.sql:48` → `generation_job_id uuid,`(无 `REFERENCES`);`:47` 注释明写 `FK ... stripped (M4 补回)`;全文件 15 处 `REFERENCES` 无一涉及该列 | ✅ 对上 | +| 6 | 埋点白名单 42 条 | **42** | **41** | `EventDictionary.java` 单一 `WHITELIST` Map,`Map.entry(` 计数 = **41**,去重事件名 = **41** | ❌ **对不上(−1)** | +| 7 | ADR 001~022 齐全无缺号 | 001~022 | **22 条,001~022 连续无缺号** | `decisions.md` 标题计数 = 22;`grep -o "ADR-[0-9]\{3\}" \| sort -u` 输出 001…022 连续 | ✅ 对上 | +| 8 | 部署六容器(含自托管 MinIO) | 6 | **6** | `docker-compose.yml` services = `postgres`(postgres:18)、`minio`(minio/minio:RELEASE.2025-04-22)、`user`、`auth`、`pet`、`community`;另有 2 个 volume(`pgdata`/`minio-data`,非容器) | ✅ 对上(MinIO 自托管确认,ADR-016) | +| 9a | E2E 脚本 4 份 | 仓库根 4 份「脚本」 | 4 份,位于 **patbond-flutter 仓库根**,为 **`.dart` 非 `.sh`** | `patbond-flutter/test_e2e_{,m2_,m3_,m35_}manual.dart`;工作区根 `ls *.sh` → 无此文件 | ✅ 对上(措辞校正见 §2.3) | +| 9b | E2E 42 场景 | 42 | **42**(7+11+14+10) | 各脚本自述与收尾断言:`test_e2e_m2_manual.dart:767` `$_passed/11`;`test_e2e_m3_manual.dart:1345` `$_passed/14`;`test_e2e_m35_manual.dart:1139` `$_passed/10`;M1 脚本 `[N/7]` 分段 | ✅ 对上 | +| 9c | E2E 234 断言 | 234 | **静态调用点仅 207**(M2 41 / M3 83 / M3.5 83)+ M1 另一体例 | `grep -c "check("` 去定义行;脚本内**无断言计数器**(`_passed` 只数场景) | ❌ **未取证 / 不可静态复核**(见 §2.3) | +| 9d | v0.4.0 发布时零失败 | 零失败 | **本次未复跑**(后端六容器未运行) | `docker compose ps` 空 | ⚠️ **未取证**(沿用 `iteration-3.5/08` 记录,非本次实证) | +| 10a | mkdocs 可构建 | 可构建 | **exit 0,零 warning** | `mkdocs build --strict -d /tmp/mkdocs-audit-site` → `Documentation built in 1.66 seconds`,`MKDOCS_EXIT=0` | ✅ 对上 | +| 10b | 导航无死链 | 无死链 | **0 条** nav 指向缺失文件 | 脚本比对:nav 引用 102 个 `.md`,全部存在 | ✅ 对上 | +| 10c | 无孤立文档 | 无孤立 | **0 个孤立** | `docs/` 下 102 个 `.md`,nav 引用 102 个,差集为空 | ✅ 对上 | + +--- + +## 2. 关键条目的取证细节 + +### 2.1 契约规模(基线 3b)——三个数字全部机械复核 + +用 PyYAML 解析后按 OpenAPI 方法名白名单统计操作数: + +``` +info.version = 1.4.0 +paths = 32 +operations = 45 +schemas = 75 +``` + +32 条路径的操作分布(逐条列出以便 M4 增量时对照):`/auth/{login,logout,refresh,register}` 各 1、 +`/breeds` 1、`/care-reminders/{reminderId}` 1、`/comments/{commentId}` 1、`/events` 1、`/feed` 1、 +`/health-events/{eventId}` 1、`/me` **2**、`/me/{bookmarks,community-stats,posts}` 各 1、 +`/media/uploads` 1、`/media/uploads/{assetId}/complete` 1、`/pets` **2**、`/pets/{petId}` **2**、 +`/pets/{petId}/{care-reminders,health-events,vaccinations,weights}` 各 **2**、`/pets/{petId}/summary` 1、 +`/posts` 1、`/posts/{postId}` **3**、`/posts/{postId}/{bookmark,comments,like}` 各 **2**、 +`/users/{userId}/follow` **2**、`/users/{userId}/follow-stats` 1、`/vaccinations/{vaccinationId}` 1、 +`/vaccine-catalog` 1。 + +### 2.2 契约矩阵 181 格(基线 4)——为什么标「不可机械计数」 + +`iteration-3.5/04-contract-freeze-v140.md:88-96` 的表格自身算术正确: + +| 模块 | 测试类 | 操作 | 单元格 | +| --- | --- | --- | --- | +| patbond-auth | `AuthContractConformanceTest` | 7 | 24 | +| patbond-pet | `ContractConformanceTest` | 18 | 83 | +| patbond-community | `CommunityContractConformanceTest` | 18 | 66 | +| patbond-user | `MediaContractConformanceTest` | 2 | 8 | +| **合计** | 4 类 | **45** | **181** | + +四个类都存在且全绿,`@Test` 方法数为 **10 / 12 / 13 / 5 = 40**。 +但「单元格」是**报告里人工定义的「操作 × 响应类」概念单位**,与 `@Test` 方法数不是一对一 +(一个 `@Test` 内常覆盖多格,如 §3.1 里 `404` 一格含 `40400`/`40405` 两码)。 +代码侧**没有任何断言锁住「181」这个总数**。 +故本条裁决为:**「零漂移」已由 40 个绿测试实证;「181 格」只能信报告口径,无法独立机械复核。** + +### 2.3 E2E 场景与断言(基线 9)——场景可证,断言不可 + +**场景数 42 = 已机械证实。** 每份脚本收尾都打印 `$_passed/N`,且 `_passed++` 出现次数与 N 相符 +(如 `test_e2e_m35_manual.dart` 内 `_passed++` 恰 10 次,行 343/391/431/493/618/690/745/880/943/1136)。 + +**断言数 234 = 无法复核。** 三点证据: + +1. **脚本里没有断言计数器**。`_passed` 只在场景末自增,数的是场景不是断言 + (`test_e2e_m35_manual.dart:78` `int _passed = 0;`,无 `_asserts` 之类)。 + 报告 `iteration-3.5/08:18-24` 的「断言数」列(M1 7 / M2 44 / M3 88 / M3.5 95)是**人工清点值**, + 非程序输出。 +2. **静态调用点少于声称值**: + + | 脚本 | 报告声称断言 | `check()` 静态调用点 | 差 | + | --- | --- | --- | --- | + | `test_e2e_manual.dart`(M1) | 7 | **无 `check()` 体例**(16 处 `✗` 失败守卫) | 体例不同 | + | `test_e2e_m2_manual.dart` | 44 | **41** | −3 | + | `test_e2e_m3_manual.dart` | 88 | **83** | −5 | + | `test_e2e_m35_manual.dart` | 95 | **83** | −12 | + + 差值**可由循环内断言解释**(M3.5 的 710/717/899 行 `check()` 位于 `for` 循环内,运行时会执行多次), + 但报告未说明「断言数」是运行时计数,读者无从判断。 +3. **口径不统一**:M1 记 7(≈ 每场景 1 条),而 M2/M3/M3.5 按单条 `check()` 记。 + 把两种口径相加得到的 **234 不是一个同质量纲的数**。 + +**结论**:`42 场景`可直接引用;`234 断言`建议**停止作为门禁数字引用**,或改为脚本自打印 +(在 `check()` 内加一个 `_asserts++` 并在收尾打印,一行改动即可让此数自证)。 +后端六容器未运行,本次**未复跑**任何 E2E 脚本,「零失败」沿用旧记录、非本次实证。 + +### 2.4 埋点白名单 41 vs 42(基线 6)——差在哪里 + +唯一权威源是 `patbond-api/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java` +(单一 `WHITELIST` 为 `Map.ofEntries(...)`,`isKnownEvent()` 直接查它,无第二个注册表)。 + +- `grep -c "Map.entry("` → **41** +- 去重事件名 → **41**(逐条已列,此处略) + +分域核算:auth 11 + `page_viewed` 1 + pet 3 + health_record 7 = **22**(= v2 基线,与文档一致); +post 域 **8**(发布漏斗 5 + 草稿/删除 + 媒体三段中的 started/succeeded/failed)、feed **2**、互动 **8** += community 增量 **18**;再加 `experiment_exposed` **1** → **22 + 18 + 1 = 41**。 + +**文档口径错在把 community 增量记为 19。** 三处文档同错(转述扩散): + +- `feature-checklist.md:218` → 「community 域 **19** + experiment_exposed」「EventDictionary 22→**42**」 +- `iteration-3/29-m3-summary.md:20` → 「**42 事件**(+19 community 域 + experiment_exposed)」 +- `iteration-3/27-wave3-closure.md:17,27` 与 `iteration-3/index.md:47` → 「22→**42**」 + +而 `EventDictionary.java` 的**类 Javadoc 自身是对的**——它写 `post domain (8...)`、`feed domain (2...)`、 +`interactions (8...)`,合计 18,另记 `experiment_exposed`。即:**代码注释 = 41,四处文档 = 42**。 + +**放大这个问题的根因**:`EventDictionaryTest.java`(155 行、13 个 `@Test`)逐事件断言 props 键集, +但**没有任何一条断言锁住白名单的总条数**(无 `hasSize` / `size()` 断言)。 +所以「文档 42 / 实现 41」这类漂移不会被任何门禁拦住。 + +--- + +## 3. 专项 A:真机验证挂起项逐项清点 + +**声称**:共 8 项(M2 两项 + M3 四项 + M3.5 两项)。 +**实测**:`docs/development/device-verification.md` 实际登记 **10 项**。 + +| 迭代 | # | 项目 | 步骤完整性 | 前置齐备 | 执行记录 | +| --- | --- | --- | --- | --- | --- | +| **M2** | 1 | 验证一:Android 事件真实落库(~10 min) | ✅ 4 步 + 3 条通过标准 + psql 命令 | ✅ | ⬜ 待填 | +| **M2** | 2 | 验证二:SessionTracker 30 分钟后台换会话(~45 min) | ✅ 4 步 + 2 条通过标准 + 巡检 SQL 兜底 | ✅(接验证一同一登录) | ⬜ 待填 | +| **M3** | 1 | 媒体上传弱网表现 | ✅ (a)~(e) 五档 | ✅ 明写 `PATBOND_MINIO_PUBLIC_ENDPOINT` 必配 | ⬜ 待补 | +| **M3** | 2 | 乐观更新真机手感 | ✅ (a)~(e) | ✅ 含造数指引 | ⬜ 待补 | +| **M3** | 3 | Feed 图片加载 | ✅ (a)~(e) | ✅ 含「先发 ≥26 帖」造数 | ⬜ 待补 | +| **M3** | 4 | 社区事件落库 | ✅ 步骤 + (a)~(f) 六条标准 + psql | ✅ | ⬜ 待补 | +| **M3.5** | 1 | 头像上传弱网表现 | ✅ (a)~(f) 六档 | ✅ | ⬜ 待补 | +| **M3.5** | 2 | 头像缓存表现 | ✅ (a)~(d) | ✅ | ⬜ 待补 | +| **M3.5** | 3 | **caregiver 账号改宠物头像** | ✅ (a)~(c) | ❌ **前置不可执行**(见下) | ⬜ 待补 | +| **M3.5** | 4 | **获赞数与帖子点赞数对账** | ✅ (a)~(d) | ⚠️ **无「前置」小节**(1/2/3 都有) | ⬜ 待补 | + +### A.1 「8 项」是怎么来的——两份文档对不上 + +「8」出自 **功能完成清单**,不是真机清单本身: + +- `feature-checklist.md:221` → 「Android 真机验证(**M2 两项 + M3 四项**)」= 6 +- `feature-checklist.md:244` → 「真机项(**头像上传弱网、头像缓存**)」= 2 +- 合计 8。`releases.md` 的 v0.4.0「已知遗留」同样只写「M3.5 新增(头像上传弱网、头像缓存)」。 + +但 `device-verification.md` 的 M3.5 节开篇明写「下列**四项**是桌面替代不了的部分」, +并编号登记了 4 项。 + +> **⚠️ 实质风险**:M3.5 的第 3 项(caregiver 改宠物头像)与第 4 项(获赞数对账) +> **只存在于常设真机清单,没有进入功能完成清单的跟踪行**。 +> 功能清单是收官时的销账依据——这两项当前**处于「已登记但无人跟踪」状态**, +> 按现有流程走完 M4 收官也不会有人发现漏了它们。 + +### A.2 M3.5 第 3 项前置不可执行(唯一的硬阻塞) + +原文: + +> **前置**:两个账号 A(owner)/ B(caregiver),B 对 A 的宠物有 caregiver 角色 +> (关系授予入口尚未开放,按 `pet_health` 的协作表直接造数据,**收口时补 SQL**)。 + +「收口时补 SQL」**从未补**——文件里没有任何造数 SQL。执行人拿到这一项无法开工。 + +**取证:所需信息其实都已就位,写出这段 SQL 没有障碍**: + +- 表与角色枚举存在:`V3__pet_health_baseline.sql:97` `CREATE TABLE pet_health.pet_owners (`, + `:104` `CONSTRAINT ck_pet_owners_role CHECK (role IN ('owner', 'caregiver', 'viewer'))` +- 后端授予路径已被测试覆盖:`PetAvatarIntegrationTest.java:214` + `void caregiverMayChangeTheAvatarButNotTheProfile()`,`:218` `grantRole(petId, caregiver, "caregiver")` + +**建议**:把 `grantRole` 的等价 `INSERT INTO pet_health.pet_owners (...)` 写进该项前置,此项即可执行。 + +### A.3 有没有「其实已做过但没销账」的项? + +**没有任何一项已完整完成**——四处执行记录(M2 节、M3 节、M3.5 节)全部为「待填 / 待补」。 +但**有两项的后端部分已被证实,真机清单里没有反映**: + +1. **M3.5 第 4 项(获赞数对账)的后端口径已证**: + `iteration-3.5/08-release-e2e-regression.md:302` → + 「其中『获赞数对账』的**后端口径**已在场景 10 证明(详情 `likeCount` == `receivedLikeCount`), + 真机侧待验的只剩 UI 呈现。」 + → 该项 4 个勾选框在 `device-verification.md` 里仍全空,未标注「(a)(b) 后端已证、真机只验 UI」。 +2. **M3.5 第 3 项(caregiver)的后端行为已被单测覆盖**: + `iteration-3.5/08` §未覆盖范围 → 「后端已有 `PetAvatarIntegrationTest` 三段断言覆盖」, + 本次已直接在源码核实(见 A.2)。 + → 真机清单同样未标注,真机侧实际只需验 **UI 角标可见性分档**(WRITE vs MANAGE)。 + +**判断**:不是「做完没销账」,而是**「已缩小的范围没有回写」**——真机项的实际剩余工作量 +比清单表面看起来小,但清单不写,下一个执行人会重复评估。 + +### A.4 M2 两项的时限已进入最后一周 + +`device-writer.md` 内的时限提醒(引自 `iteration-3/06`): + +> **时限提醒**:建议在 **2026-09-21(北极星首次出数日)前完成**,否则首批读数只能标「未验收」。 + +今天 **2026-09-14**,剩 **7 天**。两项合计耗时自述 ~55 分钟(10 min + 45 min,其中 40 min 是等待), +**唯一前置是一台 Android 真机或模拟器**。步骤、psql、通过标准全部齐备,可立即执行。 + +> **注**:M2 验证二的巡检 SQL 兜底(`count(DISTINCT session_id)/count(*)` 应远小于 0.9) +> 是防「每事件一个 sessionId」缺陷复发的,与北极星读数直接相关——这也是时限挂在 09-21 的原因。 + +### A.5 其它文档债(不阻塞,记录备查) + +1. **通用前置的 `flutter run` 只传 3 个 base URL**,靠一句注解补 community: + 「M3 起若新增服务端口(如 community :8084),相应补 `PATBOND_COMMUNITY_API_BASE_URL`」。 + 结果是 M3/M3.5 的每一项都要重复叮嘱「四个 base URL 全传」。建议把通用前置的命令块直接补全到四个。 +2. **勾选框体例不一致**:M3 的第 1/2/3 项用 `- (a)` 普通列表,M3 第 4 项与 M3.5 全部用 `- [ ]` 勾选框。 + 真机执行时无法在前三项上打勾销账。 + +--- + +## 4. 专项 B:发布流程文档一致性 + +### B.1 「发布后生效的纪律」现状——存在、基本可执行,但有一处已过期 + +该节位于 `docs/development/releases.md` 的 **v0.3.0** 小节末尾(不在 v0.4.0 小节)。内容核实: + +| 条目 | 内容 | 裁决 | +| --- | --- | --- | +| `main` 禁直推 | 一切影响 main 的变更走 PR + CI 状态检查 | ✅ 已生效并已被 v0.4.0 实证 | +| 五步 PR 流程 | ①完成 checklist 1~2 步 → ②建 `dev → main` PR → ③等状态检查绿 → ④Gitea 合并(应为快进)→ ⑤续 checklist 5、7 步 | ⚠️ **第 ① 步文字过期**(见下) | +| checklist 第 4 步作废 | 本地 `merge --ff-only && push` 仅适用首次发布 | ✅ 已明确记录 | +| checklist 第 3、6 步一次性 | 不再重复 | ✅ 已明确记录 | +| 分叉即排查 | PR 显示无法快进 → 先查明原因,不用合并提交掩盖 | ✅ 清晰 | + +**过期点**:五步流程第 ① 步写「完成 checklist 第 1~2 步(三仓 CI 绿 + **E2E 双份回归 PASS**)」。 +而同一文件 v0.4.0 小节的「checklist 变更(下次发布适用)」已宣布 +「**回归清单由两份改为四份**:M1/M2/M3/M3.5」。 +**同一份 `releases.md` 内自相矛盾**:v0.3.0 节的纪律说双份,v0.4.0 节说四份。 + +### B.2 checklist 里**没有**写成四份(核心发现) + +用户问「核实 checklist 里是否真的写成了四份」——**答案:没有,仍写着两份。** + +`releases.md` 顶部「维护约定」指向的权威 checklist 是 +`iterations/iteration-3/08-git-workflow-plan.md` §3.3。原文第 2 步(**该文件 84 行**): + +> 2. **实测**:compose 全栈起,跑 **M2+M3 两份** E2E 烟囱脚本,全场景 PASS,证据入波次报告; + +**这份 checklist 自 M3 起从未被修改。** 八步中有 **4 步已与现实脱节**: + +| 步 | 原文要点 | 现状 | 修正记录在哪 | +| --- | --- | --- | --- | +| 2 | 跑 **M2+M3 两份** E2E | 应为 **四份**(M1/M2/M3/M3.5) | 只在 `releases.md` v0.4.0「checklist 变更」段 | +| 3 | 命名统一 + api main 重建 | **一次性项,已完成** | 只在 `releases.md` v0.3.0 纪律段 | +| 4 | 本地 `checkout main && merge --ff-only && push` | **已失效**(main 受保护会被拒) | 只在 `releases.md` v0.3.0 纪律段 | +| 6 | Gitea 开启分支保护 | **一次性项,已完成** | 只在 `releases.md` v0.3.0 纪律段 | + +**即:checklist 本体是 M3 时代的原稿,全部修正只散落在发布记录页的散文里。** +下次发布若有人照 §3.3 逐步执行,会跑错 E2E 份数、并撞上一条已被分支保护拒绝的 git 命令。 + +### B.3 checklist 至今仍是「草案」,且从未固化进常设文档 + +`iteration-3/08-git-workflow-plan.md` §3.3 的标题原文: + +> ### 3.3 发布 checklist 草案(**首次发布用,验证后固化进 `git-workflow.md`**) + +这个固化动作**从未发生**。核实 `docs/development/git-workflow.md`(64 行,常设文档): + +``` +grep -n "checklist\|发布\|E2E\|四份\|两份\|PR" docs/development/git-workflow.md +→ (无任何输出) +``` + +**该常设文档里完全没有发布流程、没有 checklist、没有 PR 纪律、没有「main 受保护」的说法。** +它的「禁止事项」只说「不 force push 共享分支(`dev` / `main`)」,仍是保护启用前的口径。 + +> **⚠️ 实质风险**:唯一的权威发布 checklist 是一份**标着「草案」的 iteration-3 迭代报告**, +> 内容已 4 步过期;而常设的 `git-workflow.md` 对发布流程**完全沉默**。 +> 两次发布的经验只以散文形式沉在 `releases.md` 的两个版本小节里。 + +### B.4 附带核实:doc 仓 main 未受保护 + +`releases.md` v0.3.0 的分支保护表(经 Gitea API 核实的记录): + +| 仓库 | protected | 状态检查上下文 | +| --- | --- | --- | +| patbond-api | `true` | `CI / backend-test (push)` | +| patbond-flutter | `true` | `CI / flutter-gates (push)` | +| **patbond-doc** | **未启用** | —— | + +即「main 受保护禁直推」**只适用 api 与 flutter 两仓**;doc 仓 `main` 仍是直推流 +(`git-workflow.md` 亦写「patbond-doc:直接提交 `main`」)。本报告即直接写在 doc 仓 main 工作区。 +这是**按规划的有意安排**(doc main 即日常分支、不承担发布分支语义),非缺陷,仅澄清口径 +——避免「下次发布必须走 PR」被误读为三仓皆然。 + +--- + +## 5. 专项 C:文档站公开可访问 / 访问控制待评估项 + +> 纪律声明:本节**纯取证 + 建议**。未连接服务器、未读取或修改任何 nginx / 防火墙配置。 +> 下述服务器侧事实均引自仓库内的 `server-exposure.md` 登记(该文件本身即为核对产物), +> 并明确标注哪些是「文档记载」而非「本次实测」。 + +### C.1 待评估项的登记现状——只有一句话,在表格单元格里 + +文件确认存在:`docs/development/server-exposure.md`(常设文档,2026-09-11 因安全事件新建)。 + +待评估项的**全部原文**,位于 §2「常驻服务」表格的「状态」列单元格内: + +> **文档站(patbond-doc)** | 经 nginx 443 | mkdocs 构建产物,含架构/部署/迭代全部文档 | —— | +> ✅ 运行;⚠️ **公开可访问,待评估是否加 basic auth 或 IP 白名单**(无凭证内容,但暴露内部架构细节) + +`releases.md` v0.4.0 末尾有一行呼应: + +> **待评估**:文档站公开可访问是否加访问控制(见服务器暴露面清单)。 + +**登记质量问题**(三项俱缺): + +1. **无归属决策**:该行的「归属决策」列是 `——`(空)。同表其它服务都指向 ADR 或 CI 手册 + (gitea → CI Runner 手册、dockerd → ADR-006)。文档站**没有任何 ADR 覆盖它的暴露决策**。 +2. **无时限、无归属人**:不像真机 M2 两项挂着明确的 `2026-09-21`。 +3. **不在处置记录里**:§5「处置与核对记录」只有 2026-09-11 一行(安全事件处置), + 待评估项**未登记为待办事项**,只以表格内 `⚠️` 存在——检索性差,收官核对时极易滑过。 + +### C.2 当前暴露面(据 `server-exposure.md` 登记,2026-09-11 核对) + +对公网开放端口 **4 项**:22(SSH)、80(跳 443)、443(HTTPS)、ICMP。 +其中 443 由 nginx 按域名分流,`sites-enabled/` 内两个站点:`git.patbond.cn` 与 `patbond-doc`。 + +即:**文档站与 Gitea 共用 443,靠域名分流,无任何认证层。** + +已于 2026-09-11 关闭并记录防重开:3000(Gitea 直连,本次事件入口)、8848/9848(Nacos,ADR-002 已移除)、 +2222(无服务监听的空规则)。 + +**「最后确认」列全部为 `2026-09-11`。** 而 `server-exposure.md` 自身的维护约定 ② 要求 +「**每次迭代收官核对一遍**,更新『最后确认』」——M3.5 于 09-11 收官、`v0.4.0` 于 **09-14** 发布, +发布当日未再核对。属轻微逾期(3 天),M4 开工正是补这次核对的时机。 + +### C.3 暴露内容评估(本次实测部分) + +我可以实测的是**文档站会发布什么内容**(构建产物侧),这部分不需要碰服务器: + +- 构建产物含 **102 个页面**(`mkdocs build --strict` 全量成功,nav 102 项零孤立)。 +- 内容面包括:完整 OpenAPI 契约(`docs/api/openapi.yaml`,32 路径全部端点与错误码)、 + 数据库全量 DDL(`docs/database/patbond_postgresql.sql`)、22 条 ADR、 + **`server-exposure.md` 本身(服务器端口与常驻服务清单)**、 + `ci-runner-setup.md`(CI runner 部署细节)、以及含安全事件复盘的 + `iteration-3.5/07-security-incident-20260911.md`。 +- 凭证面:三仓 `check-secrets.sh --all` 在 v0.4.0 门禁 exit 0(引自 `releases.md`,本次未复跑)。 + 故「无凭证内容」的判断有依据。 + +> **⚠️ 值得指出的悖论**:因安全事件而建立的 `server-exposure.md`——那份逐条列出 +> 「哪些端口开着、哪些服务在跑、内部 API 走哪条路径、凭证轮换触发条件」的清单—— +> **本身正随文档站公开发布**。同理还有 `ci-runner-setup.md` 与安全事件复盘全文 +> (后者详述了注入路径与处置手法)。 +> 这不是凭证泄漏,但**恰好是攻击者做侦察最想读的三份文档**,且描述的是刚被攻击过的这台主机。 +> 这一点在待评估项的括注「无凭证内容,但暴露内部架构细节」里被显著低估了。 + +### C.4 建议(不改配置,仅供拍板) + +优先级排序: + +1. **先把待评估项从表格单元格提成一条正式待办**(§5 处置记录里加一行,或建 M4 工单), + 补齐归属人 + 时限。当前形态检索不到,等于没登记。 +2. **推荐方案:IP 白名单 优先于 basic auth。** 理由:读者集合当前就是维护者本人; + nginx 侧 `allow/deny` 比 basic auth 少一套凭证要管(而按纪律 6,新增凭证还要走「不回显」流程)。 + 若将来需给外部评审看,再叠加 basic auth。 +3. **若维持公开,则做内容分层**:把 `server-exposure.md`、`ci-runner-setup.md`、 + 安全事件复盘三份移出公开构建(mkdocs `exclude_docs` 或独立私有站), + 契约与 ADR 保持公开。**这三份的敏感度与其余 99 份不在一个档位。** +4. **无论选哪条,补一条 ADR**,让文档站的暴露决策像其它服务一样有归属决策可查 + ——这正是 `server-exposure.md` 缘起段所说「代码侧有 ADR 防『决策变了实现没跟上』, + 服务器侧此前无任何对应机制」要补的那一环。 +5. 顺手更新 §1/§2 的「最后确认」到本次核对日期。 + +--- + +## 6. 所有对不上的差异(按严重度排序) + +| # | 严重度 | 差异 | 声称 | 实测 | 证据 | +| --- | --- | --- | --- | --- | --- | +| D1 | **高** | 发布 checklist 未更新为四份 E2E,且 4/8 步已脱节 | 「已由两份改为四份」 | checklist 本体仍写「M2+M3 两份」 | `iteration-3/08-git-workflow-plan.md:84` | +| D2 | **高** | 真机挂起项数量 | 8 项 | **10 项**(M2 2 + M3 4 + **M3.5 4**) | `device-verification.md` M3.5 节「下列四项」+ 4 条编号项;对比 `feature-checklist.md:221,244` | +| D3 | **中** | 埋点白名单条数 | 42 条 | **41 条** | `EventDictionary.java` `Map.entry(` = 41;错误口径见 `feature-checklist.md:218`、`iteration-3/29:20`、`iteration-3/27:17,27`、`iteration-3/index.md:47` | +| D4 | **中** | api 测试基线 | 379 | **381** | surefire 聚合 40+3+107+100+131;`iteration-3.5/04` 自身已写「379→381」,但 `releases.md` v0.4.0 门禁表与 tag 表仍写 379 | +| D5 | **中** | `releases.md` 内部自相矛盾 | —— | v0.3.0 纪律段写「E2E **双份**回归」,v0.4.0 段写「改为**四份**」 | `releases.md` 两节并存 | +| D6 | **低** | flutter「597 全绿」未披露跳过项 | 597 双绿 | 597 passed **+ 2 skipped** | `flutter test` → `+597 ~2`;跳过项为 `test/smoke/media_upload_smoke_test.dart:133`、`test/smoke/detail_interactions_smoke_test.dart:173`(需 compose 后端 + 环境变量) | +| D7 | **低** | E2E 脚本形态措辞 | 「仓库根 4 份脚本」 | 在 **patbond-flutter** 仓库根,为 `.dart`(`dart run`)非 `.sh`;工作区根无 `*.sh` | `ls /home/lx/workspace/patbond/*.sh` → 无此文件 | + +--- + +## 7. 所有未取证项(诚实清单) + +| # | 项 | 为何未取证 | 补证方式 | +| --- | --- | --- | --- | +| U1 | **E2E 234 断言** | 脚本无断言计数器,静态调用点 207 与声称值不符;差值疑为循环内执行但报告未说明口径 | 在 `check()` 内加 `_asserts++` 并于收尾打印;或明确标注为运行时计数 | +| U2 | **E2E「v0.4.0 零失败」** | 后端六容器未运行(`docker compose ps` 空),本次未复跑四份脚本 | `docker compose up -d` 后串行跑四份(须串行,M3 场景 10 与 M3.5 场景 10 的全量翻页比对会被并发发帖污染) | +| U3 | **契约矩阵「181 格」** | 「单元格」是报告人工定义单位,代码侧无断言锁总数(4 类共 40 个 `@Test`,非一对一) | 若要可复核,在契约测试里加一条总数断言;否则明确标注为文档口径 | +| U4 | **CI 三仓绿** | 未查 Gitea commit status API(本次为本地取证,且不宜在 8 agent 并发时打服务器) | `GET /repos/{owner}/{repo}/commits/{sha}/status` 三仓各一次 | +| U5 | **服务器侧实际暴露面** | 按任务纪律不碰服务器配置;C.2 全部引自 `server-exposure.md` 登记(2026-09-11),非本次实测 | 按该文件 §3「核对方法」在服务器上执行 `ss -tlnp` 等四条命令 | +| U6 | **`check-secrets.sh --all` 三仓 exit 0** | 未复跑(v0.4.0 门禁记录为绿,本次未验) | 三仓各 `sh scripts/check-secrets.sh --all` | +| U7 | **零迁移在存量库上实证** | 需 compose 起于既有 `pgdata` volume;容器未运行 | 与 U2 同一轮 compose 启动时看 Flyway 日志 | + +--- + +## 8. 找到的真实问题清单(按严重度排序) + +### P1(高)发布 checklist 是 M3 时代原稿,4/8 步已脱节,且从未固化进常设文档 + +- **证据**:`iteration-3/08-git-workflow-plan.md:84` 仍写「M2+M3 **两份** E2E」; + §3.3 标题仍标「**草案**(首次发布用,验证后固化进 `git-workflow.md`)」; + `grep "checklist\|发布\|E2E\|PR" docs/development/git-workflow.md` → **零输出**。 +- **影响**:下次发布照单执行会 ①漏跑 M1/M3.5 两份回归、②撞上第 4 步已被分支保护拒绝的 + `push origin main`。修正只以散文存在于 `releases.md` 两个版本小节。 +- **建议**:把 checklist 固化进 `git-workflow.md` 并就地改正 4 步(2 改四份、3/6 标一次性已完成、 + 4 换 PR 流程),iteration-3/08 §3.3 加一行「已被 `git-workflow.md` 取代」。M4 开工前即可完成。 + +### P2(高)真机挂起项漏跟踪 2 项,且其中 1 项前置不可执行 + +- **证据**:`device-verification.md` 登记 **10** 项,`feature-checklist.md:221,244` 只跟踪 **8** 项 + ——M3.5 的 caregiver 改宠物头像、获赞数对账**未进功能清单**。 + 且 caregiver 项前置写「**收口时补 SQL**」,SQL 从未补(造数所需的 + `pet_health.pet_owners` 表与 `caregiver` 角色枚举早已存在:`V3:97,104`; + 授予写法可照抄 `PetAvatarIntegrationTest.java:218` 的 `grantRole`)。 +- **影响**:两项在现行销账流程外,M4 收官不会被发现;caregiver 项即便有人接手也无法开工。 +- **建议**:功能清单补齐 4 项;把 `INSERT INTO pet_health.pet_owners` 造数 SQL 写进前置。 + +### P3(中)埋点白名单实为 41 条,四处文档写 42,且无测试锁总数 + +- **证据**:`EventDictionary.java` `Map.entry(` = **41**(类 Javadoc 自身的分域说明 8+2+8 也等于 18, + 与 41 自洽);文档四处写 42(`feature-checklist.md:218`、`iteration-3/29:20`、 + `iteration-3/27:17,27`、`iteration-3/index.md:47`),错在把 community 增量记为 19(实为 18)。 + `EventDictionaryTest.java`(13 个 `@Test`)**无任何总数断言**。 +- **影响**:M4 要新增 AI 创作域事件,增量必然基于错误基数计算 + (「42 + N」会与实现的「41 + N」持续差 1)。这正是 M3.5 教训里「转述即污染」的同型问题。 +- **建议**:**M4 开工前先修基数**——四处文档改 41,并在 `EventDictionaryTest` 加一条 + `assertThat(WHITELIST).hasSize(41)` 级别的断言(需暴露 size 或用 + `isKnownEvent` 遍历),让此数此后自证。 + +### P4(中)`v0.4.0` 发布门禁表引用的 api 测试数(379)低于实际(381) + +- **证据**:surefire 聚合 **381**、0 失败(在 tag `v0.4.0` = `3cd8005` 工作区实测); + `releases.md` v0.4.0 的 tag 表与门禁表均写 379; + 而 `iteration-3.5/04-contract-freeze-v140.md:8` 自己写的是「379→**381**」。 +- **影响**:门禁证据数字与被 tag 的代码不符。数字虽小,但发布记录是**跨迭代对账基准** + ——M4 收官时「381 → N」的增量核算会从错的起点算。 +- **建议**:`releases.md` 两处 379 改 381(历史记录订正加脚注说明,不静默改)。 + +### P5(中)4 份 `integration_test/` 活体测试完全在自动化门禁之外 + +- **证据**:`flutter test` 的 597 项**不含** `integration_test/`(日志内 `integration_test/` 出现 **0** 次); + 该目录下 4 个文件(`profile_avatar_live_test.dart`、`feed_live_test.dart`、 + `client_ux_live_test.dart`、`publish_live_test.dart`)均 `skip: !enabled` 且需真后端。 +- **影响**:`iteration-3.5/05` 号报告用 `integration_test/profile_avatar_live_test.dart` + 作为「桌面全链路已通」的证据,而这份证据**不被任何门禁重跑**,回归时不会报警。 + 另有 2 个 `test/smoke/*` 在 597 里被静默跳过(D6)。 +- **建议**:要么在发布 checklist 里把这几份列为手动必跑项(与 E2E 同级), + 要么明确标注为「一次性取证、非回归资产」,避免后续报告继续把它当活证据引用。 + +### P6(中)doc 仓无 `.gitignore`,`site/` 不入库的纪律仅靠开发者机器的全局配置兜着 + +- **证据**:`patbond-doc/` 根**无 `.gitignore`**(`cat .gitignore` → 无此文件); + `git check-ignore -v site/` → `/home/lx/.gitignore_global:183:/site` + ——即 `site/` 只被**用户级全局 gitignore** 忽略。 + 而 `git-workflow.md` 的门禁表明确要求「不把 `site/` 落进仓库」。 + 取证期间 `site/` 目录 mtime 为 `14:10`(本次会话期间被并发 agent 重建,本报告未触碰它)。 +- **影响**:换一台机器、或 CI 里执行 `mkdocs build`(默认输出 `site/`)后跑 `git add -A`, + 102 个页面的构建产物会直接入库。纪律写在文档里,仓库侧零强制。 +- **建议**:`patbond-doc/` 加一份最小 `.gitignore`(至少 `site/`)。一行改动消除整类风险。 + (本次未代改——按纪律只写本报告。) + +### P7(低)安全事件催生的三份服务器文档,正随公开文档站发布 + +- **证据**:`server-exposure.md` §2 登记文档站「公开可访问」且「归属决策」列为空; + 该文件本身、`ci-runner-setup.md`、`iteration-3.5/07-security-incident-20260911.md` + 均在 `mkdocs.yml` nav 的 102 项之内(零孤立,即全部发布)。 +- **影响**:端口清单 / 常驻服务 / 内部 API 路径 / 凭证轮换触发条件 / 刚发生的注入路径与处置手法, + 对这台刚被攻击过的主机构成现成侦察材料。待评估项的括注 + 「无凭证内容,但暴露内部架构细节」低估了这一点。 +- **建议**:见 §5.C.4——优先把待评估项提成正式待办(当前只是表格单元格里的一句 `⚠️`,检索不到), + 倾向 IP 白名单;若维持公开则把这三份移出公开构建。**不要在 M4 里继续挂着不决。** + +--- + +## 9. 给 M4 开工的直接结论 + +**可以放心引用的基线**(本次机械复核通过): + +- 三仓 `v0.4.0` 干净同点位:api `3cd8005` / flutter `fbcd734` / doc `5cc6361` +- flutter **597** 测试(记得注明另有 2 项跳过) +- 契约 **v1.4.0**:**32 路径 / 45 操作 / 75 schema**,五份副本字节级同一(md5 `a7081fb8…5801`) +- Flyway **V1~V5**;`community.posts.generation_job_id` **裸列已就位、确无 FK** + (`V5:48`)——**M4 补 FK 时新增 `V6`,不得改 V5**(`git-workflow.md` 禁改已推送迁移) +- ADR **001~022** 连续无缺号 → M4 首个新决策为 **ADR-023** +- 部署 **六容器**(postgres:18 + MinIO + auth/user/pet/community) +- E2E **42 场景**(M1 7 / M2 11 / M3 14 / M3.5 10) +- 文档站:`mkdocs build --strict` exit 0 零 warning,102 页 nav 全覆盖、零孤立、零死链 + +**开工前建议先修的三个基数/流程问题**(都是小改动,但会污染 M4 全程估算): + +1. **埋点白名单 41 而非 42**(P3)——M4 必然新增 AI 创作域事件,基数错则增量全错 +2. **发布 checklist 改四份 E2E 并固化进 `git-workflow.md`**(P1)——M4 收官要发版 +3. **api 测试基线 381 而非 379**(P4)——M4 增量核算的起点 + +**时限压力**:真机 M2 两项挂着 **2026-09-21**(北极星首次出数),今天 **09-14**,剩 7 天; +两项合计 ~55 分钟且步骤齐备,**唯一缺一台 Android 设备**。逾期则首批北极星读数只能标「未验收」。 + +**本报告未做的事**:未 commit / push;未改 `mkdocs.yml` +(本文件需由 `mkdocs.yml` 负责人挂进 iteration-4 导航节,否则不进文档站); +未改任何生产代码、未碰服务器配置。 + +--- + +**取证人**:Evidence Collector +**取证日期**:2026-09-14 +**基线点位**:api `3cd8005` / flutter `fbcd734` / doc `5cc6361`(三仓均 `v0.4.0`) +**裁决汇总**:基线 10 条 → 对上 6 / 对不上 3 / 不可静态复核 1;专项 A/B/C 各有实质发现; +真实问题 **7 个**(高 2 / 中 4 / 低 1);未取证项 **7 个** diff --git a/docs/development/iterations/iteration-4/08-git-workflow-plan.md b/docs/development/iterations/iteration-4/08-git-workflow-plan.md new file mode 100644 index 0000000..5a73e0f --- /dev/null +++ b/docs/development/iterations/iteration-4/08-git-workflow-plan.md @@ -0,0 +1,806 @@ +# M4「AI 创作」Git 工作流与发布流程规划 + +> **角色**:Git Workflow Master | **日期**:2026-09-14 | **性质**:只读调研 + 规划,本报告未执行任何 commit / push / tag / 分支创建 / 远端配置变更。 +> **前置**:v0.4.0 已发布(三仓 tag 齐)。本报告是 [iteration-3/08](../iteration-3/08-git-workflow-plan.md) 的 M4 续篇,并接管其 §3.3「发布 checklist 草案」的固化职责。 + +--- + +## 结论摘要 + +1. **用户交付的 6 条分支保护描述,逐条核实全部为真**(Gitea API 原样贴在 §1.2):api/flutter 的 `main` 均 `protected=true`、`enable_status_check=true`、上下文显式填写、`required_approvals=0`;两仓 `dev` 均未保护;doc 仓 `main` 未保护。 +2. **但填的是错的触发器——这是本报告最重要的发现(§1.4)**。必需上下文是 `CI / backend-test (push)`,而 `push` 触发器被限定在 `branches: [dev]`。推论有两个后果:门禁实际由「推 dev」满足而非 PR 自身;且**任何非 dev 分支 → main 的 PR 将永久无法合并**,与「hotfix 走短命分支 + PR」的既定纪律直接冲突。修复方向与真实上下文字符串见 §1.4 / 拍板 D1。 +3. **发布 checklist 从未固化**:`releases.md:4` 指向的正典是 iteration-3/08 §3.3,标题至今仍是「草案(验证后固化进 git-workflow.md)」,而 `git-workflow.md` 全文 65 行无任何发布/PR/E2E 章节。所有修正只以散文形式活在 `releases.md`,且该文件**自我矛盾**。处置见 §4.0。 +4. **M4 分支策略推荐:继续 dev 直推**,不引入常态 feature 分支——理由之一是 CI 的 `push` 触发器不覆盖 `feat/**`,开分支等于自断快速反馈(§2)。 +5. **契约 v1.4.0 → v1.5.0 需同步 9 处**(doc 2 处 + api 4 份快照 + 4 个守卫常量 + 4×4 计数断言),且**字节级一致性无 CI 保障**、全靠人工 md5 —— 操作顺序见 §3。 +6. **M4 必须新增第五份 E2E 脚本** `test_e2e_m4_manual.dart`,发布回归门禁由四份变五份(§4)。 +7. M4 引入 AI provider 凭证,而 `check-secrets.sh` 的 7 条规则对 `sk-` / `sk-ant-` / 通用 `api_key` 形态**零覆盖**(§5)。 + +--- + +## 1. 核实结果(原始配置取证) + +### 1.1 三仓 HEAD / tag / 远端一致性 —— 全部符合 + +`git ls-remote origin` 与本地 `for-each-ref` 双向比对(2026-09-14): + +| 仓库 | 本地当前分支 | origin/dev | origin/main | tag v0.4.0 → commit | 工作区 | +| --- | --- | --- | --- | --- | --- | +| patbond-api | `dev` @ `3cd8005` | `3cd8005` | `3cd8005` | `46afe8a`(annotated)→ `3cd8005` | clean | +| patbond-flutter | `dev` @ `fbcd734` | `fbcd734` | `fbcd734` | `c963a0a`(annotated)→ `fbcd734` | clean | +| patbond-doc | `main` @ `5cc6361` | —(无 dev) | `5cc6361` | `08fda68`(annotated)→ `5cc6361` | clean | + +三仓 `dev == main == tag`,历史线性,**符合描述**。三个 v0.4.0 均为 **annotated tag**(`git cat-file -t` = `tag`),带完整发布说明正文——延续此惯例。 + +补充事实(用户未提及,非差异但需知悉): + +- **v0.4.0 的 tag 实际打于 2026-09-14 11:17**(`taggerdate`:api/flutter `11:17:35~36`、doc `11:19:17`),tagger `Lixi20`。v0.3.0 打于 `2026-09-10 15:47`。即**发布动作发生在今天上午**,而非 M3.5 收官日 09-11——安全事件导致的 CI 中断修复与发布挤在同一天。 +- api 仓存在本地私有 ref `refs/backup/old-main-ff876bc` → `ff876bc`(v0.3.0 重建 main 时保留的孤儿提交备份)。`ls-remote` 无此 ref ⇒ **仅存于本机**。若换机器或本仓重克隆,该备份即消失。 +- **分支拓扑不对称**:api 本地只有 `dev` 一个分支;flutter 本地另有 `main` @ `030b11f`,**落后 origin/main(`fbcd734`)**。这是个陷阱:flutter 仓若有人 `git checkout main` 会拿到陈旧的 main。M4 期间建议直接删掉 flutter 本地 main(发布走 PR,本地根本不需要 main)。 +- doc 仓远端**确实只有 main**(`GET /branches/dev` → `not found`),不参与发布分支语义 —— 符合描述。 + +### 1.2 分支保护实配 —— 6 条描述全部为真 + +取证方式:`GET /api/v1/repos/zhaoyuxi/{repo}/branches/{branch}`(Gitea 1.26.4,`https://132.232.242.77`,自签证书需 `-k`)。该端点**匿名可读**并直接返回生效的保护字段。原始响应关键字段: + +```text +patbond-api main : protected=true enable_status_check=true + status_check_contexts=["CI / backend-test (push)"] + required_approvals=0 +patbond-api dev : protected=false enable_status_check=false status_check_contexts=[] +patbond-flutter main : protected=true enable_status_check=true + status_check_contexts=["CI / flutter-gates (push)"] + required_approvals=0 +patbond-flutter dev : protected=false enable_status_check=false status_check_contexts=[] +patbond-doc main : protected=false enable_status_check=false status_check_contexts=[] +patbond-doc dev : {"message":"not found"} +``` + +与 `releases.md:112-113` 的表格**逐格一致**,与用户描述**逐条一致**。 + +**取证边界(必须声明)**:`GET /repos/{owner}/{repo}/branch_protections`(完整保护规则对象)**返回 401 `{"message":"token is required"}`**——本机无 Gitea token(已查:无 `~/.gitea*`、无 `~/.netrc`、无 `~/.config/tea`、无 `tea` CLI、环境变量无 token)。因此: + +- ✅ **已证实**:`protected` / `enable_status_check` / `status_check_contexts` / `required_approvals` 四项(上表,来自 `/branches/{branch}`)。 +- ⚠️ **未直接证实**:「**禁直接推送**」的具体机制。响应里的 `user_can_push=false` 是**匿名身份**的结果,不能作为「已认证的 Lixi20 也被禁推」的证据。该结论目前的支撑是间接的:`protected=true` + v0.4.0 **确实被迫走了 PR**(§1.5,两仓各有一个 merged PR,而 v0.3.0 是直推)。 +- **复核命令**(拿到 token 后执行,建议 M4 开工时补做并把响应贴入本节): + + ```bash + # 只需 read:repository 权限的 token(Gitea → Settings → Applications → Generate Token) + for r in patbond-api patbond-flutter patbond-doc; do + echo "=== $r ===" + curl -sk -H "Authorization: token $GITEA_TOKEN" \ + "https://132.232.242.77/api/v1/repos/zhaoyuxi/$r/branch_protections" | + python3 -m json.tool + done + ``` + + 重点看 `enable_push`(false = 禁一切直推)、`enable_push_whitelist` / `push_whitelist_usernames`、`enable_merge_whitelist`、`block_on_official_review_requests`、`required_approvals`、`status_check_contexts`。 + +### 1.3 CI 工作流触发器 —— 符合,但覆盖面是 §1.4 问题的根源 + +三仓均只有 `.gitea/workflows/ci.yml`,**无 `.github/workflows/`**。触发器原文: + +| 仓库 | 文件:行号 | 触发器 | job 名 | +| --- | --- | --- | --- | +| patbond-api | `.gitea/workflows/ci.yml:15-18` | `on: push: branches: [dev]` + `pull_request:`(无分支过滤) | `backend-test`(:26) | +| patbond-flutter | `.gitea/workflows/ci.yml:9-12` | `on: push: branches: [dev]` + `pull_request:`(无分支过滤) | `flutter-gates`(:19) | +| patbond-doc | `.gitea/workflows/ci.yml:5-8` | `on: push: branches: [main]` + `pull_request:`(无分支过滤) | `docs-build`(:16) | + +api `ci.yml:15-18` 原文: + +```yaml +on: + push: + branches: [dev] + pull_request: +``` + +**关键点:`push` 触发器被白名单限定在单一分支**(api/flutter 是 `dev`,doc 是 `main`);`pull_request` 无分支过滤,任何 PR 都会跑。 + +门禁命令与本地门禁同源(`git-workflow.md:58-62`):api = `./mvnw -B clean test`(`ci.yml:55`);flutter = `dart format --output=none --set-exit-if-changed lib test` / `flutter analyze` / `flutter test`(`ci.yml:56-58`);doc = `mkdocs build --strict -d /tmp/site`。三仓首个 step 均为 `sh scripts/check-secrets.sh --all`(api `ci.yml:41-42`、flutter `:31-32`、doc `:22-24`)。 + +三仓 `concurrency: group: ci-${{ github.ref }}` + `cancel-in-progress: true`:push 与 PR 的 `github.ref` 不同(`refs/heads/dev` vs `refs/pull/N/...`)⇒ **两条流水线并行互不取消**,与 §1.5 实测的「同一提交同时挂两个上下文」吻合。 + +### 1.4 ⚠️ 必需状态检查选错触发器 —— **确认成立,且比预想更严重** + +Reality Checker 提出、本节独立复核**确认成立**。取证:`GET /repos/zhaoyuxi/{repo}/commits/{sha}/statuses?limit=50`(匿名可读)。api `3cd8005`(= dev tip = main = v0.4.0)的**全部 9 条** commit status,按时间倒序: + +```text +[success ] ctx='CI / backend-test (pull_request)' 2026-09-14T09:54:37+08:00 runs/77 +[pending ] ctx='CI / backend-test (pull_request)' 2026-09-14T09:47:54+08:00 runs/77 +[success ] ctx='CI / backend-test (push)' 2026-09-14T09:44:05+08:00 runs/73 ← 唯一被要求的上下文 +[pending ] ctx='CI / backend-test (pull_request)' 2026-09-14T09:39:50+08:00 runs/77 +[pending ] ctx='CI / backend-test (push)' 2026-09-14T09:38:34+08:00 runs/73 +[pending ] ctx='CI / backend-test (push)' 2026-09-14T09:38:30+08:00 runs/73 +[failure ] ctx='CI / backend-test (push)' 2026-09-11T10:33:31+08:00 runs/73 +[pending ] ctx='CI / backend-test (push)' 2026-09-11T10:33:29+08:00 runs/73 +[pending ] ctx='CI / backend-test (push)' 2026-09-11T10:33:28+08:00 runs/73 +``` + +flutter `fbcd734` 同构(6 条,`CI / flutter-gates (pull_request)` success `10:02:08` / `CI / flutter-gates (push)` success `09:58:23`)。doc `5cc6361` 只有 `CI / docs-build (push)` success `11:20:44`(doc 无 PR)。 + +**合并后的 combined status**(`/commits/{sha}/status`):`state: success`,含两个上下文 —— 即**每个 dev 提交同时携带 `(push)` 与 `(pull_request)` 两条独立状态**。 + +#### 三条事实推出的结论 + +| # | 事实 | 出处 | +| --- | --- | --- | +| A | 必需上下文 = `CI / backend-test (push)`(**仅此一个**) | §1.2 API 响应 | +| B | `push` 触发器限定 `branches: [dev]` | `api/.gitea/workflows/ci.yml:16-17` | +| C | Gitea 的上下文名格式为 ` / ()` | §1.4 实测两种 event 各自成名 | + +**推论一(门禁语义错位,已成立)**:`(push)` 状态只可能由「推送到 dev」产生。因此 `dev → main` PR 的合并门禁**实际由 dev 的 push 流水线满足**,而非 PR 自身的流水线。`(pull_request)` 上下文**不在必需列表中 ⇒ 它红也不阻塞合并**。M3 当初显式填写上下文名是为堵「留空 = 空集为真 = 放行」的漏洞(`releases.md:116` 记载该理由),但填成了 `(push)`——把「空集漏洞」换成了「错触发器漏洞」。 + +⚠️ 对 v0.4.0 的判断需要修正:`releases.md:47` 写「等 `pull_request` 状态检查转绿后合并」「**分支保护确实要求该检查通过**」——**后半句不成立**。当时真正解除阻塞的是 09:44:05 转绿的 `(push)`;`(pull_request)` 在 09:54:37 才绿,两者在 11:14:55 合并前都已绿,所以**表象正确、机制归因错误**。这正是「文档转述被全链路采信」的又一例。 + +**推论二(更严重,尚未被触发)**:**任何非 `dev` 分支 → `main` 的 PR 将永久无法合并。** 因为其 head 提交从未被推送到 `dev` ⇒ 永远不会产生 `(push)` 上下文 ⇒ 必需检查永远处于「缺失」⇒ 无法合并(除非管理员临时改保护配置)。这与两条既定纪律**直接冲突**: + +- `git-workflow.md:9`:「改动跨多天、有破坏性风险…从最新 dev 拉出 `feat/<主题>` / `fix/<主题>` 分支」 +- iteration-3/08 §3.3 第 8 步:「影响 main 的 hotfix 一律走短命分支 + PR」 + +即**当前配置下 hotfix 路径是死的**。M4 若出现需要绕过 dev 直修 main 的线上问题,会在最紧急的时刻撞上这个死锁。 + +#### 修复方向(真实上下文字符串,非猜测) + +正确的必需上下文字符串已从 §1.4 的 CI 实跑记录中取到真名: + +| 仓库 | 应填的上下文(实测真名) | +| --- | --- | +| patbond-api | `CI / backend-test (pull_request)` | +| patbond-flutter | `CI / flutter-gates (pull_request)` | + +**推荐(拍板 D1)**:把必需上下文**替换**为 `(pull_request)` 单值,而非追加。理由: + +1. **语义正确**:PR 的合并门禁应由 PR 自己的流水线把关。 +2. **解锁 hotfix 路径**:`pull_request` 触发器无分支过滤(`ci.yml:18`),任何分支的 PR 都会跑 ⇒ `feat/*` / `hotfix/*` → main 的 PR 可正常合并。 +3. **不重新引入空集漏洞**:仍是显式命名,只是名字改对。 +4. **不损失保证**:有人会担心失去「dev tip 自身 push 绿」的冗余。但 ff-only 合并下 PR head ≡ dev tip,两条流水线跑的是同一棵树、同一套命令 ⇒ 该保证是**重复的**,不是额外的。 +5. **若改为「两者都要」**:严格性更高,但 hotfix 死锁**依然存在**(`(push)` 仍缺失)⇒ 不解决推论二,不推荐。 + +**变更后必做的一次性验证**(否则等于换一个未验证的配置):改配置后在任一仓开一个**空改动的试验 PR**(或就用 v0.5.0 的正式 PR),确认 Gitea 的 PR 页面把 `(pull_request)` 显示为 required 且它红时合并按钮确实禁用。M3 的教训是「配置改了但语义没验」——这次要在 v0.5.0 发布**之前**验完,不要拿正式发布当试验场。 + +**注意上下文名的脆弱性**:字符串由 `name: CI`(workflow 名)+ job key(`backend-test`)+ event 三部分拼成。**改任何一个都会使必需上下文永久缺失、PR 永久不可合并**。见 §5 的预案。 + +### 1.5 v0.4.0 PR 流程实证 —— 符合描述 + +`GET /repos/zhaoyuxi/{repo}/pulls?state=all`: + +| 仓库 | PR | 标题 | base ← head | merged | merge_commit_sha | merged_at | +| --- | --- | --- | --- | --- | --- | --- | +| patbond-api | #1 | `v0.4.0 M3.5 体验补齐` | `main` ← `dev` | true | `3cd8005` | 2026-09-14T11:14:55+08:00 | +| patbond-flutter | #1 | `v0.4.0 M3.5 体验补齐` | `main` ← `dev` | true | `fbcd734` | 2026-09-14T11:15:05+08:00 | + +**`merge_commit_sha` 恰等于 head sha** ⇒ **Fast-forward、零合并提交**,与描述一致。`ls-remote` 存在 `refs/pull/1/head`(无 `refs/pull/1/merge`)。两仓各仅 1 个 PR ⇒ 历史上从未有过其他 PR,**「非 dev 分支 → main」这条路径从未被走过**,故 §1.4 推论二至今未暴露。 + +「PR 会自动跟随 dev 新提交重跑」——**间接证实**:`3cd8005` 上有 **3 条** `(pull_request)` 状态(09:39:50 pending → 09:47:54 pending → 09:54:37 success),同一 run 77 被重复上报,与 `releases.md:48` 描述的「发布途中 E2E 脚本提交推入 dev,PR 随即重跑」吻合。 + +**CI 当前并非红**:三仓五个上下文在 09-14 全部 `success`(api 两个、flutter 两个、doc 一个)。iteration-3.5/04 §7.3 记载的 `3cd8005` **`failure`("Failing after 2s",runner 装配级故障)** 确实存在于 `2026-09-11T10:33:31`,但**同一 run 73 于 2026-09-14 09:38 重跑并于 09:44:05 转绿** ⇒ 该 runner 故障已闭环,不是 M4 的开工障碍。 + +### 1.6 契约快照锁实配 —— 符合,但**字节一致性无 CI 保障** + +正典:`patbond-doc/docs/api/openapi.yaml`(`info.version: 1.4.0`,见该文件 `:4`)。api 侧四份快照 + 正典**五路 md5 全等**: + +```text +a7081fb84f1207eef579ab94025f5801 patbond-doc/docs/api/openapi.yaml +a7081fb84f1207eef579ab94025f5801 patbond-api/patbond-auth/src/test/resources/contract/openapi-v1.4.0.yaml +a7081fb84f1207eef579ab94025f5801 patbond-api/patbond-pet/.../openapi-v1.4.0.yaml +a7081fb84f1207eef579ab94025f5801 patbond-api/patbond-user/.../openapi-v1.4.0.yaml +a7081fb84f1207eef579ab94025f5801 patbond-api/patbond-community/.../openapi-v1.4.0.yaml +``` + +注意**文件名不同**:正典是无版本号的 `openapi.yaml`,快照是 `openapi-v1.4.0.yaml`(内容字节相同)。 + +四个守卫常量 + 四组计数断言(升版必改的 8 个文件): + +| 模块 | `RESOURCE` 常量 | 计数断言(version / paths / operations / schemas) | +| --- | --- | --- | +| patbond-auth | `OpenApiContract.java:39` | `AuthContractConformanceTest.java:401-404` → `1.4.0` / 32 / 45 / 75 | +| patbond-pet | `OpenApiContract.java:35` | `ContractConformanceTest.java:757-760` → 同上 | +| patbond-user | `OpenApiContract.java:39` | `MediaContractConformanceTest.java:248-251` → 同上 | +| patbond-community | `OpenApiContract.java:39` | `CommunityContractConformanceTest.java:524-527` → 同上 | + +**关键缺口**:`./mvnw clean test` 只读 api 仓内文件 ⇒ CI 能发现「api 内部快照与守卫不自洽」,**但完全无法发现「api 快照与 doc 正典不一致」**。跨仓字节一致性**纯靠人工 md5 比对**,无自动门禁。这是 M4 升版最容易静默漂移的一环(§3 给出强制校验命令,§6 拍板 D5 提议补自动化)。 + +### 1.7 E2E 脚本清单与命名 —— 四份成立,但**命名不齐** + +`patbond-flutter` 仓根目录(`ls` 实测): + +| 迭代 | 文件名 | 大小 | 场景数 | +| --- | --- | --- | --- | +| M1 | `test_e2e_manual.dart` ⚠️ **无版本号** | 11015 | 7 | +| M2 | `test_e2e_m2_manual.dart` | 27886 | 11 | +| M3 | `test_e2e_m3_manual.dart` | 51212 | 14 | +| M3.5 | `test_e2e_m35_manual.dart` | 45758 | 10 | + +**差异**:M1 那份叫 `test_e2e_manual.dart`,**不是** `test_e2e_m1_manual.dart` —— 用户描述的「延续 `test_e2e_m4_manual.dart` 口径」对 M2/M3/M3.5 成立,M1 是例外。回归清单变五份后,一个不带版本号的名字最容易被误读成「总入口脚本」。见拍板 D7。 + +**运行方式**(`test_e2e_m35_manual.dart:9` 头注释):`dart run test_e2e_m35_manual.dart`;前置为 api 仓 `docker compose up -d --build`(六容器 postgres + minio + auth:8081 + user:8082 + pet:8083 + community:8084)。 + +**这些脚本与 CI 门禁的关系(三条,M4 新脚本必须遵守)**: + +1. **不被 `flutter test` 执行**:脚本在仓根而非 `test/` ⇒ `flutter test`(`ci.yml:58`)不会收集它们。这是有意的(需要活的后端),保持现状。 +2. **被 `flutter analyze` 检查**:`analysis_options.yaml` 只 `include: package:flutter_lints/flutter.yaml`,**无任何 exclude** ⇒ 分析器覆盖整包含仓根 `.dart` 文件。故新脚本**必须过 analyze**,且需照既有惯例在文件头加 `// ignore_for_file: avoid_print`(`test_e2e_m35_manual.dart:3` 原文即如此)。 +3. **不被格式门禁检查**:`dart format --output=none --set-exit-if-changed lib test`(`ci.yml:56`)**只覆盖 `lib` 与 `test`**,仓根脚本不在范围内。⇒ 新脚本格式跑偏不会红 CI,但建议仍手动 `dart format` 保持一致。 + +另有 4 个 `integration_test/*.dart`(`client_ux_live_test.dart` / `feed_live_test.dart` / `profile_avatar_live_test.dart` / `publish_live_test.dart`),是真机/活后端集成测试,与发布回归的五份 E2E 是**不同体系**,不计入门禁计数。 + +### 1.8 其他差异与既存缺口 + +以下均为**本报告独立实测发现**,不在用户描述范围内,但属 Git 工作流职责域: + +#### (1) ⚠️ flutter 仓提交前缀大面积违反规范 + +`git-workflow.md:13` 原文:「格式:`<前缀>: <中文主题>`,前缀取 `feat` / `fix` / `refactor` / `docs` / `test` / `chore`」。 + +flutter 仓全部 43 个提交的前缀普查(`git log --format='%s' | sed 's/[::].*//' | sort | uniq -c`): + +```text + 20 新增 ← 违反 + 5 fix + 4 修复 ← 违反 + 3 feat + 2 test + 2 chore + 1 重构 ← 违反 + 1 style ← 前缀不在白名单 + 1 新增静态演示界面 / 1 完善README / 1 Update project README / 1 update README.md / 1 Initial ... +``` + +即 **25/43 用中文前缀**(新增 20 + 修复 4 + 重构 1),**规范与实况反向**——实况才是主流。api 仓相反,全用英文(`feat` / `test` / `chore` / `refactor`);doc 仓全用 `docs`。 + +另有**规范未覆盖的 scope 形态**已在实用:api `test(contract): …`、doc `docs(api): …` / `docs(architecture): …`。`git-workflow.md:13` 的格式串里没有 scope。 + +⇒ 规范文档与三仓实况三方不一致,M4 需收口(拍板 D6)。 + +#### (2) 凭证防泄漏第一层(pre-commit)**三仓全部未启用** + +`git-workflow.md:40-47` 要求「每人每仓启用一次」`git config core.hooksPath scripts/hooks`。实测: + +```text +patbond-api core.hooksPath: (unset) scripts/hooks/pre-commit: 存在 +patbond-flutter core.hooksPath: (unset) scripts/hooks/pre-commit: 存在 +patbond-doc core.hooksPath: (unset) scripts/hooks/pre-commit: 存在 +``` + +⇒ **钩子脚本入库了但一处都没挂上**,第一层完全未生效,当前只有 CI 兜底(第二层)在防。规范写的是「推荐」不是「强制」,故非违规,但 M4 要落 AI provider 凭证(§5),第一层的价值从「锦上添花」变成「拦在 push 之前」。三条 `git config` 命令即可修复(拍板 D9)。 + +三仓 `scripts/check-secrets.sh` **md5 全等**(`0d4deed251e9161e33a4f1126def4927`)⇒ 副本同步纪律执行到位。 + +#### (3) ⚠️ patbond-doc 仓**完全没有 `.gitignore`** + +实测:`cat .gitignore` → 「没有那个文件或目录」。而本地存在 `site/`(mkdocs 默认输出目录)。 + +`git -c core.excludesFile=/dev/null check-ignore -v site/` → **未被仓库规则忽略**;仅 `git check-ignore -v site/` 命中 **`/home/lx/.gitignore_global:183`** —— 即**只靠本机用户级全局忽略文件屏蔽**。 + +- 当前 `git ls-files site/ | wc -l` = **0**(尚未误提交,工作区 clean)。 +- 风险:换机器、新 clone、或任何未配置全局忽略的环境下,一次 `git add -A` 就会把整个 `site/` 构建产物提交进去(约百余文件)。 +- 对比:api 仓有 `.gitignore`(`:1` `target/`、`:10-11` 本地 `application.yml` + `.sample` 例外、`:14` `.env`),flutter 仓有 `.gitignore`(`build` / `.dart_tool` 均 repo-ignored)。**只有 doc 仓是裸的。** +- 这与 `git-workflow.md:62` 的「不把 `site/` 落进仓库」是同一条纪律 —— 但该纪律**只写在文档里,没有机器保障**。 + +⇒ 一行修复(doc 仓新建 `.gitignore` 写入 `site/`),列为 M4 第一波待办(拍板 D8)。本次**只规划不改**。 + +#### (4) 数字口径矛盾(三处,需在 v0.5.0 发布记录中统一) + +本报告**未运行测试**,故下列数字**非本报告实测**,仅记录来源冲突,供 PM/RC 收口: + +| 项 | `releases.md` / `feature-checklist.md` 记载 | 其他来源 | 判断 | +| --- | --- | --- | --- | +| api 测试数 | **379**(`releases.md:17` / `:35`、`feature-checklist.md:5`) | **381**(iteration-3.5/04 §7.2 原文「BUILD SUCCESS,381 测试全绿」;该报告同时说明测试 379→381 因新增 2 个矩阵方法) | **381 应为准**;379 是冻结前口径,被下游照抄 | +| E2E 断言数 | **234**(`releases.md:36`) | **226**(Evidence Collector 机械计数:210 个 `check(` + M1 的 16 个 `✗`) | 倾向 **226**;本报告未复核计数方法 | +| 真机挂起项 | v0.4.0 段落列「M2 两项 + M3 四项 + M3.5 新增两项」= 8 | **10 项**(Evidence Collector) | 未复核,以 EC/RC 口径为准 | +| 埋点白名单 | 42 | **41** | 未复核,以 EC/RC 口径为准 | + +E2E **场景数 42 正确**(7+11+14+10),本报告独立核算一致。 + +⇒ 纯文档口径问题,不影响 Git 流程,但 v0.5.0 发布记录**不要再照抄**上游数字,一律以当次实跑输出为准(写进 §4 checklist)。 + + +## 2. M4 分支与提交规划 + +### 2.1 分支策略:**继续 dev 直推**(不引入常态 feature 分支) + +推荐 **dev 直推为默认**,仅在两种情形开短命分支。理由按证据强度排序: + +1. **CI 的 `push` 触发器不覆盖 feature 分支**(`ci.yml:16-17` `branches: [dev]`)⇒ 推到 `feat/xxx` **不会跑任何 CI**。开分支等于自断快速反馈,除非每个分支都立刻开 PR 换 `pull_request` 触发(`ci.yml:18` 无分支过滤,这条是通的)。这是本项目特有的、比通用最佳实践更强的约束。 +2. **既有纪律本就如此**:`git-workflow.md:9` 把 feature 分支限定为「跨多天 / 有破坏性风险 / 多人并行同仓」三种情形,M1–M3.5 四个迭代全程 dev 直推,43(flutter)/ 数十(api)提交零合并提交、历史线性。没有出现需要分支的问题。 +3. **单人开发**:ADR-011 的 PR 强制条款已在 iteration-3/08 §6 #1 降级为「两人并行同仓」与「影响 main 的变更」两种情形。M4 若仍是单人推进,feature 分支的核心收益(隔离并行)不存在。 + +**例外——建议开短命分支的两种 M4 情形**: + +| 情形 | 分支名 | 处置 | +| --- | --- | --- | +| **AI provider 接入探针(spike)**:第三方 SDK/HTTP 选型、prompt 迭代、成本与延迟实测,大概率产生大量废弃代码 | `spike/ai-provider` | 探完**不合并**,结论写报告,实现另起干净提交直推 dev。分支删除。 | +| **AI 创作可能触发的跨模块重构**(如 media 域被复用为 AI 产物存储) | `refactor/<主题>` | 完成后 `git rebase dev` 整理为原子提交序列,`--ff-only` 合回 dev 并删分支(`git-workflow.md:9` 的「短命分支,不留长期分叉」) | + +两种情形都**不要**从 feature 分支直接开 PR 到 `main` —— §1.4 推论二下那种 PR 永久不可合。 + +**dev 必须随时可构建**(`git-workflow.md:7`):M4 的契约升版、新模块接入等「一改就全红」的动作必须**攒成单个原子提交**再推,不要分两次推让 dev 中途红(§3 详述)。 + +### 2.2 提交粒度与信息规范 + +**粒度**(沿 `git-workflow.md:17`「一次提交做一件事,可独立回退」)。M4 具体切法建议: + +| M4 工作项 | 建议提交粒度 | +| --- | --- | +| Flyway 迁移(若有) | **迁移 + 其验证集成测试 = 一个提交**(沿 T3-01 先例:`feat: Flyway V5 community schema 基线 + 迁移验证集成测试`)。迁移一旦推入 dev 即不可变(`git-workflow.md:33`),故必须与验证同时落地 | +| 新模块骨架(若有) | 单独一个提交(沿 T3-02 `feat: 新建 patbond-community 模块骨架`),含根 pom `` 一行 | +| 每个对外端点 | 一个 `feat:` 提交,含实现 + 单测 + 集成测试 | +| **契约升版 v1.5.0** | **doc 侧一个提交、api 侧一个原子提交**,两侧都不与业务代码混(§3) | +| 客户端每页 | 一个提交(沿 flutter 惯例,一页一提交) | +| 第五份 E2E 脚本 | 单独一个提交(沿 v0.4.0 先例 `新增:M3.5 E2E 烟囱脚本…(v0.4.0 发布门禁)`) | +| 报告入档 | doc 仓按波次批量一个 `docs:` 提交(沿既有惯例) | + +**信息规范**(M4 起统一,见拍板 D6): + +```text +<前缀>[()]: <中文主题>(<工单号>[,ADR-0xx]) + +- 关键改动列表 +- 门禁:<命令输出结论>(测试数量与结果) +``` + +- 前缀白名单:`feat` / `fix` / `refactor` / `docs` / `test` / `chore` / `style`(`style` 已在 flutter 实用,补进白名单)。 +- **scope 可选**,正式承认 `test(contract):` / `docs(api):` 形态。 +- 主题用中文一句话;引用 M4 工单号(`T4-xx`)与 ADR 编号。 +- 正文必须带**验收证据**(`git-workflow.md:16`)——测试数、门禁命令结论。这是 M4 数字口径不再漂移的第一道防线(§1.8(4))。 +- **不改写已推送历史**(`git-workflow.md:34`);**不 force push `dev`/`main`**(`:32`)。 + +### 2.3 三仓同步点(契约不漂移的机制) + +M4 有 **4 个强同步点**,其中契约升版是唯一「必须同一时间窗内完成」的: + +| 同步点 | 涉及仓 | 顺序约束 | 漂移检测 | +| --- | --- | --- | --- | +| **S1 契约升版 v1.5.0** | doc(正典)→ api(4 份快照+守卫) | **doc 先,api 后**,同一工作时段完成 | 五路 `md5sum` 人工比对(**无 CI 保障**,§1.6)+ api 侧守卫计数断言 | +| **S2 客户端消费新契约** | api → flutter | api 端点落地并契约冻结后,flutter 才接线 | flutter 数据层测试对齐契约操作数(沿 T3-12「契约 v1.3.0 十九操作全覆盖」惯例) | +| **S3 防泄漏规则增补** | api + flutter + doc **三仓同时** | 无先后,但必须**同批提交** | `md5sum` 三仓 `scripts/check-secrets.sh` 全等(`git-workflow.md:38` 明文要求;当前全等 ✅) | +| **S4 发布** | api + flutter(PR)→ doc(记录+tag) | 两仓 PR 合并并打 tag 后,doc 记录+打 tag | 三仓 tag 同名 v0.5.0,互为对照 | + +**S1 防漂移的硬机制**(因 CI 不管跨仓,必须靠人工纪律 + 一条命令): + +```bash +WS=<你的工作区> # 例:~/workspace/patbond +V=1.5.0 +md5sum "$WS/patbond-doc/docs/api/openapi.yaml" \ + "$WS"/patbond-api/patbond-{auth,pet,user,community}/src/test/resources/contract/openapi-v$V.yaml | + awk '{print $1}' | sort -u | wc -l +# 必须输出 1(五路字节全等);输出 ≥2 即已漂移,禁止提交 +``` + +把这条命令写进 M4 契约升版工单的验收条件,并在 api 侧升版提交的正文里贴出 md5 值(沿 T3.5-07 先例:其提交正文原文含 `md5 与正典逐一比对一致 a7081fb84f1207eef579ab94025f5801`)。 + +**S1 的一个易漏点**(M3.5 踩过,已写入代码注释):`/api/v1/me` 的契约守卫在 **patbond-auth** 模块,而实现在 patbond-user。M4 若再扩 `/me` 面,先看 auth 模块。 + + +## 3. 契约 v1.5.0 升版的 CI 联动操作顺序 + +### 3.1 升版实际要动 9 处(清单) + +| # | 位置 | 改什么 | +| --- | --- | --- | +| 1 | `patbond-doc/docs/api/openapi.yaml:4` | `version: 1.4.0` → `1.5.0` | +| 2 | 同文件 `:5+` `info.description` | 追加 **1.5.0 M4 冻结**段落 + 重申「对 v1.4.0 纯增量」承诺 | +| 3 | `patbond-doc/docs/api/index.md:3` | 「(OpenAPI 3,v1.4.0),当前 32 路径 / 45 操作」→ 新版本与新计数 | +| 4 | 同文件 `:36` | 冻结纪律行追加「AI 创作域已冻结(1.5.0)」 | +| 5 | api ×4 快照文件 | 新增 `openapi-v1.5.0.yaml`(正典字节副本)、**`git rm` 旧 `openapi-v1.4.0.yaml`** | +| 6 | api ×4 `OpenApiContract.java` | `RESOURCE` 常量:auth`:39` / pet`:35` / user`:39` / community`:39`,值改 `/contract/openapi-v1.5.0.yaml`;顺带改类 javadoc 里的版本字样 | +| 7 | api ×4 conformance test | 计数断言 4 行 × 4 文件:auth`:401-404` / pet`:757-760` / user`:248-251` / community`:524-527` | +| 8 | api ×N conformance test | 新增操作的响应矩阵入场(新格数) | +| 9 | flutter 数据层测试 | 对齐新操作数(S2,可延后到客户端波次) | + +**旧快照删除是既定纪律**(T3-19 先例、T3.5-07 复述):守卫只认一份,保留旧快照是死重,历史版本由 git 与 doc 仓承载。 + +### 3.2 操作顺序(避免 CI 红的关键:**api 侧必须是单个原子提交**) + +理解为什么能避免红,先记住一条已核实的事实(§1.6):**api 的 CI 只读 api 仓内文件**。它检测的是「快照 ↔ 守卫 ↔ 矩阵」三者**内部自洽**,不检测与 doc 正典的一致性。所以: + +- doc 与 api 之间**没有 CI 层面的先后依赖**,doc 先推不会让 api 红,api 后推也不会让 doc 红。 +- 但 **api 仓内部**只要三者中任一处未同步,`./mvnw clean test` 立即红(守卫的 `info.version` 断言就是为此设计的)。⇒ **红与不红只取决于 api 侧是否原子提交。** + +推荐顺序(延续 T3.5-07 实证路径:doc `5f02909` 先,api `3cd8005` 后): + +**第 0 步 — 前置(契约先行纪律)** +`docs/api/index.md:36` 明文要求「契约变更须先改本文件目录下的 OpenAPI,再改实现」。故 M4 的端点实现落地前,先在 doc 侧定型契约(可以是草案报告,正式升版在实现定型后)。 + +**第 1 步 — doc 仓:升正典,单独提交** +```bash +cd $WS/patbond-doc +# 改 openapi.yaml 的 version + description、index.md 的版本与计数、冻结纪律行 +python3 -c "import yaml;yaml.safe_load(open('docs/api/openapi.yaml'))" # 解析必须通过 +mkdocs build --strict -d /tmp/site # exit 0 零 warning +sh scripts/check-secrets.sh --all # exit 0 +git add docs/api/openapi.yaml docs/api/index.md +git commit -m "docs(api): M4 契约冻结 v1.5.0——AI 创作域(T4-xx)" +git push origin main # doc CI 只跑 mkdocs strict + secret scan,与 api 无关 +``` +补充校验(沿 T3.5-07 §7.2 惯例,建议保留):全部 `$ref` 可解析、`operationId` 无重复无缺失、零未引用 schema、`tags` 声明与使用双向闭合。 + +**第 2 步 — 记录正典 md5(后续所有比对的基准)** +```bash +md5sum $WS/patbond-doc/docs/api/openapi.yaml +``` + +**第 3 步 — api 仓:一次性改完 5~8 项,本地全绿后才提交(⚠️ 不要中途 commit)** +```bash +cd $WS/patbond-api +CANON=$WS/patbond-doc/docs/api/openapi.yaml +for m in auth pet user community; do + cp "$CANON" patbond-$m/src/test/resources/contract/openapi-v1.5.0.yaml + git rm -q patbond-$m/src/test/resources/contract/openapi-v1.4.0.yaml +done +# 改 4 个 RESOURCE 常量、4×4 计数断言、新增操作矩阵(见 §3.1 的 6/7/8) +``` +提交**前**必须过的三道校验: +```bash +# (a) 五路字节全等 —— 唯一能防跨仓漂移的检查,无 CI 兜底 +md5sum "$CANON" patbond-{auth,pet,user,community}/src/test/resources/contract/openapi-v1.5.0.yaml | + awk '{print $1}' | sort -u | wc -l # 必须为 1 + +# (b) 全量测试(守卫 + 矩阵) +JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test # BUILD SUCCESS,0 失败 + +# (c) 防泄漏 +sh scripts/check-secrets.sh --all # exit 0 + +# (d) 确认没有残留旧快照 +git ls-files '*openapi-v*' | sort # 只应出现 4 个 v1.5.0 +``` +三道全过再一次性提交: +```bash +git commit -m "test(contract): v1.5.0 快照四模块同步 + AI 创作矩阵入场(T4-xx) + +- 四模块快照 openapi-v1.4.0.yaml → openapi-v1.5.0.yaml,删旧文件(守卫只认一份) +- 四份守卫期望升版:1.4.0/32/45/75 → 1.5.0/<路径>/<操作>/ +- 矩阵 181 → <新格数> 格 +- md5 与正典五路逐一比对一致 +- 门禁:mvnw clean test BUILD SUCCESS, 测试全绿;check-secrets --all exit 0" +git push origin dev +``` + +**第 4 步 — 等 dev 的 `(push)` CI 转绿**,确认 `CI / backend-test (push)` 为 success 再继续后续工单: +```bash +SHA=$(git rev-parse HEAD) +curl -sk "https://132.232.242.77/api/v1/repos/zhaoyuxi/patbond-api/commits/$SHA/status" | + python3 -m json.tool +``` + +**第 5 步 — flutter 侧(S2,可延后)**:客户端数据层测试对齐新操作数,单独提交。 + +### 3.3 反模式(会红 CI,明确禁止) + +| 反模式 | 后果 | +| --- | --- | +| 只 `cp` 新快照就 commit,守卫常量下一提交再改 | 守卫仍指向已被 `git rm` 的 v1.4.0 → `契约快照缺失` 抛错,**dev 全红** | +| 只改守卫版本断言,没换快照文件 | `info.version` 断言 `1.5.0` vs 快照 `1.4.0` → 四模块全红 | +| 只同步 2~3 个模块 | 未同步模块红。**四个模块必须同批**:auth / pet / user / community | +| 手改 api 侧快照而非从正典 `cp` | 字节漂移,md5 不等;CI **不会发现**(§1.6),漂移静默进库 —— 最危险的一种 | +| 新增操作只升计数不进矩阵 | 计数断言绿,但「未声明字段即漂移」类断言红(M3.5 曾一次性红 11 格) | +| 保留旧快照「以防万一」 | 违反 T3-19 纪律;且 `git ls-files '*openapi-v*'` 会暴露死重 | + +### 3.4 M4 若新增 api 模块:契约成本会从 4 处变 5 处 + +已核实 iteration-3/08 §5.1 的「新模块 CI 零成本」结论**至今仍准确**:根 pom `` 现为 common/user/auth/pet/community 五个,`ci.yml:55` 是根反应堆 `./mvnw -B clean test`,新增模块只需根 pom 加一行、**`ci.yml` 零改动**。 + +但**契约侧不是零成本**:当前四个模块各自持有一份 `OpenApiContract.java` + 快照(同构副本纪律,与 `BearerAuthFilter` 同)。若 M4 新建模块**也承载契约一致性测试**,升版就要同步 **5 份快照 + 5 个常量 + 5 组断言**,每次升版成本 +25%。 + +⇒ 建议:**新模块默认不复制契约框架**,仅当它确实需要对冻结契约做一致性断言时才复制;其端点的契约矩阵可挂在既有模块(`/me` 系挂 auth 就是先例)。见拍板 D3。 + +**Flyway**:现有 V1~V5 **全部位于 `patbond-user/src/main/resources/db/migration/`**(含 pet_health 与 community 的 schema),即**迁移集中由 user 模块承载**。M4 若需新表,下一个版本号是 **`V6__*.sql`**,仍放该目录(沿 5/5 先例),**不要**在新模块另起迁移目录(会导致两个 Flyway 位置,历史表版本冲突)。已推送的 V1~V5 不可修改(`git-workflow.md:33`)。 + + +## 4. v0.5.0 发布 checklist + +### 4.0 先解决「流程该固化到哪」与双处矛盾 + +**问题(Evidence Collector 提出,本报告独立复核确认)**:发布流程目前**没有一份可信的单一来源**。 + +已核实的三条事实: + +1. **`releases.md:4` 指向的正典是一份「草案」**:原文「按 `[发布 checklist](iterations/iteration-3/08-git-workflow-plan.md)`(§3.3)执行」,而该节标题至今是「**§3.3 发布 checklist 草案(首次发布用,验证后固化进 git-workflow.md)**」。 +2. **固化从未发生**:本报告通读 `git-workflow.md` 全文 **65 行**,章节仅 5 个——分支模型(:5) / 提交信息约定(:11) / 禁止事项(:28) / 凭证防泄漏(:36) / 提交前本地门禁(:56)。**无任何发布、PR、E2E、checklist 内容**。 +3. **iteration-3/08 §3.3 的 8 步里 4 步已过期**: + - 第 2 步原文「跑 **M2+M3 两份** E2E 烟囱脚本」→ 实际已是**四份**(M1/M2/M3/M3.5),M4 后为五份; + - 第 3 步(命名统一 + api main 重建)→ 一次性项,已完成,不再重复; + - 第 4 步「`git checkout main && git merge --ff-only dev && git push origin main`」→ **保护启用后该命令必被拒**; + - 第 6 步(启用分支保护)→ 一次性项,已完成。 + +4. **`releases.md` 自我矛盾**:`:36` 与 `:56` 写「**四份**」E2E,但 `:131`(「发布后生效的纪律」的前瞻步骤 1)仍写「完成 checklist 第 1~2 步(三仓 CI 绿 + **E2E 双份回归** PASS)」。**错在前瞻侧**——照 `:131` 执行会只跑 M2+M3,**漏跑 M1 的 7 场景 + M3.5 的 10 场景,共 17 个场景**(M4 后漏跑 17 + M4 新增份数)。 + +⇒ 一份「草案」被当正典、修正散落在发布记录的散文里、且该散文自我矛盾。**这正是 M3.5「转述被全链路采信」教训的同类结构。** + +#### 推荐处置(拍板 D2) + +**把发布流程固化为 `git-workflow.md` 的新增章节「## 发布流程(dev → main)」**,理由: + +- `git-workflow.md` 是**跨迭代常设规范**,本就是 iteration-3/08 §3.3 自己指定的固化目标(「验证后固化进 git-workflow.md」),只是没执行; +- 迭代报告(iteration-3/08)是**历史快照**,不应承担常设正典职责——它写的是「M3 时的规划」,随时间必然过期; +- `releases.md` 定位是「每次发布**追加一条记录**」(`:3`),不是流程正典。 + +配套三个动作(本报告不执行,建议由主会话统一处置): + +| 动作 | 目标文件 | 内容 | +| --- | --- | --- | +| A | `git-workflow.md` | **新增**「## 发布流程(dev → main)」章节,正文为 §4.1 的 checklist | +| B | `iteration-3/08-git-workflow-plan.md` §3.3 | **标题加历史标注**:「(**已于 M4 固化进 git-workflow.md,本节仅存历史,勿照此执行**)」。不删改正文(历史报告不改写) | +| C | `releases.md:4` + `:131` | `:4` 的链接改指 `git-workflow.md` 的新章节;`:131` 的「E2E 双份回归」**改为「E2E 全份回归(份数以 git-workflow.md 为准)」**——不写死份数,避免再次出现「数字散落多处」 | + +**防复发设计**:份数**只在一处写死**(`git-workflow.md` 的发布流程章节),其他所有位置一律表述为「全份 E2E 回归」并链接过去。M4 新增第五份时只改那一处。 + +### 4.1 v0.5.0 发布 checklist(可直接执行) + +> **适用前提**:`main` 已启用保护、禁直推(§1.2);必需状态检查上下文**已按拍板 D1 修正为 `(pull_request)` 并完成一次性验证**(§1.4)。若 D1 未采纳,第 4 步的门禁语义仍是错位的,见 §5.1。 +> **变量**:`WS=<你的工作区>`,`GITEA=https://132.232.242.77`(自签证书,`curl` 需 `-k`)。 + +#### 第 1 步 — 冻结与本地门禁(三仓) +```bash +# api +cd $WS/patbond-api && git status --porcelain # 必须为空 +JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test # BUILD SUCCESS,0 失败 +sh scripts/check-secrets.sh --all # exit 0 +# flutter +cd $WS/patbond-flutter +dart format --output=none --set-exit-if-changed lib test # 0 changed +flutter analyze # No issues +flutter test # All tests passed +sh scripts/check-secrets.sh --all # exit 0 +# doc +cd $WS/patbond-doc && mkdocs build --strict -d /tmp/site # exit 0 零 warning +sh scripts/check-secrets.sh --all # exit 0 +``` +**记录实跑输出的测试数**(api / flutter 各一个数字),后续发布记录**一律用这两个数**,不得照抄上游文档(§1.8(4) 的 379/381 就是照抄事故)。 + +#### 第 2 步 — 三仓 CI 绿(查 API,不看网页印象) +```bash +for r in patbond-api patbond-flutter patbond-doc; do + cd $WS/$r; SHA=$(git rev-parse HEAD) + echo "=== $r $SHA ===" + curl -sk "$GITEA/api/v1/repos/zhaoyuxi/$r/commits/$SHA/status" | + python3 -c "import sys,json;d=json.load(sys.stdin);print(' state:',d['state']);[print(' ',s['status'],repr(s['context'])) for s in d['statuses']]" +done +``` +放行标准:三仓 `state: success`。**必须是当前 HEAD 的状态**——若 dev 在此期间又有提交推入,重跑本步(`releases.md:57` 的教训:不要用旧的绿色状态放行)。 + +#### 第 3 步 — E2E **五份**全份回归(发布门禁核心) +```bash +cd $WS/patbond-api && docker compose up -d --build # 六容器 +docker compose logs postgres | grep -i flyway # 记录 Current version(零迁移/新迁移均需取证) +cd $WS/patbond-flutter +dart run test_e2e_manual.dart # M1 7 场景 +dart run test_e2e_m2_manual.dart # M2 11 场景 +dart run test_e2e_m3_manual.dart # M3 14 场景 +dart run test_e2e_m35_manual.dart # M3.5 10 场景 +dart run test_e2e_m4_manual.dart # M4 场景 ← 本迭代新增,见 §4.2 +``` +放行标准:**五份全 PASS,场景数 42 + M4 的 N,零失败**;契约偏差 **0**(逐场景对照 v1.5.0)。同环境串行跑,证据入迭代报告。 + +⚠️ **门禁是五份,不是两份也不是四份**。`releases.md:131` 的「双份」表述**已过期,勿照执行**(§4.0)。 + +#### 第 4 步 — 建 PR(api + flutter 各一个) +在 Gitea 网页建 `dev → main` PR: +- 标题:`v0.5.0 M4 AI 创作` +- 正文:贴门禁证据链接(测试数、五份 E2E 报告、契约 v1.5.0 纯增量比对结论) +- 等**必需状态检查**转绿。按 D1 修正后应为 `CI / backend-test (pull_request)` / `CI / flutter-gates (pull_request)`。 +- **合并方式必须选 Fast-forward only**(v0.4.0 先例:`merge_commit_sha == head sha`,零合并提交、历史线性)。 +- 合并后核验: +```bash +for r in patbond-api patbond-flutter; do + cd $WS/$r && git fetch -q origin + echo "$r main=$(git rev-parse origin/main) dev=$(git rev-parse origin/dev)" +done # 两个 sha 必须相等 +``` + +#### 第 5 步 — 打 tag(三仓,annotated) +```bash +cd $WS/patbond-api && git tag -a v0.5.0 -m "M4 AI 创作" && git push origin v0.5.0 +cd $WS/patbond-flutter && git tag -a v0.5.0 -m "M4 AI 创作" && git push origin v0.5.0 +cd $WS/patbond-doc && git tag -a v0.5.0 -m "M4 AI 创作" && git push origin v0.5.0 +``` +延续惯例:**annotated tag**,正文写完整发布说明(api 的 v0.4.0 tag 正文即模板:版本内容分条 + 契约版本 + 测试数 + 门禁结论)。doc 仓在**发布记录提交之后**打,使 tag 指向含本次记录的提交。 + +#### 第 6 步 — 发布记录入 doc +在 `releases.md` **顶部**追加 v0.5.0 段落(最新在最上,`:4` 约定):三仓 tag 与哈希、版本内容增量、门禁证据表(**测试数用第 1 步实跑值**、E2E 写「五份 / 场景数 / 断言数」)、已知遗留、checklist 变更。 + +#### 第 7 步 — 发布后核验(三仓一致性) +```bash +for r in patbond-api patbond-flutter patbond-doc; do + echo "=== $r ==="; cd $WS/$r + git ls-remote origin | grep -E 'refs/heads/(dev|main)$|refs/tags/v0.5.0' +done +``` +放行:api/flutter 的 `dev == main == v0.5.0^{}`;doc 的 `main == v0.5.0^{}`。 + +**已废止的步骤**(勿执行):iteration-3/08 §3.3 的第 3 步与第 6 步(一次性项,已完成);第 4 步的本地 `checkout main && merge --ff-only && push`(**保护启用后必被拒**)。 + +### 4.2 第五份 E2E 脚本的落位与命名约定 + +| 项 | 约定 | +| --- | --- | +| **路径** | `patbond-flutter/` **仓根**(与既有四份并列,**不进 `test/`**——`flutter test` 不应收集它,它需要活的后端) | +| **文件名** | `test_e2e_m4_manual.dart`(延续 m2/m3/m35 口径) | +| **运行** | `dart run test_e2e_m4_manual.dart`;前置 api 仓 `docker compose up -d --build` | +| **头注释** | 照 `test_e2e_m35_manual.dart:1-40` 模板:shebang、`// ignore_for_file: avoid_print`、前置条件、运行方式、**与前四份的分工声明**、冻结契约版本(v1.5.0 + 路径/操作/schema 计数)、场景清单编号、脱敏说明、`library;` | +| **覆盖范围** | **只覆盖 M4 增量对外面**,回归由前四份承担(M3.5 脚本明文如此声明,延续) | +| **必过门禁** | `flutter analyze`(分析器覆盖仓根,`analysis_options.yaml` 无 exclude)⇒ 必须加 `// ignore_for_file: avoid_print`,否则 CI 红 | +| **不受门禁** | `dart format` 只覆盖 `lib test`(`ci.yml:56`)⇒ 仓根脚本不被格式门禁拦,但仍建议手动 `dart format` | +| **脱敏纪律** | token 截断、预签名 URL 签名 query 抹为 ``、Idempotency-Key 占位(M3.5 脚本既有纪律)。**M4 新增:AI provider 的 API key 与 prompt/响应中的用户内容同样必须脱敏**,且脚本本身会被 `check-secrets.sh --all` 扫(`ALLOW` 白名单含 `redacted`,用 `<...REDACTED>` 形态即可放行) | +| **提交** | 单独一个提交,信息延续 `新增:M4 E2E 烟囱脚本——<主题>N 场景(v0.5.0 发布门禁)` | +| **登记** | 场景数与断言数写进迭代报告;**份数写进 `git-workflow.md` 的发布流程章节(唯一写死处,§4.0 防复发设计)** | + + +## 5. 风险与预案 + +### 5.1 PR 出现非快进(无法 ff) + +**成因判别先于动作**。`dev` 与 `main` 分叉只有三种可能,处置完全不同: + +```bash +cd $WS/<仓> && git fetch origin +git rev-list --left-right --count origin/main...origin/dev # 输出「AB」 +# A=0 → main 无独有提交,可 ff(正常) +# A>0 → main 上有 dev 没有的提交 ⇒ 分叉,先查明 +git log --oneline origin/dev..origin/main # 列出 main 独有的提交,逐个看作者与时间 +``` + +| 成因 | 判别特征 | 处置 | +| --- | --- | --- | +| **(a) 有人绕过 dev 直接改了 main** | main 独有提交的作者/时间可查;理论上被保护阻止,但管理员可临时关保护 | **不要用合并提交掩盖**(`releases.md:138` 明文)。查明该提交内容,把它 **cherry-pick 到 dev**,然后 main 重新 ff。同时查为什么保护被绕过 | +| **(b) 上次发布用了非 ff 的合并方式** | main 独有提交是一个 merge commit | 历史已污染但可接受,本次仍可 ff(merge commit 是 dev 的后代?若否则同 (a))。**下次严格选 Fast-forward only** | +| **(c) 误在 main 上打了 hotfix** | 见 (a) | 同 (a),且暴露 §1.4 推论二:hotfix 想走 PR 会死锁,所以有人图省事直推了 main | + +**明确禁止**:`git push --force origin dev:main`(破「不 force push 共享分支」戒律,`git-workflow.md:32`);`merge --allow-unrelated-histories`(iteration-3/08 §3.1 选项 C 已判定不推荐)。 + +**若确实必须做非 ff 合并**(例如 main 上有必须保留的独有提交且无法 cherry-pick):在 Gitea PR 里选 `Create merge commit`,并**在发布记录中显式登记为例外 + 写清原因**。历史线性性让位于可追溯性,但必须留痕。 + +### 5.2 CI 状态检查上下文名变化 —— **最高杀伤力的低概率风险** + +上下文字符串 = ` / ()`,三段任一改动即使必需上下文**永久缺失**,PR **永久不可合并**(不是变红,是「检查从未上报」)。触发改名的动作: + +| 动作 | 后果 | +| --- | --- | +| 改 `ci.yml` 的 `name: CI` | 全部上下文改名 | +| 改 job key(`backend-test` / `flutter-gates`) | 该仓上下文改名 | +| 增删 job(如把 `backend-test` 拆成 `unit` + `integration`) | 旧上下文消失 | +| 改 `on:` 触发器(如给 `pull_request` 加分支过滤) | 对应 event 的上下文可能不再产生 | + +**预案(顺序执行)**: + +1. **改名前先改保护配置**,不要反过来。顺序:Gitea 保护里**先追加新上下文名**(此时新旧并存,required 是「都要绿」)→ 推 `ci.yml` 改名 → 确认新上下文上报成功 → **再从保护里删掉旧上下文名**。这样任一时刻都不会出现「required 上下文无人上报」。 +2. **M4 期间尽量不动 `ci.yml` 的 name/job key**。若 M4 新增 api 模块,根反应堆 `./mvnw -B clean test`(`ci.yml:55`)自动覆盖,**无需新增 job** ⇒ 天然规避(§3.4)。 +3. **卡死时的解锁手段**(记录下来,免得临场慌):仓库 admin 在 Gitea → Settings → Branches → main → 暂时取消勾选「Enable Status Check」或删掉失效上下文 → 合并 → **立即改回正确上下文名**。每次这样操作都要在发布记录里留痕。 +4. **改名后必须做一次空 PR 验证**(同 §1.4 的一次性验证要求),不要拿正式发布当试验场。 + +**取真名的唯一可靠方法**(不要猜、不要凭记忆): +```bash +SHA=$(cd $WS/patbond-api && git rev-parse origin/dev) +curl -sk "$GITEA/api/v1/repos/zhaoyuxi/patbond-api/commits/$SHA/statuses?limit=50" | + python3 -c "import sys,json;[print(repr(s['context']),s['status']) for s in json.load(sys.stdin)]" +``` + +### 5.3 doc 仓不受保护带来的风险 + +doc 仓 `main` **未启用保护、可直推、且是唯一分支**(§1.2)。四条实际风险与处置: + +| 风险 | 现状评估 | 处置 | +| --- | --- | --- | +| **契约正典可被无门禁直推** | ⚠️ **这是最实质的风险**:`docs/api/openapi.yaml` 是 api 四份快照的**唯一上游**(§1.6),改它没有任何 CI 门禁能验证下游一致性。一次误推正典 → api 侧 md5 静默不等 → 漂移进库 | **不靠保护,靠流程**:契约升版严格按 §3.2 顺序,api 侧提交正文**必须贴 md5**(可事后审计)。中期方案见拍板 D5(在 api CI 里加跨仓 md5 校验 step) | +| **构建产物误入库** | ⚠️ **`.gitignore` 完全不存在**(§1.8(3)),`site/` 仅靠 `~/.gitignore_global:183` 屏蔽 | 一行修复:doc 仓新建 `.gitignore` 含 `site/`(拍板 D8) | +| **误推可直接改写发布记录** | 中等:`releases.md` 是发布事实的唯一记录,无审核 | 依赖 tag 作为交叉对照(三仓同名 tag 互证,`releases.md:5` 既有设计)。可接受 | +| **doc CI 红也能推上去** | 低:`push` 触发器覆盖 main(`ci.yml:7`),红了看得见,但不阻塞 | 保持现状;提交前跑 `mkdocs build --strict` 是既有纪律(`git-workflow.md:62`) | + +**是否给 doc 仓 main 启用保护?推荐不启用**(拍板 D10):doc 是日常直推分支,启用保护意味着每条文档改动都要开 PR,成本远超收益;且 doc 无 dev 分支,启用后连日常提交都要建分支。**替代方案**是把关键风险(契约正典)用 CI 校验兜住(D5),而不是用分支保护。 + +### 5.4 tag 打错的回退方式 + +三种错法,处置不同。**前提**:v0.5.0 tag 一旦被他人 fetch,改动就是历史改写——但本项目是单人 + 三仓自托管,回退窗口实际很宽。 + +**(a) tag 打在错误的提交上**(最常见) +```bash +cd $WS/<仓> +git tag -d v0.5.0 # 删本地 +git push origin :refs/tags/v0.5.0 # 删远端(冒号前缀 = 删除 ref) +git tag -a v0.5.0 <正确的sha> -m "M4 AI 创作" +git push origin v0.5.0 +``` +⚠️ 删远端 tag 后**务必立即重打并推**,不要留「三仓 tag 不齐」的中间态。**三仓必须同批处理**——若只有 api 的 tag 错了,也只需改 api,但要重新核验 §4.1 第 7 步的三仓一致性。 + +**(b) tag 名写错**(如打成 `v0.5` / `V0.5.0`) +```bash +git tag v0.5.0 v0.5 # 从旧 tag 建正确名(annotated 会退化为 lightweight) +# 更稳:直接按 (a) 用 -a 重建,保留完整发布说明正文 +git tag -d v0.5 && git push origin :refs/tags/v0.5 +``` +本项目三仓全部 v0.3.0/v0.4.0 均为 **annotated**,重建时**必须用 `-a` 并补回说明正文**,否则 tag 类型漂移(`git cat-file -t` 会从 `tag` 变 `commit`)。 + +**(c) tag 说明写错但指向正确** +```bash +git tag -a v0.5.0 -f -m "<修正后的说明>" # -f 覆盖本地 +git push origin -f v0.5.0 # 强推该 tag +``` +这是**本项目唯一被接受的 force push 形态**——`git-workflow.md:32` 禁的是共享**分支**,tag 不在其列。但仍建议在发布记录里留一句「tag 说明于 <时间> 修正」。 + +**预防**:打 tag 前先核对指向(`git rev-parse origin/main` 与将要打 tag 的 sha 一致),并按 §4.1 第 7 步一次性核验三仓。 + +### 5.5 M4 特有风险:AI provider 凭证扫描规则**零覆盖** + +**已核实**:`scripts/check-secrets.sh:28-37` 的规则表共 **7 条**: + +```text +AK-AWS AKIA[0-9A-Z]{16} +AK-QCLOUD AKID[0-9A-Za-z]{16,} +AK-ALIYUN LTAI[0-9A-Za-z]{12,} +MINIO-DEFAULT minio[-_.]?admin +PRIVATE-KEY ^\s*-----BEGIN [A-Z ]*PRIVATE KEY-----\s*$ +KEY-ASSIGN (access[-_]?key(_?id)?|secret[-_]?(access[-_]?)?key)["']?\s*[:=]\s*["']?[A-Za-z0-9+/=_-]{8,} +JWT-SECRET (jwt[-_.]?secret|signing[-_]?key|token[-_]?secret|hmac[-_]?(key|secret))["']?\s*[:=]\s*["']?[A-Za-z0-9+/=_-]{8,} +DB-PASSWORD (password|passwd|pwd)... (仅限 .ya?ml|properties|toml|conf|ini 文件) +``` + +**缺口分析**: + +- **无任何规则匹配 `sk-` 前缀族**(OpenAI `sk-` / `sk-proj-`、Anthropic `sk-ant-`、DeepSeek / Moonshot / 阿里百炼 DashScope 均为 `sk-` 形态)。 +- **`KEY-ASSIGN` 不覆盖 `api_key`**:其模式只认 `access_key` / `secret_key`,**不含通用 `api[-_]?key`**。⇒ `ANTHROPIC_API_KEY=sk-ant-xxx`、`openai_api_key: sk-xxx`、`dashscope_api_key: sk-xxx` **全部漏网**。 +- 文件名黑名单(`:24` `DENY_NAME`)覆盖 `.env` / `credentials*` / `*AccessKey*.csv` / `rootkey.csv`,对 AI provider 尚可(凭证通常进 `.env`),但内容层无兜底。 + +**这与 M3 的处境完全同构**:iteration-3/08 §4.2 当年把「对象存储凭证进入任何开发机之前必须上线 CI 兜底」定为 M3 第一波必做。**M4 的 AI provider key 风险更高**——它是第三方控制台签发的长期凭证、直接绑计费、泄漏即可被外部调用刷额度,且 AI 辅助开发下 key 从配置流向「示例代码 / 测试 / 报告 / prompt 样例」的路径比对象存储更多。 + +**处置(拍板 D4,M4 第一波、先于任何 AI 凭证落地)**:向规则表增补,三仓**同批提交**(`git-workflow.md:38` 要求副本同步,当前三仓 md5 全等需保持): + +```text +AK-LLM-SK - - \bsk-(ant-)?[A-Za-z0-9_-]{20,} +API-KEY-ASSIGN i - (api[-_]?key|apikey)["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{16,} +``` + +注意两点:(1) 现有 `ALLOW` 白名单(`:18`)含 `example|sample|dummy|fake|redacted|placeholder|your[-_]…|\$\{…\}`,文档里写 `sk-ant-your-key-here` 或 `sk-xxx-example` 会被放行 ⇒ **文档与 E2E 脚本里的示例 key 一律用这些占位形态**。(2) 增补后必须跑 `sh scripts/check-secrets.sh --all` 三仓验证**无误报**(历史文档里可能有形似字符串),有误报则收紧模式而非放宽白名单。 + +**配套**:同时启用第一层 pre-commit(§1.8(2),三仓 `core.hooksPath` 全未设置)——AI 凭证场景下「拦在 commit 之前」比「push 后 CI 才红」价值大得多,因为后者意味着凭证已经在远端历史里了。 + +**若真泄漏**:第一动作是**去 AI provider 控制台吊销/轮换该 key**,之后才是清理历史(`git-workflow.md:54` 与 `check-secrets.sh:12` 双处明文)。M4 需在 `server-exposure.md` 增登记 AI provider 凭证条目。 + + +## 6. 待拍板决策清单 + +共 **10 项**。标 ⭐ 的三项最关键(前两项建议在 M4 开工第一波就定,不要拖到发布前)。 + +| # | 事项 | 推荐 | 理由摘要 | 见 | +| --- | --- | --- | --- | --- | +| ⭐**D1** | **修正 main 的必需状态检查上下文**:`CI / backend-test (push)` → `CI / backend-test (pull_request)`;flutter 同理 `flutter-gates`。**替换而非追加**。改完做一次空 PR 验证 | **采纳** | 当前门禁由「推 dev」满足而非 PR 自身,`(pull_request)` 红也能合;且非 dev 分支 → main 的 PR **永久不可合**,hotfix 路径是死的。上下文真名已从 CI 实跑记录取到,非猜测 | §1.4 | +| ⭐**D2** | **发布流程固化到 `git-workflow.md` 新增章节**「## 发布流程(dev → main)」;iteration-3/08 §3.3 标题加「已固化,仅存历史,勿照执行」标注;`releases.md:4` 改指新章节、`:131` 的「E2E 双份回归」改为「全份回归」并链接过去。E2E 份数**只在一处写死** | **采纳** | 现正典是一份「草案」,固化从未发生(`git-workflow.md` 65 行无发布内容),8 步里 4 步过期,且 `releases.md` 自我矛盾(`:36/:56` 四份 vs `:131` 双份)。照 `:131` 执行会漏跑 17 个场景 | §4.0 | +| ⭐**D4** | **`check-secrets.sh` 增补 AI provider 凭证规则**(`sk-` / `sk-ant-` 前缀族 + 通用 `api_key` 赋值),三仓同批提交;**M4 第一波、先于任何 AI 凭证落地** | **采纳** | 现有 7 条规则对 `sk-` 零覆盖,`KEY-ASSIGN` 只认 `access_key`/`secret_key` 不认 `api_key` ⇒ `ANTHROPIC_API_KEY=sk-ant-…` 全漏网。风险高于 M3 的对象存储凭证(第三方签发、绑计费、泄漏即可外部刷额度)。沿 iteration-3/08 §4.2 先例 | §5.5 | +| D3 | **M4 若新增 api 模块,默认不复制契约框架**(`OpenApiContract.java` + 快照);新端点的契约矩阵挂既有模块 | **不复制** | 复制会使契约升版成本从 4 处变 5 处(+25%/次)。`/me` 守卫挂 auth 而实现在 user 已是先例。模块本身接 CI 是零成本(根 pom 加一行,`ci.yml` 零改) | §3.4 | +| D5 | **在 api CI 增加跨仓 md5 校验 step**(拉 doc 仓正典比对四份快照) | **本迭代不做,登记为技术债** | 缺口真实存在(api CI 完全不校验与 doc 正典的字节一致性,漂移会静默进库)。但实现要在 api CI 里 clone doc 仓,触发归属含糊(沿 iteration-3/08 §5.2 对 E2E 进 CI 的同类判断)。M4 先用「提交正文贴 md5」做可审计留痕,验证纪律有效性后再自动化 | §1.6 / §5.3 | +| D6 | **提交前缀规范收口**:`git-workflow.md:13` 增补 `style` 前缀与可选 `(scope)` 形态;**M4 起新提交统一用英文前缀**;历史不改写 | **采纳(改规范 + 改新实践,不改历史)** | 三方不一致:规范只认 6 个英文前缀;flutter 仓 **25/43 用中文前缀**(新增/修复/重构);api/doc 已在用规范未覆盖的 `test(contract):`/`docs(api):`。反向方案(规范承认中文前缀)会让 api 与 flutter 永久分裂 | §1.8(1) | +| D7 | **M1 的 E2E 脚本改名** `test_e2e_manual.dart` → `test_e2e_m1_manual.dart`(`git mv`,同步 4 处文档引用) | **采纳** | 回归清单变五份后,一个不带版本号的名字最易被误读成「总入口脚本」,进而漏跑。纯 rename,零逻辑风险。若嫌动文档,可推迟到 M4 收官波 | §1.7 | +| D8 | **doc 仓新建 `.gitignore`**(至少含 `site/`) | **采纳,M4 第一波** | doc 仓**完全没有 `.gitignore`**,`site/` 仅靠本机 `~/.gitignore_global:183` 屏蔽。换机器或新 clone 时一次 `git add -A` 即提交上百个构建产物。一行修复 | §1.8(3) | +| D9 | **三仓启用 pre-commit 第一层**:各执行一次 `git config core.hooksPath scripts/hooks` | **采纳,与 D4 同批** | 钩子脚本已入库但三仓 `core.hooksPath` **全部 unset** ⇒ 第一层完全未生效,当前只有 CI 兜底。AI 凭证场景下「拦在 commit 前」远优于「push 后 CI 才红」(后者凭证已进远端历史) | §1.8(2) | +| D10 | **doc 仓 main 不启用分支保护**(延续现状) | **不启用** | doc 是日常直推分支且无 dev,启用后每条文档改动都要建分支+PR,成本远超收益。关键风险(契约正典无门禁)用 D5 的 CI 校验兜,而非用分支保护兜 | §5.3 | + +### 6.1 采纳后需落实的改动(本报告均未执行) + +| 归属 | 动作 | +| --- | --- | +| **Gitea 平台**(需 admin) | D1 改两仓必需上下文 + 一次性空 PR 验证;补做 §1.2 的 `branch_protections` 带 token 复核并把响应贴回本报告 | +| **patbond-doc** | D2 三处文档改动(`git-workflow.md` 新增章节、iteration-3/08 §3.3 加标注、`releases.md:4`/`:131` 修正);D6 规范增补;D8 新建 `.gitignore`;本报告挂入 `mkdocs.yml` 导航(**本次未动 mkdocs.yml**,8 agent 并发中,由主会话统一处置) | +| **三仓同批** | D4 规则表增补 + `md5sum` 三仓校验 + `--all` 无误报验证;D9 各执行一次 `git config` | +| **patbond-flutter** | D7 `git mv` 改名 + 4 处文档引用同步 | +| **PM / RC** | §1.8(4) 的数字口径统一(api 测试 **381** 非 379、断言 **226** 非 234、真机挂起 **10** 项、埋点白名单 **41** 非 42),v0.5.0 发布记录一律用当次实跑值 | + +### 6.2 本报告的取证边界(明确未证实项) + +诚实登记,避免本报告本身成为「被转述采信」的下一个源头: + +| 项 | 状态 | 缺什么 | +| --- | --- | --- | +| main「**禁直接推送**」的具体机制 | ⚠️ **未直接证实** | `GET /branch_protections` 返 401(本机无 token)。已证实的是 `protected=true` + v0.4.0 确实被迫走 PR。复核命令见 §1.2 | +| api 测试数 381 / flutter 597 | ⚠️ **本报告未运行测试** | 仅记录来源冲突(iteration-3.5/04 §7.2 = 381 vs `releases.md:17` = 379),未自行跑 `mvnw clean test` | +| E2E 断言数 226 | ⚠️ 未复核 | 采信 Evidence Collector 的机械计数,本报告未独立数 | +| 真机挂起 10 项 / 埋点白名单 41 | ⚠️ 未复核 | 非本角色职责域,采信 EC/RC 口径 | +| `(pull_request)` 作为必需上下文能否正常 gate | ⚠️ **未验证**(配置尚未改) | 必须按 D1 做一次性空 PR 验证。**不要拿 v0.5.0 正式发布当试验场** | +| M4 契约的路径/操作/schema 计数 | 未知 | M4 契约尚未定型,§3.1 的计数留占位 | + +--- + +**已证实的核心结论一句话**:分支保护配置本身与描述**完全一致**,但**必需状态检查填的是 `(push)` 而非 `(pull_request)`**——门禁语义错位、hotfix 路径死锁;同时发布流程的「正典」是一份从未固化的草案且自我矛盾。这两条是 M4 开工前应优先修掉的。 + + +--- + +> 本报告只写不提交(随波末统一入档);未修改 `mkdocs.yml`、`releases.md`、`git-workflow.md` 及任何 checklist —— 改动建议见 §6.1,由主会话统一处置。 diff --git a/mkdocs.yml b/mkdocs.yml index 1903946..d0e20d1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -106,6 +106,16 @@ nav: - 06 M3.5 收口: development/iterations/iteration-3.5/06-wave2-closure.md - 07 安全事件复盘: development/iterations/iteration-3.5/07-security-incident-20260911.md - 08 发布 E2E 回归: development/iterations/iteration-3.5/08-release-e2e-regression.md + - 第四迭代 AI 创作: + - 00 开工汇总与拍板清单: development/iterations/iteration-4/00-kickoff-index.md + - 01 任务分解: development/iterations/iteration-4/01-pm-task-breakdown.md + - 02 后端技术评估: development/iterations/iteration-4/02-backend-technical-assessment.md + - 03 Flutter 技术评估: development/iterations/iteration-4/03-flutter-technical-assessment.md + - 04 现实核查: development/iterations/iteration-4/04-reality-check.md + - 05 AI 创作 UI 规格: development/iterations/iteration-4/05-ai-create-ui-spec.md + - 06 埋点与实验规划: development/iterations/iteration-4/06-experiment-tracking-plan.md + - 07 基线证据审计: development/iterations/iteration-4/07-evidence-baseline-audit.md + - 08 Git 流程规划: development/iterations/iteration-4/08-git-workflow-plan.md - API: - 契约说明: api/index.md - 架构: