# 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`(三仓均干净)