八角色并行开工分析,合计 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>
119 KiB
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 结论摘要与关键数字
- §2 现有可复用资产逐项取证
- §3 create 页 AI 模拟代码精确定位
- §4 进度反馈机制:轮询 vs SSE vs WebSocket
- §5 任务中断恢复方案
- §6 失败、重试与配额(429/Retry-After)的 UI 契约
- §7 生成结果 → 社区草稿的复用面
- §8 新增页面与 widget 清单
- §9 状态管理方案
- §10 埋点增量
- §11 demo/占位盘点与「demo 消亡」清单
- §12 历史遗留搭车判断
- §13 测试增量估计
- §14 风险清单
- §15 需要用户拍板的决策
- §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 |
§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.dartpatbond-flutter/integration_test/feed_live_test.dartpatbond-flutter/integration_test/profile_avatar_live_test.dartpatbond-flutter/integration_test/publish_live_test.dart
完全在 flutter test 之外(flutter test 缺省只扫 test/),且没有任何门禁会跑它们。
因此凡以「桌面/真机实测已验证」为依据的结论,本报告一律降级为「有脚本,无门禁」:
脚本存在证明设计上考虑过真链路,但不构成「链路已验证」的证据。受影响的具体结论:
- 媒体两步上传真链路(T3-13):
media_uploader.dart:571-577注释称「桌面真链路只替换选图与压缩两层, 其余全为生产实现」——这条设计意图成立,但「实测通过」无门禁背书。M4 复用两步上传时应视为 单测覆盖充分、端到端未持续验证。 /api/v1/media/uploads归属 user:8082:这条由community_repository.dart:71-74注释称 「T3-13 真链路实测修正」。所幸该结论另有独立取证:openapi.yaml:199(tagmedia的 description 写明patbond-user)与:179(server127.0.0.1:8082description 列出/api/v1/media/**)。 故此结论不降级——它有契约层证据,不依赖真机脚本。- 发布页真链路(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<T>游标分页泛型 (community_repository.dart:192, 203, 218, 282四处消费)。AI 任务历史列表直接复用。patbond-flutter/lib/features/community/community_models.dart:8-13:_enumFromJson未知值抛FormatException(12 行),刻意让契约漂移在测试期暴露。AI 模型照此纪律。patbond-flutter/lib/core/network/signed_network_image.dart:presignedImageCacheKey(10-26) 剥离X-Amz-*签名参数作稳定缓存 key,SignedNetworkImage(31-67)按剥签名 key 判等。 AI 结果图必然是预签名 GET URL(TTL 1 小时),必须走这个 provider, 否则轮询期间每次刷新都会重新下载整张大图。这是本迭代最容易漏的一条复用。
2.5 MediaUploader:长耗时多阶段编排器的现成范式
patbond-flutter/lib/features/community/media_uploader.dart(577 行)是全仓最接近 AI 任务编排的资产。
AI 生成任务与媒体上传的形状高度同构(多阶段 + 进度 + 可重试失败 + 取消作废),
故本类的结构应被 CreationController 逐条对照借用:
| 可借用的设计 | 行号 | 对 AI 任务的映射 |
|---|---|---|
enum MediaItemPhase{queued,compressing,uploading,confirming,ready,failed} |
17-24 | AI 任务态机(见 §9) |
@immutable MediaUploadItem 不可变快照对外 |
27-65 | AI 任务快照 |
构造期断言绑定「唯一可交付态」:assetId != null ⟺ phase == ready |
37-40 | AI resultAssetId ⟺ succeeded;从类型上杜绝未完成结果被引用 |
内部可变 _UploadTask 与对外快照分离;cancelled 旗标作废在途结果 |
68-103 | 轮询在途响应作废 |
attemptSeq(从 1 起,retry 递增)+ attemptStartedAt(durationMs 口径) |
80-84 | AI 重试埋点同口径 |
buildAttachRequests() 非全 ready 即抛 StateError |
216-228 | 结果建草稿的孤儿防护 |
单飞槽位 _acquireSlot/_releaseSlot(maxConcurrentUploads=2) |
548-564 | AI 并发任务上限闸门 |
凭据过期预检 credentialsSafetyMargin = 30s |
170, 484-485 | AI 结果 URL 过期即重取 |
_failFromApi 按错误码判定 retryable(40000 参数错→终态不可重试) |
487-506 | AI 失败可重试性判定 |
_reportCancelled(在途被删按 cancelled 上报一条失败) |
531-540 | AI 取消口径 |
MediaUploaderFactory typedef 注入口(测试/桌面替换选图压缩层) |
573-577 | AI 时钟与轮询器注入口 |
DateTime Function()? now 时钟注入 |
138, 142, 153 | 轮询测试免真实等待的关键 |
判定:这是 M4 最高价值的复用资产,但是「结构复用」而非「代码复用」——
不应把 AI 任务硬塞进 MediaUploader(用途/阶段/交付物都不同),
而应新建 CreationController 并逐条对照上表。已有 test/features/community/media_uploader_test.dart
是配套的测试写法样板(含 now 注入 + fake_async)。
2.6 埋点:强类型封装 + 持久化队列 + 退避
| 资产 | 位置 | 复用判定 |
|---|---|---|
AnalyticsService.trackEvent(name, props)(永不抛、永不 await 网络) |
lib/analytics/analytics_service.dart:126-157 |
直接复用 |
本地隐私红线正则拦截(password|token|secret|phone|...) |
同上 288-295 | 直接复用 |
分段持久化队列(shared_preferences,cap 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<CreationMode> AI 图片 / AI 视频 |
两种生成模式 | CreationMode 枚举(8 行)。视频路径与图片路径走同一段假延时、同一张假结果图(区别仅 87-89 行的标题文案) |
| M12 | 94-120 | addTag() 话题弹窗 |
添加话题 | 纯本地 List<String> tags(46 行,初值 ['可爱修勾','AI宠物'])。契约无话题端点——post_analytics.dart:287 已注明「话题无契约端点,M3 恒 0」 |
| M13 | 369-374 | 位置 ListTile |
「北京市 · 朝阳区」 | 写死字符串,trailing 有箭头但无 onTap,点了没反应 |
3.2 状态字段的模拟性质
_CreatePageState(32-46 行)13 个字段,按 M4 后的去向分类:
mode (35) → 保留(但 D4-F9 若砍视频则退化为常量)
selectedStyle (36) → 保留,改由服务端字典驱动
selectedModel (37) → 保留,改由服务端字典驱动
duration (38) → 视频参数,随 D4-F9 定去留
resolution (39) → 保留,改由服务端字典驱动
upscaling (40) → 零消费死字段,删或接真参数
uploaded (41) → 消亡(改为真实 PickedMediaImage / assetId)
uploading (42) → 消亡(改为 MediaItemPhase)
generating (43) → 消亡(改为 CreationTaskPhase)
generationStep (44) → 消亡(假 step 1..4 → 服务端 progress 或阶段枚举)
resultUrl (45) → 消亡(改为服务端 resultAssetId + 预签名 url)
tags (46) → 消亡(无契约端点)
titleController / contentController (33-34) → 移交 PostComposePage(§7)
3.3 会被保留的部分
唯一真实的部分是 _ComposeEntryCard(394-419 行)——顶部「发布动态」入口,
onTap: widget.onOpenCompose → main_shell_page.dart:190 → openCompose(PostEntryPoint.createTab)
→ 真实 PostComposePage。这一段 T3-17 已真实化,M4 不动。
3.4 M4 对本文件的处置建议
create_page.dart 563 行中,约 470 行属于模拟(M1-M13),
其中 _UploadCard(421-493)、_ChoiceRow(495-530)、_GenerationProgress(532-563)
三个 private widget 可改造后迁入 lib/features/creation/,其余整段删除。
建议:不在原地改,新建 lib/features/creation/ai_create_page.dart,
create_page.dart 缩为只含 _ComposeEntryCard + 新页入口的薄壳——
理由是原地改会让 diff 无法审查(470 行删除 + 400 行新增混在一个文件里)。
§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 |
每任务一个挂起线程/连接 | 同 SSE |
| 首屏「有反馈」延迟 | ≤ 首个轮询间隔(1s) | 即时 | 即时 |
4.3 推荐:A. 自适应轮询(并为 B 预留升级位)
推荐理由(按权重排序):
- 现有网络层与流式模型结构性不兼容。客户端全部请求走
ApiClient.request→ 信封解包(api_client.dart:163-176)+ 401/40101 单飞刷新重放一次(114-128)。 SSE 流两条都用不上,等于旁路整条已建好的鉴权链,形成第二套鉴权路径。 M3.5 的/api/v1/me接错端口事故根因就是「跨模块调用没有走既有接线纪律」—— 再开一条旁路只会重演。 - 后端零基础设施。
SseEmitter0 命中意味着 M4 若选 SSE, 后端要在同一迭代内同时做「AI 模型调用 + 任务表 + 配额 + 长连接层」四件新事。 这是 M4 最可能失控的地方(见 §14 R1)。 - 中断恢复是硬需求,而它天然要求「查状态」端点。§5 论证:无论选哪种推送, 都必须有一个「按 taskId 查当前状态」和「列我的进行中任务」的 GET。 一旦有了这两个 GET,轮询就已经免费到手;SSE 只是在其上叠一层优化。 换言之:A 是 B 的真子集,先做 A 不是走弯路。
- 可测性直接决定测试增量能否兑现。轮询逻辑可用
now注入 +fake_async(MediaUploader已有此范式:media_uploader.dart:138,142,153;analytics_flush_scheduler_test.dart已有fake_async用法)在纯单测里 把「1s→2s→3s 退避」「超时」「代次作废」全测掉,不需要真服务器。 - 已有退避语义可照抄。
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),客户端改造面清单
(备查,非推荐路径)
- 新建
lib/core/network/sse_client.dart:裸Dio实例(无AuthInterceptor, 照DioMediaDirectUploadClient先例——media_direct_upload.dart:39-52是「独立 Dio 实例」的样板),ResponseType.stream+ 手写data:/event:/id:/retry:帧解析 +\n\n分帧。 - Token 过期处理:不能复用
ApiClient的「重放一次」。需自建 「流断 →TokenRefresher.refresh()→ 带Last-Event-ID重连」,且要防重连风暴。 - 必须同时实现 A(查状态 GET)作为兜底:退后台断流、Doze、Wi-Fi 切蜂窝都会断。
- 契约需新增
text/event-stream响应描述 +components.headers首次引入。 - 测试需起真
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 服务端事实来源(+ 极轻量本地提示)
理由:
- 与既有纪律一致。
community_controller.dart:20明写「服务端是唯一事实来源,内存副本仅作展示缓存」。 R-B 会开一个「本地也是事实来源」的口子。 - 零跨账号泄漏面。M3 第一波专门做过「防泄漏」(
app.dart:242-249登出即petsController.reset()/communityController.reset()/profileController.reset())。 持久化任务态就要在这里再加一处清理,且清理失败是静默的(安全事件 2026-09-11 的教训类型)。 - R-A 的端点本来就要做。§4.3 论证:任何推送方案都需要查状态端点。 列表端点只是多一个 query 参数级别的增量。
- 换设备场景真实存在。用户在手机提交生成、去平板看,R-B 完全失效。
建议的极轻量本地补充(不违反上述纪律):
仅持久化一个 int——「上次离开时有 N 个任务在跑」,用于冷启动首屏立刻显示「创作」Tab 角标
而不必等首次网络返回。真值仍由 R-A 端点校正。这个可选,D4-F2 里作为子选项。
5.4 恢复的三个具体入口(UI 层)
- 创作 Tab 角标:
main_shell_page.dart:277-281的NavigationDestination(创作 Tab) 加NavigationDestination(icon: Badge(label: Text('$n'), child: Icon(...)))。 进行中任务数 > 0 时显示。这是最重要的一个——它让用户知道「东西还在」。 - AI 任务列表页(§8 新页 3):进行中置顶 + 历史结果按时间倒序(游标分页复用
CursorPage)。 - 进创作 Tab 时自动恢复:
AiCreatePage.initState拉一次进行中列表; 若恰有 1 个在跑,直接把页面渲染成进行中态(照post_compose_page.dart:132unawaited(_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 处)
api_exception.dart:111-113—ApiRateLimitException加final 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,57、post_analytics.dart:94、feed_analytics/pet域同款)——它们都用_通配,不解构字段。api_client.dart:163-167—_unwrap读头:Retry-After按 RFC 7231 有两种格式(delta-seconds整数 与 HTTP-date), 必须两种都解析(只解析整数在服务端给日期时会静默回落 null)。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 设计。 精确表述应是:
- 两步上传确实不适用。
MediaUploader的入口是PickedMediaImage(media_uploader.dart:69final PickedMediaImage source), 管线为「压缩(_compress325-357)→ createUpload(471-482)→ 预签名 PUT 直传(399-408)→ confirm(443)」。 AI 结果图在服务端生成,客户端手里没有字节。让客户端下载再上传是荒谬的(双倍流量 + 双倍存储)。 - 但「无 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):
- 新增构造参数
List<PostMediaAttachRequest>? presetMedia+PostCategory? presetCategoryString? generationJobId(现有构造 38-46 行)。
_canPublish(159-163)需改:当前条件(_uploader.isEmpty || _uploader.allReady)在预置媒体场景下恒 true,但正文仍必填——这条不变; 需补「预置媒体存在时不允许_uploader再加图」或允许混合(待 UI 规范定)。_mediaAttachOrNull()(249-250)需改:现在只从_uploader取, 需变成presetMedia ?? (_uploader.isEmpty ? null : _uploader.buildAttachRequests())。_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 为什么「进行中」不独立成页
- 独立成页会与中断恢复冲突。若进行中是一个 push 页,用户返回后这个页就没了, 必须靠列表页找回——等于两条恢复路径。内联在创作 Tab 则「回到创作 Tab 就看到」。
IndexedStack保活是免费的。main_shell_page.dart:266IndexedStack(index: currentIndex, children: pages)——Tab 切走时页面不销毁,State保留,轮询可继续(降频)。这是现成的保活机制。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-140openPost与:146-156openCompose先例),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_viewedprops 白名单,否则整条 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 级单例(不是页面级),理由:
- 进行中任务需跨 Tab 存活(用户切去首页刷 Feed,任务要继续轮询 + Tab 角标要更新)。
- 创作页、结果页、任务列表页三页共享同一份任务内存副本——
与
CommunityController让首页 Feed 与详情页共享帖子副本同构(community_controller.dart:120-121)。 - 轮询定时器需要一个比页面更长的宿主。
装配位置: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)的写法,编译期枚举锁死:
/// 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 即抛 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 时钟与轮询器注入(测试可行性的前提)
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<TrackedEvent> events; |
任一元素校验失败 → MethodArgumentNotValidException → 整个请求 400 |
| 逐条 rejected 的逻辑在更后面 | AnalyticsService.java:49(unknown_event_name)、:55(identity_mismatch)、:65(forbidden_field)、:83(schema_invalid) |
这四类才是逐条 rejected;platform 根本走不到这里 |
| 客户端把 4xx 当永久拒绝 | analytics_service.dart:273-281:if (status >= 400 && status < 500) { …dropping ${events.length} events…; return true; } |
整批静默丢弃 |
| 丢弃即删段 | 同上 :229 _store.removeSegments(batch.segmentIds, countAsDropped: rejected) |
不重试、不保留 |
净效果:Linux 桌面(以及 macOS/Windows/Web)跑 app 时, 每一批埋点都在 DTO 校验层被整批 400,客户端随即整批删除。 桌面上的埋点是 100% 丢失,不是「逐条 rejected 的少量损耗」。
M4 影响:integration_test/ 4 份真机脚本(本身已无门禁,见 §1)若在 Linux 桌面跑,
埋点断言无法成立。AI 链路的漏斗埋点若只在桌面验证,等于没验证。
修复选项(D4-F8):
- (a) 后端
@Pattern加桌面值:^(android|ios|linux|macos|windows|web)$+ 契约 enum 同步 +EventDictionary无关。 优点:桌面实测可验埋点。缺点:生产数据里混入开发平台,需在分析侧过滤。 - (b) 客户端桌面直接不上报:
_platformName()返回非 android/ios 时,trackEvent直接 return(一行短路)。优点:数据干净、零后端改动。 缺点:桌面永远验不了埋点——与 AI 长链路的验证需求冲突。 - (c) 后端保留两值,但把
platform校验从 DTO 层下移到逐条校验: 即在AnalyticsService里判 platform 并EventResult.rejected(…, "platform_invalid")。 优点:让客户端注释描述的行为变成真的(逐条 rejected、不整批 400), 桌面上其余字段仍走通全链路。缺点:改后端校验层次。 - 推荐 (c) + (a) 组合:把 platform 从 DTO
@Pattern移到逐条校验(消除整批 400 这个真实缺陷, 这是任何平台的健壮性改进),同时加一个--dart-define门控的桌面 platform 映射 (桌面实测时映射为android以验通全链路)。
无论选哪个,analytics_service.dart:99-102 那条注释必须改——它现在在误导后续所有人。
10.4 AI 创作链路需要的新事件(建议 9 条)
命名沿用既有域前缀风格(post_* / feed_* / pet_*),新前缀 creation_*。
全部需要改 EventDictionary.java(不可配置)。
| # | 事件名 | 触发时机 | props(建议) |
|---|---|---|---|
| 1 | creation_started |
进创作页并产生首次参数改动或选源图(每次进入记一次,照 post_create_started 口径 post_analytics.dart:129) |
entryPoint(create_tab/pet_detail/ai_task_list) |
| 2 | creation_submitted |
提交生成成功受理(taskId 已回) | modelId、styleId、aspectRatio、hasSourceImage(bool)、attemptSeq |
| 3 | creation_submit_failed |
提交被拒 / 网络失败 / 配额耗尽 | failureReason、attemptSeq、errorCode?、httpStatus? |
| 4 | creation_succeeded |
任务终态 succeeded |
durationMs(submitted→succeeded)、modelId、styleId、queueWaitMsBucket |
| 5 | creation_failed |
任务终态 failed / 客户端超时 |
failureReason、durationMs、modelId、attemptSeq、errorCode? |
| 6 | creation_cancelled |
用户主动取消在途任务 | phaseAtCancel(queued/running)、elapsedMsBucket |
| 7 | creation_result_viewed |
结果页曝光(注:也可只靠 page_viewed,见下) |
modelId、styleId |
| 8 | creation_published |
AI 结果成功发到社区 | durationMs(succeeded→published)、fromTaskList(bool) |
| 9 | creation_quota_exhausted |
配额耗尽横幅首次呈现 | quotaScope(daily/monthly) |
可裁剪建议:#7 与 page_viewed{pageName: ai_result} 重复,
建议删掉 #7,靠 page_viewed 覆盖(AnalyticsRouteObserver 自动产生,零代码)。
→ 净新增 8 条,白名单 41 → 49。
10.5 隐私红线复核(三条都要守)
| 红线 | 出处 | AI 链路的落点 |
|---|---|---|
| 内容 ID 不进 props | post_analytics.dart:11-12(红线 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<FeedCard> / CursorPage<Post> |
| 列表卡片 widget | ✅ 复用 | 收藏页用 PostCard(lib/core/widgets/post_card.dart,返回 FeedCard,形态完全一致) |
| 入口 | ⚠️ 当前是演示提示 | profile_page.dart:235 onTap: () => showDemoMessage(context, item.$2) |
| 缺什么 | 只缺 2 个页面 | 数据层、卡片、分页、错误话术全部现成 |
判定:搭车成本最低、价值最高的一项。 且与 M4 有直接协同——
AI 结果建成草稿后,用户需要一个地方找到它。没有草稿列表页,AI 草稿就是黑洞。
草稿页还需要「继续编辑」入口 → PostComposePage(需支持传入指定 draftId,
当前只能恢复「最新一条」,post_compose_page.dart:194-199 limit: 1)。
改造点:_restoreLatestDraft() 需扩展为可选指定 draftId。
12.2 草稿自动保存 —— 建议不搭车(或仅做最小版)
| 项 | 状态 | 取证 |
|---|---|---|
| 当前保存触发 | 仅 2 种:手动「存草稿」+ 离页确认 | post_analytics.dart:31-41 DraftSaveTrigger{manual, onExit} |
| 埋点已预留纪律 | 「自动保存不埋」 | post_analytics.dart:30 注释原文「自动保存不埋,防高频」;:133 重申 |
| 幂等键管理会冲突 | ⚠️ | _markDirty()(post_compose_page.dart:179-182)在每次输入时置 _idempotencyKey = null。自动保存若在输入过程中触发,会不断换新键 → 每次自动保存建一个新草稿 |
| 乐观锁 version 竞态 | ⚠️ | 自动保存与手动保存并发会撞 40902(_draftVersion 单值,post_compose_page.dart:79) |
判定:风险大于收益。 幂等键 + 乐观锁两套机制都是按「用户显式提交」设计的,
加自动保存需要重新设计「草稿 upsert」语义(debounce + 单飞 + version 串行化)。
若一定要做,最小版:仅在「已有 _draftPostId」时做 debounce 30s 的 PATCH(不建新草稿),
避开建草稿的幂等键问题。估计 +12~18 测试,且引入一类难测的时序竞态。
12.3 月份网格选择器 —— 建议搭车(低风险、已有真实误录事故)
| 项 | 状态 | 取证 |
|---|---|---|
| 根因已归档 | ✅ 详细 | lib/core/widgets/app_date_picker.dart:3-13——原文记录「从 9 月回到 4 月要点 5 次箭头。已实际导致误录——用户把当月(2026-09)的就医记录记成了 2026-04-09,进而误判『本月花费 ¥0』是统计坏了」 |
| M3.5 的处置 | ⚠️ 绕开而非解决 | pickAppDate(:54-79)统一 7 处调用 + 保留手输切换;AppDateFieldTrailing(:86-144)加「今天」快捷键。注释 :22-25 说明「原生 showDatePicker 无法注入自定义动作(builder 只能包裹整个 Dialog,拿不到内部选中态),所以快捷键放在调用方表单行而非弹窗内」 |
| 收口面 | ✅ 单点 | 7 处调用已全部收口到 pickAppDate 一个函数 |
| 主题已定制 | ✅ | app_theme.dart:215-296 _datePickerTheme(含 7 行色对对比度表) |
判定:收口已完成,改造面是单个函数,风险低。
做法:自建一个 MonthYearGridPicker(年+月网格,两级),
在 pickAppDate 里作为可选入口(或替换 showDatePicker)。
注意:完全自建会丢掉 M3.5-01 挂 zh-CN delegate 才拿到的「手输模式 + 格式校验」
(app_date_picker.dart:8-13 记录了这条路「才真正走通」)——
必须保留手输,否则是退步。估计 +15 测试。
12.4 SegmentedButton 粉底主题债 —— 必须搭车(AI 页是重灾区)
| 项 | 状态 | 取证 |
|---|---|---|
| 主题完全未定制 | ✅ 确认 | grep -n "segmentedButton|SegmentedButton|ChoiceChip|chipTheme" lib/core/theme/app_theme.dart → 0 命中 |
| 回退路径 | ColorScheme.fromSeed(seedColor: #FF6F4C) 派生的 M3 调和色 |
app_theme.dart:88-92;选中态吃 secondaryContainer(珊瑚橙派生出的粉/浅褐调),与品牌色脱节 |
| 使用点 6 处 | create_page.dart:138、pet_form_page.dart:426, 457、home_page.dart:385、services_page.dart:102、vaccination_form_page.dart:340 |
|
ChoiceChip 使用点 |
create_page.dart:519(_ChoiceRow)、post_compose_page.dart:596 |
|
| 根因与 M3.5-01 完全同构 | ✅ | app_theme.dart:200-204 记录 DatePicker 的同一根因:「此前未定制,showDatePicker 完全走 ColorScheme.fromSeed 由珊瑚橙 #FF6F4C 派生出的 M3 调和色(选中日为暗红棕实底),与全 app 品牌色脱节」 |
| 修复样板现成 | ✅ | _datePickerTheme(app_theme.dart:215-296)就是样板:复用已审计色对、不新造色值、附对比度表 |
判定:必须搭车。 AI 创作页是 SegmentedButton + ChoiceChip 最密集的页面
(模型 / 风格 / 尺寸 / 图片-视频四组选择器)。不修就是把新页面直接建在债上。
修法:照 _datePickerTheme 的纪律加 segmentedButtonTheme + chipTheme,
选中态用已审计的 surfaceTint 底 + primaryDark 字(7.98:1,app_theme.dart:208 已记录该色对来源)。
成本极低(一个 theme 块),估计 +6~8 测试。
12.5 widthPx/heightPx 恒 null → 单图帖回落 4:3 —— 判定需修正(部分是客户端问题,部分不是)
任务书的表述「widthPx/heightPx 恒 null 导致单图帖一律回落 4:3」不完全准确。 逐层取证:
| 层 | 事实 | 取证 |
|---|---|---|
| 契约有字段 | ✅ PostMediaItem.widthPx/heightPx 与 MediaAsset.widthPx/heightPx 都存在且可空 |
community_models.dart:133-134, 143-144(PostMediaItem);:549-550, 563-564(MediaAsset) |
| 详情页已正确消费 | ✅ 不是 4:3 硬编码 | post_detail_page.dart:515-521:var ratio = 4/3; if (width != null && height != null && width>0 && height>0) { ratio = (width/height).clamp(1/1.33, 1/0.75); } ——有维度就用真比例,只在 null 时回落 4:3 |
| Feed 卡片是硬编码 4:3 | ❌ 客户端缺陷 | post_card.dart 单图分支:AspectRatio(aspectRatio: 4/3, ...) ——card.coverImage!.widthPx/heightPx 可用但被完全忽略 |
PostMediaGrid 丢掉了维度 |
❌ 客户端缺陷 | post_media_grid.dart:29 入参只有 final List<String> urls——维度在调用点就被丢弃;_CollapsedCover(:313-367):326-327 硬编码 aspectRatio: 4/3 |
| 客户端无法提供维度 | ⚠️ 契约缺口 | CreateMediaUploadRequest(community_models.dart:468-489)只有 kind/purpose/mimeType/byteSize/sha256 ——没有 widthPx/heightPx 字段。客户端压缩后知道尺寸,但契约不收 |
| 服务端能否自己测量 | 未取证 | 需确认服务端 confirm 时是否读取对象并探测图片尺寸。若不读,则 widthPx/heightPx 无来源 → 恒 null |
修正后的准确表述(三段,缺一不可):
- 契约缺口:
CreateMediaUploadRequest无维度字段,客户端有维度也传不了。 → 要么契约加字段(客户端压缩后填),要么服务端 confirm 时探测对象。 - Feed 卡片客户端缺陷:
post_card.dart与post_media_grid.dart:326-327硬编码 4: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 场景:
- 提交生成 → 轮询到 succeeded → 结果 asset 可下载(逐字节校验,照 M3.5 场景 4 口径)
- 同
Idempotency-Key重放 → 命中首个任务,不重复消耗配额 - 同键异 payload → 40905
- 配额耗尽 → 429 +
Retry-After头存在且可解析 - 限流与配额耗尽的两个错误码可区分
- 任务失败 →
failureReason在枚举内 + retryable 标记正确 - 取消在途任务 → 终态
cancelled,不可再转 running - 「我的进行中任务」列表:跨会话(新 token)仍能查到
- AI 结果建草稿 →
category=ai_creation写入成功(验证写侧枚举已放开) - AI 草稿 → 迁移发布 → Feed 可见且
category=ai_creation - 引用他人的 / 幽灵的 / 错 purpose 的结果 asset → 404/40405
- 引用未完成任务的 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):
- 现有网络层围绕「信封 + 401 单飞刷新重放」构建(
api_client.dart:97-131, 163-176), SSE 两条都用不上 → 形成第二套鉴权旁路。M3.5 的端口事故根因正是「跨模块调用绕开既有接线纪律」。 - 两侧流式基础设施均为零(
SseEmitter0 命中、客户端ResponseType0 命中、无流式依赖); M4 后端已有四件新事(模型调用/任务表/配额/schema),不宜再加长连接层。 - A 是 B 的真子集:任何推送方案都必须有「查状态」GET 作兜底(退后台/Doze/断网), 有了它轮询已免费到手。先做 A 不是弯路。
- 可测性:
now注入 +fake_async(已有依赖)可确定性测全部时序;SSE 需起真 HTTP 流服务器, 会破掉analytics_service.dart:255-257「用@protected替换以避免起真服务器」的既有纪律。 - 无网关(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。理由:
- 与既有纪律一致——
community_controller.dart:20明写「服务端是唯一事实来源,内存副本仅作展示缓存」。 - 零跨账号泄漏面。全仓没有任何业务任务态被持久化(这是 M3 第一波「防泄漏」的刻意结果);
R-B 要在
app.dart:242-249再加一处清理,漏则是安全缺口(参照 2026-09-11 安全事件的教训类型)。 - 换设备场景真实存在(手机提交、平板查看),R-B 完全失效。
- 该端点本来就要做(见 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。理由:
purpose决定 objectKey 前缀与生命周期策略(community_models.dart:64-66); AI 生成图的清理策略、配额统计、成本归因口径与用户上传图不同,混在一起将来无法分开。- 加值成本极低:DB 层无 CHECK 约束(
V1__identity_media_baseline.sql:209是裸varchar(32)), 无需迁移——只改MediaProperties.java:66+application.yml:45+ 契约:3460。 - 读侧天然兼容:
MediaAsset.purpose在契约(openapi.yaml:3524-3526)是无 enum 的自由 string。 - 客户端
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):
IndexedStack(main_shell_page.dart:266)Tab 切走不销毁 State,是现成的保活机制,轮询可继续。- 进行中若是 push 页,返回后就没了 → 必须靠列表页找回 → 两条恢复路径。 内联则「回创作 Tab 就看到」。
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。理由:
- 两类的 UI 行为根本不同——限流给倒计时重试,配额耗尽给「明日重置」且必须禁用重试。 共用一条话术会让用户一直点(§6.3)。
- 项目已有五位业务码体系(
api_exception.dart:3-81,27 个码), 且post_analytics.dart:180-182已有httpStatus = errorCode ~/ 100的推导约定 ——429xx天然吻合。 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。理由:
- 先例充分:
pets_repository.dart:146-153listBreeds、:155-165listVaccineCatalog已是两个字典端点(契约内/api/v1/breeds、/api/v1/vaccine-catalog), 数据播种在V4__pet_health_dictionary_seed.sql。照抄这套即可。 - 硬编码意味着「上/下线一个风格要发版」——AI 模型迭代频率远高于 app 发版频率。
- 风格卡需要封面图,硬编码就是继续挂 unsplash 外链(
demo_data.dart:206-233现状)。
D4-F8 桌面埋点整批 400 的修复方式
| 选项 | 说明 |
|---|---|
a. 后端 @Pattern 加桌面值 + 契约 enum 同步 |
生产数据混入开发平台 |
| b. 客户端桌面直接不上报 | 桌面永远验不了埋点 |
| c. platform 校验从 DTO 层下移到逐条校验(推荐) | 消除「整批 400」这个真实缺陷 |
| c + 门控映射(推荐组合) | 再加 --dart-define 门控把桌面映射为 android 以验通全链路 |
推荐 c + 门控映射。理由:
- 「整批 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)才是正解。 - AI 长链路的漏斗埋点需要能在开发机上验通(R7:真机测试无门禁)。
- 数据侧不受污染(生产仍只接受 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。理由:
MediaKind只有image(community_models.dart:56-61,注释「M3 仅 image,视频后置」), 视频要动契约枚举 + 播放器依赖 + 缩略图 + 时长 + 转码 —— 是一个独立里程碑的量。- 不留占位是关键:本项目每个迭代都以「demo 消亡」为产出口径,
在消亡 13 处占位的同一个迭代里新增一处占位,是自相矛盾的。
现有
CreationMode.video(create_page.dart:8)与视频时长选择(:189-197)应整段删除。 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。理由:
integration_test/4 份当前无任何门禁(§1 证据降级),把它变成门禁是独立工程。- 手工 E2E 脚本已有成熟模式与门禁要求(4 份 / 42 场景),M4 加第五份是最短路径。
- 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(不影响客户端),属预期」 - 实际机制(全链取证):
TrackEventsRequest.java:56-58:platform上有@Pattern(regexp="^(android|ios)$"), 这是 DTO 层 Bean Validation。TrackEventsRequest.java:16-19:@Valid级联到List<TrackedEvent>每个元素 → 任一元素失败即MethodArgumentNotValidException→ 整个请求 400。- 逐条 rejected 的逻辑在更后面且永远走不到:
AnalyticsService.java:49(unknown_event_name)、:55(identity_mismatch)、:65(forbidden_field)、:83(schema_invalid)。 - 客户端
analytics_service.dart:273-281:4xx 视为永久拒绝 →return true→:229removeSegments(..., countAsDropped: true)→ 整批删除,不重试。
- 净效果:Linux/macOS/Windows/Web 上跑 app,埋点 100% 丢失,不是「少量损耗」。
- 更严重的衍生问题:
@Valid级联意味着任何一条事件的任何一个字段校验失败 都会毒死整批 50 条。这是一个毒丸批次放大器,与平台无关。 → 已列为 D4-F8 推荐修法的第一理由。
16.3 【修正表述】「widthPx/heightPx 恒 null 导致单图帖一律回落 4:3」——三段事实,客户端不全是受害者
- 任务书原文暗示这是单一的后端字段缺失问题。
- 实测三段:
- 详情页客户端已正确消费:
post_detail_page.dart:515-521var ratio = 4/3; if (width != null && height != null && ...) ratio = (width/height).clamp(1/1.33, 1/0.75);——有维度就用真比例,服务端一给就自动生效,无需改动。 - 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 卡片也不会生效。 - 客户端根本无法提供维度:
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:199tagmediadescription 写明patbond-user;:179server 描述列出/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-48generation_job_id uuid(裸列,FK 剥离);:95CREATE INDEX ix_posts_generation_job;:68ck_posts_category已放行三值(含ai_creation);:17注释指向creation.generation_jobs(id) ON DELETE SET NULL(M4 补回)。 - 不存在:
creationschema 与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-283listMyBookmarks、:179-193listMyPosts({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(三仓均干净)