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

119 KiB
Raw Blame History

M4「AI 创作」客户端技术评估(Flutter)

角色Frontend DeveloperFlutter 日期2026-09-14 基线patbond-flutter dev@fbcd734tag v0.4.0),工作区干净 测试基线flutter test597 通过 + 2 skipped(实测 +597 ~2: All other tests passed!,耗时 01:07 契约基线openapi.yaml v1.4.032 路径 / 45 操作 / 75 schema Flutter SDK3.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 结论摘要与关键数字

数值 取证
客户端测试基线 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:4YAML 解析统计
契约中 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-104grep -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 +205240 → **802837**;含全部搭车 +295330 → **892927** §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-13media_uploader.dart:571-577 注释称「桌面真链路只替换选图与压缩两层, 其余全为生产实现」——这条设计意图成立,但「实测通过」无门禁背书。M4 复用两步上传时应视为 单测覆盖充分、端到端未持续验证
  2. /api/v1/media/uploads 归属 user:8082:这条由 community_repository.dart:71-74 注释称 「T3-13 真链路实测修正」。所幸该结论另有独立取证openapi.yaml:199tag media 的 description 写明 patbond-user)与 :179server 127.0.0.1:8082 description 列出 /api/v1/media/**)。 故此结论不降级——它有契约层证据,不依赖真机脚本。
  3. 发布页真链路(T3-17publish_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

资产 行号 复用判定
patbondApiBaseUrlauth,默认 :8081 8-11 直接复用
patbondUserApiBaseUrluser,默认 :8082/me+/events+/media/** 15-18 直接复用
patbondPetApiBaseUrlpet,默认 :8083 23-26 不涉及
patbondCommunityApiBaseUrlcommunity,默认 :8084 31-34 直接复用
buildPatbondDio({session, baseUrl}) 40-53 直接复用;AI 服务若独立端口需加第 5 个常量
AuthInterceptorBearer + X-Device-Id 57-76 直接复用
ApiClient.request(...) 信封解包 + 401/40101 刷新重放一次 97-131 直接复用
_unwrapstatus == 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 走 :8084api), media 两步上传单独接 mediaApipatbondUserApiBaseUrl:8082221-229)。
  • _ensureRefresher() 161-166:全部服务共享同一个 TokenRefresher401 单飞刷新不会打成 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 ApiBusinessException9-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 CommunityRepository19 操作全覆盖) 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),但列表页本单未做」。 核实成立——listMyBookmarks273-283)与 listMyPosts(178-193)在仓库层已实现且 在 597 测试内有覆盖(test/features/community/community_repository_test.dart)。 缺的只有两个页面,不缺任何数据层。见 §12。

2.4 分页、模型与图片

  • patbond-flutter/lib/core/models/cursor_page.dartCursorPage<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.dartpresignedImageCacheKey10-26 剥离 X-Amz-* 签名参数作稳定缓存 keySignedNetworkImage(31-67)按剥签名 key 判等。 AI 结果图必然是预签名 GET URL(TTL 1 小时),必须走这个 provider, 否则轮询期间每次刷新都会重新下载整张大图。这是本迭代最容易漏的一条复用。

2.5 MediaUploader:长耗时多阶段编排器的现成范式

patbond-flutter/lib/features/community/media_uploader.dart577 行)是全仓最接近 AI 任务编排的资产。 AI 生成任务与媒体上传的形状高度同构(多阶段 + 进度 + 可重试失败 + 取消作废), 故本类的结构应被 CreationController 逐条对照借用:

可借用的设计 行号 对 AI 任务的映射
enum MediaItemPhase{queued,compressing,uploading,confirming,ready,failed} 17-24 AI 任务态机(见 §9
@immutable MediaUploadItem 不可变快照对外 27-65 AI 任务快照
构造期断言绑定「唯一可交付态」assetId != nullphase == ready 37-40 AI resultAssetIdsucceeded;从类型上杜绝未完成结果被引用
内部可变 _UploadTask 与对外快照分离;cancelled 旗标作废在途结果 68-103 轮询在途响应作废
attemptSeq(从 1 起,retry 递增)+ attemptStartedAtdurationMs 口径) 80-84 AI 重试埋点同口径
buildAttachRequests() 非全 ready 即抛 StateError 216-228 结果建草稿的孤儿防护
单飞槽位 _acquireSlot/_releaseSlotmaxConcurrentUploads=2 548-564 AI 并发任务上限闸门
凭据过期预检 credentialsSafetyMargin = 30s 170, 484-485 AI 结果 URL 过期即重取
_failFromApi 按错误码判定 retryable40000 参数错→终态不可重试) 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_preferencescap 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 页名同样可先登记枚举,但要与后端 EventDictionarypage_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 页大量用 SegmentedButtonChoiceChip 选模型/风格/尺寸,会直接吃到这个主题债,见 §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 起改由真实发布页 /// 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..4delay(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.length545)由假 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> tags46 行,初值 ['可爱修勾','AI宠物'])。契约无话题端点——post_analytics.dart:287 已注明「话题无契约端点,M3 恒 0」
M13 369-374 位置 ListTile 「北京市 · 朝阳区」 写死字符串,trailing 有箭头但onTap,点了没反应

3.2 状态字段的模拟性质

_CreatePageState32-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.onOpenComposemain_shell_page.dart:190openCompose(PostEntryPoint.createTab) → 真实 PostComposePage。这一段 T3-17 已真实化,M4 不动

3.4 M4 对本文件的处置建议

create_page.dart 563 行中,约 470 行属于模拟M1-M13), 其中 _UploadCard421-493)、_ChoiceRow495-530)、_GenerationProgress532-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.yamlsse/stream/text/event-stream0 命中
契约 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-apiSseEmitter/text/event-stream0 命中
后端无 WebSocket 实现 WebSocket/STOMP → 未见(AI 相关代码整体 0 实现)
客户端无流式依赖 pubspec.yamlweb_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
客户端新依赖 0dio 已有) 0dio ResponseType.stream)但需手写 text/event-stream 帧解析dio 不提供) 需新增 web_socket_channel
后端新基础设施 一个 GET /…/{id} SseEmitter + 心跳 + 超时 + 连接数管理 WS endpoint + 握手鉴权 + 会话注册表 + 心跳
能否复用 ApiClient 能,100% 不能:流不走 {code,message,data} 信封,_unwrapapi_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 图片生成 1060s2s 间隔 ≈ 530 次轻量 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-4730s→×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 实例(无 AuthInterceptorDioMediaDirectUploadClient 先例——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. 测试需起真 HttpServeranalytics_service.dart:255-257 刻意用 @protected uploadBatch 让测试子类替换以避免起真服务器——SSE 会把这条纪律破掉)。

估算:选 B 会让客户端测试增量再 +40~60,且引入一类难以确定性测试的时序 flake。


§5 任务中断恢复

5.1 现有持久化能力盘点

机制 位置 适用性
flutter_secure_storagetoken / userId / deviceId lib/features/auth/session_manager.dart:18-28, 43-48 仅凭据,不放业务态
shared_preferencesAppState 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_preferencespb.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-281NavigationDestination(创作 TabNavigationDestination(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 态整页
postPublishErrorMessage7 分支,每条语义可辨) 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.yaml0 输出
契约无任何响应头机制 components.headers: [],全契约 headers: 键 0 次出现
后端无限流实现 grep -rniE "429|TOO_MANY_REQUESTS|Retry-After|RateLimit" patbond-*/src/main0 命中
客户端已能识别 429 api_client.dart:164-167if (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-113ApiRateLimitExceptionfinal Duration? retryAfter
    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,57post_analytics.dart:94feed_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_rejectedpost_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 乐观锁冲突自动重提 _patchPublishPostVersionConflictExceptiongetPost 取新 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-239PostEntryPoint 需加值,见下
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 「不保留」软删服务端草稿 _discardDraftdeletePost + postDeleted() post_compose_page.dart:434-443
15 发布成功后回首页刷 Feed openCompose 返回 trueselectTab(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-35aiCreation('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:68ck_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:193if (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 NULLM4 补回)。 但该列未出现在契约任何 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 只需要 assetIdcommunity_models.dart:158), 不关心 asset 是怎么来的;MediaAsset.purpose 在契约读侧 openapi.yaml:3524-3526)是无 enum 约束的自由 string,读侧向后兼容。

因此 purpose 白名单是唯一的真实卡点

事实 取证
契约写侧 purpose 三值 openapi.yaml:3460enum: [post_image, user_avatar, pet_avatar]
后端白名单三值(应用层,不可配置外的默认) MediaProperties.java:66application.yml:45
DB 层无 CHECK 约束 V1__identity_media_baseline.sql:209purpose 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. _canPublish159-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 收藏/草稿菜单项 onTapshowDemoMessage 改为真实导航 §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 先例), AnalyticsRouteObserverapp.dart:109-112, 317)自动产生 page_viewed零额外埋点代码
  • 页 1 是 Tab 内联,需手动补点——照 main_shell_page.dart:99-105 _tabPages 映射表。 但注意:创作 Tab 当前映射到 AnalyticsPageName.create:100 第 2 项)。 改造后是否换页名(createai_create需与数据侧对齐—— 换名会断掉历史趋势,analytics_page_name.dart:97-98 有先例记录 (「档案 Tab 自 T2-12 起页名由 pet_archive 改报 pet_list」)。建议保留 create 不换
  • 新页名必须同时进后端 EventDictionarypage_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 CreationControllerTab 级单例

判定为 Tab 级单例(不是页面级),理由:

  1. 进行中任务需跨 Tab 存活(用户切去首页刷 Feed,任务要继续轮询 + Tab 角标要更新)。
  2. 创作页、结果页、任务列表页三页共享同一份任务内存副本—— 与 CommunityController 让首页 Feed 与详情页共享帖子副本同构(community_controller.dart:120-121)。
  3. 轮询定时器需要一个比页面更长的宿主。

装配位置app.dart 与其他三个 controller 并列(app.dart:115-132 附近), 注入 MainShellPageapp.dart:286-302 参数列表)。 必须同时在 _reportAuthStateChangeapp.dart:242-249)加 creationController.reset()—— 否则跨账号泄漏(这是 M3 第一波「防泄漏」的既定纪律,漏一处就是安全缺口)。

