# 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.3(P3 规范)、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 14(padding 撑到 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` | 进页后**首次输入**(首个字符或首次选媒体),每次进入一次 | entryPoint(create_tab / feed) | 发布页输入与 uploader 监听 | | 2 | `post_draft_saved` | 草稿保存**成功响应后** | trigger(manual / 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=android,props 逐键 按 §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 提示与埋点)、存草稿 5(manual / 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 Developer(Flutter)** **日期**:2026-09-10