Files
lixi f5457c2f4c
CI / docs-build (push) Successful in 2m2s
docs: M3 第三波收口——报告 21~27 入档挂导航
- 21~26 Flutter 社区接入五单 + 字典 v3 白名单(flutter 286→502、api 325→334)
- 27 收口总表:社区 demo 三页消亡、M3 四条验收标准逐条取证、
  乐观更新与媒体链路端到端、实现期修正记录
- device-verification.md 的 M3 四项真机步骤已由各单收口补全

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:48:33 +08:00

304 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 26 M3 第三波:发布页替换(T3-17)
**执行日期**2026-09-10
**工单**:T3-17 发布页替换——第三波收尾单,组装 T3-13 的 MediaUploader,接通发布漏斗埋点
**依赖**21 号(T3-12 数据层)、23 号(T3-13 MediaUploader 与孤儿防护)、15 号(后端帖子生命周期语义)、05 号 §2.3/§3.2/§3.3P3 规范)、22 号(事件白名单 v3 实际收录名)、25 号(T3-15/16 埋点封装与页面惯例)
**提交**patbond-flutter dev `9892b65`(基线 `f873acf`),已推送 origin/dev
---
## 0. 概要
社区发布链路整条真实化:新建 `PostComposePage`(05 号 P3 规范,push 全屏页、
路由名 `post_form`),组装 `MediaUploader` + `UploadProgressOverlay` 成编辑态
九宫格;发布按「**createPost(draft) → PATCH status=published**」两步走,
三条失败语义(40905 / 42203 / 网络)各有明确 UI 与埋点;发布漏斗五事件 +
媒体上传三段全部挂接。create 页只余 AI 生成模拟(M4 原样保留),
`AppState.posts` / `publishPost` / `updatePost` 及其 shared_preferences
持久化整体退役。**未动 patbond-api。**
| 文件 | 性质 | 职责 |
|------|------|------|
| `lib/features/community/post_compose_page.dart` | 新增 | 发布页本体(结构、gating、两路径、失败语义、草稿恢复) |
| `lib/features/community/post_analytics.dart` | 新增 | post 域埋点封装(发布漏斗 5 + 媒体三段 3,枚举编译期锁死) |
| `lib/core/widgets/post_media_grid.dart` | 扩展 | 新增 `PostMediaEditGrid` 编辑态(+格/删除角标/进度层/重试) |
| `lib/features/community/media_uploader.dart` | 扩展 | 媒体三段埋点挂接(attemptSeq / durationMs / cancelled |
| `lib/features/community/community_repository.dart` | 扩展 | `createPost` 支持调用方持键(同键重放) |
| `lib/features/community/community_display.dart` | 扩展 | 发布/草稿失败话术映射(服务端 message 不上屏) |
| `lib/analytics/analytics_page_name.dart` | 扩展 | 页名枚举补 `post_form`(字典 v3 页面族) |
| `lib/features/main/main_shell_page.dart` | 扩展 | `openCompose(entryPoint)` push + 发布成功回 Feed 刷新 |
| `lib/features/create/create_page.dart` | 改造 | demo 发布流退役 + 顶部「发布动态」真入口;AI 模拟零改动 |
| `lib/features/home/home_page.dart` | 改造 | story「发布」与空态 CTA 改为 push 发布页(entryPoint=feed |
| `lib/state/app_state.dart` / `profile_page.dart` | 改造 | demo 帖子列表与持久化退役;「我的作品」改直读 demo 家具 |
| `integration_test/publish_live_test.dart` | 新增 | 桌面真链路实测(env 门控,默认跳过) |
**质量门禁**`flutter test` 502/502 全绿(基线 458+44;另 2 个 env 门控
compose 冒烟默认跳过)、`flutter analyze` 0 问题、
`dart format --set-exit-if-changed` 无 diff、compose 六容器真链路实测通过(§5)。
## 1. 页面结构(05 号 §2.3 逐条对照)
自上而下:`已恢复上次草稿`提示条 → 发布失败横幅(+「草稿已保存」附注)→
`已保存草稿 ✓`**媒体编辑区**(3 列九宫格 + 「+」格)→ 页级上传汇总条 →
正文(`minLines 6` 自增、`maxLength 1000` 计数器)→ 分类(`日常分享` /
`求助` 二选)→ 位置 ListTile(占位);AppBar:左「取消」、标题「发布动态」、
右「存草稿」+「发布」(高 40 / 水平 padding 20,禁用与转圈锁定)。
`PostMediaEditGrid`(05 §3.2 编辑态)实现要点:1:1 `cover`、格间距 4、圆角
`sm`(12);缩略图直接 `Image.memory(previewBytes)`(选图原始字节,不落磁盘、
不走网络,解码失败回落 `surfaceTint` + pets 图标);每格叠
`UploadProgressOverlay` 六态→四视觉态;删除角标 22 圆 `ink` 80% + 白 close
14padding 撑到 32 触控热区);「+」格 1.5px **虚线**Flutter 无内置虚线
边框,按规范自绘 `_DashedBorderPainter`)、满 9 张隐藏。页级汇总条为
「正在上传 n/N」+ `LinearProgressIndicator`(值条 `primaryStrong`、轨道
`surfaceTint`)。
**与 05 号的偏差(4 项,均记录理由)**
| # | 规范 | 本单实现 | 理由 |
|---|------|---------|------|
| 1 | 可发布条件「正文非空**或**媒体 ≥1」 | 正文非空 **且** 在场媒体全 ready | 后端 `content` 全程必填 1~10000(15 号 §2.4),「只发图不写字」在服务端不可能成功,不给按不亮的钮 |
| 2 | 展示态与编辑态「一个组件」 | 同文件两个类(`PostMediaGrid` / `PostMediaEditGrid`) | 展示态以「≥1 张图 + URL 列表」为构造前提(既有断言),编辑态常态是「0 张图 + 一个+格」;共用签名会让两边都别扭 |
| 3 | 内容变更后**静默自动保存**(防抖 2s) | 不做自动保存,只有「存草稿」与「取消 → 保留」两个显式动作 | 一次 `createPost` 只能建一份草稿(幂等键一次一用),自动保存要么反复建草稿要么每次 PATCH,收益不抵复杂度;且 06 §1.4 明确「自动保存不埋点」,无观测价值。列入遗留(§7) |
| 4 | 「已保存草稿 ✓」置底部安全区上方 | 置提示条区(AppBar 之下) | 提交钮在 AppBar(顶部),反馈跟随触点;置底会出现「点了顶部按钮、底部看不见的反馈」 |
| 5 | 话题行(TopicChip + 话题选择 shet | 不渲染 | 契约无话题端点(21 号 §5),`topicCount` 埋点恒 0;随话题域落地补 |
拖拽排序(05 §6 D9 可选项)未做:**删格即整组重排**——position 由
`buildAttachRequests` 按当前列表序 0..n-1 重发号(widget 测试实证「3 图删中间
→ position 0,1、assetId 为 a-1/a-3」)。
## 2. 两条提交路径与草稿最小实现
```
直接发布:createPost(status=draft, media=全ready挂接) ──► PATCH {version, status=published}
↑ 幂等键由页面持有(同键重放) ↑ 失败时草稿已在服务端
存草稿退出:createPost(status=draft) 或 PATCH(已有草稿:内容/类目/media 增量)──► 离页
```
**为什么发布也先建草稿**:这样「发布失败但草稿已保存」是事实而非话术——
迁移那一步失败时草稿已落库,UI 才敢显示「草稿已保存,可稍后继续发布」,
重试也只补 PATCH 不重建帖(widget 测试断言 `created` 仍为 1 条)。
**幂等纪律**:建草稿的 `Idempotency-Key` 由**页面**持有(仓库层新增
`createPost(request, {idempotencyKey})`,缺省仍是每次换新键,既有调用方
不受影响):网络失败重试沿用同键 → 服务端命中首帖不重复建帖;**表单一经
改动即弃用旧键**(下次提交换新键),使 40905 不会常态化。
**media 三态用法**(15 号 §2.6):以「上次同步到服务端的 ready assetId 签名」
与当前签名比对——一致则 PATCH **缺席不动**(刚建的草稿不重复整组替换,也
保住恢复草稿的既有图),不一致则整组替换,本地清空则传 `[]`
**草稿管理最小实现**:进页 `listMyPosts(status=draft, limit=1)` 恢复最新一条
(提示条「已恢复上次草稿」+「清空」;正文/类目预填;既有图以「草稿已含 N
张图片(发布时保留;重新选图将整组替换)」呈现——`MediaUploader` 只持本地
选图字节,服务端 asset 不回灌编辑器)。恢复失败静默降级为新建,不打扰。
「取消 → 不保留」且服务端已有草稿 → `deletePost` 软删(`post_deleted`
M3 唯一触点)。**完整草稿列表页(`draft_list`)留待**(§7)。
## 3. gating 与失败语义
**gating**`正文非空 && (无媒体 || 全部 ready) && 无在途提交`——「全部
ready」直接用 `MediaUploader.allReady`,与 `buildAttachRequests`
`StateError` 孤儿防护形成双保险(gating 拦在前,类型层兜在后)。
| 失败 | UI 呈现(横幅,页内停留) | 客户端动作 | 埋点 failureReason |
|------|--------------------------|-----------|-------------------|
| **40905** 同键异 hash | 「提交内容与上次重试不一致,已重置提交标识,请再点一次「发布」」 | 弃用旧幂等键(下次换新键即成功) | `validation_error`+errorCode 40905 / httpStatus 409 |
| **42203** asset 未 ready | 「有图片还没上传完成,请等图片就绪后再发布」 | 保留内容,等图 ready 后重试 | `media_upload_incomplete` |
| **网络失败** | 「网络异常,请检查网络后重试」(+ 草稿已落则附「草稿已保存,可稍后继续发布」) | 同键重放;已建草稿只补 PATCH | `network_error`(无 errorCode |
| 40000 参数 | 「内容不符合发布要求,请修改后重试」 | 保留内容 | `validation_error` |
| 40403 草稿已被别处删 | 「草稿已不存在(可能已在别处删除),请重新发布」 | 解除草稿关联,重试走全新建草稿 | `not_found`(沿 06 §1.4 失败枚举基底的 not_found 复用条) |
| 40902 乐观锁 | (不上屏)自动 `getPost` 取新 version 重提一次 | 再失败才落横幅 | `server_error` 兜底 |
| 会话失效 | 应用自动回登录页 | — | **不上报**feed / 互动域同款口径) |
发布成功:`MediaUploader.reset()``pop(true)` → 主壳切首页 Tab +
`controller.refresh()` 整体替换 → 新帖按 `(published_at DESC, id DESC)`
落首位 + SnackBar「已发布,去首页看看吧 🐾」(主壳级 widget 测试逐条断言)。
## 4. 埋点挂接清单(8 事件,按 22 号实际收录名)
| # | 事件 | 触发点 | props | 挂接位置 |
|---|------|--------|-------|---------|
| 1 | `post_create_started` | 进页后**首次输入**(首个字符或首次选媒体),每次进入一次 | entryPointcreate_tab / feed | 发布页输入与 uploader 监听 |
| 2 | `post_draft_saved` | 草稿保存**成功响应后** | triggermanual / on_exit)、mediaCount | 「存草稿」与「取消 → 保留」 |
| 3 | `post_publish_succeeded` | 迁移发布成功响应后 | durationMs、mediaCount、topicCount、textLengthBucket、fromDraft | 发布回调 |
| 4 | `post_publish_failed` | 发布任一步失败 | failureReason、errorCode、httpStatus、attemptSeq | 发布回调(§3 映射表) |
| 5 | `post_deleted` | 「不保留草稿」软删成功后 | (空集) | 离页确认弹窗 |
| 6 | `post_media_upload_started` | 单文件一次尝试开始(含压缩段) | mediaType、sizeBucket | `MediaUploader._run` |
| 7 | `post_media_upload_succeeded` | confirm 返回 ready 后 | mediaType、sizeBucket、durationMs | `MediaUploader._uploadAndConfirm` |
| 8 | `post_media_upload_failed` | 单文件失败 / 在途被删格(cancelled | mediaType、sizeBucket、failureReason、errorCode、httpStatus、attemptSeq | `MediaUploader._fail` / `_reportCancelled` |
口径说明(单测/widget 测试逐字段断言):
- **`entryPoint` 收敛为两值**`create_tab`(创作 Tab 顶部「发布动态」)与
`feed`(首页 story 环「发布」+ Feed 空态 CTA);topic_detail / pet_detail
随对应页面启用。
- **`fromDraft` 口径**:指「本次发布基于**先前保存/恢复的草稿**」;发布内部
的建草稿→迁移两步**不算**(否则该字段恒真、失去分析意义)。
- **`durationMs`**`post_create_started` → 发布成功;媒体段为单次尝试
started → ready。
- **`sizeBucket` 取原图字节数**(压缩前),保证同一次尝试三段事件桶值一致;
精确字节数、文件名、路径、URL 一律不出端(红线 4)。
- **`attemptSeq`**:发布为本页发布尝试序号;媒体为单图尝试序号(retry 递增,
重试的 started 与 failed 同序号)。
- **`textLengthBucket`** 复用 `community_interaction_analytics.dart`
`textLengthBucketOf`(不重复实现),精确字数不出端(红线 1)。
- **`topicCount` 恒 0**(无话题端点);postId / assetId 等内容 ID 一律不进
props(红线 2)。
- **自动保存不埋**(06 §1.4)——本单索性不做自动保存(§1 偏差 3)。
- **锁死事件不发**`post_impression` / `post_viewed` / `comment_create_started`
/ 单点互动失败等 7 项(22 号 §1 末段)本单未提供任何封装。
- **page_viewed 页名核对**:发布页是 push 路由,`RouteSettings(name:
'post_form')` 由既有 `AnalyticsRouteObserver` 自动上报;`post_form` 已在
22 号 §2 的 v3 页面族内(字典侧仅 javadoc 登记,**后端零改动**)。客户端
枚举补 `postForm`。创作 Tab 仍报 `create`AI 创作面,语义未变)。
- **未接触点**`post_deleted` 除草稿丢弃外的「删已发布帖」触点无 UI(M3
无删帖入口),随删帖 UI 启用;`experiment_exposed` 仍属 M4。
## 5. compose 实测
后端 patbond-api dev@`8089c06`(零改动)六容器 `docker compose up -d --build`
全部 Up、postgres/minio healthy;测毕 `docker compose down` 干净退出。
```bash
cd <你的工作区>/patbond-api
./deploy/init-secrets.sh
JAVA_HOME=<你的 JDK17 路径> ./mvnw -DskipTests package # BUILD SUCCESS
docker compose up -d --build
cd <你的工作区>/patbond-flutter
PATBOND_PUBLISH_LIVE=1 flutter test integration_test/publish_live_test.dart -d linux
# 00:06 +1: All tests passed!
cd <你的工作区>/patbond-api && docker compose down
```
### (a)Linux 桌面真链路 + 跨客户端可见性取证
`integration_test/publish_live_test.dart`env 门控 `PATBOND_PUBLISH_LIVE=1`
默认跳过)驱动**真实 App**(桌面渲染管线 + 生产 ApiClient / Repository /
CommunityController / MediaUploader / 直传客户端)走完整一轮:
注册两个一次性账号 → **A 登录** → 创作 Tab「发布动态」→ 输入正文 → 选图
(真 `createUpload`@user:8082 → 真预签名 PUT@MinIO:9000 → 真 `confirm`)→
发布钮由禁用转可点(gating 实证)→ 发布(建草稿 → PATCH 迁移)→
**回首页 Feed,新帖置顶且 mediaCount=1** → **另起一个全新 App 实例**
(换 key 强制重建:新 SessionManager / 新 Controller / 新 HTTP 客户端,
等价于另一台客户端首次登录)**以 B 账号登录 → B 的 Feed 首位就是该帖**
——M3 验收「发布后可在另一客户端看到」取证。**一次通过。**
桌面替身仅两处:**选图与压缩**——`image_picker` 与
`flutter_image_compress` 均无 Linux 平台实现(桌面选图这一步在 Linux 上物理
不可达),实测注入 1x1 真 PNG 字节与透传压缩,其余全为生产实现。原生选图/
压缩行为仍属真机项(device-verification M3 第 1 项 (e))。
落库核对(psql):
```text
community.posts: status=published, category=general, version=1, published_at≠null, media=1
media.assets: status=ready, mime_type=image/png, byte_size=70
```
### (b)后端语义三点复核(curl,与客户端实现对齐)
| 复核 | 结果 |
|------|------|
| 建草稿 → 迁移发布的 version 走线 | `createPost(draft)` 返回 **version 0** → `PATCH {version:0, status:published}` → **published / version 1 / publishedAt 非空**(客户端用响应 version,不硬编码) |
| 同键异 payload | 同 `Idempotency-Key` 改正文 → **40905「幂等键已用于不同请求」** |
| 引用未 ready asset | `createUpload` 后不上传直接发帖 → **42203「媒体尚未就绪」** |
三条与 §3 的 UI 语义一一对应,映射无偏差。
### (c)发布/媒体 8 事件白名单验真(curl,客户端真实 payload 形状)
`POST /api/v1/events`user :8082)一批 8 条(platform=androidprops 逐键
按 §4 客户端实际形状)→ **202 accepted 8 / duplicated 0 / rejected 0**
`platform.product_events` 落库 props 完整无剥离:
```text
post_create_started {"entryPoint": "create_tab"}
post_draft_saved {"trigger": "on_exit", "mediaCount": 2}
post_publish_succeeded {"fromDraft": true, "durationMs": 18200, "mediaCount": 2, "topicCount": 0, "textLengthBucket": "short"}
post_publish_failed {"errorCode": 42203, "attemptSeq": 1, "httpStatus": 422, "failureReason": "media_upload_incomplete"}
post_deleted {}
post_media_upload_started {"mediaType": "image", "sizeBucket": "lt_1mb"}
post_media_upload_succeeded {"mediaType": "image", "durationMs": 640, "sizeBucket": "lt_1mb"}
post_media_upload_failed {"mediaType": "image", "attemptSeq": 2, "sizeBucket": "mb_1_5", "failureReason": "cancelled"}
```
### (d)实测附带发现:桌面端埋点整批被拒(非回归,属既有预期)
桌面真链路运行时日志出现 `Analytics batch permanently rejected (400)`——
原因是桌面 `platform` 值为 `linux`,而契约校验为
`@Pattern(^(android|ios)$)`**bean 校验整批 400**(不是逐条 rejected)。
`analytics_service.dart` 的注释已声明桌面属「开发调试形态、上报被拒属预期」,
但措辞是「逐条 rejected」,与实况(整批 400)有出入——**不改行为**,已在
device-verification M3 第 4 项写明「v3 事件落库只能在 Android 上验证」,
措辞修正留给埋点侧工单顺带处理(§7)。
## 6. 测试数变化
| 项 | 基线 | 本单后 |
|----|------|--------|
| flutter test | 458+2 门控冒烟跳过) | **502(+44,门控冒烟跳过 2** |
| flutter analyze | 0 | 0 |
| dart format | 无 diff | 无 diff |
新增分布:
- **发布页 widget 22**`post_compose_page_test.dart`):gating 2(空正文禁用
/ 在途禁用与全 ready 放行 + 汇总条)、直接发布 3(纯文字帖请求形状与漏斗
事件、求助类目两图 position/封面/mediaCount、**删格重排** position 重发号)、
失败三语义 4(网络**同键重放**实证两次同键、迁移失败「草稿已保存」且重试只
补 PATCH、40905 换新键、42203 提示与埋点)、存草稿 5manual / on_exit /
「不保留」软删 + post_deleted / 「继续编辑」不动服务端 / 空表单直接离页)、
草稿恢复 6(提示条与预填、恢复后只 PATCH 且 fromDraft=true、重新选图整组
替换、40902 自动重提、「清空」、恢复失败静默降级)、结构 2。
- **post 域埋点单测 11**(键集与白名单逐一对齐、分桶四档边界、异常 →
failureReason 映射、隐私红线断言「无 postId / 无精确字数 / 无字节数」)。
- **媒体三段埋点 7**`media_uploader_test.dart` 扩展):成功一对且 sizeBucket
同值 + durationMs、压缩终态 media_too_large、断连 → retry 的 attemptSeq
递增、createUpload 40000 → unsupported_format 带 errorCode/httpStatus、
在途删格 cancelled 与 ready 后删格不报、会话失效不上报。
- **编辑态九宫格 widget 4**(空列表只出+格 / 满 9 隐藏+格 / 删除角标回传
localId / 上传中与失败态覆盖层与整格重试,终态无重试通栏)。
- **主壳发布闭环 1**`main_shell_publish_test.dart`):创作 Tab 入口 → 发布 →
回首页 + **两次 getFeed(整体刷新)** + 新帖置顶 + SnackBar。
- 首页测试 1 处随回调改名更新(`onOpenCreate` → `onOpenCompose`),helpers 扩
createPost/updatePost/deletePost/listMyPosts 钩子与幂等键记录、真 PNG 字节。
验证命令(patbond-flutter 仓库根执行):
```bash
flutter analyze
flutter test
dart format --set-exit-if-changed --output=none .
```
## 7. 遗留与交接
- **草稿自动保存与草稿列表页**:本单只做「显式两路径 + 进页恢复最新一条」。
完整草稿管理(`draft_list` 页名已在 v3 页面族预留、我的帖子按 status 过滤
的接口已就位)与 05 §2.3 的自动保存(防抖 2s)留待——自动保存需先定「一份
草稿反复 PATCH」的语义与 `post_draft_saved` 不埋自动保存的口径衔接。
- **话题域**TopicChip / 话题选择 sheet / `topic_followed` 事件均待契约端点,
`topicCount` 现恒 0。
- **位置**:ListTile 为占位(无契约字段),点按 SnackBar 提示。
- **AI 作品发布**create 页 AI 结果是生成图(无本地文件、无 media asset),
走不了两步上传,其「发布到社区」现为占位提示,随 M4 AI 能力一并接。
- **拖拽排序**(05 §6 D9 可选)未做;删格重排已保证 position 正确。
- **真机项**device-verification.md「M3 预登记」第 4 项(社区事件落库)
**本单已补全细则**——含发布漏斗成链、媒体三段逐文件成对与 attemptSeq、
隐私红线核对、Feed 与互动事件、`page_viewed(post_form)` 页名归一化、
rejected=0 六条通过标准,并写明「桌面 platform=linux 整批 400,落库只能在
Android 验证」的前置事实。第 1 项(媒体弱网)与本项建议同一轮执行。
- **埋点侧措辞修正**(非阻塞):`analytics_service.dart` 关于桌面上报被拒的
注释应由「逐条 rejected」改为「整批 400」(§5d),留给埋点侧工单顺带处理。
- `MediaUploaderFactory` 是**测试与桌面实测专用**注入口(生产恒缺省):
Linux 桌面既无 image_picker 也无 flutter_image_compress 原生实现,真链路
实测只替换选图与压缩两层。
---
**Frontend DeveloperFlutter**
**日期**2026-09-10