9.3 任务态机

MediaItemPhasemedia_uploader.dart:17-24)的写法,编译期枚举锁死:

/// 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

assert((resultAssetId != null) == (phase == CreationTaskPhase.succeeded),
       'resultAssetId 与 succeeded 态严格绑定(未完成结果不得被引用)');

这条断言让「把未完成任务的结果发到社区」在类型层面不可能发生—— 与 buildAttachRequests() 非全 ready 即抛 StateErrormedia_uploader.dart:216-219)同一思路。

对外快照不可变@immutable class CreationTaskcontroller 内部持可变 _CreationTaskState(含 cancelled 旗标、attemptSeqattemptStartedAtpollTimer)。

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 时钟与轮询器注入(测试可行性的前提)

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:42WHITELIST = Map.ofEntries(),条目 43-103,闭合 104
精确条数 41 grep -c "Map.entry(" EventDictionary.java41
不可配置 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_startedauth_token_refresh_succeeded/failedauth_session_restore_started/succeeded/failedhealth_record_deletedexperiment_exposed

结论:任务书里「现有白名单 42 条」应更正为 41 条。 experiment_exposedEventDictionary.java:103)是 A/B 前置,M4 首用(非 AI 本身)。

10.2 客户端埋点实现位置

组件 路径
上报客户端(队列 + 冲刷 + 退避 + 隐私红线) lib/analytics/analytics_service.dart296 行)
分段持久化队列 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.dartfeatures/pets/health_record_analytics.dartfeatures/community/feed_analytics.dartfeatures/community/community_interaction_analytics.dartfeatures/community/post_analytics.dart
装配 app.dart:97-101apiBaseUrl: 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-2375enum: [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:49unknown_event_name)、:55identity_mismatch)、:65forbidden_field)、:83schema_invalid 这四类才是逐条 rejectedplatform 根本走不到这里
客户端把 4xx 当永久拒绝 analytics_service.dart:273-281if (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 entryPointcreate_tab/pet_detail/ai_task_list
2 creation_submitted 提交生成成功受理taskId 已回) modelIdstyleIdaspectRatiohasSourceImage(bool)、attemptSeq
3 creation_submit_failed 提交被拒 / 网络失败 / 配额耗尽 failureReasonattemptSeqerrorCode?httpStatus?
4 creation_succeeded 任务终态 succeeded durationMssubmitted→succeeded)、modelIdstyleIdqueueWaitMsBucket
5 creation_failed 任务终态 failed / 客户端超时 failureReasondurationMsmodelIdattemptSeqerrorCode?
6 creation_cancelled 用户主动取消在途任务 phaseAtCancelqueued/running)、elapsedMsBucket
7 creation_result_viewed 结果页曝光(:也可只靠 page_viewed,见下) modelIdstyleId
8 creation_published AI 结果成功发到社区 durationMssucceeded→published)、fromTaskList(bool)
9 creation_quota_exhausted 配额耗尽横幅首次呈现 quotaScopedaily/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 排队时长、生成耗时用 *BucketdurationMs 沿用 post 域先例(已放行精确毫秒)
文件名/路径/URL 禁止 post_analytics.dart:12(红线 4 源图与结果图的任何 URL 不上报
客户端本地正则拦截 analytics_service.dart:288-295password|token|secret|phone|mobile|email|credential|idfa|gaid prompt 文本若上报会被这条放过(不含敏感词)——故 prompt 原文必须由纪律禁止,不能靠正则

新增红线建议用户输入的 prompt 文本一律不上报(只报长度分桶)。 理由:prompt 可能含人名、地址、宠物医院名等。这条现有正则拦不住,必须写进 creation_analytics.dart 的 doc 注释锁死。

10.6 page_viewed props 白名单同步

EventDictionary.java:59page_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:235showDemoMessage:81-85 真实导航(搭车项D4-F9

消亡后 create_page.dart 563 行 → 约 40 行(只剩 _ComposeEntryCard + 新页入口)。 demo_data.dartcreate 相关消费点从 4 个减到 3 个 grep -rn "demo_data.dart" lib/home_page.dart:7services_page.dart:3app_state.dart:4create_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:162widget.appState.pet.avatarUrl 传给 _UploadCard 作假图源。 AiCreatePage 若要「用我的宠物照片生成」,应改从 PetsController 取真实宠物头像 pets_controller.dartTab 级单例,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<FeedCard> / CursorPage<Post>
列表卡片 widget 复用 收藏页用 PostCardlib/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.dart0 命中
回退路径 ColorScheme.fromSeed(seedColor: #FF6F4C) 派生的 M3 调和色 app_theme.dart:88-92;选中态吃 secondaryContainer(珊瑚橙派生出的粉/浅褐调),与品牌色脱节
使用点 6 处 create_page.dart:138pet_form_page.dart:426, 457home_page.dart:385services_page.dart:102vaccination_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 品牌色脱节」
修复样板现成 _datePickerThemeapp_theme.dart:215-296)就是样板:复用已审计色对、不新造色值、附对比度表

判定:必须搭车。 AI 创作页是 SegmentedButton + ChoiceChip 最密集的页面 (模型 / 风格 / 尺寸 / 图片-视频四组选择器)。不修就是把新页面直接建在债上。 修法:照 _datePickerTheme 的纪律加 segmentedButtonTheme + chipTheme 选中态用已审计的 surfaceTint 底 + primaryDark 字(7.98:1app_theme.dart:208 已记录该色对来源)。 成本极低(一个 theme 块),估计 +6~8 测试。

12.5 widthPx/heightPx 恒 null → 单图帖回落 4:3 —— 判定需修正(部分是客户端问题,部分不是)

任务书的表述「widthPx/heightPx 恒 null 导致单图帖一律回落 4:3」不完全准确。 逐层取证:

事实 取证
契约有字段 PostMediaItem.widthPx/heightPxMediaAsset.widthPx/heightPx 都存在且可空 community_models.dart:133-134, 143-144PostMediaItem);:549-550, 563-564MediaAsset
详情页已正确消费 不是 4:3 硬编码 post_detail_page.dart:515-521var 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
客户端无法提供维度 ⚠️ 契约缺口 CreateMediaUploadRequestcommunity_models.dart:468-489)只有 kind/purpose/mimeType/byteSize/sha256 ——没有 widthPx/heightPx 字段。客户端压缩后知道尺寸,但契约不收
服务端能否自己测量 未取证 需确认服务端 confirm 时是否读取对象并探测图片尺寸。若不读,则 widthPx/heightPx 无来源 → 恒 null

