Files
patbond-doc/docs/development/iterations/iteration-4/03-flutter-technical-assessment.md
T
lixi 79b33dba31 docs: M4「AI 创作」开工分析八份报告 + 汇总拍板页入档挂导航
八角色并行开工分析,合计 7448 行;另出 00 汇总页(跨角色收敛结论、
13 项待拍板、6 项待仲裁分歧、未取证项汇总),挂第四迭代导航最前。
mkdocs build --strict 通过。

基线实测修正(文档与实况不符):
- api 测试 381(releases.md 记 379,成因待仲裁)
- 埋点白名单 41(四份文档记 42,experiment_exposed 重复计数)
- 真机验证挂起 10 项(转述链 4→6→8→10 每跳丢项)
- E2E 断言机械可数 226(声称 234 无可复核来源)
- v0.4.0 实际发布 09-14 11:17;CI 非红,三仓五上下文全绿

多方独立收敛(无需拍板):
- 队列用 Postgres SKIP LOCKED + 租约列,不引入 Redis/MQ
- 服务端零对象写能力(ObjectStorage 无 put/get),M4 立足点缺地基
- 「四模块字节级快照锁 CI」不存在,实际门禁仅结构断言
- 定稿模型 input_asset_id NOT NULL,即图生图不支持文生图
- 跨 schema 外键补回是 V5 自身指令,裁剪理由已不成立

阻塞项与安全缺口:
- AI provider BLOCKED:零 SDK/endpoint/额度,正典种子即 fixture
- 分支保护必需上下文选错触发器:(push) 限定 branches:[dev],
  致「推 dev 即满足门禁」且「非 dev 分支 PR 永久无法合并」
- check-secrets.sh 对 sk-/sk-ant- 零覆盖,须先于任何 AI key 落地
- 北极星 09-21 窗口已于 09-13 关闭,补救无从下手,建议改事件驱动

本批核心教训:13 处文档/注释与代码相反且多已被下游采信,其中
5 处造成实际规模误判(widthPx M→S、数据模型早已定稿 L→M、
社区侧 purpose 校验实际不存在等)。汇总页 §0 立转述纪律。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:48:56 +08:00

1671 lines
119 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M4「AI 创作」客户端技术评估(Flutter)
**角色**Frontend DeveloperFlutter
**日期**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 stableframework `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)
---
<a id="1"></a>
## §1 结论摘要与关键数字
| 项 | 数值 | 取证 |
| --- | --- | --- |
| 客户端测试基线 | **597 通过 + 2 skipped** | 实测 `flutter test --reporter compact``+597 ~2: All other tests passed!` |
| 客户端 dart 源文件 | 90 个 `lib/`62 个 `test/` | `find . -name '*.dart'` |
| 契约版本 | v1.4.032 路径 / 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 场景 | 42M1 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。
---
<a id="2"></a>
## §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-193auth 走 `api``:8081`),`/api/v1/me` 单独接 `userApi``:8082`),
注释 176-177 明确「`/api/v1/me` 由 user 服务(:8082)提供,auth:8081)上没有该路由」。
这正是 M3.5 教训的落地物。
- `_buildPetsRepository()` 195-206`:8083`
- `_buildCommunityRepository()` 211-231community 走 `: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(...)` switch74-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<T>` 游标分页泛型
`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 500oldest-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-CNAI 页零改动 |
| `_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。
---
<a id="3"></a>
## §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 起改由真实发布页`
> `/// PostComposePagepush 全屏)承担`
`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<CreationMode>` AI 图片 / AI 视频 | 两种生成模式 | `CreationMode` 枚举(8 行)。视频路径与图片路径**走同一段假延时、同一张假结果图**(区别仅 87-89 行的标题文案) |
| M12 | **94-120** | `addTag()` 话题弹窗 | 添加话题 | 纯本地 `List<String> 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 行新增混在一个文件里)。
---
<a id="4"></a>
## §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` 已有) | 0dio `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~60s2s 间隔 ≈ 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。
---
<a id="5"></a>
## §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` 语义)。
---
<a id="6"></a>
## §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 成本。
---
<a id="7"></a>
## §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)→ createUpload471-482)→ 预签名 PUT 直传(399-408)→ confirm443)」。
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<PostMediaAttachRequest>? 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 版必然漂移。
---
<a id="8"></a>
## §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)。
---
<a id="9"></a>
## §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**——这是测试增量能否兑现的技术前提。
---
<a id="10"></a>
## §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<TrackedEvent> 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`(红线 2postId/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。
---
<a id="11"></a>
## §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 白名单卡点)。
---
<a id="12"></a>
## §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<FeedCard>` / `CursorPage<Post>` |
| 列表卡片 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<String> 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 下滑关闭手势 | ❌ **不搭车** | — | 需新依赖或自定义手势;结果页复用现有查看器即可 |
---
<a id="13"></a>
## §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/`(无门禁)不同——手工脚本有门禁要求,需在发布清单里明确。
---
<a id="14"></a>
## §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 三段取证 | 两侧同批做,或本迭代不做 |
---
<a id="15"></a>
## §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 的遗留清单**——
它是一个长期被当作证据使用的空头承诺,应当或补门禁、或降级为「开发辅助脚本」并改名。
---
<a id="16"></a>
## §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<TrackedEvent>` 每个元素
→ 任一元素失败即 `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<String> 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 DeveloperFlutter
**完成日期**2026-09-14
**基线**patbond-flutter `dev@fbcd734` / patbond-api `dev@3cd8005` / patbond-doc `main@5cc6361`(三仓均干净)