docs: M3 第三波收口——报告 21~27 入档挂导航
CI / docs-build (push) Successful in 2m2s

- 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>
This commit is contained in:
2026-09-10 14:48:33 +08:00
parent a611acb358
commit f5457c2f4c
9 changed files with 1150 additions and 4 deletions
@@ -0,0 +1,182 @@
# 23 M3 第三波:媒体上传客户端(T3-13)
**执行日期**2026-09-09
**工单**:T3-13 媒体上传客户端(L,关键路径)——选图到确认的完整客户端链路,T3-17 发布页依赖本单
**协议依据**:13 号报告 §3 凭据形态定型表 + §4 偏差清单(两步上传协议权威描述)、契约 v1.3.0
**提交**patbond-flutter dev `1441f01`(基线 `19bd8c1`
---
## 0. 概要
在 T3-12 数据层(createUpload / confirm 协议层)之上补齐直传 PUT 本体与编排,
交付五个生产文件 + 五个测试文件:
| 文件 | 职责 |
|------|------|
| `lib/features/community/media_uploader.dart` | MediaUploader 编排状态机(本单核心,接口按 03 号评估 §4.3 冻结稿定稿) |
| `lib/features/community/media_picking.dart` | 选图抽象 + image_picker 系统选择器实现 |
| `lib/features/community/media_compression.dart` | 压缩抽象 + flutter_image_compress 原生实现(长边 ≤2048、统一转码 jpeg、不保留 EXIF |
| `lib/features/community/media_direct_upload.dart` | 预签名 PUT 直传客户端(裸 Dio,无鉴权拦截器,进度回调) |
| `lib/core/widgets/upload_progress_overlay.dart` | 可复用进度覆盖层(05 号规范 §3.3 四态) |
**并发现并修正一处 T3-12 遗留缺陷**(§4):media 两步上传端点误挂 community
客户端。**compose 六容器真链路实测通过**(§5)。
**质量门禁**`flutter test` 379/379 全绿(基线 347,+32;另有 1 个默认跳过的
compose 冒烟测试)、`flutter analyze` 0 问题、`dart format --set-exit-if-changed`
无 diff。
## 1. MediaUploader 状态机
单张图生命周期(`MediaItemPhase`):
```
queued ──► compressing ──► uploading(progress 0..1) ──► confirming ──► ready(assetId)
│ │ │ │
│ 超限终态失败 网络中断/存储拒绝 42205 / 网络异常
│ ▼ ▼ ▼
└──────► failed(retryable?) ◄─────┴───────────────────────┘
│ retry(仅 retryable
└──► queued(复用压缩产物,从 createUpload 全新开始,换新 assetId
```
uploader 级另有 `isPicking`(系统选择器拉起中)。编排要点:
- **压缩策略**03 号 §4.1 + 13 号偏差 #6):长边 ≤2048 重采样、统一转码
JPEG、降质阶梯 80 → 60;两档后仍超 10 MiB → **终态失败(不可重试)**,
不发起任何网络调用。`keepExif` 保持关闭,顺带剥离 GPS 隐私;
`autoCorrectionAngle` 矫正方向。
- **多图并发与顺序保持**:并发槽位默认 2(信号量覆盖压缩到 confirm 全段);
items 顺序 = 加入顺序 = position 语义,完成先后乱序不影响
`buildAttachRequests` 发号(测试实证第 2 张先 ready 仍归位 index 1)。
单图失败不拖垮整批,其余照常 ready。
- **凭据纪律**:预签名凭据只以局部变量存在、用完即弃,不持久化(沿用纪律);
直传 PUT 原样携带 `requiredHeaders`Content-Type 已签进签名)。
- **孤儿防护(未 confirm 的 asset 不得被引用)三重保证**:
1. confirm 前的服务端 assetId 只以管线局部变量存在,不落任务状态;
2. 对外快照 `MediaUploadItem.assetId` 与 ready 态**构造期断言绑定**;
3. 交付口 `buildAttachRequests` 在任何非 ready 项在场时抛 `StateError`
移除/reset 后的在途结果一律作废(不 confirm,服务端 uploading 超时清理
兜底,13 号 §6 方案)。
## 2. 弱网 / 失败语义矩阵
| 故障点 | 表现 | 客户端语义 | 自动处置 | 手动 retry 后 |
|--------|------|-----------|---------|--------------|
| 压缩后仍超 10 MiB | 本地判定 | failed **终态** | 无 | no-op |
| createUpload 400/40000mime/大小白名单外) | 参数拒绝 | failed **终态** | 无 | no-op |
| createUpload 网络异常 | — | failed 可重试 | 无 | 全新 createUpload |
| PUT 前凭据已过期(30s 安全边距预检) | 本地判定 | 透明恢复 | 重新 createUpload **一次**(换新 assetId/凭据),仍过期才 failed | 全新 createUpload |
| 直传 PUT 403(签名过期/被改动) | 存储侧拒绝 | 透明恢复 | 重新 createUpload **一次**并重传,再 403 才 failed(可重试) | 全新 createUpload |
| 直传 PUT 断连/超时 | 网络型 | failed 可重试 | 无 | 全新 createUpload |
| confirm 42205(对象未上传,服务端保持 uploading) | 可恢复 | failed 可重试 | 无 | 全新 createUpload |
| confirm 42205(内容不符,服务端置 failed 终态) | 不可恢复 | failed 可重试* | 无 | 全新 createUpload |
| confirm 返回非 ready(防御分支) | — | failed 可重试 | 无 | 全新 createUpload |
\* 两种 42205 客户端不可区分(同码同形态),统一按「可重试 + 重试换新
asset」处理:对「保持 uploading」分支旧 asset 成为服务端可清理的 uploading
僵尸,对「置 failed」分支旧 asset 本就终态——两分支都正确收敛,旧 assetId
一律弃引用(孤儿防护保证其不会被发帖引用)。重试复用压缩产物(不重压缩)。
## 3. 可复用进度组件
`UploadProgressOverlay`(05 号 §3.3 逐条落位):排队(ink 40% scrim +
「等待中」白字衬 ink 80% 胶囊)/ 上传中(白色环形进度 36 value 态 + 百分比
胶囊;confirming 定格 100%/ 成功(scrim 150ms 淡出无残留,IgnorePointer
不拦截点击)/ 失败(error 12% scrim + errorDark 图标 + 底部「重试」通栏,
整格点按重试;**终态失败不显示重试通栏**)。九宫格组装与页级线性汇总条
`overallProgress` 已暴露)留 T3-17。
## 4. T3-12 遗留缺陷修正:media 端点线路
**发现**media 两步上传端点(`POST /api/v1/media/uploads[...]`)由 **user
服务**提供(13 号 §2MediaController 在 patbond-user :8082),community
服务只有帖子/评论路由与媒体**读取侧**签名(MediaUrlSigner);而 T3-12 的
`ApiCommunityRepository` 把 19 操作全部挂在 community 客户端(:8084)——
media 两操作真链路必 404(T3-12 只做了协议层,无实测暴露点)。
**修正**`ApiCommunityRepository` 增可选 `mediaApi` 客户端,media 两方法
走它(未提供回落主客户端,既有测试桩不受影响);app.dart 装配处为其构建
user 服务基址(`patbondUserApiBaseUrl`)的第二 ApiClient,共享
SessionManager 与单飞 TokenRefresher。仓库测试改为双 adapter 断言线路不串。
compose 真链路实测(§5)证实修正必要且有效。**未动 patbond-api。**
## 5. compose 六容器真链路实测
实测记录(2026-09-09,本机):
```bash
cd <你的工作区>/patbond-api
./deploy/init-secrets.sh
JAVA_HOME=<你的 JDK17 路径> ./mvnw -DskipTests package # BUILD SUCCESS
docker compose up -d --build # 六容器全部 Uppostgres/minio healthy
cd <你的工作区>/patbond-flutter
PATBOND_MEDIA_SMOKE=1 flutter test test/smoke/media_upload_smoke_test.dart
# 00:01 +1: All tests passed!
cd <你的工作区>/patbond-api && docker compose down # 干净退出
```
冒烟测试(`test/smoke/media_upload_smoke_test.dart`,默认 skip 不计入常规
套件)驱动**真实 MediaUploader** 走完整链路:注册一次性账号取 token →
createUploaduser :8082,凭据 uploadUrl 指向 MinIO :9000)→ 预签名 PUT
直传(真实 DioMediaDirectUploadClient)→ confirm → ready assetId →
`buildAttachRequests` 引用发帖(community :8084published)→ 帖子响应中
预签名 GET URL 回读 **200 且字节与上传逐字节一致** → 删帖收尾。压缩层用
透传实现(flutter test VM 无原生编解码平台通道),其余全为生产实现。
期间修正一处冒烟脚本自身问题(注册手机号须 E.164 格式)。
## 6. 依赖新增说明
| 依赖 | 版本 | 理由 |
|------|------|------|
| `image_picker` | ^1.2.0 | 03 号评估 §4.1 选型:官方维护、pickMultiImage 多选;不引入重型相册组件 |
| `flutter_image_compress` | ^2.4.0 | 同上:原生编解码(纯 Dart image 包中端机秒级卡顿排除);质量 + 尺寸重采样 + EXIF 方向矫正 |
直传 PUT 未新增依赖(复用既有 dio,独立裸实例)。桌面平台 generated
plugin 注册文件随 pub get 更新一并入库。
## 7. 测试数变化
| 项 | 基线 | 本单后 |
|----|------|--------|
| flutter test | 347 | **379+32,另 1 个默认跳过的 compose 冒烟)** |
| flutter analyze | 0 | 0 |
| dart format | 无 diff | 无 diff |
新增分布:MediaUploader 状态机 20happy path 3、并发顺序 2、弱网失败语义
9、孤儿防护 4、选图容量 4,含凭据过期重取、403 换凭据、42205 重试换新
asset、终态 retry no-op、在途 remove/reset 作废不 confirm、全生命周期快照
assetId 仅 ready 非空);直传层本地 HttpServer 3200 逐字节到达 +
requiredHeaders 原样 + 无 Bearer/设备头泄漏、403 → isCredentialRejected、
半途断连 → 网络型可重试,照埋点队列测试先例);UploadProgressOverlay
widget 6(三态 + confirming 定格 + 终态无重试 + 成功淡出);仓库 media
线路双 adapter 改造 2(计入原有数);FakeCommunityRepository 扩 media 钩子。
验证命令(patbond-flutter 仓库根执行):
```bash
flutter analyze
flutter test
dart format --set-exit-if-changed --output=none .
```
## 8. 遗留与交接
- **T3-17(发布页)接入面**`MediaUploader`(注入 CommunityController 同源
repository 即可,其余依赖有生产默认值)+ `UploadProgressOverlay` +
`buildAttachRequests(coverIndex:)``overallProgress`/`readyCount` 供页级
汇总条;发布 gating 用 `allReady`05 号 §2.2:全部 ready 才放行提交)。
- **真机专属项**:蜂窝/弱 Wi-Fi 实测已按维护约定登记到
`docs/development/device-verification.md` M3 预登记第 1 项(步骤与通过
标准已补全)。
- flutter_image_compress 的原生压缩行为(HEIC 输入转码、超大图内存)只能
真机验证,随上项一并覆盖。
- uploading 僵尸 asset 服务端清理任务(13 号 §6)仍未实现,客户端弃引用
策略已按其到位为前提设计,无正确性风险(业务侧只认 ready)。
---
**Frontend DeveloperFlutter**
**日期**2026-09-09