79b33dba31
八角色并行开工分析,合计 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>
1671 lines
119 KiB
Markdown
1671 lines
119 KiB
Markdown
# M4「AI 创作」客户端技术评估(Flutter)
|
||
|
||
**角色**:Frontend Developer(Flutter)
|
||
**日期**:2026-09-14
|
||
**基线**:patbond-flutter `dev@fbcd734`(tag `v0.4.0`),工作区干净
|
||
**测试基线**:`flutter test` → **597 通过 + 2 skipped**(实测 `+597 ~2: All other tests passed!`,耗时 01:07)
|
||
**契约基线**:openapi.yaml **v1.4.0**,32 路径 / 45 操作 / 75 schema
|
||
**Flutter SDK**:3.44.6 stable(framework `ee80f08bbf`)
|
||
|
||
> 路径约定:本报告中源码路径均为**仓库相对路径**(`patbond-flutter/...`、`patbond-api/...`、`patbond-doc/...`),
|
||
> 不写死本机绝对路径。行号以上述基线 HEAD 为准。
|
||
|
||
---
|
||
|
||
## 一句话结论
|
||
|
||
M4 客户端是**纯增量新建**——契约 v1.4.0 里 AI 面只有一个读侧枚举值 `ai_creation`,
|
||
零端点、零 schema、零流式基础设施;进度反馈**推荐自适应轮询**(不是 SSE/WebSocket),
|
||
因为现有网络层(信封 + 401 单飞刷新重放)与流式模型结构性不兼容,且后端 `SseEmitter` 零命中。
|
||
create 页的 AI 模拟集中在 `patbond-flutter/lib/features/create/create_page.dart:55-129`(三段假延时 + 假结果 + 占位发布)。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
- [§1 结论摘要与关键数字](#1)
|
||
- [§2 现有可复用资产逐项取证](#2)
|
||
- [§3 create 页 AI 模拟代码精确定位](#3)
|
||
- [§4 进度反馈机制:轮询 vs SSE vs WebSocket](#4)
|
||
- [§5 任务中断恢复方案](#5)
|
||
- [§6 失败、重试与配额(429/Retry-After)的 UI 契约](#6)
|
||
- [§7 生成结果 → 社区草稿的复用面](#7)
|
||
- [§8 新增页面与 widget 清单](#8)
|
||
- [§9 状态管理方案](#9)
|
||
- [§10 埋点增量](#10)
|
||
- [§11 demo/占位盘点与「demo 消亡」清单](#11)
|
||
- [§12 历史遗留搭车判断](#12)
|
||
- [§13 测试增量估计](#13)
|
||
- [§14 风险清单](#14)
|
||
- [§15 需要用户拍板的决策](#15)
|
||
- [§16 我推翻或修正的既有文档结论](#16)
|
||
|
||
---
|
||
|
||
<a id="1"></a>
|
||
## §1 结论摘要与关键数字
|
||
|
||
| 项 | 数值 | 取证 |
|
||
| --- | --- | --- |
|
||
| 客户端测试基线 | **597 通过 + 2 skipped** | 实测 `flutter test --reporter compact` → `+597 ~2: All other tests passed!` |
|
||
| 客户端 dart 源文件 | 90 个 `lib/`,62 个 `test/` | `find . -name '*.dart'` |
|
||
| 契约版本 | v1.4.0(32 路径 / 45 操作 / 75 schema) | `patbond-doc/docs/api/openapi.yaml:4`;YAML 解析统计 |
|
||
| 契约中 AI 端点数 | **0** | 搜 `ai/generation/generate/task/job/sse/stream/quota/model/style` → 仅 4 处说明文字 + 1 个枚举值 |
|
||
| 后端 AI 实现 | **0 行** | `SseEmitter`/`text/event-stream`/`quota`/`AiTask`/`GenerationTask` 全 0 命中 |
|
||
| 后端埋点白名单 | **41 条**(不是 42) | `patbond-api/patbond-user/.../analytics/EventDictionary.java:42-104`,`grep -c "Map.entry("` = 41 |
|
||
| 客户端实际发出的事件 | **33 个** | `_track('…')` 28 个 + `auth_repository.dart` 直调 5 个 |
|
||
| 手工 E2E 场景 | 42(M1 7 + M2 11 + M3 14 + M3.5 10) | `test_e2e_m35_manual.dart:12-13` 自述 + 各脚本头部 |
|
||
| `integration_test/` 真机测试 | **4 份,无任何门禁运行** | 见下方「证据降级」 |
|
||
| 新增页面 | **6 个**(4 AI + 2 历史遗留搭车) | §8 |
|
||
| 预计测试增量 | 核心 AI +205~240 → **802~837**;含全部搭车 +295~330 → **892~927** | §13 |
|
||
| 待拍板决策 | **11 项** | §15 |
|
||
|
||
### 证据降级声明(重要)
|
||
|
||
仓库根有 4 份手工 E2E 脚本(`test_e2e_manual.dart` 298 行 / `test_e2e_m2_manual.dart` 777 行 /
|
||
`test_e2e_m3_manual.dart` 1373 行 / `test_e2e_m35_manual.dart` 1166 行),这些是 `dart run` 手动脚本,
|
||
发布门禁要求四份全跑——**这部分证据成立**。
|
||
|
||
但 `integration_test/` 下的 4 份真机测试:
|
||
|
||
- `patbond-flutter/integration_test/client_ux_live_test.dart`
|
||
- `patbond-flutter/integration_test/feed_live_test.dart`
|
||
- `patbond-flutter/integration_test/profile_avatar_live_test.dart`
|
||
- `patbond-flutter/integration_test/publish_live_test.dart`
|
||
|
||
**完全在 `flutter test` 之外**(`flutter test` 缺省只扫 `test/`),且**没有任何门禁会跑它们**。
|
||
因此凡以「桌面/真机实测已验证」为依据的结论,本报告一律降级为「**有脚本,无门禁**」:
|
||
脚本存在证明设计上考虑过真链路,但不构成「链路已验证」的证据。受影响的具体结论:
|
||
|
||
1. **媒体两步上传真链路(T3-13)**:`media_uploader.dart:571-577` 注释称「桌面真链路只替换选图与压缩两层,
|
||
其余全为生产实现」——这条设计意图成立,但「实测通过」无门禁背书。M4 复用两步上传时应视为
|
||
**单测覆盖充分、端到端未持续验证**。
|
||
2. **`/api/v1/media/uploads` 归属 user:8082**:这条由 `community_repository.dart:71-74` 注释称
|
||
「T3-13 真链路实测修正」。所幸该结论**另有独立取证**:`openapi.yaml:199`(tag `media` 的
|
||
description 写明 `patbond-user`)与 `:179`(server `127.0.0.1:8082` description 列出 `/api/v1/media/**`)。
|
||
故此结论**不降级**——它有契约层证据,不依赖真机脚本。
|
||
3. **发布页真链路(T3-17)**:`publish_live_test.dart` 同样无门禁。发布链路的 widget 测试
|
||
(`test/features/community/post_compose_page_test.dart`)在 597 之内,这部分成立。
|
||
|
||
**M4 建议**:AI 创作链路的长耗时特性使真链路验证比社区功能更关键(轮询、超时、中断恢复
|
||
都无法只靠 widget 测试证明)。参见 §14 风险 R7 与 §15 决策 D4-F11。
|
||
|
||
---
|
||
|
||
<a id="2"></a>
|
||
## §2 现有可复用资产逐项取证
|
||
|
||
### 2.1 网络层:分端口直连 + 信封 + 401 单飞刷新重放
|
||
|
||
`patbond-flutter/lib/core/network/api_client.dart`:
|
||
|
||
| 资产 | 行号 | 复用判定 |
|
||
| --- | --- | --- |
|
||
| `patbondApiBaseUrl`(auth,默认 `:8081`) | 8-11 | 直接复用 |
|
||
| `patbondUserApiBaseUrl`(user,默认 `:8082`,`/me`+`/events`+`/media/**`) | 15-18 | 直接复用 |
|
||
| `patbondPetApiBaseUrl`(pet,默认 `:8083`) | 23-26 | 不涉及 |
|
||
| `patbondCommunityApiBaseUrl`(community,默认 `:8084`) | 31-34 | 直接复用 |
|
||
| `buildPatbondDio({session, baseUrl})` | 40-53 | 直接复用;**AI 服务若独立端口需加第 5 个常量** |
|
||
| `AuthInterceptor`(Bearer + `X-Device-Id`) | 57-76 | 直接复用 |
|
||
| `ApiClient.request(...)` 信封解包 + 401/40101 刷新重放一次 | 97-131 | 直接复用 |
|
||
| `_unwrap` 中 `status == 429 → ApiRateLimitException` | 164-167 | **需改造**:不读 `Retry-After` 头,见 §6 |
|
||
|
||
**跨端口纪律核实(硬性要求)**:`patbond-flutter/lib/app/app.dart` 里每条跨模块调用都单独接线,
|
||
并在注释里写明理由——不存在「挂错端口」的悬空风险:
|
||
|
||
- `_buildRepository()` 168-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。
|
||
|
||
---
|
||
|
||
<a id="3"></a>
|
||
## §3 create 页 AI 模拟代码精确定位
|
||
|
||
**文件**:`patbond-flutter/lib/features/create/create_page.dart`(共 563 行)
|
||
|
||
该文件已被前序迭代显式登记为 M4 替换目标——文件头 doc 注释(10-15 行)原文:
|
||
|
||
> `/// **AI 生成模拟(700/650/500ms 假延时、风格/模型/分辨率设置、结果卡)`
|
||
> `/// 属 M4 范围,T3-17 原样保留**;社区发布半边自 T3-17 起改由真实发布页`
|
||
> `/// (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 行新增混在一个文件里)。
|
||
|
||
---
|
||
|
||
<a id="4"></a>
|
||
## §4 进度反馈机制:轮询 vs SSE vs WebSocket
|
||
|
||
### 4.1 先摆事实:两侧的流式基础设施都是零
|
||
|
||
| 事实 | 取证 |
|
||
| --- | --- |
|
||
| 契约无任何流式端点 | `openapi.yaml` 搜 `sse`/`stream`/`text/event-stream` → **0 命中** |
|
||
| 契约 `components.headers` 为空集 | YAML 解析 → `components.headers: []`;全契约 `headers:` 键 0 次出现 |
|
||
| 契约全部响应码 | `['200','201','202','400','401','403','404','409','422','423']`——**无 429** |
|
||
| 唯一 `202 Accepted` 端点 | `POST /api/v1/events`(埋点批量),**不是**异步任务 |
|
||
| 后端无 SSE 实现 | `patbond-api` 搜 `SseEmitter`/`text/event-stream` → **0 命中** |
|
||
| 后端无 WebSocket 实现 | 搜 `WebSocket`/`STOMP` → 未见(AI 相关代码整体 0 实现) |
|
||
| 客户端无流式依赖 | `pubspec.yaml` 无 `web_socket_channel`、无 `sse_channel`、无 `flutter_client_sse` |
|
||
| 客户端从未用过 dio 流式 | `grep -rn "ResponseType\|responseType" lib/` → **0 命中** |
|
||
| 无网关(ADR-002) | 分端口直连,`app.dart:159` 注释「三服务分端口直连(ADR-002 无网关)」 |
|
||
|
||
**这决定了选项的真实成本**:SSE/WebSocket 不是「客户端选个库」的问题,
|
||
而是**两侧在同一个迭代内同时首建长连接基础设施**。
|
||
|
||
### 4.2 三选项对比
|
||
|
||
| 维度 | A. 自适应轮询 | B. SSE | C. WebSocket |
|
||
| --- | --- | --- | --- |
|
||
| **客户端新依赖** | 0(`dio` 已有) | 0(dio `ResponseType.stream`)但需**手写 `text/event-stream` 帧解析**(dio 不提供) | **需新增** `web_socket_channel` |
|
||
| **后端新基础设施** | 一个 `GET /…/{id}` | `SseEmitter` + 心跳 + 超时 + 连接数管理 | WS endpoint + 握手鉴权 + 会话注册表 + 心跳 |
|
||
| **能否复用 `ApiClient`** | **能,100%** | **不能**:流不走 `{code,message,data}` 信封,`_unwrap`(`api_client.dart:163-176`)完全不适用 | 不能 |
|
||
| **能否复用 401/40101 单飞刷新重放** | **能**(`api_client.dart:114-128`) | **不能**:长连接中途 token 过期无「重放一次」语义,需自建「断线→刷新→按 `Last-Event-ID` 续传」 | 不能,需自建 |
|
||
| **中断恢复(杀进程后)** | **天然满足**:重进页面就是一次 GET | 需额外一个「查当前状态」端点兜底——**等于还要做 A** | 同 SSE |
|
||
| **Android Doze / 退后台** | 不受影响(无长连接) | 进后台即断,回前台要重连 | 同 SSE |
|
||
| **将来加网关** | 无影响 | 反代缓冲会吃掉 SSE 帧,需专门配置(Nginx `proxy_buffering off`),届时返工 | 需 `Upgrade` 头透传配置 |
|
||
| **端到端可测性** | **高**:注入假时钟 + 桩仓库,`fake_async` 直测(已有依赖 `fake_async: ^1.3.3`) | 低:需起真 HTTP 流服务器 | 低 |
|
||
| **契约表达成本** | 零新机制 | 需给 OpenAPI 加 `text/event-stream` 媒体类型(3.0.3 表达能力有限) | OpenAPI 无法表达,需另开文档 |
|
||
| **服务器成本(单机 MVP)** | 图片生成 10~60s,2s 间隔 ≈ 5~30 次轻量 GET/任务 | 每任务一个挂起线程/连接 | 同 SSE |
|
||
| **首屏「有反馈」延迟** | ≤ 首个轮询间隔(1s) | 即时 | 即时 |
|
||
|
||
### 4.3 推荐:**A. 自适应轮询**(并为 B 预留升级位)
|
||
|
||
**推荐理由(按权重排序)**:
|
||
|
||
1. **现有网络层与流式模型结构性不兼容**。客户端全部请求走 `ApiClient.request`
|
||
→ 信封解包(`api_client.dart:163-176`)+ 401/40101 单飞刷新重放一次(114-128)。
|
||
SSE 流两条都用不上,等于**旁路整条已建好的鉴权链,形成第二套鉴权路径**。
|
||
M3.5 的 `/api/v1/me` 接错端口事故根因就是「跨模块调用没有走既有接线纪律」——
|
||
再开一条旁路只会重演。
|
||
2. **后端零基础设施**。`SseEmitter` 0 命中意味着 M4 若选 SSE,
|
||
后端要在同一迭代内同时做「AI 模型调用 + 任务表 + 配额 + 长连接层」四件新事。
|
||
这是 M4 最可能失控的地方(见 §14 R1)。
|
||
3. **中断恢复是硬需求,而它天然要求「查状态」端点**。§5 论证:无论选哪种推送,
|
||
都必须有一个「按 taskId 查当前状态」和「列我的进行中任务」的 GET。
|
||
一旦有了这两个 GET,轮询就已经免费到手;SSE 只是在其上叠一层优化。
|
||
**换言之:A 是 B 的真子集,先做 A 不是走弯路。**
|
||
4. **可测性直接决定测试增量能否兑现**。轮询逻辑可用 `now` 注入 + `fake_async`
|
||
(`MediaUploader` 已有此范式:`media_uploader.dart:138,142,153`;
|
||
`analytics_flush_scheduler_test.dart` 已有 `fake_async` 用法)在纯单测里
|
||
把「1s→2s→3s 退避」「超时」「代次作废」全测掉,不需要真服务器。
|
||
5. **已有退避语义可照抄**。`analytics_service.dart:46-47`(30s→×2→封顶 5min)、
|
||
`204-209`(退避窗口内跳过定时冲刷、显式触发不受限)、`241-250`(`_scheduleBackoff`/`_resetBackoff`)
|
||
是一套已被 597 测试覆盖的成熟语义。
|
||
|
||
### 4.4 推荐的轮询参数(建议值,待 D4-F1 拍板)
|
||
|
||
```
|
||
阶段化间隔(首屏快、后期省):
|
||
第 1~3 次 : 1s → 短任务(缓存命中/失败快返)在 3s 内出终态
|
||
第 4~8 次 : 2s → 覆盖 10s 档
|
||
第 9 次起 : 3s → 覆盖 60s 档
|
||
上限间隔 : 5s
|
||
硬超时 : 5min(可 --dart-define 覆盖)→ 超时置 failed(retryable),不无限轮
|
||
|
||
网络错误(ApiNetworkException)时:叠加指数退避 ×2,封顶 15s;
|
||
连续 5 次网络失败 → 转「连接不稳定」提示 + 手动「重试」钮(不静默死循环)
|
||
429(配额/限流)时:读 Retry-After(§6),按其值等待;缺该头回落 30s
|
||
页面不可见(Tab 切走 / push 到别的页)时:降频到 10s(不停)
|
||
App 退后台时:停轮询(照 SessionTracker 的 onLeaveForeground 触发点)
|
||
回前台时:立即轮询一次(照 analytics 的 startPeriodicFlush 幂等模式)
|
||
```
|
||
|
||
**关键实现纪律(照抄 `CommunityController._generation`)**:
|
||
`community_controller.dart:118, 157, 163, 183, 189` 用「刷新代次」丢弃在途旧代次响应。
|
||
轮询必须有同款守卫——用户切任务/取消/重试后,旧轮询的迟到响应一律作废,
|
||
否则会出现「已取消的任务把 UI 推回 running」。
|
||
|
||
### 4.5 若拍板选 B(SSE),客户端改造面清单
|
||
|
||
(备查,非推荐路径)
|
||
|
||
1. 新建 `lib/core/network/sse_client.dart`:裸 `Dio` 实例(无 `AuthInterceptor`,
|
||
照 `DioMediaDirectUploadClient` 先例——`media_direct_upload.dart:39-52` 是「独立 Dio 实例」的样板),
|
||
`ResponseType.stream` + 手写 `data:`/`event:`/`id:`/`retry:` 帧解析 + `\n\n` 分帧。
|
||
2. Token 过期处理:不能复用 `ApiClient` 的「重放一次」。需自建
|
||
「流断 → `TokenRefresher.refresh()` → 带 `Last-Event-ID` 重连」,且要防重连风暴。
|
||
3. 必须**同时**实现 A(查状态 GET)作为兜底:退后台断流、Doze、Wi-Fi 切蜂窝都会断。
|
||
4. 契约需新增 `text/event-stream` 响应描述 + `components.headers` 首次引入。
|
||
5. 测试需起真 `HttpServer`(`analytics_service.dart:255-257` 刻意用
|
||
`@protected uploadBatch` 让测试子类替换以**避免**起真服务器——SSE 会把这条纪律破掉)。
|
||
|
||
**估算**:选 B 会让客户端测试增量再 +40~60,且引入一类难以确定性测试的时序 flake。
|
||
|
||
---
|
||
|
||
<a id="5"></a>
|
||
## §5 任务中断恢复
|
||
|
||
### 5.1 现有持久化能力盘点
|
||
|
||
| 机制 | 位置 | 适用性 |
|
||
| --- | --- | --- |
|
||
| `flutter_secure_storage`(token / userId / deviceId) | `lib/features/auth/session_manager.dart:18-28, 43-48` | 仅凭据,不放业务态 |
|
||
| `shared_preferences`(AppState demo 宠物 + 天气) | `lib/state/app_state.dart:9-10, 23-45` | demo 家具,不宜扩张 |
|
||
| `shared_preferences` 分段队列(埋点,cap 500) | `lib/analytics/analytics_event_store.dart:21-27` | 埋点专用 |
|
||
| `shared_preferences` 稳定 `anonymousId` | `lib/analytics/analytics_service.dart:51, 177-190` | 埋点专用 |
|
||
| **预签名凭据刻意不持久化** | `media_uploader.dart:124` 注释「预签名凭据只存内存、用完即弃,不持久化(既有纪律)」 | **这是一条现存纪律,M4 必须遵守** |
|
||
|
||
**关键观察**:`CommunityController` 是 Tab 级单例但**内存态**,
|
||
登出即 `reset()` 清空(`community_controller.dart:246-263`,理由是「避免上一账号数据跨会话泄漏」)。
|
||
全仓**没有任何业务任务态被持久化**。这是刻意的(防跨账号泄漏,M3 第一波「防泄漏」交付物)。
|
||
|
||
### 5.2 三种恢复方案
|
||
|
||
| 方案 | 做法 | 优点 | 缺点 |
|
||
| --- | --- | --- | --- |
|
||
| **R-A 服务端事实来源** | 新增 `GET /api/v1/creations?status=queued,running`(我的进行中任务列表)+ `GET /api/v1/creations/{id}` | **跨设备一致**;杀进程、换设备、清缓存都能找回;零本地持久化 → 零跨账号泄漏风险;与「服务端是唯一事实来源」纪律一致(`community_controller.dart:20` 注释原文) | 需后端一个列表端点 |
|
||
| **R-B 本地持久化 taskId** | `shared_preferences` 存 `pb.creation.activeTaskIds` | 后端只需单个查询端点 | 换设备找不回;清缓存丢失;**引入跨账号泄漏面**(需在 `reset()` 里清,多一处易漏);本地与服务端可能不一致(任务已完成但本地还挂着) |
|
||
| **R-C 不做恢复** | 离页即忘 | 零成本 | 图片生成 10~60s,用户切走看别的是常态,「回来发现没了」是致命体验缺陷 |
|
||
|
||
### 5.3 推荐:**R-A 服务端事实来源**(+ 极轻量本地提示)
|
||
|
||
**理由**:
|
||
1. **与既有纪律一致**。`community_controller.dart:20` 明写「服务端是唯一事实来源,内存副本仅作展示缓存」。
|
||
R-B 会开一个「本地也是事实来源」的口子。
|
||
2. **零跨账号泄漏面**。M3 第一波专门做过「防泄漏」(`app.dart:242-249` 登出即
|
||
`petsController.reset()` / `communityController.reset()` / `profileController.reset()`)。
|
||
持久化任务态就要在这里再加一处清理,且清理失败是静默的(安全事件 2026-09-11 的教训类型)。
|
||
3. **R-A 的端点本来就要做**。§4.3 论证:任何推送方案都需要查状态端点。
|
||
列表端点只是多一个 query 参数级别的增量。
|
||
4. **换设备场景真实存在**。用户在手机提交生成、去平板看,R-B 完全失效。
|
||
|
||
**建议的极轻量本地补充**(不违反上述纪律):
|
||
仅持久化一个 `int`——「上次离开时有 N 个任务在跑」,用于**冷启动首屏立刻显示「创作」Tab 角标**
|
||
而不必等首次网络返回。真值仍由 R-A 端点校正。**这个可选,D4-F2 里作为子选项。**
|
||
|
||
### 5.4 恢复的三个具体入口(UI 层)
|
||
|
||
1. **创作 Tab 角标**:`main_shell_page.dart:277-281` 的 `NavigationDestination`(创作 Tab)
|
||
加 `NavigationDestination(icon: Badge(label: Text('$n'), child: Icon(...)))`。
|
||
进行中任务数 > 0 时显示。**这是最重要的一个**——它让用户知道「东西还在」。
|
||
2. **AI 任务列表页**(§8 新页 3):进行中置顶 + 历史结果按时间倒序(游标分页复用 `CursorPage`)。
|
||
3. **进创作 Tab 时自动恢复**:`AiCreatePage.initState` 拉一次进行中列表;
|
||
若恰有 1 个在跑,**直接把页面渲染成进行中态**(照 `post_compose_page.dart:132`
|
||
`unawaited(_restoreLatestDraft())` + `_restoredBannerVisible` 提示条的先例,
|
||
见 `post_compose_page.dart:194-220, 643-670`)。
|
||
|
||
### 5.5 与 App 生命周期的接线
|
||
|
||
`lib/analytics/session_tracker.dart` 已实现 `WidgetsBindingObserver`
|
||
(`app.dart:87-95` 装配,`onLeaveForeground` / `onEnterForeground` 两个回调)。
|
||
`CreationController` 应挂同一套触发点:退后台停轮询、回前台立即轮询一次。
|
||
**注意**:`SessionTracker` 当前只被 `_analytics` 消费(`app.dart:88-93`),
|
||
需要把它改成多订阅者,或给 `CreationController` 单独加一个 observer。
|
||
建议后者(不动已被 597 测试覆盖的 `SessionTracker` 语义)。
|
||
|
||
---
|
||
|
||
<a id="6"></a>
|
||
## §6 失败、重试与配额(429/Retro-After)的 UI 契约
|
||
|
||
### 6.1 现有三层错误呈现纪律
|
||
|
||
`api_exception.dart:83-85` 注释原文:「页面按类型映射为三层错误呈现
|
||
(字段级 / 表单横幅 / SnackBar,见 12 号组装稿 §4),**服务端原始 message 一律不直接透出给用户**」。
|
||
落地样板见 `community_display.dart`:
|
||
|
||
| 函数 | 行号 | 形态 |
|
||
| --- | --- | --- |
|
||
| `feedLoadErrorMessage` | 30-34 | 首屏 error 态整页 |
|
||
| `postPublishErrorMessage`(7 分支,每条语义可辨) | 42-52 | **页内横幅**(`InlineErrorBanner`),刻意不用 SnackBar——「页内停留供对照」(`post_compose_page.dart:103`) |
|
||
| `draftSaveErrorMessage` | 55-59 | SnackBar |
|
||
|
||
**M4 照抄**:新建 `lib/features/creation/creation_display.dart`,提供
|
||
`creationSubmitErrorMessage` / `creationTaskFailureMessage` / `creationQuotaMessage`。
|
||
|
||
### 6.2 429 / Retry-After:契约与客户端双缺口(**本迭代首次引入**)
|
||
|
||
**取证(两侧都空白)**:
|
||
|
||
| 事实 | 取证 |
|
||
| --- | --- |
|
||
| 契约无 429 | 45 个 operation 的响应码全集 `['200','201','202','400','401','403','404','409','422','423']`,**429 不在其中** |
|
||
| 契约无 `Retry-After` | `grep -n "429\|Retry-After\|RateLimit\|限流" openapi.yaml` → **0 输出** |
|
||
| 契约无任何响应头机制 | `components.headers: []`,全契约 `headers:` 键 0 次出现 |
|
||
| 后端无限流实现 | `grep -rniE "429\|TOO_MANY_REQUESTS\|Retry-After\|RateLimit" patbond-*/src/main` → **0 命中** |
|
||
| 客户端**已能识别** 429 | `api_client.dart:164-167`:`if (status == 429) throw const ApiRateLimitException();` |
|
||
| 客户端**不读** `Retry-After` | 同上——`const` 构造,**丢弃了 `response.headers`** |
|
||
| `ApiRateLimitException` 无字段 | `api_exception.dart:111-113`:只有继承的 `message`,无 `retryAfter` |
|
||
| 已有 429 话术(但无时长) | `community_display.dart:32, 48, 57`:三处「请求过于频繁,请稍后再试」 |
|
||
| 埋点侧 429 的历史处置 | `analytics_service.dart:268-272` 注释原文:「429 按网络错误同路径处理(保段 + 指数退避):**后端限流尚未实现**(iteration-2/09 出入项),**Retry-After 分支待其落地后一并做**」 |
|
||
|
||
**结论**:`analytics_service.dart:268-272` 那条「待后端落地后一并做」的历史挂账,
|
||
**M4 就是它的兑付时点**——AI 配额是本项目第一个真正需要 429 的场景。
|
||
|
||
### 6.3 429 改造面(客户端,3 处)
|
||
|
||
1. **`api_exception.dart:111-113`** — `ApiRateLimitException` 加 `final Duration? retryAfter`:
|
||
```dart
|
||
final class ApiRateLimitException extends ApiException {
|
||
const ApiRateLimitException([super.message = '请求过于频繁', this.retryAfter]);
|
||
final Duration? retryAfter; // 缺该头时为 null,调用方回落缺省等待
|
||
}
|
||
```
|
||
**注意**:这是 `sealed class ApiException` 的子类,加可选位置参数**不破坏**
|
||
现有 `switch (error) { ApiRateLimitException _ => ... }` 的 6 处模式匹配
|
||
(`community_display.dart:32,48,57`、`post_analytics.dart:94`、
|
||
`feed_analytics`/`pet` 域同款)——它们都用 `_` 通配,不解构字段。
|
||
2. **`api_client.dart:163-167`** — `_unwrap` 读头:
|
||
`Retry-After` 按 RFC 7231 有**两种格式**(`delta-seconds` 整数 与 HTTP-date),
|
||
必须两种都解析(只解析整数在服务端给日期时会静默回落 null)。
|
||
3. **`creation_display.dart`(新建)** — 配额话术需区分两类:
|
||
- **限流**(短期太频繁):「操作太频繁,请 N 秒后再试」+ 倒计时禁用 CTA
|
||
- **配额耗尽**(当日/当月额度用尽):「今日免费额度已用完(N/N),明日 0 点重置」
|
||
——**这两类不能共用一条 429 话术**,否则用户会一直点重试。
|
||
区分方式待 D4-F6 拍板:建议靠**独立业务错误码**(如 `42901` 限流 / `42902` 配额耗尽),
|
||
而不是靠 429 本身——因为 `ApiCodes` 已有五位码体系(`api_exception.dart:3-81`),
|
||
且 `post_analytics.dart:180-182` 已有 `httpStatus = errorCode ~/ 100` 的推导约定。
|
||
|
||
### 6.4 失败与重试的 UI 契约(建议)
|
||
|
||
| 失败类型 | 判定依据 | 可重试 | UI 形态 | 埋点 `failureReason` |
|
||
| --- | --- | --- | --- | --- |
|
||
| 提交时参数非法 | `ApiCodes.paramError`(40000) | ❌ 终态 | 字段级红字 | `validation_error` |
|
||
| 源图 asset 未 ready | `ApiCodes.mediaNotReady`(42203) | ✅ | 页内横幅「图片还没传完」 | `media_upload_incomplete` |
|
||
| 源图 asset 不符 | `ApiCodes.mediaNotFound`(40405) | ❌ | 页内横幅 + 重选图 | `not_found` |
|
||
| 限流 | 429 + 新码 | ✅ 定时 | 倒计时禁用 CTA | `rate_limited` |
|
||
| 配额耗尽 | 429 + 新码 | ❌ 今日 | 配额横幅 + 「了解额度」 | `quota_exceeded`(新) |
|
||
| 生成中服务端失败 | 任务终态 `failed` + `failureReason` | ✅ | 结果卡位置显示失败态 + 「重试」 | 服务端给的原因 |
|
||
| 内容安全拦截 | 任务终态 + 专有原因 | ❌ 终态 | 「内容不符合规范」不给重试 | `content_rejected`(**注**:`post_analytics.dart:43-44` 已记录此枚举值「待拍板未启用」) |
|
||
| 生成超时 | 客户端硬超时 5min | ✅ | 「生成超时」+ 重试 | `timeout`(新) |
|
||
| 网络失败 | `ApiNetworkException` | ✅ | SnackBar + 自动退避重试 | `network_error` |
|
||
| 会话失效 | `SessionExpiredException` | — | 自动回登录页 | **不上报**(`post_analytics.dart:95` 先例:`SessionExpiredException _ => null`) |
|
||
|
||
**重试幂等纪律**:提交生成任务必须带 `Idempotency-Key` 且**调用方持键**——
|
||
照 `post_compose_page.dart:32-34` 注释的纪律:「网络失败重试沿用同键(服务端命中首帖,不重复建帖);
|
||
表单一经改动即换新键(避免『同键异 payload』的 40905 常态化)」,
|
||
落地在 `post_compose_page.dart:98`(`_idempotencyKey` 字段)+ `:179-182`(`_markDirty()` 置 null)
|
||
+ `:267`(`_idempotencyKey ??= _uuid.v4()`)。
|
||
**AI 生成尤其需要这条**——重复提交等于重复消耗配额和 GPU 成本。
|
||
|
||
---
|
||
|
||
<a id="7"></a>
|
||
## §7 生成结果 → 社区草稿的复用面(逐项取证)
|
||
|
||
### 7.1 逐项判定表
|
||
|
||
| # | 环节 | 现有资产 | 能否直接复用 | 取证与说明 |
|
||
| --- | --- | --- | --- | --- |
|
||
| 1 | 建草稿 `POST /api/v1/posts {status: draft}` | `createPost(request, {idempotencyKey})` | ✅ **可** | `community_repository.dart:143-155` |
|
||
| 2 | 迁移发布 `PATCH {status: published}` | `updatePost` | ✅ **可** | `community_repository.dart:164-171`;两步而非一步的理由见 `post_compose_page.dart:23-30` |
|
||
| 3 | 乐观锁冲突自动重提 | `_patchPublish` 捕 `PostVersionConflictException` → `getPost` 取新 version → 重提一次 | ✅ **可** | `post_compose_page.dart:303-328` |
|
||
| 4 | 幂等键调用方持键 + 改动换新键 | `_idempotencyKey` / `_markDirty()` | ✅ **可** | `post_compose_page.dart:98, 179-182, 267, 351-354` |
|
||
| 5 | 挂接媒体 `PostMediaAttachRequest{assetId, position, isCover, caption}` | 请求模型已定型 | ✅ **可** | `community_models.dart:150-169` |
|
||
| 6 | `media` 三态语义(null 缺席不动 / `[]` 清空 / 非空整组替换) | `UpdatePostRequest.media` | ✅ **可** | `community_models.dart:205`(注释)+ `:232` |
|
||
| 7 | 草稿恢复(最新一条) | `listMyPosts(limit:1, status: draft)` + 提示条 | ✅ **可** | `post_compose_page.dart:194-220`;提示条 widget `:643-670` |
|
||
| 8 | 发布漏斗五事件 + 媒体三段 | `PostAnalytics` | ✅ **可**(需加 AI 归因) | `post_analytics.dart:119-239`;`PostEntryPoint` 需加值,见下 |
|
||
| 9 | 发布 gating(正文非空 + 媒体全 ready + 无在途) | `_canPublish` | ⚠️ **需调整** | `post_compose_page.dart:159-163`——AI 结果不经 `_uploader`,`_uploader.isEmpty` 恒 true |
|
||
| 10 | 类目 `ai_creation` | 客户端枚举已有 | ⚠️ **读侧有、写侧被封** | 详见 7.2 |
|
||
| 11 | 媒体两步上传(选图→压缩→直传→confirm) | `MediaUploader` 全链 | ❌ **不适用** | 详见 7.3 |
|
||
| 12 | 服务端既有媒体的只读呈现 | `_draftMedia` + 提示文案 | ⚠️ **形态可借,语义要改** | `post_compose_page.dart:82, 621-636`——当前是「只读呈现,重新选图整组替换」,AI 场景是「结果图就是主体」 |
|
||
| 13 | 「保留草稿?」离页三选一 | `_onCancel` + `_ExitChoice` | ✅ **可** | `post_compose_page.dart:447-484, 641` |
|
||
| 14 | 「不保留」软删服务端草稿 | `_discardDraft` → `deletePost` + `postDeleted()` | ✅ **可** | `post_compose_page.dart:434-443` |
|
||
| 15 | 发布成功后回首页刷 Feed | `openCompose` 返回 `true` → `selectTab(0)` + `refresh()` | ✅ **可** | `main_shell_page.dart:145-163` |
|
||
| 16 | 话题 / 位置 | 无 | ❌ **无契约端点** | `post_analytics.dart:287-288` 注明「话题无契约端点,M3 恒 0」;位置 `post_compose_page.dart:612` 是 SnackBar 占位 |
|
||
|
||
**复用率结论**:16 项里 **9 项直接可用、4 项需调整、3 项不适用**。
|
||
核心的「建草稿 → 迁移发布 → 幂等 → 乐观锁 → 离页保留」整条骨架**完全可复用**,
|
||
这是 M3 留下的最大红利。真正的新工作集中在「AI 结果如何变成一个可引用的 asset」(7.3)。
|
||
|
||
### 7.2 `ai_creation` 类目:读侧已通、写侧被封(**关键取证**)
|
||
|
||
| 层 | 状态 | 取证 |
|
||
| --- | --- | --- |
|
||
| 客户端枚举 | ✅ 三值齐备 | `community_models.dart:19-35`,`aiCreation('ai_creation')` 在 22 行;注释 18 行「M4 预留值,仅读侧出现」 |
|
||
| 契约读侧 `Post` | ✅ `[general, help, ai_creation]` | `openapi.yaml:3747`(+ `:3748` description) |
|
||
| 契约读侧 `FeedCard` | ✅ 三值 | `openapi.yaml:3826` |
|
||
| 契约写侧 `CreatePostRequest` | ❌ **仅 `[general, help]`** | `openapi.yaml:3657`;`:3659` description「`ai_creation` 为 M4 预留值,M3 不开放写入(提交 400/40000)」 |
|
||
| 契约写侧 `UpdatePostRequest` | ❌ 仅两值 | `openapi.yaml:3697` |
|
||
| 后端 DTO 校验 | ❌ 正则封锁 | `CreatePostRequest.java:26` / `UpdatePostRequest.java:33`:`@Pattern(regexp = "general\|help")` |
|
||
| **数据库** | ✅ **已放行三值** | `V5__community_baseline.sql:68`:`ck_posts_category CHECK (category IN ('general','help','ai_creation'))` |
|
||
| 后端测试 | 断言当前拒绝 | `PostLifecycleIntegrationTest.java:102-104` |
|
||
| 客户端发布页的当前处置 | 读到 `ai_creation` 回落 `general` | `post_compose_page.dart:209-211`:`_category = draft.category == PostCategory.aiCreation ? PostCategory.general : draft.category` |
|
||
|
||
**M4 客户端影响**:**零模型改动**——`community_models.dart:193` 的
|
||
`if (category != null) 'category': category!.wire` 已能序列化 `'ai_creation'`。
|
||
只需后端放开两处 `@Pattern` + 契约两处 enum。
|
||
但 **`post_compose_page.dart:209-211` 那个回落必须改**——
|
||
否则 AI 草稿被恢复后类目会被悄悄降级为 `general`,是一个真实的数据损坏路径。
|
||
|
||
**额外发现(后端已预埋)**:`V5__community_baseline.sql:47-48` 已有裸列 `generation_job_id uuid`
|
||
(FK 剥离),`:95` 已有 `CREATE INDEX ix_posts_generation_job`,`:17` 注释指向
|
||
`creation.generation_jobs(id) ON DELETE SET NULL`(M4 补回)。
|
||
但**该列未出现在契约任何 schema 中**——`openapi.yaml:136` 与 `:3717` 明确声明
|
||
`generationJob` 整体裁剪不出现。
|
||
→ **客户端要不要知道「这个帖来自哪个生成任务」?** 这是 D4-F5 的一部分。
|
||
建议:**读侧暴露 `generationJobId`**(让 AI 作品在详情页能显示「查看生成参数」),
|
||
写侧由服务端从建帖请求推导(客户端传 `generationJobId` 而非自己拼 media)。
|
||
|
||
### 7.3 媒体链路:**两步上传对 AI 结果整段不适用**(推翻 create 页注释的部分表述)
|
||
|
||
`create_page.dart:122-124` 现有注释:
|
||
> `/// AI 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media`
|
||
> `/// asset),走不了两步上传,故不接真实发布链路`
|
||
|
||
**核实:前半句成立,但「无 media asset」的表述会误导 M4 设计。** 精确表述应是:
|
||
|
||
1. **两步上传确实不适用**。`MediaUploader` 的入口是 `PickedMediaImage`
|
||
(`media_uploader.dart:69` `final PickedMediaImage source`),
|
||
管线为「压缩(`_compress` 325-357)→ createUpload(471-482)→ 预签名 PUT 直传(399-408)→ confirm(443)」。
|
||
AI 结果图**在服务端生成,客户端手里没有字节**。让客户端下载再上传是荒谬的(双倍流量 + 双倍存储)。
|
||
2. **但「无 media asset」是错的方向**。正确做法是**服务端在生成完成时直接建 `media.assets` 行**,
|
||
任务结果里返回 `resultAssetId`,客户端**只引用**。
|
||
证据这条路是通的:`PostMediaAttachRequest` 只需要 `assetId`(`community_models.dart:158`),
|
||
不关心 asset 是怎么来的;`MediaAsset.purpose` 在契约读侧
|
||
(`openapi.yaml:3524-3526`)是**无 enum 约束的自由 string**,读侧向后兼容。
|
||
|
||
**因此 purpose 白名单是唯一的真实卡点**:
|
||
|
||
| 事实 | 取证 |
|
||
| --- | --- |
|
||
| 契约写侧 purpose 三值 | `openapi.yaml:3460`:`enum: [post_image, user_avatar, pet_avatar]` |
|
||
| 后端白名单三值(应用层,不可配置外的默认) | `MediaProperties.java:66`;`application.yml:45` |
|
||
| DB 层无 CHECK 约束 | `V1__identity_media_baseline.sql:209`:`purpose varchar(32) NOT NULL`——**纯应用层白名单,加值无需迁移** |
|
||
| 客户端枚举三值 | `community_models.dart:67-75` |
|
||
| 「用途即引用侧类型检查」 | `community_models.dart:64-66` 注释:「引用时服务端校验 purpose 相符,故帖图不能当头像」;不符即 404/40405 |
|
||
| **契约内部已有不一致** | `openapi.yaml:1228` 端点 description 仍写「purpose 仅 post_image」,与 `:3460` 三值矛盾——M3.5 追加枚举时漏改。**若 M4 再加值,这处必须一并修** |
|
||
|
||
**推荐(D4-F3)**:新增 purpose 值 `ai_image`,并在建帖引用校验里与 `post_image` **同等放行**。
|
||
不复用 `post_image` 的理由:purpose 决定 objectKey 前缀与生命周期策略,
|
||
AI 生成图的清理/配额统计口径与用户上传图不同,混在一起将来无法分开。
|
||
|
||
### 7.4 结果 → 草稿的落地方式:复用 `PostComposePage` 还是新建
|
||
|
||
**推荐:复用 `PostComposePage`,扩展一个「预置服务端 asset」入口**(D4-F5)。
|
||
|
||
需要的改动(4 处,均在 `post_compose_page.dart`):
|
||
|
||
1. **新增构造参数** `List<PostMediaAttachRequest>? presetMedia` + `PostCategory? presetCategory`
|
||
+ `String? generationJobId`(现有构造 38-46 行)。
|
||
2. **`_canPublish`(159-163)需改**:当前条件 `(_uploader.isEmpty || _uploader.allReady)`
|
||
在预置媒体场景下恒 true,但正文仍必填——这条不变;
|
||
需补「预置媒体存在时不允许 `_uploader` 再加图」或允许混合(待 UI 规范定)。
|
||
3. **`_mediaAttachOrNull()`(249-250)需改**:现在只从 `_uploader` 取,
|
||
需变成 `presetMedia ?? (_uploader.isEmpty ? null : _uploader.buildAttachRequests())`。
|
||
4. **`_restoreLatestDraft()`(194-220)的 `ai_creation` 回落(209-211)必须去掉**(见 7.2)。
|
||
|
||
**不新建 AI 专用发布页的理由**:`PostComposePage` 704 行里承载了
|
||
幂等键管理、乐观锁重提、草稿恢复、离页三选一、发布失败但草稿已存的语义——
|
||
这些都是 T3-17 踩过坑才写对的(`post_compose_page.dart:23-34` 注释记录了取舍)。
|
||
复制一份 AI 版必然漂移。
|
||
|
||
---
|
||
|
||
<a id="8"></a>
|
||
## §8 新增页面与 widget 清单
|
||
|
||
### 8.1 新增页面:**6 个**(4 AI + 2 历史遗留搭车)
|
||
|
||
| # | 页面 | 建议路径 | 形态 | `AnalyticsPageName` | 说明 |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| 1 | **AI 创作页** | `lib/features/creation/ai_create_page.dart` | Tab 内联(替换 `CreatePage` 模拟半边) | `aiCreate('ai_create')` | 源图选择 + 模型/风格/尺寸 + 配额条 + 「开始生成」;有在途任务时渲染进行中态 |
|
||
| 2 | **AI 结果页** | `lib/features/creation/ai_result_page.dart` | push 全屏 | `aiResult('ai_result')` | 结果预览(可放大)+ 「重新生成」+ **「发布到社区」→ `PostComposePage`(预置 asset)** |
|
||
| 3 | **我的创作** | `lib/features/creation/ai_task_list_page.dart` | push 全屏 | `aiTaskList('ai_task_list')` | 进行中置顶 + 历史结果游标分页;**中断恢复主入口** |
|
||
| 4 | **配额/额度说明** | `lib/features/creation/ai_quota_sheet.dart` | `showModalBottomSheet` | 不登记(sheet 非路由) | 已用/剩余/重置时间;429 配额耗尽时的落地页 |
|
||
| 5 | **我的收藏** | `lib/features/community/my_bookmarks_page.dart` | push 全屏 | `myBookmarks('my_bookmarks')` | 搭车项;数据层已就绪(`listMyBookmarks`) |
|
||
| 6 | **我的草稿** | `lib/features/community/my_drafts_page.dart` | push 全屏 | `myDrafts('my_drafts')` | 搭车项;数据层已就绪(`listMyPosts(status: draft)`) |
|
||
|
||
**页面数口径说明**:若 D4-F4 拍板「进行中独立成页」则为 7 个;
|
||
本报告推荐**进行中不独立成页**(内联在页 1),理由见 8.4。
|
||
|
||
### 8.2 新增 feature 目录文件(非页面)
|
||
|
||
```
|
||
lib/features/creation/
|
||
creation_models.dart # 任务/结果/配额/模型字典/风格字典 请求响应模型
|
||
creation_repository.dart # abstract + ApiCreationRepository(照 community 结构)
|
||
creation_exceptions.dart # AI 域错误码 → 类型化异常 + map 函数
|
||
creation_display.dart # 错误话术 + 阶段文案 + 相对时间(照 community_display)
|
||
creation_controller.dart # 任务编排 + 轮询 + 代次守卫(照 MediaUploader/CommunityController)
|
||
creation_analytics.dart # 强类型埋点封装(照 post_analytics.dart)
|
||
ai_create_page.dart
|
||
ai_result_page.dart
|
||
ai_task_list_page.dart
|
||
ai_quota_sheet.dart
|
||
```
|
||
|
||
**命名建议 `creation/` 而非 `ai/`**:与后端已预埋的 schema 名一致
|
||
(`V5__community_baseline.sql:17` 注释 `creation.generation_jobs`),跨仓一个词。
|
||
|
||
### 8.3 新增 / 改造 widget 清单
|
||
|
||
**新增(建议 6 个)**:
|
||
|
||
| widget | 建议路径 | 用途 | 复用基础 |
|
||
| --- | --- | --- | --- |
|
||
| `GenerationProgressCard` | `lib/core/widgets/generation_progress_card.dart` | 排队/进行中卡:阶段清单 + 进度条 + 预计剩余 + 取消 | 改造 `create_page.dart:532-563` `_GenerationProgress`(去掉假 step) |
|
||
| `QuotaBanner` | `lib/core/widgets/quota_banner.dart` | 「今日剩余 N 次」/ 耗尽态 | 形态照 `InlineErrorBanner` |
|
||
| `StylePickerStrip` | `lib/features/creation/…` | 横滑风格卡(选中态描边) | 直接改造 `create_page.dart:218-292`(已是成品,只需换数据源) |
|
||
| `AiTaskTile` | `lib/features/creation/…` | 任务列表条目(缩略图 + 状态徽章 + 时间) | 参考 `PostCard` 结构 |
|
||
| `CountdownRetryButton` | `lib/core/widgets/…` | 429 倒计时禁用 CTA | 无现成,新写 |
|
||
| `AspectRatioChoiceRow` | 复用 `create_page.dart:495-530` `_ChoiceRow` | 尺寸/比例选择 | **已是成品**,提升为公共 widget |
|
||
|
||
**改造(4 处)**:
|
||
|
||
| 位置 | 改造内容 | 关联 |
|
||
| --- | --- | --- |
|
||
| `lib/core/widgets/upload_progress_overlay.dart` | 六态映射扩展到 AI 任务态(或新建同构 overlay) | §2.5 |
|
||
| `lib/features/main/main_shell_page.dart:277-281` | 创作 Tab 加 `Badge`(进行中任务数) | §5.4 |
|
||
| `lib/features/community/post_compose_page.dart` | 4 处改动(见 §7.4) | §7.4 |
|
||
| `lib/features/profile/profile_page.dart:235` | 收藏/草稿菜单项 `onTap` 从 `showDemoMessage` 改为真实导航 | §12.1 |
|
||
|
||
### 8.4 为什么「进行中」不独立成页
|
||
|
||
1. **独立成页会与中断恢复冲突**。若进行中是一个 push 页,用户返回后这个页就没了,
|
||
必须靠列表页找回——等于两条恢复路径。内联在创作 Tab 则「回到创作 Tab 就看到」。
|
||
2. **`IndexedStack` 保活是免费的**。`main_shell_page.dart:266`
|
||
`IndexedStack(index: currentIndex, children: pages)`——Tab 切走时页面**不销毁**,
|
||
`State` 保留,轮询可继续(降频)。这是现成的保活机制。
|
||
3. **`CreatePage` 已是 Tab 内联页**(`main_shell_page.dart:187-191`),改造成本最低。
|
||
|
||
**代价**:`AiCreatePage` 会同时承载「参数选择」与「进行中」两种态,
|
||
需要清晰的态机切换(见 §9),否则会长成第二个 704 行文件。
|
||
建议把两态拆成两个 private widget,页面本体只做态分发。
|
||
|
||
### 8.5 路由与埋点接线
|
||
|
||
- 页 2/3/5/6 是 push 页,走 `MaterialPageRoute` + `settings: RouteSettings(name: …pageName)`
|
||
(照 `main_shell_page.dart:130-140` `openPost` 与 `:146-156` `openCompose` 先例),
|
||
`AnalyticsRouteObserver`(`app.dart:109-112, 317`)自动产生 `page_viewed`,**零额外埋点代码**。
|
||
- 页 1 是 Tab 内联,需手动补点——照 `main_shell_page.dart:99-105` `_tabPages` 映射表。
|
||
**但注意**:创作 Tab 当前映射到 `AnalyticsPageName.create`(`:100` 第 2 项)。
|
||
改造后是否换页名(`create` → `ai_create`)**需与数据侧对齐**——
|
||
换名会断掉历史趋势,`analytics_page_name.dart:97-98` 有先例记录
|
||
(「档案 Tab 自 T2-12 起页名由 `pet_archive` 改报 `pet_list`」)。建议**保留 `create` 不换**。
|
||
- 新页名必须同时进后端 `EventDictionary` 的 `page_viewed` props 白名单,否则整条 rejected(§10)。
|
||
|
||
---
|
||
|
||
<a id="9"></a>
|
||
## §9 状态管理方案
|
||
|
||
### 9.1 沿用现有分层,不引入新框架
|
||
|
||
**现状取证**:全仓无 `provider` / `riverpod` / `bloc` / `get_it`
|
||
(`pubspec.yaml` 依赖仅 `shared_preferences` / `dio` / `flutter_secure_storage` / `uuid` /
|
||
`package_info_plus` / `image_picker` / `flutter_image_compress` / `flutter_localizations` / `intl`)。
|
||
状态管理是**原生 `ChangeNotifier` + 构造注入 + `ListenableBuilder`/`AnimatedBuilder`**:
|
||
|
||
| 层 | 实例 | 装配点 |
|
||
| --- | --- | --- |
|
||
| Tab 级单例 controller | `PetsController` / `CommunityController` / `ProfileController` | `app.dart:115-132`,构造注入进 `MainShellPage` |
|
||
| 页面级状态 | 各 `State`(如 `_PostComposePageState`) | 按页自建 |
|
||
| 编排器(页面生命周期) | `MediaUploader`(每次进发布页建一个,退出即 dispose) | `post_compose_page.dart:126-129, 140-148` |
|
||
| 抽象注入口(测试) | `App({sessionManager, authRepository, petsRepository, communityRepository, mediaUploaderFactory, avatarUploaderFactory})` | `app.dart:31-40` |
|
||
|
||
**纪律原文**(`community_controller.dart:18-20`):
|
||
「Tab 级单例:Feed 是游标累积流,且详情页与首页共享同一份帖子内存副本,不做页面级 state。
|
||
评论列表只属详情页,按『页面级状态按页自建』纪律经 repository 自取,不膨胀本控制器。
|
||
服务端是唯一事实来源,内存副本仅作展示缓存。」
|
||
|
||
**M4 判定:不引入任何新状态管理库。** 理由:597 测试全部建立在
|
||
「构造注入假实现 + `pumpWidget`」的模式上;引入 DI 容器要重写测试基建。
|
||
|
||
### 9.2 `CreationController`:Tab 级单例
|
||
|
||
**判定为 Tab 级单例(不是页面级)**,理由:
|
||
1. 进行中任务需跨 Tab 存活(用户切去首页刷 Feed,任务要继续轮询 + Tab 角标要更新)。
|
||
2. 创作页、结果页、任务列表页**三页共享同一份任务内存副本**——
|
||
与 `CommunityController` 让首页 Feed 与详情页共享帖子副本同构(`community_controller.dart:120-121`)。
|
||
3. 轮询定时器需要一个比页面更长的宿主。
|
||
|
||
**装配位置**:`app.dart` 与其他三个 controller 并列(`app.dart:115-132` 附近),
|
||
注入 `MainShellPage`(`app.dart:286-302` 参数列表)。
|
||
**必须同时在 `_reportAuthStateChange`(`app.dart:242-249`)加 `creationController.reset()`**——
|
||
否则跨账号泄漏(这是 M3 第一波「防泄漏」的既定纪律,漏一处就是安全缺口)。
|
||
|
||
### 9.3 任务态机
|
||
|
||
照 `MediaItemPhase`(`media_uploader.dart:17-24`)的写法,编译期枚举锁死:
|
||
|
||
```dart
|
||
/// AI 生成任务阶段。服务端权威,客户端只做映射与呈现。
|
||
/// 生命周期:submitting → queued → running → succeeded | failed | cancelled
|
||
enum CreationTaskPhase {
|
||
submitting, // 客户端本地态:请求已发出、taskId 未回
|
||
queued, // 服务端已受理,排队中
|
||
running, // 生成中(可带 progress 或阶段标签)
|
||
succeeded, // 有 resultAssetId(唯一可交付态)
|
||
failed, // 带 failureReason + retryable
|
||
cancelled, // 用户主动取消
|
||
}
|
||
```
|
||
|
||
**关键不变式(照 `MediaUploadItem` 的构造期断言,`media_uploader.dart:37-40`)**:
|
||
|
||
```dart
|
||
assert((resultAssetId != null) == (phase == CreationTaskPhase.succeeded),
|
||
'resultAssetId 与 succeeded 态严格绑定(未完成结果不得被引用)');
|
||
```
|
||
|
||
这条断言让「把未完成任务的结果发到社区」在类型层面不可能发生——
|
||
与 `buildAttachRequests()` 非全 ready 即抛 `StateError`(`media_uploader.dart:216-219`)同一思路。
|
||
|
||
**对外快照不可变**:`@immutable class CreationTask`,controller 内部持可变
|
||
`_CreationTaskState`(含 `cancelled` 旗标、`attemptSeq`、`attemptStartedAt`、`pollTimer`)。
|
||
|
||
### 9.4 页面态(`AiCreatePage`)
|
||
|
||
```
|
||
配置态(无在途任务) → 参数选择 UI + 「开始生成」CTA
|
||
在途态(1 个在途) → GenerationProgressCard + 取消
|
||
在途态(多个在途) → 汇总条 + 「查看全部」→ 任务列表页
|
||
恢复中(首帧拉列表) → FeedSkeleton 骨架(复用)
|
||
配额耗尽 → QuotaBanner + CTA 禁用
|
||
```
|
||
|
||
### 9.5 必须实现的三条守卫
|
||
|
||
| 守卫 | 现有先例 | 为什么必须 |
|
||
| --- | --- | --- |
|
||
| **代次守卫** `_generation` | `community_controller.dart:118, 157, 163, 183, 189` | 取消/重试/切任务后,旧轮询的迟到响应必须作废,否则 UI 被推回错误态 |
|
||
| **`cancelled` 旗标** | `media_uploader.dart:78, 309, 317, 371` 等 12 处检查 | 任务被删除后在途请求结果一律丢弃 |
|
||
| **`_disposed` 旗标 + `_notify()`** | `community_controller.dart:123, 327-329, 331-335` | 轮询回调在 controller dispose 后触发 `notifyListeners` 会抛异常 |
|
||
|
||
### 9.6 时钟与轮询器注入(测试可行性的前提)
|
||
|
||
```dart
|
||
CreationController({
|
||
required CreationRepository repository,
|
||
CreationAnalytics? analytics,
|
||
DateTime Function()? now, // 照 media_uploader.dart:138,142
|
||
Duration Function(int attempt)? interval, // 轮询间隔策略,测试可注入常量
|
||
})
|
||
```
|
||
|
||
`fake_async: ^1.3.3` 已在 `dev_dependencies`,
|
||
`test/analytics/analytics_flush_scheduler_test.dart` 已有用法样板。
|
||
**没有这两个注入口,轮询就只能用真实 `await Future.delayed` 测,
|
||
单测会慢到不可接受且 flake**——这是测试增量能否兑现的技术前提。
|
||
|
||
---
|
||
|
||
<a id="10"></a>
|
||
## §10 埋点增量
|
||
|
||
### 10.1 权威白名单是 **41 条**,不是 42(修正)
|
||
|
||
| 事实 | 取证 |
|
||
| --- | --- |
|
||
| 白名单唯一权威来源 | `patbond-api/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java:42`(`WHITELIST = Map.ofEntries(`),条目 43-103,闭合 104 |
|
||
| **精确条数 41** | `grep -c "Map.entry(" EventDictionary.java` → **41** |
|
||
| **不可配置** | `grep -rn "allowed-events\|allowedEvents\|eventWhitelist"` → **0 命中**——只能改 Java 源码 |
|
||
| 分域构成 | 11 auth + 1 page + 3 pet + 7 health_record + 8 post + 2 feed + 8 interactions + 1 experiment = 41 |
|
||
| **无任何 AI 事件** | 无 `ai_*` / `generation_*` / `creation_*` |
|
||
| 客户端实际发出 | **33 个**(`_track('…')` 28 个 + `auth_repository.dart:78,83,109,113,134` 直调 5 个) |
|
||
| 差额 8 个(白名单有、客户端未发) | `auth_register_started`、`auth_token_refresh_succeeded/failed`、`auth_session_restore_started/succeeded/failed`、`health_record_deleted`、`experiment_exposed` |
|
||
|
||
**结论:任务书里「现有白名单 42 条」应更正为 41 条。**
|
||
`experiment_exposed`(`EventDictionary.java:103`)是 A/B 前置,M4 首用(非 AI 本身)。
|
||
|
||
### 10.2 客户端埋点实现位置
|
||
|
||
| 组件 | 路径 |
|
||
| --- | --- |
|
||
| 上报客户端(队列 + 冲刷 + 退避 + 隐私红线) | `lib/analytics/analytics_service.dart`(296 行) |
|
||
| 分段持久化队列 | `lib/analytics/analytics_event_store.dart` |
|
||
| 会话与前后台 | `lib/analytics/session_tracker.dart` |
|
||
| 页面曝光 | `lib/analytics/page_view_tracker.dart` + `analytics_route_observer.dart` |
|
||
| 页名枚举 | `lib/analytics/analytics_page_name.dart:7-39` |
|
||
| 域封装(5 个) | `features/pets/pet_analytics.dart`、`features/pets/health_record_analytics.dart`、`features/community/feed_analytics.dart`、`features/community/community_interaction_analytics.dart`、`features/community/post_analytics.dart` |
|
||
| 装配 | `app.dart:97-101`(`apiBaseUrl: patbondUserApiBaseUrl` → **:8082 正确**)、`:139-143` |
|
||
|
||
### 10.3 `platform=linux` 桌面批量 400:根因取证(**推翻客户端注释**)
|
||
|
||
**客户端注释的说法**(`lib/analytics/analytics_service.dart:99-102`):
|
||
|
||
> `/// 平台标识。契约枚举为 android/ios;Web/桌面为开发调试形态,`
|
||
> `/// 上报值不在枚举内会被服务端`**`逐条 rejected`**`(不影响客户端),属预期。`
|
||
|
||
**核实结论:这条注释是错的。** 实际是**整批 400,全批丢弃**。
|
||
|
||
| 环节 | 取证 | 说明 |
|
||
| --- | --- | --- |
|
||
| 客户端桌面上报 `'linux'` | `analytics_service.dart:101-112` `_platformName()`:非 web/android/ios 时 `return Platform.operatingSystem` → Linux 桌面得 `'linux'` | |
|
||
| 契约枚举两值 | `openapi.yaml:2373-2375`:`enum: [android, ios]`;`platform` 在 required(`:2328`) | |
|
||
| 后端**DTO 层** Bean Validation | `TrackEventsRequest.java:56-58`:`@NotNull` + `@Pattern(regexp = "^(android\|ios)$")` on `platform` | **关键** |
|
||
| **`@Valid` 级联到列表元素** | `TrackEventsRequest.java:16-19`:`@Valid @NotNull @Size(min=1,max=50) private List<TrackedEvent> events;` | 任一元素校验失败 → `MethodArgumentNotValidException` → **整个请求 400** |
|
||
| 逐条 rejected 的逻辑在**更后面** | `AnalyticsService.java:49`(`unknown_event_name`)、`:55`(`identity_mismatch`)、`:65`(`forbidden_field`)、`:83`(`schema_invalid`) | 这四类**才是**逐条 rejected;`platform` 根本走不到这里 |
|
||
| 客户端把 4xx 当永久拒绝 | `analytics_service.dart:273-281`:`if (status >= 400 && status < 500) { …dropping ${events.length} events…; return true; }` | **整批静默丢弃** |
|
||
| 丢弃即删段 | 同上 `:229` `_store.removeSegments(batch.segmentIds, countAsDropped: rejected)` | 不重试、不保留 |
|
||
|
||
**净效果**:Linux 桌面(以及 macOS/Windows/Web)跑 app 时,
|
||
**每一批埋点都在 DTO 校验层被整批 400,客户端随即整批删除**。
|
||
桌面上的埋点是 **100% 丢失**,不是「逐条 rejected 的少量损耗」。
|
||
|
||
**M4 影响**:`integration_test/` 4 份真机脚本(本身已无门禁,见 §1)若在 Linux 桌面跑,
|
||
埋点断言无法成立。AI 链路的漏斗埋点若只在桌面验证,等于没验证。
|
||
|
||
**修复选项(D4-F8)**:
|
||
- **(a) 后端 `@Pattern` 加桌面值**:`^(android|ios|linux|macos|windows|web)$` + 契约 enum 同步 + `EventDictionary` 无关。
|
||
优点:桌面实测可验埋点。缺点:生产数据里混入开发平台,需在分析侧过滤。
|
||
- **(b) 客户端桌面直接不上报**:`_platformName()` 返回非 android/ios 时,
|
||
`trackEvent` 直接 return(一行短路)。优点:数据干净、零后端改动。
|
||
缺点:桌面永远验不了埋点——**与 AI 长链路的验证需求冲突**。
|
||
- **(c) 后端保留两值,但把 `platform` 校验从 DTO 层下移到逐条校验**:
|
||
即在 `AnalyticsService` 里判 platform 并 `EventResult.rejected(…, "platform_invalid")`。
|
||
优点:让客户端注释描述的行为**变成真的**(逐条 rejected、不整批 400),
|
||
桌面上其余字段仍走通全链路。缺点:改后端校验层次。
|
||
- **推荐 (c) + (a) 组合**:把 platform 从 DTO `@Pattern` 移到逐条校验(消除整批 400 这个真实缺陷,
|
||
这是**任何平台**的健壮性改进),同时加一个 `--dart-define` 门控的桌面 platform 映射
|
||
(桌面实测时映射为 `android` 以验通全链路)。
|
||
|
||
**无论选哪个,`analytics_service.dart:99-102` 那条注释必须改**——它现在在误导后续所有人。
|
||
|
||
### 10.4 AI 创作链路需要的新事件(建议 9 条)
|
||
|
||
命名沿用既有域前缀风格(`post_*` / `feed_*` / `pet_*`),新前缀 `creation_*`。
|
||
**全部需要改 `EventDictionary.java`**(不可配置)。
|
||
|
||
| # | 事件名 | 触发时机 | props(建议) |
|
||
| --- | --- | --- | --- |
|
||
| 1 | `creation_started` | 进创作页并产生**首次参数改动或选源图**(每次进入记一次,照 `post_create_started` 口径 `post_analytics.dart:129`) | `entryPoint`(`create_tab`/`pet_detail`/`ai_task_list`) |
|
||
| 2 | `creation_submitted` | 提交生成**成功受理**(taskId 已回) | `modelId`、`styleId`、`aspectRatio`、`hasSourceImage`(bool)、`attemptSeq` |
|
||
| 3 | `creation_submit_failed` | 提交被拒 / 网络失败 / 配额耗尽 | `failureReason`、`attemptSeq`、`errorCode?`、`httpStatus?` |
|
||
| 4 | `creation_succeeded` | 任务终态 `succeeded` | `durationMs`(submitted→succeeded)、`modelId`、`styleId`、`queueWaitMsBucket` |
|
||
| 5 | `creation_failed` | 任务终态 `failed` / 客户端超时 | `failureReason`、`durationMs`、`modelId`、`attemptSeq`、`errorCode?` |
|
||
| 6 | `creation_cancelled` | 用户主动取消在途任务 | `phaseAtCancel`(`queued`/`running`)、`elapsedMsBucket` |
|
||
| 7 | `creation_result_viewed` | 结果页曝光(**注**:也可只靠 `page_viewed`,见下) | `modelId`、`styleId` |
|
||
| 8 | `creation_published` | AI 结果**成功发到社区** | `durationMs`(succeeded→published)、`fromTaskList`(bool) |
|
||
| 9 | `creation_quota_exhausted` | 配额耗尽横幅**首次呈现** | `quotaScope`(`daily`/`monthly`) |
|
||
|
||
**可裁剪建议**:#7 与 `page_viewed{pageName: ai_result}` 重复,
|
||
建议**删掉 #7,靠 `page_viewed` 覆盖**(`AnalyticsRouteObserver` 自动产生,零代码)。
|
||
→ 净新增 **8 条**,白名单 41 → **49**。
|
||
|
||
### 10.5 隐私红线复核(三条都要守)
|
||
|
||
| 红线 | 出处 | AI 链路的落点 |
|
||
| --- | --- | --- |
|
||
| 内容 ID 不进 props | `post_analytics.dart:11-12`(红线 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。
|
||
|
||
---
|
||
|
||
<a id="11"></a>
|
||
## §11 demo/占位盘点与「demo 消亡」清单
|
||
|
||
延续前三个迭代的「demo 消亡」口径(M2 消亡 demo 发布流、M3 消亡详情三页、M3.5 消亡首页问候语硬编码)。
|
||
|
||
### 11.1 M4 会让哪些占位消亡(**确定消亡**)
|
||
|
||
| # | 占位 | 位置 | 消亡方式 |
|
||
| --- | --- | --- | --- |
|
||
| 1 | AI 生成三段假延时(700/650/500ms) | `create_page.dart:57, 78, 82` | 真实任务提交 + 轮询 |
|
||
| 2 | 假结果图 = 风格卡自己的封面 | `create_page.dart:86` | 服务端 `resultAssetId` |
|
||
| 3 | 假四步进度清单 | `create_page.dart:539` + `_GenerationProgress` 532-563 | 服务端阶段/进度 |
|
||
| 4 | 假上传(读 demo 宠物头像) | `create_page.dart:55-64` + `_UploadCard` 421-493(含 486 行「演示模式会读取豆豆的档案头像」) | 真实 `image_picker` + 两步上传 |
|
||
| 5 | AI 发布占位 SnackBar | `create_page.dart:122-129` | 真实 → `PostComposePage`(预置 asset) |
|
||
| 6 | 自动填标题/正文的字符串拼接 | `create_page.dart:87-90` | 删除(或服务端文案建议) |
|
||
| 7 | 硬编码模型列表 | `create_page.dart:179` `['Patbond-V1','Pet-Art Pro','Cute Motion']` | 服务端字典端点 |
|
||
| 8 | 硬编码分辨率/时长 | `create_page.dart:193, 201` | 服务端字典端点 |
|
||
| 9 | 死字段 `upscaling`(零消费) | `create_page.dart:40, 210` | 删除或接真参数 |
|
||
| 10 | 本地 `tags` 列表 + `addTag()` 弹窗 | `create_page.dart:46, 94-120, 348-367` | **删除**(契约无话题端点) |
|
||
| 11 | 写死位置「北京市 · 朝阳区」(无 `onTap`) | `create_page.dart:369-374` | 删除 |
|
||
| 12 | `creationStyles` demo 常量(4 个 unsplash URL) | `lib/data/demo_data.dart:206-233` | 服务端风格字典 |
|
||
| 13 | `CreationStyle` demo 模型类 | `lib/models/models.dart:285-297` | 替换为 `creation_models.dart` 中的契约模型 |
|
||
| 14 | 「我的收藏与草稿」演示提示 | `profile_page.dart:235` → `showDemoMessage`(`:81-85`) | 真实导航(**搭车项**,D4-F9) |
|
||
|
||
**消亡后 `create_page.dart` 563 行 → 约 40 行**(只剩 `_ComposeEntryCard` + 新页入口)。
|
||
`demo_data.dart` 的 `create` 相关消费点从 4 个减到 3 个
|
||
(`grep -rn "demo_data.dart" lib/`:`home_page.dart:7`、`services_page.dart:3`、`app_state.dart:4`、
|
||
`create_page.dart:3` → 后者消亡)。
|
||
|
||
### 11.2 M4 **不消亡**(刻意保留,已由 ADR-022 决策 D3.5-1 钉死)
|
||
|
||
| 占位 | 位置 | 归属里程碑 |
|
||
| --- | --- | --- |
|
||
| 首页天气与定位(需外部服务 + API key + 配额) | `home_page.dart:583-584, 781`(「当前为演示天气」) | M5 |
|
||
| 首页服务商卡 demo 图 | `home_page.dart:684, 689` | M5 |
|
||
| 首页「柴犬圈 / 猫咪圈」话题圈 | `home_page.dart:930` | 无契约端点 |
|
||
| 首页促销卡「去使用」 | `home_page.dart:1003` | M5 |
|
||
| 首页搜索仅过滤已加载缓存(不发检索请求) | `home_page.dart:266` | 需检索端点 |
|
||
| 本地服务 Tab 全页 demo | `services_page.dart` + `demo_data.dart:235-245` `serviceCategories` | M5 服务域 |
|
||
| 「我的预约订单」「宠物健康卡包」「地址与定位管理」「设置与关于」 | `profile_page.dart:44-45, 51-55` | M5 / 待有可设项 |
|
||
| 「恢复演示数据」按钮 | `profile_page.dart:104-129, 247` | 随 `AppState` demo 家具收敛 |
|
||
| `AppState.pet`(首页问候卡 / 主壳头像仍消费的 demo 宠物) | `app_state.dart:12-19` | 「随后续工单收敛」(注释原文) |
|
||
| 通知按钮「暂无新通知 🐾」 | `main_shell_page.dart:254-259` | 需通知端点 |
|
||
| 发布页位置「位置功能即将上线」 | `post_compose_page.dart:612` | 无契约端点 |
|
||
| 详情页分享「分享功能即将上线」 | `post_detail_page.dart:446` | 待定 |
|
||
| 宠物表单头像本地占位(ADR-010) | `pet_form_page.dart:23, 398` | **注**:M3.5-09 已接宠物头像上传,此注释可能已过时,**未取证** |
|
||
| 登录页预留区(不渲染任何占位,ADR-004) | `login_page.dart:221` | 刻意不做 |
|
||
|
||
### 11.3 一个需要注意的 demo 依赖
|
||
|
||
`create_page.dart:162` 把 `widget.appState.pet.avatarUrl` 传给 `_UploadCard` 作假图源。
|
||
`AiCreatePage` 若要「用我的宠物照片生成」,**应改从 `PetsController` 取真实宠物头像**
|
||
(`pets_controller.dart`,Tab 级单例,`app.dart:115-117` 已装配),
|
||
而不是继续依赖 `AppState.pet` 这个 demo 家具。
|
||
|
||
**这是一个真实的产品机会**:宠物档案里已有真实头像(M3.5-09 落地,
|
||
`lib/core/widgets/pet_avatar.dart` + `avatar_upload_sheet.dart`),
|
||
AI 创作直接「选一只我的宠物」比「从相册选图」体验好得多,且省一次上传。
|
||
→ 建议列入 D4-F4 的子选项:**源图支持「从我的宠物档案选」+「从相册选」双入口**。
|
||
前者复用已 ready 的 `pet_avatar` asset,**零上传**(但需确认 purpose 校验是否放行
|
||
`pet_avatar` 作为 AI 输入——见 §7.3 的 purpose 白名单卡点)。
|
||
|
||
---
|
||
|
||
<a id="12"></a>
|
||
## §12 历史遗留搭车判断
|
||
|
||
### 12.1 「我的收藏与草稿」列表页 —— **强烈建议搭车**
|
||
|
||
| 项 | 状态 | 取证 |
|
||
| --- | --- | --- |
|
||
| 后端能力 | ✅ 就绪 | 契约 `/api/v1/me/bookmarks`、`/api/v1/me/posts?status=draft` |
|
||
| 客户端仓库方法 | ✅ **已实现** | `community_repository.dart:274-283` `listMyBookmarks`;`:179-193` `listMyPosts({status})` |
|
||
| 已有测试覆盖 | ✅ 在 597 内 | `test/features/community/community_repository_test.dart` |
|
||
| 分页泛型 | ✅ 复用 | `CursorPage<FeedCard>` / `CursorPage<Post>` |
|
||
| 列表卡片 widget | ✅ 复用 | 收藏页用 `PostCard`(`lib/core/widgets/post_card.dart`,返回 `FeedCard`,形态完全一致) |
|
||
| 入口 | ⚠️ 当前是演示提示 | `profile_page.dart:235` `onTap: () => showDemoMessage(context, item.$2)` |
|
||
| **缺什么** | **只缺 2 个页面** | 数据层、卡片、分页、错误话术全部现成 |
|
||
|
||
**判定:搭车成本最低、价值最高的一项。** 且与 M4 有直接协同——
|
||
AI 结果建成草稿后,用户需要一个地方找到它。**没有草稿列表页,AI 草稿就是黑洞。**
|
||
草稿页还需要「继续编辑」入口 → `PostComposePage`(需支持传入指定 draftId,
|
||
当前只能恢复「最新一条」,`post_compose_page.dart:194-199` `limit: 1`)。
|
||
|
||
**改造点**:`_restoreLatestDraft()` 需扩展为可选指定 draftId。
|
||
|
||
### 12.2 草稿自动保存 —— **建议不搭车(或仅做最小版)**
|
||
|
||
| 项 | 状态 | 取证 |
|
||
| --- | --- | --- |
|
||
| 当前保存触发 | 仅 2 种:手动「存草稿」+ 离页确认 | `post_analytics.dart:31-41` `DraftSaveTrigger{manual, onExit}` |
|
||
| 埋点已预留纪律 | **「自动保存不埋」** | `post_analytics.dart:30` 注释原文「自动保存不埋,防高频」;`:133` 重申 |
|
||
| 幂等键管理会冲突 | ⚠️ | `_markDirty()`(`post_compose_page.dart:179-182`)在每次输入时置 `_idempotencyKey = null`。自动保存若在输入过程中触发,会不断换新键 → 每次自动保存建一个新草稿 |
|
||
| 乐观锁 version 竞态 | ⚠️ | 自动保存与手动保存并发会撞 40902(`_draftVersion` 单值,`post_compose_page.dart:79`) |
|
||
|
||
**判定:风险大于收益。** 幂等键 + 乐观锁两套机制都是按「用户显式提交」设计的,
|
||
加自动保存需要重新设计「草稿 upsert」语义(debounce + 单飞 + version 串行化)。
|
||
**若一定要做,最小版**:仅在「已有 `_draftPostId`」时做 debounce 30s 的 PATCH(不建新草稿),
|
||
避开建草稿的幂等键问题。**估计 +12~18 测试,且引入一类难测的时序竞态。**
|
||
|
||
### 12.3 月份网格选择器 —— **建议搭车(低风险、已有真实误录事故)**
|
||
|
||
| 项 | 状态 | 取证 |
|
||
| --- | --- | --- |
|
||
| 根因已归档 | ✅ 详细 | `lib/core/widgets/app_date_picker.dart:3-13`——原文记录「从 9 月回到 4 月要点 5 次箭头。**已实际导致误录**——用户把当月(2026-09)的就医记录记成了 2026-04-09,进而误判『本月花费 ¥0』是统计坏了」 |
|
||
| M3.5 的处置 | ⚠️ **绕开而非解决** | `pickAppDate`(`:54-79`)统一 7 处调用 + 保留手输切换;`AppDateFieldTrailing`(`:86-144`)加「今天」快捷键。注释 `:22-25` 说明「原生 `showDatePicker` 无法注入自定义动作(`builder` 只能包裹整个 Dialog,拿不到内部选中态),所以快捷键放在调用方表单行而非弹窗内」 |
|
||
| 收口面 | ✅ 单点 | 7 处调用已全部收口到 `pickAppDate` 一个函数 |
|
||
| 主题已定制 | ✅ | `app_theme.dart:215-296` `_datePickerTheme`(含 7 行色对对比度表) |
|
||
|
||
**判定:收口已完成,改造面是单个函数,风险低。**
|
||
做法:自建一个 `MonthYearGridPicker`(年+月网格,两级),
|
||
在 `pickAppDate` 里作为可选入口(或替换 `showDatePicker`)。
|
||
**注意**:完全自建会丢掉 M3.5-01 挂 zh-CN delegate 才拿到的「手输模式 + 格式校验」
|
||
(`app_date_picker.dart:8-13` 记录了这条路「才真正走通」)——
|
||
**必须保留手输**,否则是退步。估计 +15 测试。
|
||
|
||
### 12.4 `SegmentedButton` 粉底主题债 —— **必须搭车(AI 页是重灾区)**
|
||
|
||
| 项 | 状态 | 取证 |
|
||
| --- | --- | --- |
|
||
| 主题**完全未定制** | ✅ 确认 | `grep -n "segmentedButton\|SegmentedButton\|ChoiceChip\|chipTheme" lib/core/theme/app_theme.dart` → **0 命中** |
|
||
| 回退路径 | `ColorScheme.fromSeed(seedColor: #FF6F4C)` 派生的 M3 调和色 | `app_theme.dart:88-92`;选中态吃 `secondaryContainer`(珊瑚橙派生出的粉/浅褐调),与品牌色脱节 |
|
||
| 使用点 6 处 | | `create_page.dart:138`、`pet_form_page.dart:426, 457`、`home_page.dart:385`、`services_page.dart:102`、`vaccination_form_page.dart:340` |
|
||
| `ChoiceChip` 使用点 | | `create_page.dart:519`(`_ChoiceRow`)、`post_compose_page.dart:596` |
|
||
| **根因与 M3.5-01 完全同构** | ✅ | `app_theme.dart:200-204` 记录 DatePicker 的同一根因:「此前未定制,`showDatePicker` 完全走 `ColorScheme.fromSeed` 由珊瑚橙 `#FF6F4C` 派生出的 M3 调和色(选中日为暗红棕实底),与全 app 品牌色脱节」 |
|
||
| **修复样板现成** | ✅ | `_datePickerTheme`(`app_theme.dart:215-296`)就是样板:复用已审计色对、不新造色值、附对比度表 |
|
||
|
||
**判定:必须搭车。** AI 创作页是 `SegmentedButton` + `ChoiceChip` 最密集的页面
|
||
(模型 / 风格 / 尺寸 / 图片-视频四组选择器)。不修就是把新页面直接建在债上。
|
||
**修法**:照 `_datePickerTheme` 的纪律加 `segmentedButtonTheme` + `chipTheme`,
|
||
选中态用已审计的 `surfaceTint` 底 + `primaryDark` 字(7.98:1,`app_theme.dart:208` 已记录该色对来源)。
|
||
**成本极低**(一个 theme 块),估计 +6~8 测试。
|
||
|
||
### 12.5 `widthPx`/`heightPx` 恒 null → 单图帖回落 4:3 —— **判定需修正(部分是客户端问题,部分不是)**
|
||
|
||
**任务书的表述「widthPx/heightPx 恒 null 导致单图帖一律回落 4:3」不完全准确。** 逐层取证:
|
||
|
||
| 层 | 事实 | 取证 |
|
||
| --- | --- | --- |
|
||
| 契约有字段 | ✅ `PostMediaItem.widthPx/heightPx` 与 `MediaAsset.widthPx/heightPx` 都存在且可空 | `community_models.dart:133-134, 143-144`(PostMediaItem);`:549-550, 563-564`(MediaAsset) |
|
||
| **详情页已正确消费** | ✅ **不是 4:3 硬编码** | `post_detail_page.dart:515-521`:`var ratio = 4/3; if (width != null && height != null && width>0 && height>0) { ratio = (width/height).clamp(1/1.33, 1/0.75); }` ——**有维度就用真比例,只在 null 时回落 4:3** |
|
||
| **Feed 卡片是硬编码 4:3** | ❌ **客户端缺陷** | `post_card.dart` 单图分支:`AspectRatio(aspectRatio: 4/3, ...)` ——`card.coverImage!.widthPx/heightPx` **可用但被完全忽略** |
|
||
| **`PostMediaGrid` 丢掉了维度** | ❌ **客户端缺陷** | `post_media_grid.dart:29` 入参只有 `final List<String> urls`——维度在调用点就被丢弃;`_CollapsedCover`(`:313-367`)`:326-327` 硬编码 `aspectRatio: 4/3` |
|
||
| **客户端无法提供维度** | ⚠️ **契约缺口** | `CreateMediaUploadRequest`(`community_models.dart:468-489`)只有 `kind/purpose/mimeType/byteSize/sha256` ——**没有 widthPx/heightPx 字段**。客户端压缩后知道尺寸,但契约不收 |
|
||
| 服务端能否自己测量 | **未取证** | 需确认服务端 confirm 时是否读取对象并探测图片尺寸。若不读,则 `widthPx/heightPx` 无来源 → 恒 null |
|
||
|
||
**修正后的准确表述**(三段,缺一不可):
|
||
|
||
1. **契约缺口**:`CreateMediaUploadRequest` 无维度字段,客户端有维度也传不了。
|
||
→ 要么契约加字段(客户端压缩后填),要么服务端 confirm 时探测对象。
|
||
2. **Feed 卡片客户端缺陷**:`post_card.dart` 与 `post_media_grid.dart:326-327` 硬编码 4:3,
|
||
**即使服务端将来给了维度也不会生效**。
|
||
3. **详情页客户端已就绪**:`post_detail_page.dart:515-521` 逻辑正确,
|
||
服务端一给维度就自动生效,**无需改动**。
|
||
|
||
**判定:建议搭车做第 2 段(客户端 2 处)**,成本低(+10 测试);
|
||
第 1 段是契约/后端决策,需 D4-F9 与后端对齐。
|
||
**注意**:只做第 2 段而后端不给维度,用户感知**零变化**(仍恒 null 回落 4:3)——
|
||
所以这一项必须两侧同批做,否则等于没做。这是本项最容易误判为「已修」的地方。
|
||
|
||
### 12.6 大图下滑关闭手势 —— **建议不搭车**
|
||
|
||
| 项 | 状态 | 取证 |
|
||
| --- | --- | --- |
|
||
| 已明确记录为已知冲突 | ✅ | `post_detail_page.dart:877-878` 原文:「(03 号拍板 E:内置 `InteractiveViewer`,体验不达再升级 `photo_view`)… **下滑关闭与 `InteractiveViewer` 平移手势冲突,留待手势方案升级**」 |
|
||
| 当前实现 | `PageView.builder`(`:922`)+ `InteractiveViewer`(`:933`),零依赖 | |
|
||
| 冲突本质 | 缩放后的平移与「下滑关闭」抢同一个垂直拖拽 | 需自定义 `GestureRecognizer` 或换 `photo_view` |
|
||
|
||
**判定:不搭车。** 这是纯体验优化,与 M4 主线无关;
|
||
正确解法是引入 `photo_view`(新依赖)或写自定义手势竞技场——两者都不该在 AI 迭代里做。
|
||
**但 AI 结果预览页会遇到同一个问题**(结果图要能放大看细节)。
|
||
**建议**:结果页**直接复用现有 `_GalleryPage`**(`post_detail_page.dart:877` 起),
|
||
承接同样的已知限制,不在 M4 引入第二套图片查看器。
|
||
|
||
### 12.7 搭车总建议
|
||
|
||
| 项 | 建议 | 测试增量 | 理由 |
|
||
| --- | --- | --- | --- |
|
||
| 12.1 收藏与草稿列表页 | ✅ **搭车** | +36 | 数据层已就绪;AI 草稿的落脚点,M4 强协同 |
|
||
| 12.4 SegmentedButton 主题债 | ✅ **搭车** | +7 | AI 页重灾区;成本极低;修法有样板 |
|
||
| 12.3 月份网格选择器 | ✅ **搭车**(若排期允许) | +15 | 已致真实误录;收口面单点 |
|
||
| 12.5 单图比例(客户端 2 处) | ⚠️ **条件搭车** | +10 | **必须与后端同批**,否则用户零感知 |
|
||
| 12.2 草稿自动保存 | ❌ **不搭车** | (+12~18) | 与幂等键/乐观锁语义冲突,需重新设计 |
|
||
| 12.6 下滑关闭手势 | ❌ **不搭车** | — | 需新依赖或自定义手势;结果页复用现有查看器即可 |
|
||
|
||
---
|
||
|
||
<a id="13"></a>
|
||
## §13 测试增量估计
|
||
|
||
### 13.1 历史增量参照(校准基准)
|
||
|
||
| 迭代 | 收官测试数 | 增量 | 交付面 |
|
||
| --- | --- | --- | --- |
|
||
| M2 收官 | 272 | — | 宠物档案 + 健康记录页面族 |
|
||
| M3 收官 | 502 | **+230** | 社区五单(数据层 + Feed + 详情 + 互动 + 发布 + 媒体上传) |
|
||
| M3.5 收官 | 597 | **+95** | 本地化 + 日期共享层 + 资料页/编辑页 + 双头像 + 问候语 |
|
||
| **M4 目标** | **?** | 见下 | AI 创作链路 + 搭车项 |
|
||
|
||
**校准结论**:M3 用 230 测试换了 6 个页面 + 1 个编排器 + 1 套数据层。
|
||
M4 的核心 AI 面规模略小于 M3(4 页 vs 6 页,无 Feed/评论/互动),
|
||
但**轮询与任务态机的测试密度更高**(时序、退避、代次、恢复各成一组)。
|
||
|
||
### 13.2 核心 AI 链路增量(逐文件)
|
||
|
||
| 测试文件 | 估计 | 覆盖要点 |
|
||
| --- | --- | --- |
|
||
| `test/features/creation/creation_models_test.dart` | 28 | 枚举严格解析(未知值抛 `FormatException`)、`fromJson`/`toJson` 往返、可空字段、边界 |
|
||
| `test/features/creation/creation_repository_test.dart` | 32 | 每端点路径/方法/query、`Idempotency-Key` 携带与持键、业务码→类型化异常映射、**端口接线断言** |
|
||
| `test/features/creation/creation_controller_test.dart` | **48** | 态机全迁移、轮询间隔阶梯、网络失败退避、硬超时、**代次守卫**(取消后旧响应作废)、`cancelled` 旗标、`_disposed` 守卫、并发上限、恢复(列表→在途态)、`succeeded ⟺ resultAssetId` 断言 |
|
||
| `test/features/creation/creation_display_test.dart` | 16 | 各异常→话术映射(含 429 两类区分)、阶段文案、相对时间 |
|
||
| `test/features/creation/creation_analytics_test.dart` | 18 | 8 事件的名与 props 逐一断言、分桶、**隐私红线**(taskId/assetId/prompt 不出现) |
|
||
| `test/features/creation/ai_create_page_test.dart` | 30 | 配置态/在途态/恢复中/配额耗尽四态渲染、参数选择、CTA gating、提交、取消 |
|
||
| `test/features/creation/ai_result_page_test.dart` | 24 | 结果渲染(`SignedNetworkImage`)、重新生成、**「发布到社区」→ 预置 asset 移交** |
|
||
| `test/features/creation/ai_task_list_page_test.dart` | 22 | 进行中置顶、游标分页、加载更多失败重试条、空态 |
|
||
| `test/core/network/api_client_retry_after_test.dart` | 12 | 429 两种 `Retry-After` 格式(delta-seconds / HTTP-date)、缺该头回落 null |
|
||
| `test/features/community/post_compose_preset_test.dart` | 16 | 预置 media 路径、`ai_creation` 类目不再回落、`_canPublish` 新条件 |
|
||
| `test/features/main/main_shell_creation_test.dart` | 10 | 创作 Tab 角标、导航接线、`reset()` 清理 |
|
||
| **核心小计** | **256** | |
|
||
|
||
**保守区间**:部分测试会合并(如 display 与 analytics 可能各少 3~5 条),
|
||
`creation_controller_test` 若轮询策略简化可降到 38。
|
||
→ **核心 AI 链路 +205 ~ +256**,取中 **+230**(与 M3 同量级,符合规模判断)。
|
||
|
||
### 13.3 搭车项增量
|
||
|
||
| 搭车项 | 估计 | 备注 |
|
||
| --- | --- | --- |
|
||
| 12.1 收藏页 + 草稿页(含指定 draftId 恢复) | +36 | 两页各 ~16 + 恢复改造 4 |
|
||
| 12.4 SegmentedButton/Chip 主题 | +7 | 主题断言 + 对比度回归 |
|
||
| 12.3 月份网格选择器 | +15 | 网格导航 + 手输保留 + 越界钳制 |
|
||
| 12.5 单图比例(客户端 2 处) | +10 | `post_card` + `_CollapsedCover` 各 5 |
|
||
| 10.3 platform 埋点修复(客户端侧) | +5 | 桌面映射门控 |
|
||
| **搭车小计(推荐集)** | **+73** | 不含 12.2 / 12.6 |
|
||
|
||
### 13.4 汇总
|
||
|
||
| 方案 | 增量 | **预计总数** |
|
||
| --- | --- | --- |
|
||
| 仅核心 AI 链路 | +205 ~ +256 | **802 ~ 853** |
|
||
| 核心 + 推荐搭车集(12.1/12.4/12.3/12.5/10.3) | +278 ~ +329 | **875 ~ 926** |
|
||
| **规划建议值** | **+300** | **约 897** |
|
||
|
||
**若 D4-F1 拍板选 SSE**,再 +40~60(流解析 + 重连 + 续传 + 兜底轮询),
|
||
且引入时序 flake 风险 → 总数 **约 950**,但**可靠性下降**。
|
||
|
||
### 13.5 手工 E2E 增量
|
||
|
||
现有 42 场景(M1 7 / M2 11 / M3 14 / M3.5 10),
|
||
`test_e2e_m35_manual.dart:12-13` 自述「本脚本只覆盖 M3.5 增量面,回归由前三份承担」。
|
||
**M4 需新增 `test_e2e_m4_manual.dart`**,建议覆盖 **10~12 场景**:
|
||
|
||
1. 提交生成 → 轮询到 succeeded → 结果 asset 可下载(逐字节校验,照 M3.5 场景 4 口径)
|
||
2. 同 `Idempotency-Key` 重放 → 命中首个任务,不重复消耗配额
|
||
3. 同键异 payload → 40905
|
||
4. 配额耗尽 → 429 + `Retry-After` 头存在且可解析
|
||
5. 限流与配额耗尽的**两个错误码可区分**
|
||
6. 任务失败 → `failureReason` 在枚举内 + retryable 标记正确
|
||
7. 取消在途任务 → 终态 `cancelled`,不可再转 running
|
||
8. 「我的进行中任务」列表:跨会话(新 token)仍能查到
|
||
9. AI 结果建草稿 → `category=ai_creation` 写入成功(**验证写侧枚举已放开**)
|
||
10. AI 草稿 → 迁移发布 → Feed 可见且 `category=ai_creation`
|
||
11. 引用他人的 / 幽灵的 / 错 purpose 的结果 asset → 404/40405
|
||
12. 引用未完成任务的 asset → 422/42203
|
||
|
||
**发布门禁**:M4 后需五份全跑(42 + 12 = **54 场景**)。
|
||
**注意**:这些是 `dart run` 手动脚本,**不在 `flutter test` 内**,
|
||
与 `integration_test/`(无门禁)不同——手工脚本有门禁要求,需在发布清单里明确。
|
||
|
||
---
|
||
|
||
<a id="14"></a>
|
||
## §14 风险清单
|
||
|
||
| # | 风险 | 严重度 | 依据 | 缓解 |
|
||
| --- | --- | --- | --- | --- |
|
||
| **R1** | **后端 AI 面零实现**,M4 需同时新建:模型调用 + 任务表 + `creation` schema + 配额 + 错误码 + 白名单事件。客户端全部工作**阻塞在契约定稿** | 🔴 高 | `patbond-api` 搜 `SseEmitter`/`quota`/`AiTask`/`GenerationTask` → 全 0;`creation` schema 不存在(`CommunityMigrationIntegrationTest.java:16,69,73,79` 断言无 FK 指向 `creation.generation_jobs`) | 契约先行;客户端先做**不依赖 AI 端点**的部分(搭车项 12.1/12.3/12.4、`Retry-After` 改造、demo 剥离) |
|
||
| **R2** | **429/`Retry-After` 是契约首次引入响应头机制**(`components.headers: []`,全契约 `headers:` 0 次) | 🟠 中高 | §6.2 取证 | 契约需同时补 `components.headers`;客户端 `ApiRateLimitException` 加字段(不破坏 6 处现有 `switch`) |
|
||
| **R3** | **`purpose` 白名单是引用侧的类型检查**,AI 结果图若不加 `ai_image` 则无法被帖子引用(404/40405) | 🟠 中高 | `openapi.yaml:3460` 三值;`community_models.dart:64-66` 注释「用途即引用侧类型检查」 | D4-F3 拍板;DB 层无 CHECK(`V1:209`),加值无需迁移,只改 `MediaProperties.java:66` + `application.yml:45` + 契约 |
|
||
| **R4** | **契约已有内部不一致**:`openapi.yaml:1228` 写「purpose 仅 post_image」vs `:3460` 三值枚举 | 🟡 中 | 直接取证 | M4 若加 purpose,这处描述必须一并修(否则第三次漂移) |
|
||
| **R5** | **`ai_creation` 写侧被封**,AI 建帖会 400/40000;且 `post_compose_page.dart:209-211` 会把恢复的 AI 草稿**静默降级为 `general`** | 🟠 中高 | `openapi.yaml:3657,3697`;`CreatePostRequest.java:26`;`post_compose_page.dart:209-211` | 后端放开两处 `@Pattern` + 契约两处 enum;客户端去掉回落。**这是一条数据损坏路径,不是纯功能缺失** |
|
||
| **R6** | **桌面埋点 100% 丢失**(整批 400),且客户端注释错误描述为「逐条 rejected」 | 🟠 中高 | §10.3 全链取证 | D4-F8;**注释必须改**,否则继续误导 |
|
||
| **R7** | **`integration_test/` 4 份真机测试无任何门禁**;AI 长链路(轮询/超时/恢复)恰恰是 widget 测试证明不了的 | 🟠 中高 | `flutter test` 缺省只扫 `test/`;无 CI 配置引用 `integration_test` | 手工 E2E 脚本(§13.5)承担;或给 `integration_test` 建门禁(本迭代范围外,需拍板) |
|
||
| **R8** | **轮询的电量/流量成本**在真机未量化;`connectTimeout: 5s` / `receiveTimeout: 10s`(`api_client.dart:45-47`)对轮询偏长,会让失败判定慢 | 🟡 中 | 直接取证 | 轮询用独立 `Dio` 实例(短超时 3s),照 `DioMediaDirectUploadClient` 先例独立配置 |
|
||
| **R9** | **`AiCreatePage` 有长成第二个 704 行文件的倾向**(同时承载参数选择 + 进行中 + 配额 + 恢复四态) | 🟡 中 | `post_compose_page.dart` 704 行是前例 | 两态拆 private widget,页面本体只做态分发(§8.4) |
|
||
| **R10** | **`sealed class ApiException` 加子类会要求同步全部 `switch`**;若 AI 域新增非 `ApiBusinessException` 的异常类型,会打断 6+ 处已有模式匹配 | 🟡 中 | `api_exception.dart:86` `sealed`;`community_display.dart:30-59`、`post_analytics.dart:91-105` 等 | AI 域异常**一律继承 `ApiBusinessException`**(照 `community_exceptions.dart` 全部 10 个的做法),不动密封层级 |
|
||
| **R11** | **无状态管理库 + Tab 级单例增至 4 个**,`app.dart` 的 `initState`(80-147)已 67 行,装配复杂度上升 | 🟢 低中 | 直接取证 | 可接受;但 `_reportAuthStateChange`(242-249)**必须加 `creationController.reset()`**,漏则跨账号泄漏 |
|
||
| **R12** | **`prompt` 文本的隐私风险拦不住**:本地正则(`analytics_service.dart:290-293`)不含 prompt 语义词 | 🟡 中 | 直接取证 | 纪律锁死「prompt 原文不上报,只报长度分桶」,写进 `creation_analytics.dart` doc 注释 |
|
||
| **R13** | **视频生成若进 M4**:`MediaKind` 只有 `image`(`community_models.dart:56-61`),视频要动契约枚举 + 播放器依赖 + 缩略图 + 时长;`MediaType.video`(`post_analytics.dart:61`)虽已备但未启用 | 🟠 中高 | 直接取证 | D4-F10 建议 **M4 只做图片**,视频 Tab **整段下线**(不留占位——留占位就是新增一个 demo,违背「demo 消亡」口径) |
|
||
| **R14** | **模型/风格字典若硬编码在客户端**,改一个风格要发版 | 🟡 中 | `create_page.dart:179` 现状即硬编码 | D4-F7 建议走服务端字典端点,**先例充分**:`pets_repository.dart:146-165` 已有 `listBreeds` / `listVaccineCatalog` 两个字典端点 |
|
||
| **R15** | **单图比例修复若只做客户端**,用户零感知(服务端仍不给维度) | 🟡 中 | §12.5 三段取证 | 两侧同批做,或本迭代不做 |
|
||
|
||
---
|
||
|
||
<a id="15"></a>
|
||
## §15 需要用户拍板的决策(11 项)
|
||
|
||
> 编号沿用 `D4-F*`(F = Flutter/前端提出)。每项含**推荐**与**理由**。
|
||
> 标 ⭐ 的三项是**最关键**——它们决定其余决策的形状。
|
||
|
||
### ⭐ D4-F1 长耗时任务的进度反馈机制
|
||
|
||
| 选项 | 说明 |
|
||
| --- | --- |
|
||
| **A. 自适应轮询**(推荐) | 阶段化间隔 1s×3 → 2s×5 → 3s,上限 5s,硬超时 5min;网络失败叠指数退避 |
|
||
| B. SSE | dio `ResponseType.stream` + 手写帧解析 + 断线重连 + 必须另做兜底轮询 |
|
||
| C. WebSocket | 新增 `web_socket_channel`,双向能力对单向进度过度设计 |
|
||
|
||
**推荐 A。理由**(详见 §4):
|
||
1. 现有网络层围绕「信封 + 401 单飞刷新重放」构建(`api_client.dart:97-131, 163-176`),
|
||
SSE 两条都用不上 → 形成第二套鉴权旁路。M3.5 的端口事故根因正是「跨模块调用绕开既有接线纪律」。
|
||
2. 两侧流式基础设施均为零(`SseEmitter` 0 命中、客户端 `ResponseType` 0 命中、无流式依赖);
|
||
M4 后端已有四件新事(模型调用/任务表/配额/schema),不宜再加长连接层。
|
||
3. **A 是 B 的真子集**:任何推送方案都必须有「查状态」GET 作兜底(退后台/Doze/断网),
|
||
有了它轮询已免费到手。先做 A 不是弯路。
|
||
4. 可测性:`now` 注入 + `fake_async`(已有依赖)可确定性测全部时序;SSE 需起真 HTTP 流服务器,
|
||
会破掉 `analytics_service.dart:255-257` 「用 `@protected` 替换以避免起真服务器」的既有纪律。
|
||
5. 无网关(ADR-002)下 SSE 将来加反代要重做缓冲配置。
|
||
|
||
**若拍板 B**:客户端 +40~60 测试,引入时序 flake(§4.5 有改造面清单)。
|
||
|
||
### ⭐ D4-F2 任务中断恢复的事实来源 + AI 端点的服务归属
|
||
|
||
**两个子决策,必须一起拍**(后者决定客户端接线量)。
|
||
|
||
**(a) 恢复的事实来源**
|
||
|
||
| 选项 | 说明 |
|
||
| --- | --- |
|
||
| **R-A 服务端**(推荐) | `GET /api/v1/creations?status=queued,running` + `GET /api/v1/creations/{id}` |
|
||
| R-B 本地持久化 taskId | `shared_preferences` 存活跃 taskId |
|
||
| R-C 不做恢复 | 离页即忘 |
|
||
|
||
**推荐 R-A。理由**:
|
||
1. 与既有纪律一致——`community_controller.dart:20` 明写「服务端是唯一事实来源,内存副本仅作展示缓存」。
|
||
2. 零跨账号泄漏面。全仓**没有任何业务任务态被持久化**(这是 M3 第一波「防泄漏」的刻意结果);
|
||
R-B 要在 `app.dart:242-249` 再加一处清理,漏则是安全缺口(参照 2026-09-11 安全事件的教训类型)。
|
||
3. 换设备场景真实存在(手机提交、平板查看),R-B 完全失效。
|
||
4. 该端点本来就要做(见 D4-F1 理由 3)。
|
||
|
||
**可选子项**:仅持久化一个 `int`「上次离开时在途任务数」用于冷启动首屏 Tab 角标,真值由端点校正。
|
||
|
||
**(b) AI 端点落在哪个服务**
|
||
|
||
| 选项 | 客户端成本 |
|
||
| --- | --- |
|
||
| 落 community:8084 | **零接线改动** |
|
||
| 落 user:8082 | **零接线改动** |
|
||
| **新服务 creation:8085** | 需新增第 5 个 `baseUrl` 常量 + 第 5 条 `ApiClient` 接线(复用 `_sharedRefresher`) |
|
||
|
||
**推荐:由后端按内聚性决定,但务必显式写进契约的 `servers` 与 tag description**——
|
||
`openapi.yaml:179`(server 列出 `/api/v1/media/**`)与 `:199`(tag `media` 写明 `patbond-user`)
|
||
是 M3.5 端口事故后建立的好实践,AI 端点必须照办。
|
||
**客户端只要求一件事:契约里写清归属,不要让客户端猜。**
|
||
|
||
### ⭐ D4-F3 AI 结果图的 `purpose` 与引用侧放行
|
||
|
||
| 选项 | 说明 |
|
||
| --- | --- |
|
||
| **A. 新增 `ai_image`**(推荐) | 建帖引用校验中与 `post_image` 同等放行 |
|
||
| B. 复用 `post_image` | 零契约改动 |
|
||
| C. 客户端下载再两步上传 | 荒谬(双倍流量 + 双倍存储),仅列出以排除 |
|
||
|
||
**推荐 A。理由**:
|
||
1. `purpose` 决定 objectKey 前缀与生命周期策略(`community_models.dart:64-66`);
|
||
AI 生成图的清理策略、配额统计、成本归因口径与用户上传图不同,混在一起将来无法分开。
|
||
2. **加值成本极低**:DB 层无 CHECK 约束(`V1__identity_media_baseline.sql:209` 是裸 `varchar(32)`),
|
||
**无需迁移**——只改 `MediaProperties.java:66` + `application.yml:45` + 契约 `:3460`。
|
||
3. 读侧天然兼容:`MediaAsset.purpose` 在契约(`openapi.yaml:3524-3526`)是**无 enum 的自由 string**。
|
||
4. 客户端 `MediaPurpose`(`community_models.dart:67-75`)加一个枚举值即可。
|
||
|
||
**附带必做项**:修 `openapi.yaml:1228` 那句过时的「purpose 仅 post_image」(见 R4)。
|
||
**附带子问题**:`pet_avatar` 能否作 AI **输入**源图(§11.3 的产品机会)?
|
||
建议放行——它是已 ready 的 asset,零上传体验最好。
|
||
|
||
### D4-F4 AI 创作的入口与页面形态
|
||
|
||
| 选项 | 说明 |
|
||
| --- | --- |
|
||
| **A. Tab 内联参数区 + 进行中内联 + 结果 push**(推荐) | 进行中不独立成页 |
|
||
| B. 全部 push 全屏(照 `PostComposePage` 先例) | 进行中独立成页 |
|
||
|
||
**推荐 A。理由**(详见 §8.4):
|
||
1. `IndexedStack`(`main_shell_page.dart:266`)Tab 切走**不销毁 State**,是现成的保活机制,轮询可继续。
|
||
2. 进行中若是 push 页,返回后就没了 → 必须靠列表页找回 → 两条恢复路径。
|
||
内联则「回创作 Tab 就看到」。
|
||
3. `CreatePage` 已是 Tab 内联页(`main_shell_page.dart:187-191`),改造成本最低。
|
||
|
||
**子决策**:源图入口是「相册」单入口还是「我的宠物档案 + 相册」双入口?
|
||
**推荐双入口**——宠物头像已 ready(M3.5-09),选它零上传(依赖 D4-F3 子问题)。
|
||
|
||
### D4-F5 结果 → 社区草稿的落地方式
|
||
|
||
| 选项 | 说明 |
|
||
| --- | --- |
|
||
| **A. 复用 `PostComposePage` + 预置 asset 入口**(推荐) | 加 3 个构造参数,改 4 处 |
|
||
| B. 新建 AI 专用发布页 | 复制一份 |
|
||
|
||
**推荐 A。理由**:`PostComposePage` 704 行承载了幂等键管理、乐观锁重提、草稿恢复、
|
||
离页三选一、「发布失败但草稿已存」——这些都是 T3-17 踩坑后才写对的
|
||
(取舍记录在 `post_compose_page.dart:23-34`)。复制必然漂移。
|
||
改造面已列在 §7.4(4 处,均有行号)。
|
||
|
||
**子决策**:读侧是否暴露 `generationJobId`?后端 DB 已预埋该列
|
||
(`V5__community_baseline.sql:47-48, 95`)但契约明确裁剪(`openapi.yaml:136, 3717`)。
|
||
**建议读侧暴露**——让 AI 作品详情页能显示「查看生成参数」,是 AI 内容的天然卖点;
|
||
写侧建议客户端传 `generationJobId`,由服务端推导 media,而非客户端自拼。
|
||
|
||
### D4-F6 429 语义:限流与配额耗尽如何区分
|
||
|
||
| 选项 | 说明 |
|
||
| --- | --- |
|
||
| **A. 两个独立业务错误码**(推荐,如 `42901` 限流 / `42902` 配额耗尽)+ 429 + `Retry-After` | 客户端按码分两套 UI |
|
||
| B. 只用 429 + `Retry-After`,靠时长长短猜 | 无法可靠区分 |
|
||
| C. 配额耗尽用 403 | 与「无权限」语义混淆 |
|
||
|
||
**推荐 A。理由**:
|
||
1. 两类的 UI 行为**根本不同**——限流给倒计时重试,配额耗尽给「明日重置」且**必须禁用重试**。
|
||
共用一条话术会让用户一直点(§6.3)。
|
||
2. 项目已有五位业务码体系(`api_exception.dart:3-81`,27 个码),
|
||
且 `post_analytics.dart:180-182` 已有 `httpStatus = errorCode ~/ 100` 的推导约定 —— `429xx` 天然吻合。
|
||
3. `Retry-After` 仍需要(限流用),且是契约首次引入响应头(R2),需同时补 `components.headers`。
|
||
|
||
**客户端配套(必做)**:`ApiRateLimitException` 加 `Duration? retryAfter`
|
||
(`api_exception.dart:111-113`),`_unwrap` 读头并解析 **两种 RFC 7231 格式**
|
||
(`delta-seconds` 与 HTTP-date;只解析整数会在服务端给日期时静默回落 null)。
|
||
**顺带兑付**:`analytics_service.dart:268-272` 那条「Retry-After 分支待后端落地后一并做」的历史挂账。
|
||
|
||
### D4-F7 模型 / 风格 / 尺寸清单的来源
|
||
|
||
| 选项 | 说明 |
|
||
| --- | --- |
|
||
| **A. 服务端字典端点**(推荐) | 如 `GET /api/v1/creation/models`、`/creation/styles` |
|
||
| B. 客户端硬编码 | 现状(`create_page.dart:179, 193, 201`) |
|
||
| C. 远程配置 | 需新基础设施 |
|
||
|
||
**推荐 A。理由**:
|
||
1. **先例充分**:`pets_repository.dart:146-153` `listBreeds`、`:155-165` `listVaccineCatalog`
|
||
已是两个字典端点(契约内 `/api/v1/breeds`、`/api/v1/vaccine-catalog`),
|
||
数据播种在 `V4__pet_health_dictionary_seed.sql`。照抄这套即可。
|
||
2. 硬编码意味着「上/下线一个风格要发版」——AI 模型迭代频率远高于 app 发版频率。
|
||
3. 风格卡需要封面图,硬编码就是继续挂 unsplash 外链(`demo_data.dart:206-233` 现状)。
|
||
|
||
### D4-F8 桌面埋点整批 400 的修复方式
|
||
|
||
| 选项 | 说明 |
|
||
| --- | --- |
|
||
| a. 后端 `@Pattern` 加桌面值 + 契约 enum 同步 | 生产数据混入开发平台 |
|
||
| b. 客户端桌面直接不上报 | 桌面永远验不了埋点 |
|
||
| **c. platform 校验从 DTO 层下移到逐条校验**(推荐) | 消除「整批 400」这个真实缺陷 |
|
||
| **c + 门控映射**(推荐组合) | 再加 `--dart-define` 门控把桌面映射为 `android` 以验通全链路 |
|
||
|
||
**推荐 c + 门控映射。理由**:
|
||
1. **「整批 400」本身是缺陷,与平台无关**——`TrackEventsRequest.java:16` 的 `@Valid` 级联
|
||
意味着**任何一条事件的任何一个字段**校验失败都会让整批 50 条被拒,
|
||
客户端随即整批删除(`analytics_service.dart:273-281`)。这是一个**毒丸批次放大器**。
|
||
把逐条可判定的字段下移到 `AnalyticsService` 的逐条校验(那里已有
|
||
`unknown_event_name`/`identity_mismatch`/`forbidden_field`/`schema_invalid` 四类,
|
||
`AnalyticsService.java:49,55,65,83`)才是正解。
|
||
2. AI 长链路的漏斗埋点需要能在开发机上验通(R7:真机测试无门禁)。
|
||
3. 数据侧不受污染(生产仍只接受 android/ios)。
|
||
|
||
**必做的附带项**:`analytics_service.dart:99-102` 那条注释**必须改**——
|
||
它现在声称「逐条 rejected(不影响客户端)」,与事实相反,会继续误导后续所有人。
|
||
|
||
### D4-F9 历史遗留搭车范围
|
||
|
||
| 项 | 推荐 | 测试 |
|
||
| --- | --- | --- |
|
||
| 收藏与草稿列表页 | ✅ 搭车 | +36 |
|
||
| SegmentedButton/Chip 主题债 | ✅ 搭车 | +7 |
|
||
| 月份网格选择器 | ✅ 搭车(排期允许) | +15 |
|
||
| 单图比例(客户端 2 处) | ⚠️ **仅在后端同批给维度时搭车** | +10 |
|
||
| 草稿自动保存 | ❌ 不搭车 | — |
|
||
| 大图下滑关闭手势 | ❌ 不搭车 | — |
|
||
|
||
**理由概要**(详见 §12):前两项数据层/样板已现成、成本低、与 M4 强协同
|
||
(草稿页是 AI 草稿的落脚点;主题债是 AI 页重灾区)。
|
||
月份选择器已致真实误录(`app_date_picker.dart:3-13` 记录了误录事故),收口面单点。
|
||
单图比例**必须两侧同批**否则用户零感知。
|
||
自动保存与幂等键/乐观锁语义冲突(`post_compose_page.dart:179-182` vs 需要 debounce upsert)。
|
||
下滑手势需新依赖或自定义手势竞技场,且结果页可直接复用现有查看器。
|
||
|
||
### D4-F10 AI 视频是否进 M4
|
||
|
||
| 选项 | 说明 |
|
||
| --- | --- |
|
||
| **A. 只做图片,视频 Tab 整段下线**(推荐) | 不留占位 |
|
||
| B. 只做图片,视频 Tab 留「即将上线」占位 | 新增一个 demo |
|
||
| C. 图片 + 视频都做 | 需视频链路全栈 |
|
||
|
||
**推荐 A。理由**:
|
||
1. `MediaKind` 只有 `image`(`community_models.dart:56-61`,注释「M3 仅 image,视频后置」),
|
||
视频要动契约枚举 + 播放器依赖 + 缩略图 + 时长 + 转码 —— 是一个独立里程碑的量。
|
||
2. **不留占位**是关键:本项目每个迭代都以「demo 消亡」为产出口径,
|
||
在消亡 13 处占位的同一个迭代里新增一处占位,是自相矛盾的。
|
||
现有 `CreationMode.video`(`create_page.dart:8`)与视频时长选择(`:189-197`)应整段删除。
|
||
3. `MediaType.video`(`post_analytics.dart:59-66`)枚举已备,将来启用零成本,不必现在占坑。
|
||
|
||
### D4-F11 AI 长链路的验证门禁
|
||
|
||
| 选项 | 说明 |
|
||
| --- | --- |
|
||
| **A. 新增 `test_e2e_m4_manual.dart`(10~12 场景),纳入发布门禁**(推荐) | 沿用现有四份的模式 |
|
||
| B. 给 `integration_test/` 建 CI 门禁 | 范围外,需新基础设施 |
|
||
| C. 只靠 widget 测试 | 轮询/超时/恢复证明不了 |
|
||
|
||
**推荐 A。理由**:
|
||
1. `integration_test/` 4 份**当前无任何门禁**(§1 证据降级),把它变成门禁是独立工程。
|
||
2. 手工 E2E 脚本已有成熟模式与门禁要求(4 份 / 42 场景),M4 加第五份是最短路径。
|
||
3. AI 链路有若干**只能在真链路暴露**的问题:轮询到终态、幂等键不重复消耗配额、
|
||
`Retry-After` 头真的存在、`ai_creation` 写侧真的放开了、结果 asset 真的能被引用。
|
||
§13.5 已列 12 个候选场景。
|
||
|
||
**附带建议**:把「`integration_test/` 无门禁」这一事实**显式写进 M4 的遗留清单**——
|
||
它是一个长期被当作证据使用的空头承诺,应当或补门禁、或降级为「开发辅助脚本」并改名。
|
||
|
||
---
|
||
|
||
<a id="16"></a>
|
||
## §16 我推翻或修正的既有文档结论
|
||
|
||
按「影响规模预估的程度」排序。每条都有原始源码取证。
|
||
|
||
### 16.1 【修正数字】埋点白名单是 **41 条**,不是 42 条
|
||
|
||
- **任务书原文**:「现有白名单 42 条」
|
||
- **实测**:`patbond-api/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java:42-104`,
|
||
`grep -c "Map.entry(" ` → **41**
|
||
- **构成**:11 auth + 1 page + 3 pet + 7 health_record + 8 post + 2 feed + 8 interactions + 1 experiment = 41
|
||
- **附带发现**:白名单**不可配置**(`grep -rn "allowed-events\|allowedEvents\|eventWhitelist"` → 0 命中),
|
||
只能改 Java 源码。AI 新事件必然需要后端改代码,不能靠配置下发。
|
||
- **另附**:客户端实际只发 **33** 个事件;白名单里有 8 个客户端从未发出
|
||
(`auth_register_started`、`auth_token_refresh_*`、`auth_session_restore_*`、`health_record_deleted`、`experiment_exposed`)。
|
||
|
||
### 16.2 【推翻】「桌面埋点逐条 rejected,不影响客户端」——实际是**整批 400、全批丢弃**
|
||
|
||
- **被推翻的原文**(`patbond-flutter/lib/analytics/analytics_service.dart:99-102`):
|
||
「上报值不在枚举内会被服务端**逐条 rejected**(不影响客户端),属预期」
|
||
- **实际机制**(全链取证):
|
||
1. `TrackEventsRequest.java:56-58`:`platform` 上有 `@Pattern(regexp="^(android|ios)$")`,
|
||
这是 **DTO 层 Bean Validation**。
|
||
2. `TrackEventsRequest.java:16-19`:`@Valid` 级联到 `List<TrackedEvent>` 每个元素
|
||
→ 任一元素失败即 `MethodArgumentNotValidException` → **整个请求 400**。
|
||
3. 逐条 rejected 的逻辑在**更后面**且**永远走不到**:
|
||
`AnalyticsService.java:49`(`unknown_event_name`)、`:55`(`identity_mismatch`)、
|
||
`:65`(`forbidden_field`)、`:83`(`schema_invalid`)。
|
||
4. 客户端 `analytics_service.dart:273-281`:4xx 视为永久拒绝 → `return true`
|
||
→ `:229` `removeSegments(..., countAsDropped: true)` → **整批删除,不重试**。
|
||
- **净效果**:Linux/macOS/Windows/Web 上跑 app,埋点 **100% 丢失**,不是「少量损耗」。
|
||
- **更严重的衍生问题**:`@Valid` 级联意味着**任何一条事件的任何一个字段**校验失败
|
||
都会毒死整批 50 条。这是一个**毒丸批次放大器**,与平台无关。
|
||
→ 已列为 D4-F8 推荐修法的第一理由。
|
||
|
||
### 16.3 【修正表述】「widthPx/heightPx 恒 null 导致单图帖一律回落 4:3」——三段事实,客户端不全是受害者
|
||
|
||
- **任务书原文**暗示这是单一的后端字段缺失问题。
|
||
- **实测三段**:
|
||
1. **详情页客户端已正确消费**:`post_detail_page.dart:515-521`
|
||
`var ratio = 4/3; if (width != null && height != null && ...) ratio = (width/height).clamp(1/1.33, 1/0.75);`
|
||
——有维度就用真比例,服务端一给就自动生效,**无需改动**。
|
||
2. **Feed 卡片是客户端硬编码缺陷**:`post_card.dart` 单图分支 `AspectRatio(aspectRatio: 4/3, ...)`,
|
||
`card.coverImage!.widthPx/heightPx` **可用但被完全忽略**;
|
||
`post_media_grid.dart:29` 入参只有 `List<String> urls`(维度在调用点就被丢弃),
|
||
`_CollapsedCover` `:326-327` 硬编码 4:3。
|
||
→ **即使后端给了维度,Feed 卡片也不会生效。**
|
||
3. **客户端根本无法提供维度**:`CreateMediaUploadRequest`(`community_models.dart:468-489`)
|
||
只有 `kind/purpose/mimeType/byteSize/sha256` ——**契约没有维度字段**。
|
||
客户端压缩后知道尺寸也传不了。
|
||
- **结论**:这不是「后端漏填」,而是「契约无字段 + Feed 卡片硬编码」双缺口。
|
||
**只修客户端用户零感知;只修后端 Feed 卡片仍是 4:3。必须两侧同批。**
|
||
- **未取证部分**:服务端 confirm 时是否读取对象并探测图片尺寸——若不读,则维度无来源。
|
||
|
||
### 16.4 【修正表述】「AI 结果是生成图(无 media asset),走不了两步上传」——后半句会误导设计
|
||
|
||
- **被修正的原文**(`create_page.dart:122-124`):
|
||
「AI 结果是生成图(无本地文件、**无 media asset**),走不了两步上传,故不接真实发布链路」
|
||
- **成立部分**:两步上传确实不适用——`MediaUploader` 入口是 `PickedMediaImage`
|
||
(`media_uploader.dart:69`),客户端手里没有 AI 结果的字节。
|
||
- **误导部分**:「无 media asset」会把设计推向「客户端下载再上传」的荒谬方向。
|
||
正确做法是**服务端生成完成时直接建 asset**,任务结果返回 `resultAssetId`,客户端只引用。
|
||
- **这条路可行的取证**:
|
||
- `PostMediaAttachRequest` 只需要 `assetId`(`community_models.dart:158`),不关心 asset 来源;
|
||
- `MediaAsset.purpose` 在契约读侧(`openapi.yaml:3524-3526`)是**无 enum 约束的自由 string**,读侧向后兼容;
|
||
- `media.assets` 表 `purpose` 无 CHECK 约束(`V1__identity_media_baseline.sql:209`),加值无需迁移。
|
||
- **唯一真实卡点**是写侧 purpose 白名单三值(`openapi.yaml:3460`、`MediaProperties.java:66`)→ D4-F3。
|
||
|
||
### 16.5 【降级证据】`integration_test/` 4 份真机测试**无任何门禁**
|
||
|
||
- 受影响的结论(原被当作「链路已验证」):
|
||
- `media_uploader.dart:571-577`「桌面真链路只替换选图与压缩两层,其余全为生产实现」
|
||
→ 设计意图成立,**「实测通过」无门禁背书**。M4 复用两步上传时应视为「单测充分、端到端未持续验证」。
|
||
- T3-17 发布页真链路(`publish_live_test.dart`)→ 同样降级;
|
||
但其 widget 测试(`test/features/community/post_compose_page_test.dart`)在 597 内,这部分成立。
|
||
- **不降级的一条**:`community_repository.dart:71-74` 称「media 端点归属 user 服务,
|
||
T3-13 真链路实测修正」——该结论**另有契约层独立取证**
|
||
(`openapi.yaml:199` tag `media` description 写明 `patbond-user`;`:179` server 描述列出 `/api/v1/media/**`),
|
||
不依赖真机脚本,**故不降级**。
|
||
|
||
### 16.6 【发现契约内部不一致】`purpose 仅 post_image` 的过时描述
|
||
|
||
- `openapi.yaml:1228`(`/api/v1/media/uploads` 端点 description)仍写「purpose 仅 post_image」
|
||
- `openapi.yaml:3460`(`CreateMediaUploadRequest.purpose`)已是 `enum: [post_image, user_avatar, pet_avatar]`
|
||
- **M3.5 追加两个 purpose 时漏改了端点描述。** M4 若再加 `ai_image`,这处必须一并修,否则第三次漂移。
|
||
|
||
### 16.7 【发现数据损坏路径】AI 草稿恢复会被静默降级为 `general`
|
||
|
||
- `post_compose_page.dart:209-211`:
|
||
`_category = draft.category == PostCategory.aiCreation ? PostCategory.general : draft.category`
|
||
- 当前无害(`ai_creation` 写侧被封,不可能存在 AI 草稿)。
|
||
- **但 M4 放开写侧后,这行会把 AI 草稿的类目静默改成 `general` 并在下次保存时写回服务端。**
|
||
这不是「功能缺失」而是**数据损坏**,必须与放开写侧**同一个提交**改掉。
|
||
|
||
### 16.8 【补充事实】后端已为 AI 预埋 DB 结构,但 `creation` schema 不存在
|
||
|
||
- 已预埋:`V5__community_baseline.sql:47-48` `generation_job_id uuid`(裸列,FK 剥离);
|
||
`:95` `CREATE INDEX ix_posts_generation_job`;`:68` `ck_posts_category` **已放行三值**(含 `ai_creation`);
|
||
`:17` 注释指向 `creation.generation_jobs(id) ON DELETE SET NULL`(M4 补回)。
|
||
- **不存在**:`creation` schema 与 `generation_jobs` 表从未创建;
|
||
测试显式断言这一点(`CommunityMigrationIntegrationTest.java:16, 69, 73, 79`)。
|
||
- **契约裁剪**:`generationJob` 在契约中明确声明不出现(`openapi.yaml:136, 3717`)。
|
||
- → 意味着 M4 后端要新建 schema/表并补 FK;客户端若要 `generationJobId` 需契约新增(D4-F5 子决策)。
|
||
|
||
### 16.9 【补充事实】契约完全没有 429、`Retry-After`,也没有任何响应头机制
|
||
|
||
- 45 个 operation 的响应码全集:`['200','201','202','400','401','403','404','409','422','423']` —— **无 429**
|
||
- `components.headers: []`,全契约 `headers:` 键 **0 次出现**
|
||
- 后端 `grep -rniE "429|TOO_MANY_REQUESTS|Retry-After|RateLimit" patbond-*/src/main` → **0 命中**
|
||
- 客户端**已能识别** 429(`api_client.dart:164-167`)但**丢弃了响应头**(`const` 构造);
|
||
`ApiRateLimitException`(`api_exception.dart:111-113`)无 `retryAfter` 字段
|
||
- **历史挂账**:`analytics_service.dart:268-272` 原文「后端限流尚未实现(iteration-2/09 出入项),
|
||
**Retry-After 分支待其落地后一并做**」→ **M4 就是它的兑付时点**
|
||
|
||
### 16.10 【补充事实】主题层没有 `segmentedButtonTheme`,也没有 `chipTheme`
|
||
|
||
- `grep -n "segmentedButton\|SegmentedButton\|ChoiceChip\|chipTheme" lib/core/theme/app_theme.dart` → **0 命中**
|
||
- 6 处 `SegmentedButton` + 2 处 `ChoiceChip` 全部回退到
|
||
`ColorScheme.fromSeed(seedColor: #FF6F4C)`(`app_theme.dart:88-92`)派生的 M3 调和色
|
||
- **与 M3.5-01 修 DatePicker 的根因完全同构**——`app_theme.dart:200-204` 已把这个根因写得很清楚
|
||
(「此前未定制,完全走 `ColorScheme.fromSeed` 由珊瑚橙派生的 M3 调和色,与全 app 品牌色脱节」),
|
||
且 `_datePickerTheme`(`:215-296`,含 7 行色对对比度表)就是现成的修复样板。
|
||
- AI 创作页是这两个组件最密集的页面 → **不修就是把新页建在债上**(D4-F9)。
|
||
|
||
### 16.11 【确认成立】「我的收藏与草稿」后端已就绪、只缺页面
|
||
|
||
- `profile_page.dart:43-46` 注释称「后端能力已就位(`/me/bookmarks`、`/me/posts`),但列表页本单未做」
|
||
- **核实成立**:`community_repository.dart:274-283` `listMyBookmarks`、
|
||
`:179-193` `listMyPosts({status})` 均已实现,且在 597 测试内有覆盖。
|
||
- 缺的**只有 2 个页面**,不缺任何数据层、不缺卡片 widget、不缺分页泛型。
|
||
这是本次盘点中**投入产出比最高**的搭车项。
|
||
|
||
---
|
||
|
||
## 附录 A:本报告的取证方法与未取证项
|
||
|
||
**已实测执行**:`flutter test --reporter compact`(597 通过 + 2 skipped)、
|
||
`flutter --version`(3.44.6)、各源文件全文阅读、跨仓 grep 取证。
|
||
|
||
**未取证项(明确列出,供后续补齐)**:
|
||
|
||
| # | 未取证内容 | 缺什么 | 影响 |
|
||
| --- | --- | --- | --- |
|
||
| 1 | 服务端 confirm 时是否探测图片尺寸 | 需读 `MediaService` confirm 实现 | 决定 §12.5 的第 1 段是契约问题还是实现问题 |
|
||
| 2 | `EventDictionary.java:59` `page_viewed` 的 props 白名单具体取值集合 | 需读该行的 `Set.of(...)` 内容 | 决定新页名是否需同批加入(§10.6) |
|
||
| 3 | `AnalyticsService.java:83` `schema_invalid` 的判定范围 | 需读 props 校验实现 | 同上 |
|
||
| 4 | `pet_form_page.dart:23, 398` 的「头像本地占位(ADR-010)」注释是否已过时 | M3.5-09 已接宠物头像上传,注释可能未更新 | 仅影响 §11.2 清单准确性 |
|
||
| 5 | E2E 断言总数是否为 234 | 未逐一统计(仅确认场景数 42) | 仅影响 §13.5 的门禁描述 |
|
||
| 6 | AI 生成的典型耗时分布 | 需模型选型后实测 | 决定 §4.4 轮询间隔阶梯的具体数值 |
|
||
|
||
**本报告未修改任何生产代码,未修改 `mkdocs.yml`,未执行任何 git 操作。**
|
||
|
||
---
|
||
|
||
**报告人**:Frontend Developer(Flutter)
|
||
**完成日期**:2026-09-14
|
||
**基线**:patbond-flutter `dev@fbcd734` / patbond-api `dev@3cd8005` / patbond-doc `main@5cc6361`(三仓均干净)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|