修正后的准确表述(三段,缺一不可):

  1. 契约缺口CreateMediaUploadRequest 无维度字段,客户端有维度也传不了。 → 要么契约加字段(客户端压缩后填),要么服务端 confirm 时探测对象。
  2. Feed 卡片客户端缺陷post_card.dartpost_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 结果预览页会遇到同一个问题(结果图要能放大看细节)。 建议:结果页直接复用现有 _GalleryPagepost_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 testintegration_test/(无门禁)不同——手工脚本有门禁要求,需在发布清单里明确。


§14 风险清单

# 风险 严重度 依据 缓解
R1 后端 AI 面零实现,M4 需同时新建:模型调用 + 任务表 + creation schema + 配额 + 错误码 + 白名单事件。客户端全部工作阻塞在契约定稿 🔴 patbond-apiSseEmitter/quota/AiTask/GenerationTask → 全 0creation 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 层无 CHECKV1: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,3697CreatePostRequest.java:26post_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: 10sapi_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 sealedcommunity_display.dart:30-59post_analytics.dart:91-105 AI 域异常一律继承 ApiBusinessException(照 community_exceptions.dart 全部 10 个的做法),不动密封层级
R11 无状态管理库 + Tab 级单例增至 4 个app.dartinitState(80-147)已 67 行,装配复杂度上升 🟢 低中 直接取证 可接受;但 _reportAuthStateChange242-249必须加 creationController.reset(),漏则跨账号泄漏
R12 prompt 文本的隐私风险拦不住:本地正则(analytics_service.dart:290-293)不含 prompt 语义词 🟡 直接取证 纪律锁死「prompt 原文不上报,只报长度分桶」,写进 creation_analytics.dart doc 注释
R13 视频生成若进 M4MediaKind 只有 imagecommunity_models.dart:56-61),视频要动契约枚举 + 播放器依赖 + 缩略图 + 时长;MediaType.videopost_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:179server 列出 /api/v1/media/**)与 :199tag 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. 客户端 MediaPurposecommunity_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. IndexedStackmain_shell_page.dart:266Tab 切走不销毁 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-8127 个码), 且 post_analytics.dart:180-182 已有 httpStatus = errorCode ~/ 100 的推导约定 —— 429xx 天然吻合。
  3. Retry-After 仍需要(限流用),且是契约首次引入响应头(R2),需同时补 components.headers

客户端配套(必做)ApiRateLimitExceptionDuration? 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 只有 imagecommunity_models.dart:56-61,注释「M3 仅 image,视频后置」), 视频要动契约枚举 + 播放器依赖 + 缩略图 + 时长 + 转码 —— 是一个独立里程碑的量。
  2. 不留占位是关键:本项目每个迭代都以「demo 消亡」为产出口径, 在消亡 13 处占位的同一个迭代里新增一处占位,是自相矛盾的。 现有 CreationMode.videocreate_page.dart:8)与视频时长选择(:189-197)应整段删除。
  3. MediaType.videopost_analytics.dart:59-66)枚举已备,将来启用零成本,不必现在占坑。

D4-F11 AI 长链路的验证门禁

选项 说明
A. 新增 test_e2e_m4_manual.dart10~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_startedauth_token_refresh_*auth_session_restore_*health_record_deletedexperiment_exposed)。

16.2 【推翻】「桌面埋点逐条 rejected,不影响客户端」——实际是整批 400、全批丢弃

  • 被推翻的原文patbond-flutter/lib/analytics/analytics_service.dart:99-102): 「上报值不在枚举内会被服务端逐条 rejected(不影响客户端),属预期」
  • 实际机制(全链取证):
    1. TrackEventsRequest.java:56-58platform 上有 @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-2814xx 视为永久拒绝 → 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. 客户端根本无法提供维度CreateMediaUploadRequestcommunity_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 只需要 assetIdcommunity_models.dart:158),不关心 asset 来源;
    • MediaAsset.purpose 在契约读侧(openapi.yaml:3524-3526)是无 enum 约束的自由 string,读侧向后兼容;
    • media.assetspurpose 无 CHECK 约束(V1__identity_media_baseline.sql:209),加值无需迁移。
  • 唯一真实卡点是写侧 purpose 白名单三值(openapi.yaml:3460MediaProperties.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:3460CreateMediaUploadRequest.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 NULLM4 补回)。
  • 不存在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/main0 命中
  • 客户端已能识别 429api_client.dart:164-167)但丢弃了响应头const 构造); ApiRateLimitExceptionapi_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.dart0 命中
  • 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 compact597 通过 + 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(三仓均干净)