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

21 KiB
Raw Permalink Blame History

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,与 buildAttachRequestsStateError 孤儿防护形成双保险(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 口径:指「本次发布基于先前保存/恢复的草稿」;发布内部 的建草稿→迁移两步不算(否则该字段恒真、失去分析意义)。
  • durationMspost_create_started → 发布成功;媒体段为单次尝试 started → ready。
  • sizeBucket 取原图字节数(压缩前),保证同一次尝试三段事件桶值一致; 精确字节数、文件名、路径、URL 一律不出端(红线 4)。
  • attemptSeq:发布为本页发布尝试序号;媒体为单图尝试序号(retry 递增, 重试的 started 与 failed 同序号)。
  • textLengthBucket 复用 community_interaction_analytics.darttextLengthBucketOf(不重复实现),精确字数不出端(红线 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 仍报 createAI 创作面,语义未变)。
  • 未接触点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 干净退出。

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.dartenv 门控 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_pickerflutter_image_compress 均无 Linux 平台实现(桌面选图这一步在 Linux 上物理 不可达),实测注入 1x1 真 PNG 字节与透传压缩,其余全为生产实现。原生选图/ 压缩行为仍属真机项(device-verification M3 第 1 项 (e))。

落库核对(psql):

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 0PATCH {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/eventsuser :8082)一批 8 条(platform=androidprops 逐键 按 §4 客户端实际形状)→ 202 accepted 8 / duplicated 0 / rejected 0 platform.product_events 落库 props 完整无剥离:

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 22post_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 / 无精确字数 / 无字节数」)。
  • 媒体三段埋点 7media_uploader_test.dart 扩展):成功一对且 sizeBucket 同值 + durationMs、压缩终态 media_too_large、断连 → retry 的 attemptSeq 递增、createUpload 40000 → unsupported_format 带 errorCode/httpStatus、 在途删格 cancelled 与 ready 后删格不报、会话失效不上报。
  • 编辑态九宫格 widget 4(空列表只出+格 / 满 9 隐藏+格 / 删除角标回传 localId / 上传中与失败态覆盖层与整格重试,终态无重试通栏)。
  • 主壳发布闭环 1main_shell_publish_test.dart):创作 Tab 入口 → 发布 → 回首页 + 两次 getFeed(整体刷新) + 新帖置顶 + SnackBar。
  • 首页测试 1 处随回调改名更新(onOpenCreateonOpenCompose),helpers 扩 createPost/updatePost/deletePost/listMyPosts 钩子与幂等键记录、真 PNG 字节。

验证命令(patbond-flutter 仓库根执行):

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