diff --git a/docs/development/device-verification.md b/docs/development/device-verification.md index 0795680..e447e67 100644 --- a/docs/development/device-verification.md +++ b/docs/development/device-verification.md @@ -111,10 +111,57 @@ _(待真机到位后填写:日期、设备型号/Android 版本、两项结 以下为 M3 交付过程中预计产生的真机专属验证项,**各工单收口时在此补全具体步骤与通过标准**: -1. **媒体上传弱网表现**:真机蜂窝/弱 Wi-Fi 下选图→压缩→预签名直传→确认全链路;中断重试不产生孤儿 asset(对应 T3-13 验收的真机侧)。 -2. **乐观更新真机手感**:点赞/收藏快速连点的防抖与回滚动画在真机帧率下的表现(对应 T3-15/16)。 -3. **Feed 图片加载**:真机上滚动 Feed 的图片加载/缓存/占位表现;MinIO 经局域网/公网访问 URL 的可达性差异。 -4. **社区事件落库**:community 域 v3 事件(platform=android)落库观察(沿 M2 验证一的方法,事件名换 v3 增量)。 +1. **媒体上传弱网表现**(T3-13 收口补全,2026-09-09):真机蜂窝/弱 Wi-Fi 下选图→压缩→预签名直传→确认全链路;中断重试不产生孤儿 asset。 + + **前置**:通用前置准备的后端六容器在位;`PATBOND_MINIO_PUBLIC_ENDPOINT` 必须配置为手机可达地址(工作机局域网 IP:9000,.env 覆盖后重启 compose)——预签名直传 URL 直指 MinIO,漏配则手机端 PUT 必然连不上;`flutter run` 时四个 base URL 全传(含 `PATBOND_COMMUNITY_API_BASE_URL`),media 上传走 user 服务 :8082(`PATBOND_USER_API_BASE_URL`)。入口:发布页(T3-17 落地后)九宫格选图。 + + **步骤与通过标准**: + - (a)**蜂窝正常网**:相册多选 3 张 12MP 大图 → 逐格出现进度环且百分比递增(非一跳 100%)→ 全部转 ready;后端 `media.assets` 对应 3 行 `status='ready'`。压缩耗时中端机单张 ≤2s(超出记录机型上报)。 + - (b)**弱网中断重试**:开发者选项限速或电梯/地库弱网,上传中开飞行模式掐断直传 → 该格转失败态(红色蒙层 + 重试通栏),其余图不受影响;恢复网络点格内重试 → 转 ready。 + - (c)**孤儿不引用**:在(b)失败态与上传中态各尝试一次发布 → 发布钮 gating 拦截(全部 ready 前不可提交);发帖成功后 psql 核对 `community.post_media` 引用的 assetId 全部 `status='ready'`,且不含(b)中断产生的旧 assetId(该行保持 `uploading`,属服务端超时清理范围,不算失败)。 + - (d)**凭据过期**:选一张图后挂起 App >10 分钟再恢复触发重试 → 客户端自动换新凭据完成上传(用户无感知,不弹「签名过期」类错误)。 + - (e)**HEIC/方向**:iPhone 传输的 HEIC 图与横拍竖拍各一张 → 压缩层统一出 jpeg 且方向正确(服务端 mime 白名单不收 HEIC,此项只能真机验证原生编解码)。 +2. **乐观更新真机手感**(T3-15/16 收口补全,2026-09-09):点赞/收藏快速连点的合并与回滚动画在真机帧率下的表现;Feed 卡片与详情页跨页状态一致。 + + **前置**:通用前置准备的后端六容器在位;`flutter run` 时四个 base URL 全传(含 `PATBOND_COMMUNITY_API_BASE_URL=http://<局域网IP>:8084`)。数据:Feed 内至少一条他人发布的帖子(可按 iteration-3 24 号报告 §5(a)种子方式造)。 + + **步骤与通过标准**: + - (a)**激活动画帧率**:Feed 卡片与详情页各点赞一次 → 图标同帧翻转 + 240ms 弹性缩放(1→1.25→1)+ 计数即时 ±1;中低端机无可见掉帧或延迟出现的「二次跳动」。取消点赞仅颜色渐出、无缩放。 + - (b)**快速连点合并**:同一帖 1 秒内连点点赞 5~6 次 → 视觉每次即时翻转;抓包或服务端访问日志核对该帖 like 端点请求 ≤2 个(单飞 + 最终意图补发);停点后终态与最后一次点击一致,计数与 `GET /api/v1/posts/{id}` 权威值相符。 + - (c)**断网回滚**:开飞行模式后点赞 → 图标即时翻转,数秒内**零动画直接跳回**原状态(不得出现「心已灭计数未减」的中间帧或回弹动画)+ SnackBar「操作失败,请重试」恰一条;恢复网络重点 → 正常收敛。 + - (d)**跨页一致**:Feed 卡片点赞 → 进详情页应已是激活态;详情页取消收藏 → 返回 Feed 卡片同步取消(同一 ToggleSync 实例,无需刷新)。 + - (e)**减弱动态**:系统开启「移除/减弱动画」后点赞 → 状态瞬变、无缩放动画,功能不受影响。 +3. **Feed 图片加载**(T3-14 收口补全,2026-09-09):真机上滚动 Feed 的图片加载/缓存/占位表现;MinIO 经局域网/公网访问 URL 的可达性差异。 + + **前置**:通用前置准备的后端六容器在位;`PATBOND_MINIO_PUBLIC_ENDPOINT` 必须配置为手机可达地址(工作机局域网 IP:9000,.env 覆盖后重启 compose)——Feed 卡片封面 URL 是服务端现签的预签名 GET、直指 MinIO,漏配则真机图片全部走失败兜底(`surfaceTint` 底 + pets 图标);`flutter run` 时四个 base URL 全传(含 `PATBOND_COMMUNITY_API_BASE_URL=http://<局域网IP>:8084`)。数据:桌面/工作机先按 iteration-3 24 号报告 §5(a)的种子方式发 ≥26 帖(含单图/多图),保证两页以上可翻。 + + **步骤与通过标准**: + - (a)**首屏与占位**:登录进 Feed → 图片卡先出 `surfaceTint` 加载块(无白闪/布局跳动),随后出图;多图卡右下「+N」角标可读(ink 80% 胶囊白字)。 + - (b)**滚动加载**:连续滚到列表底再回顶 → 中低端机不掉帧卡死;回滚经过已看过的图**不重新转圈**(缓存 key 已剥签名参数,同图不同签名命中同一内存缓存——若出现「每次刷新同图重新下载」即为缓存 key 回归,判失败)。 + - (c)**下拉刷新后的缓存命中**:下拉刷新(服务端对同一批图重新现签、URL 必然变化)→ 已展示过的封面应即时出图不过转圈;抓包或 MinIO 访问日志核对同对象未重复 GET。 + - (d)**过期 URL 重取**:Feed 停留 >1 小时(预签名 TTL)后滚到未加载过的卡 → 旧 URL 过期图走失败兜底属预期,下拉刷新取新签 URL 后恢复出图,无崩溃。 + - (e)**可达性差异**:Wi-Fi(局域网 IP)与蜂窝(若 MinIO 未公网暴露)各滚一遍——蜂窝下连不上 MinIO 时应稳定显示失败兜底图标而非无限转圈;记录两种网络的首图出图耗时。 +4. **社区事件落库**(T3-17 收口补全,2026-09-10):community 域 v3 事件(platform=android)落库观察(沿 M2 验证一的方法,事件名换 v3 增量)。**桌面端不可替代**:Linux 桌面的 `platform=linux` 不在契约枚举内,整批 400 被拒(`analytics_service.dart` 既有预期行为),故 v3 事件的**落库**只能在 Android 上验证;键集与形态的落库正确性已在工作机以 curl 造真实 payload 验证(iteration-3/26 §5c 发布/媒体 8 事件、iteration-3/25 §5c 互动 8 事件)。 + + **前置**:通用前置准备的后端六容器在位;`flutter run` 时四个 base URL 全传(含 `PATBOND_COMMUNITY_API_BASE_URL`);`PATBOND_MINIO_PUBLIC_ENDPOINT` 配为手机可达地址(媒体三段需真实直传)。可与第 1、2 项同一轮操作合并执行。 + + **步骤**:登录 → 首页 Feed 滚两屏并下拉刷新一次 → 进一条帖详情点赞/收藏/评论一次 → 返回 → 创作 Tab「发布动态」→ 输入正文 + 选 2 张图 → 「存草稿」一次 → 「发布」→ 回 Feed 确认新帖 → **退到后台等 5 秒**(触发冲刷)→ 工作机查库: + + ```bash + docker exec patbond-postgres-1 psql -U patbond -d patbond -c \ + "SELECT event_name, platform, props FROM platform.product_events + WHERE event_name LIKE 'post\_%' OR event_name LIKE 'feed\_%' + OR event_name LIKE 'comment\_%' OR event_name LIKE 'user\_%' + ORDER BY client_ts DESC LIMIT 40;" + ``` + + **通过标准**: + - [ ] (a)**发布漏斗成链**:`post_create_started`(entryPoint=create_tab) → `post_draft_saved`(trigger=manual, mediaCount=2) → `post_publish_succeeded`(fromDraft=true、mediaCount=2、topicCount=0、textLengthBucket、durationMs>0) 三条齐全且 `platform=android`;无 `post_publish_failed`(顺利路径)。 + - [ ] (b)**媒体三段逐文件成对**:`post_media_upload_started` / `_succeeded` 各 **2** 条(每张图一条),`sizeBucket` 同一张图的 started/succeeded 取值一致,`durationMs` 为真实上传耗时(非 0);中断重试的那张(与第 1 项(b)合并执行时)另有 `post_media_upload_failed`(failureReason=network_error, attemptSeq=1) + 重试后 started 的 `attemptSeq` 递进。 + - [ ] (c)**隐私红线**:上述 props 中**不含** postId / assetId / commentId / 文件名 / 本地路径 / URL / 精确字数(`textLength`)/ 精确字节数(`byteSize`)——出现任一即验收失败(红线 1/2/4)。 + - [ ] (d)**互动与 Feed**:`post_liked`/`post_favorited`(source=feed 或 post_detail)、`comment_create_succeeded`、`feed_viewed`(离开 Feed 时一条,impressionCount>0、refreshCount=1)落库;**无** `post_impression`/`post_viewed`(字典锁死为 unknown,若出现即客户端违规)。 + - [ ] (e)**页名归一化**:`page_viewed` 出现 `pageName='post_form'`(发布页)与 `'post_detail'`,且 pageName/referrer 中**不含 UUID**。 + - [ ] (f)拒绝计数为 0:查 app 日志无 `Analytics batch permanently rejected`,或服务端响应 `rejected=0`(有 rejected 说明事件名/键集与字典不符,属回归)。 ## 执行记录(M3) diff --git a/docs/development/iterations/iteration-3/21-community-datalayer.md b/docs/development/iterations/iteration-3/21-community-datalayer.md new file mode 100644 index 0000000..91ef8ce --- /dev/null +++ b/docs/development/iterations/iteration-3/21-community-datalayer.md @@ -0,0 +1,158 @@ +# 21 M3 第三波:community feature 数据层(T3-12) + +**执行日期**:2026-09-09 +**工单**:T3-12 community feature 数据层——第三波前置,T3-13~17 全依赖本单 +**契约依据**:openapi.yaml v1.3.0(冻结稿,community/media 域 13 路径 / 19 操作) +**提交**:patbond-flutter dev `19bd8c1`(基线 `66f983d`) + +--- + +## 0. 概要 + +照 M2 pets 数据层模式(Controller / Repository / ApiClient 分层、类型化异常、 +分端口直连)新建 `lib/features/community/`,交付五个生产文件 + 四个测试文件: + +| 文件 | 职责 | +|------|------| +| `lib/features/community/community_models.dart` | 全部 DTO,手写 JSON 逐字段照契约;未知枚举抛 FormatException 暴露漂移 | +| `lib/features/community/community_exceptions.dart` | v1.3.0 新增 9 码 + 40902 共码的类型化异常与映射 | +| `lib/features/community/community_repository.dart` | 抽象接口 + ApiCommunityRepository,19 操作全覆盖 | +| `lib/features/community/toggle_sync.dart` | 点赞/收藏共用的乐观更新状态机(数据层部分) | +| `lib/features/community/community_controller.dart` | Feed 多页缓存 + 四态骨架、详情副本、reset | +| `lib/core/models/cursor_page.dart` | CursorPage 自 pet_models 上移 core(pets 侧 export 兼容,零调用方改动) | + +配套改动:`lib/core/network/api_client.dart` 新增 `patbondCommunityApiBaseUrl` +(`--dart-define=PATBOND_COMMUNITY_API_BASE_URL`,默认 `http://127.0.0.1:8084`); +`lib/core/network/api_exception.dart` ApiCodes 增 9 码;`lib/app/app.dart` 装配 +CommunityController(共享 TokenRefresher,登出与 pets 同步 reset)。 +主壳 UI 未接线(T3-14 挂 Feed segment 时注入)。 + +**质量门禁**:`flutter test` 347/347 全绿(基线 286,+61)、`flutter analyze` +0 问题、`dart format --set-exit-if-changed` 无 diff。 + +--- + +## 1. 19 操作覆盖对照表 + +| # | operationId | 方法/路径 | 仓库方法 | 备注 | +|---|-------------|-----------|----------|------| +| 1 | createMediaUpload | POST /api/v1/media/uploads | `createMediaUpload` | 两步上传第一步,返回预签名 PUT 凭据(TTL 10 min,不持久化) | +| 2 | completeMediaUpload | POST /api/v1/media/uploads/{assetId}/complete | `completeMediaUpload` | 服务端幂等(已 ready 重复 confirm 200 同 asset) | +| 3 | createPost | POST /api/v1/posts | `createPost` | **Idempotency-Key 必带** | +| 4 | getPost | GET /api/v1/posts/{postId} | `getPost` | 防枚举 40403 | +| 5 | updatePost | PATCH /api/v1/posts/{postId} | `updatePost` | version 乐观锁;`publish: true` 即 `status: published`;media 三态(缺席/[]/整组替换) | +| 6 | deletePost | DELETE /api/v1/posts/{postId} | `deletePost` | 软删,重复删同 404/40403 | +| 7 | listMyPosts | GET /api/v1/me/posts | `listMyPosts` | keyset 游标 + status 过滤(draft\|published) | +| 8 | getFeed | GET /api/v1/feed | `getFeed` | (published_at,id) 游标 | +| 9 | listComments | GET /api/v1/posts/{postId}/comments | `listComments` | 游标分页,单层平铺 | +| 10 | createComment | POST /api/v1/posts/{postId}/comments | `createComment` | **Idempotency-Key 必带**;replyToUserId 可选 @ | +| 11 | deleteComment | DELETE /api/v1/comments/{commentId} | `deleteComment` | 顶层短路径先例 | +| 12 | likePost | PUT /api/v1/posts/{postId}/like | `likePost` | 语义幂等,返回权威 {liked,likeCount} | +| 13 | unlikePost | DELETE /api/v1/posts/{postId}/like | `unlikePost` | 取消不存在的点赞 200 no-op | +| 14 | bookmarkPost | PUT /api/v1/posts/{postId}/bookmark | `bookmarkPost` | 与点赞同构 | +| 15 | unbookmarkPost | DELETE /api/v1/posts/{postId}/bookmark | `unbookmarkPost` | 同上 | +| 16 | listMyBookmarks | GET /api/v1/me/bookmarks | `listMyBookmarks` | 项形态 = FeedCard | +| 17 | followUser | PUT /api/v1/users/{userId}/follow | `followUser` | 自关注 422/42204 | +| 18 | unfollowUser | DELETE /api/v1/users/{userId}/follow | `unfollowUser` | 自取关 200 幂等 no-op | +| 19 | getFollowStats | GET /api/v1/users/{userId}/follow-stats | `getFollowStats` | 实时 COUNT,查自己 followedByMe 恒 false | + +定型语义落点(20 号收口 §1 逐条): + +- **预签名 URL 不持久化**:MediaUploadCredentials / MediaAsset.url / + PostMediaItem.url / AuthorSummary.avatarUrl 的 doc 注释均标注「每次响应现签, + 不得持久化、过期即重取」,DTO 不做任何本地缓存。直传 PUT 本体属 T3-13, + 本单只到协议层(凭据 DTO 含 `requiredHeaders` 原样映射)。 +- **Idempotency-Key 必带 + 刷新重放同键**:键在仓库层每次调用生成一次 + (UUID v4,≤128 字符),ApiClient 401/40101 单飞刷新后的重放走同一 headers + ——同键命中服务端首次结果,不重复建帖/评论(测试断言两次请求同键)。 +- **PUT/DELETE 权威终态**:四个互动方法与关注两方法直接返回服务端 + LikeState/BookmarkState/FollowState,ToggleSync 以此对账。 +- **防枚举 40403**:PostNotFoundException 注明「hidden/archived 对作者亦不露、 + 互动面 = 帖子公开面含本人草稿」。 +- **AuthorSummary nullable 降级**:`isDegraded`(nickname 与 avatarUrl 同为 + null)一个占位判定口,客户端不做昵称回退拼装。 + +## 2. 错误码映射(v1.3.0 新增 9 码 + 共码) + +| 码 | 类型化异常 | 语义 | +|----|-----------|------| +| 40301 | PostAccessDeniedException | 对可见帖/评论无操作权限 | +| 40403 | PostNotFoundException | 帖子防枚举合并 | +| 40404 | CommentNotFoundException | 评论防枚举合并 | +| 40405 | MediaAssetNotFoundException | asset 防枚举合并 | +| 40406 | CommunityUserNotFoundException | 目标用户不存在/已注销 | +| 40902 | PostVersionConflictException | 乐观锁共码,community 域独立类型 | +| 40905 | IdempotencyMismatchException | 同键异 payload | +| 42203 | MediaNotReadyException | 引用非 ready asset | +| 42204 | SelfFollowException | 自关注(仅 PUT) | +| 42205 | MediaUploadStateException | confirm 状态不允许 | + +未覆盖码(40000、40401 宠物码等)原样透传通用 ApiBusinessException, +既有按基类捕获的处理不受影响(与 pets 域映射器同构)。 + +## 3. ToggleSync 状态机(数据层部分) + +03 号评估 §3 草案的定稿实现,点赞/收藏共用一套(字段读写 read/write、 +端点 send、代次 generation 全参数化,like/bookmark 各持一实例): + +``` +点击 toggle(id) + ├─ read(id) == null(已被刷新剔除)→ 作废 + ├─ 立即 write 翻转内存副本(计数 ±1,同帧反馈) + ├─ 无在途链 → 记快照(链起点)+ 记代次 → send(target) + └─ 有在途链 → 只并入 pendingTarget,不发新请求(单飞) + +响应到达 + ├─ 链已被 reset / 代次不符(期间刷新)→ 丢弃,不覆盖不回滚 + ├─ 成功且 pendingTarget ≠ 确认态 → 以最终意图补发一次(连点至多两在途) + ├─ 成功且意图一致 → write 服务端权威 {active,count}(吸收他人并发偏差),清链 + └─ 失败 → 校验「id 仍可读且当前态 == 本轮乐观目标」后恢复快照, + onError 轻提示,不自动重试,清链 +``` + +一句话:**乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫,以服务端 +权威终态收敛**。UI 侧 SnackBar/图标反馈属 T3-15/16(controller 已暴露 +`toggleError` 一次性消费口)。 + +CommunityController 骨架:首屏四态(initial/loading/ready/error)+ 尾部 +LoadMorePhase(idle/loading/error)+ 多页内存缓存;刷新 = 代次 +1 + 整体 +替换(失败保留旧列表走 refreshError);loadMore 携带上一页 nextCursor, +旧代次尾页响应丢弃(避免刷新后重复/错位);详情 `getPost` 入 `_postCache` +并回写卡片互动字段(Feed 卡片与详情页同源);`reset()` 登出清态 +(app.dart 与 pets 同一监听点)。 + +## 4. 测试数变化 + +| 项 | 基线 | 本单后 | +|----|------|--------| +| flutter test | 286 | **347(+61)** | +| flutter analyze | 0 | 0 | +| dart format | 无 diff | 无 diff | + +新增分布:模型映射与请求序列化 15、仓库 19 操作线路 + 幂等键 + 错误映射 24、 +controller 竞态序列(四态/游标拼接/单飞补发/代次守卫/reset)22。 +竞态序列全部用 FakeCommunityRepository + Completer 控时序(test/helpers 先例)。 + +验证命令(仓库根目录执行): + +```bash +cd +flutter analyze +flutter test +dart format --set-exit-if-changed --output=none . +``` + +## 5. 契约核对与遗留 + +- 本单实现与 openapi.yaml v1.3.0 逐字段核对,**未发现契约不一致**, + 未改动契约与 patbond-api。 +- CreatePostRequest.category 只开放 general/help(ai_creation 提交 + 400/40000),DTO 读侧三值、写侧由调用方约束;Post/FeedCard 读侧可解析 + ai_creation。 +- 遗留给后续工单:T3-13 预签名 PUT 直传客户端(裸 Dio,两段异构错误)、 + T3-14 Feed segment UI 接线(主壳注入 CommunityController)、 + T3-15/16 互动 UI 反馈(SnackBar 消费 toggleError)、T3-17 发布页。 + +--- +**Frontend Developer(Flutter)** +**日期**:2026-09-09 diff --git a/docs/development/iterations/iteration-3/22-event-whitelist-v3.md b/docs/development/iterations/iteration-3/22-event-whitelist-v3.md new file mode 100644 index 0000000..8851af4 --- /dev/null +++ b/docs/development/iterations/iteration-3/22-event-whitelist-v3.md @@ -0,0 +1,76 @@ +# 22 事件字典 v3 白名单扩充(T3-20 后端,ADR-020) + +**执行日期**:2026-09-09 +**交付**:EventDictionary v2 → v3(22 → 42 事件)+ 全套边界测试,patbond-api dev @ `8089c06` +**依据**:06 号报告 §1.4/§1.5(事件与 props schema)、§1.2(feed_viewed 聚合裁定)、§1.3(隐私红线增量)、§6.1(pageName 页面族) + +--- + +## 0. 概要 + +| 项 | 值 | +|------|------| +| 新增事件 | **20**(06 号 §1.5 的 19 个 + experiment_exposed 已含其中,编号 22~40) | +| 字典总量 | 22 → **42** | +| 测试 | 325 → **334**(+9:EventDictionaryTest +6、AnalyticsIntegrationTest +3),全绿 | +| openapi.yaml | **零变更**——/api/v1/events 契约对事件名开放(键级校验在字典层),复核无需动 | +| check-secrets.sh --all | 通过(exit 0) | + +改动仅限 patbond-user analytics 包三个文件:`EventDictionary.java`、`EventDictionaryTest.java`、`AnalyticsIntegrationTest.java`。 + +## 1. 新增事件与 06 号对照清单 + +props 键集与 06 号 §1.5「工单可直接抄」代码块**逐键一致**(原样落地,零偏差): + +| # | 事件名 | props 白名单 | 06 号出处 | +|---|--------|--------------|-----------| +| 22 | `post_create_started` | entryPoint | §1.4 发布漏斗 | +| 23 | `post_draft_saved` | trigger, mediaCount | §1.4 发布漏斗 | +| 24 | `post_publish_succeeded` | durationMs, mediaCount, topicCount, textLengthBucket, fromDraft | §1.4 发布漏斗(漏斗事件) | +| 25 | `post_publish_failed` | failureReason, errorCode, httpStatus, attemptSeq | §1.4 发布漏斗 | +| 26 | `post_deleted` | (空集——单事件风格无专有属性) | §1.4 发布漏斗 | +| 27 | `post_media_upload_started` | mediaType, sizeBucket | §1.4 媒体漏斗(逐文件) | +| 28 | `post_media_upload_succeeded` | mediaType, sizeBucket, durationMs | §1.4 媒体漏斗(漏斗事件) | +| 29 | `post_media_upload_failed` | mediaType, sizeBucket, failureReason, errorCode, httpStatus, attemptSeq | §1.4 媒体漏斗 | +| 30 | `feed_viewed` | feedTab, durationMs, impressionCount, loadMoreCount, refreshCount | §1.2/§1.4 聚合曝光(首个高频事件) | +| 31 | `feed_load_failed` | feedTab, loadType, failureReason, errorCode, httpStatus | §1.4 Feed 消费 | +| 32 | `post_liked` | source | §1.4 互动 | +| 33 | `post_unliked` | source | §1.4 互动 | +| 34 | `post_favorited` | source | §1.4 互动 | +| 35 | `post_unfavorited` | source | §1.4 互动 | +| 36 | `comment_create_succeeded` | durationMs, isReply, textLengthBucket | §1.4 互动 | +| 37 | `comment_create_failed` | failureReason, errorCode, httpStatus, attemptSeq | §1.4 互动 | +| 38 | `user_followed` | source | §1.4 互动 | +| 39 | `user_unfollowed` | source | §1.4 互动 | +| 40 | `experiment_exposed` | experimentKey, variant | §1.4 实验基建(A/B 前置 #5,M4 启用字典先行) | + +**故意不进字典**(测试侧同步锁死为 unknown):`post_impression`(§1.2 逐卡曝光否决)、`post_viewed`(§1.4 由 page_viewed(post_detail) 覆盖)、`comment_create_started`(短表单不设 started)、`post_like_failed`/`user_follow_failed` 等单点互动失败(靠服务端错误率观测)、`topic_followed/unfollowed`(§1.6 缺口 3,UI 定稿前挂起待拍板)。 + +## 2. pageName 页面族核对(§6.1) + +字典侧 pageName 的登记处只有 EventDictionary 的 javadoc 注释(ingest 只校验 props **键**,`page_viewed` 键集 pageName/referrer 不变)——已按 §6.1 同步为 v3 页面族:v2 九个 + 收编 4(create/pet_archive/services/post_detail)+ 新增 9(post_form/topic_list/topic_detail/user_profile/follower_list/following_list/favorite_list/draft_list)。与 §6.1「后端零改动提示」一致,无任何校验代码变更;值级枚举仍由客户端编译期 + 离线巡检兜底。 + +## 3. 测试增量(325 → 334) + +**EventDictionaryTest +6**(沿既有 `containsExactlyInAnyOrder` 键集锁定模式): + +1. `v3PostPublishFunnelMatchesDictionary` — 发布漏斗五事件,含 post_deleted 空集断言 +2. `v3MediaUploadFunnelMatchesDictionary` — 媒体三段漏斗 +3. `v3FeedDomainMatchesDictionary` — feed_viewed 聚合键集(无任何内容 ID 键)+ feed_load_failed +4. `v3InteractionEventsMatchDictionary` — 互动八事件(分立事件名,无 action 属性) +5. `v3ExperimentExposedRegisteredAheadOfM4Use` — experimentKey/variant +6. `v3DeliberatelyAbsentEventsStayUnknown` — §1 末段七个故意不设事件 + +**AnalyticsIntegrationTest +3**(沿 v2 端到端先例): + +1. `acceptsV3FeedViewedAggregateEvent` — feed_viewed 全键入库落表 +2. `stripsContentIdPropsFromV3InteractionEvent` — post_liked 混入白名单外 `postId` 被剥离(红线 2 的 ingest 侧兜底) +3. `rejectedPerCardImpressionStaysOutOfDictionary` — post_impression 按 unknown_event_name 拒绝(§1.2 裁定锁死) + +全套 `./mvnw clean test`:**334 测试 0 失败**(user/auth/pet/community/common 五模块 BUILD SUCCESS)。 + +## 4. 边界与遗留 + +- **契约零变更**:events 接口对事件名开放,openapi.yaml/契约快照均不需动,本工单未触碰。 +- **Flutter 半边未动**:客户端强类型封装(post_analytics.dart / feed_analytics.dart / community_interaction_analytics.dart / analytics_page_name.dart 增量)属 T3-20 客户端半边,不在本工单。 +- **待拍板项不预埋**:`content_rejected` 失败枚举(审核环节待拍板)与 `topic_followed`(UI 定稿)均未进字典,拍板后按 eventVersion 惯例增补。 diff --git a/docs/development/iterations/iteration-3/23-media-upload-client.md b/docs/development/iterations/iteration-3/23-media-upload-client.md new file mode 100644 index 0000000..ee6198b --- /dev/null +++ b/docs/development/iterations/iteration-3/23-media-upload-client.md @@ -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/40000(mime/大小白名单外) | 参数拒绝 | 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 号 §2,MediaController 在 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 # 六容器全部 Up,postgres/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 → +createUpload(user :8082,凭据 uploadUrl 指向 MinIO :9000)→ 预签名 PUT +直传(真实 DioMediaDirectUploadClient)→ confirm → ready assetId → +`buildAttachRequests` 引用发帖(community :8084,published)→ 帖子响应中 +预签名 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 状态机 20(happy path 3、并发顺序 2、弱网失败语义 +9、孤儿防护 4、选图容量 4,含凭据过期重取、403 换凭据、42205 重试换新 +asset、终态 retry no-op、在途 remove/reset 作废不 confirm、全生命周期快照 +assetId 仅 ready 非空);直传层本地 HttpServer 3(200 逐字节到达 + +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 Developer(Flutter)** +**日期**:2026-09-09 diff --git a/docs/development/iterations/iteration-3/24-feed-page-report.md b/docs/development/iterations/iteration-3/24-feed-page-report.md new file mode 100644 index 0000000..c6f9a53 --- /dev/null +++ b/docs/development/iterations/iteration-3/24-feed-page-report.md @@ -0,0 +1,149 @@ +# 24 M3 第三波:首页 Feed 接入真实数据(T3-14) + +**执行日期**:2026-09-09 +**工单**:T3-14 首页 Feed segment 替换真实数据——社区 demo 消亡的第一页 +**依赖**:21 号(T3-12 数据层,CommunityController 就位)、05 号 UI 规范、06 号埋点规划(feed 域白名单已随 api dev@8089c06 就绪) +**提交**:patbond-flutter dev `8aac8c5`(基线 `1441f01`) + +--- + +## 0. 概要 + +`home_page.dart` 的 Feed segment 由「AppState demo 帖子 + 500ms 假延时刷新」 +整体切换为 `CommunityController` 真实数据:四态首屏、尾部三态、下拉刷新与 +游标翻页、聚合曝光埋点全部落地;PostCard 三形态等 4 个共享组件入 +`lib/core/widgets/`;预签名 URL 的图片缓存 key 剥签名改造全仓生效。 +**未动 patbond-api**;create/post_detail 的 demo 按工单边界留给 T3-15/17。 + +**质量门禁**:`flutter test` 421/421 全绿(基线 379,+42)、`flutter analyze` +0 问题、`dart format --set-exit-if-changed` 无 diff、compose 六容器真链路 +实测通过(§5)。 + +## 1. 四态与尾部三态覆盖表 + +| 态 | 渲染 | 交互 | widget 测试 | +|----|------|------|------------| +| 首屏 loading(initial/loading) | `FeedSkeleton` 连排 3 张(呼吸动效,尊重系统减弱动态设置静止 1.0) | — | ✓ | +| 首屏 error | `InlineErrorBanner`(pets 同款话术映射)+ 「重试」FilledButton | 重试 = 用户刷新(计入浏览段 refreshCount)| ✓(含恢复 ready)| +| 首屏 empty | `EmptyStateIllustration`(forum_outlined「还没有动态」)+ CTA「发布第一条」 | CTA → 创作 Tab | ✓ | +| ready | `PostCard` 列表(卡间距 16) | 见 §2 | ✓(含降级作者)| +| 尾部 loading | 24 转圈(primary)居中,上下留白 16 | 滚动近底(余量 400)自动触发,携上页 nextCursor | ✓ | +| 尾部 error | 错误话术 + 「加载失败,点此重试」 | **只走显式点按重试**——失败态不随滚动通知自动重打(实测发现滚动风暴会把失败态冲掉并重复请求,已加守卫) | ✓(含事件上报与重试补页)| +| 尾部到底 | 「没有更多了」12 `inkSoft` 居中 | — | ✓ | + +刷新语义照 controller 契约:下拉刷新失败且旧列表在手 → 保留列表不闪空态, +SnackBar 轻提示 + `feed_load_failed` 上报(widget 测试覆盖「失败保留旧列表 → +再刷成功整体替换不残留」全序列)。翻页不丢不重由 controller 代次守卫保证 +(21 号已测),本单 widget 测试再从 UI 侧验证:两页游标取齐后三帖各恰一张。 + +搜索框保留 demo 交互(客户端过滤已加载多页缓存;契约 v1.3.0 无检索端点), +过滤中不渲染尾部三态(翻页语义混淆);无命中沿用既有 `EmptyState`。 + +## 2. 组件落位 + +| 组件 | 落位 | 说明 | +|------|------|------| +| `PostCard` | `lib/core/widgets/post_card.dart` | 三形态:单图(mediaCount≤1 有封面)通栏出血 4:3;多图(mediaCount>1)走 PostMediaGrid 折叠封面;纯文字正文放宽 6 行、15/1.6。头部 `PetAvatar` sm32 + 名字 14/w700 + 相对时间 12 `inkSoft`;求助帖追加 `TagPill(accent)`。次级文字全部显式 `inkSoft`(DEBT-2 零新增) | +| `PostMediaGrid` | `lib/core/widgets/post_media_grid.dart` | 展示态:列数规则 2/4→2 列、3/5–9→3 列,格间距 4、圆角 sm12;超 9 图末格 `ink` 80% scrim + 白字 +N 20/w800(05 §5.1 精算,60% 档弃用)。**形态偏差**:FeedCard 契约只带 coverImage+mediaCount(裁剪形态),Feed 卡多图实渲染为 4:3 封面 + 右下 +N 胶囊角标(同 80% scrim 精算);真九宫格留给详情/发布页全量媒体场景。编辑态(+格/删除角标)随 T3-17 扩展 | +| `LikeButton` | `lib/core/widgets/like_button.dart` | 点赞/收藏参数化一件:未激活 `inkSoft`;点赞激活 `error` 图标 + `errorDark` 计数(demo `Colors.red` 3.13:1 修订清零,D6);收藏激活 `accentDark`。触控 44×44 | +| `FeedSkeleton` | `lib/core/widgets/feed_skeleton.dart` | 05 §3.7 单元结构;0.6↔1.0 呼吸 1200ms,`disableAnimations` 静止 | +| `SignedNetworkImage` | `lib/core/network/signed_network_image.dart` | 预签名 URL 缓存 key 剥离 `X-Amz-*` 签名参数(大小写不敏感、保留其余 query),`RemoteImage` 全仓换用——同对象两次响应 URL 必然不同,剥签名后命中同一 ImageCache 条目,未命中仍以完整签名 URL 请求 | +| `community_display.dart` | `lib/features/community/` | 相对时间、加载失败话术、降级作者「宠友」统一占位(`isDegraded` 一个判定口 + `PetAvatar` 无图占位形态) | + +**T3-14 互动取舍**(工单预留的选项里选了禁用态):demo 详情页按 +`appState.posts` 查 demo id,无法渲染服务端 postId 的真实帖,导航过去即崩; +故整卡点按先弹 SnackBar「帖子详情正在接入真实数据」,点赞/收藏/评论/分享 +按钮为**纯展示禁用态**(真实计数与激活态照常渲染,`onPressed` 传 null)。 +T3-15 详情页重写后接导航,T3-15/16 接 ToggleSync 与激活动画。 + +主壳装配:`app.dart` 注入 `communityController` + `feedAnalytics` 进 +`MainShellPage` → `HomePage`(`isActive = currentIndex == 0` 驱动浏览段); +`openPost(PostModel)` 保留给 create demo 流(T3-17 收编)。home 对 +`AppState.posts` 的消费清零,`posts` 字段本体随 T3-15/17 退役。 + +## 3. 曝光结算设计(feed_viewed / feed_load_failed) + +一句话:**浏览段聚合**——进入 Feed 面开段,离开(切 Tab / 切服务分段 / +退后台 / 页面销毁)时结算发**一条** `feed_viewed`,postId 只作段内内存 +去重键、绝不上报(06 §1.2 裁定 + 隐私红线 2)。 + +- **判定**:卡片可见面积 ≥50%(列表视口与卡片 RenderBox 纵向交叠比例)且 + 驻留 ≥500ms;驻留计时在 `FeedViewSegment`(`feed_exposure.dart`),跌破 + 阈值/滚出视口即取消。扫描统一调度到 post-frame(滚动通知发生在本帧布局 + 前,同步读 RenderBox 是旧位置——实测踩到,已修)且一帧至多一次。 +- **计数口径**:`refreshCount` = 用户下拉/错误重试(首屏自动预取不计); + `loadMoreCount` = 触底翻页请求(含尾部显式重试);`durationMs` 前台 + 时长(退后台即结算,段天然前台连续),30 分钟截断。 +- **生命周期**:`WidgetsBindingObserver` 只在离开 resumed 的**第一次**变更 + 结算(inactive→hidden→paused 级联不重复,SessionTracker 同款处理); + 回前台若仍在 Feed 面开新段。`settle()` 幂等,一段恰一条。 +- **feed_load_failed**:refresh / load_more 双路,`failureReason` 网络归并 + 口径同 pet 域(断网/超时/5xx → network_error),`errorCode` 仅业务码、 + `httpStatus` 由五位码推导;**会话失效不上报**(应用即将回登录页)。 +- **页名核对**:Feed 属首页 Tab,`page_viewed(home)` 由既有 Tab 补点覆盖, + 无新增页名;`post_detail` 枚举已在(T3-15 接线导航后自动生效)。 + +两事件线上验证见 §5(白名单 202 accepted + `platform.product_events` 落库)。 + +## 4. 测试数变化 + +| 项 | 基线 | 本单后 | +|----|------|--------| +| flutter test | 379 | **421(+42,另 1 个既有默认跳过冒烟)** | +| flutter analyze | 0 | 0 | +| dart format | 无 diff | 无 diff | + +新增分布:home_page widget 测试 13(四态 4、尾部三态与翻页 3、刷新失败 +序列 1、曝光结算 4——切 Tab/快速滑过/退后台/切分段、取舍与搜索 2)、 +PostCard 6(三形态/求助标/降级作者/操作行展示态)、PostMediaGrid + +FeedSkeleton 7、FeedViewSegment 5(fake_async 控驻留时序)、FeedAnalytics 4 +(属性逐字段 + 异常映射)、缓存 key 与 provider 判等 7。另 +`integration_test/feed_live_test.dart` 桌面真链路 1 条(环境变量门控, +默认跳过不计入套件)。 + +实现期修正两处(widget 测试暴露):尾部失败态被滚动通知自动重试冲掉 +(加 idle 守卫);回前台 `_lastLifecycle` 读旧值导致不开新段(resumed +分支先置状态)。 + +## 5. compose 真链路实测 + +后端 patbond-api dev@8089c06(含 feed 域白名单),六容器 `docker compose +up -d --build` 全部 Up、postgres/minio healthy。 + +**(a)数据种子 + 接口链路(curl)**:注册一次性账号 → 发 26 帖 +(24 纯文字 + 1 单图 + 1 双图,图走 media 两步上传:预签名 PUT 直传 +MinIO → confirm → ready assetId 引用发帖,全部 published)。 +`GET /api/v1/feed?limit=20` 首页 20 条 hasMore=true → 携 nextCursor 取第二页 +6 条 hasMore=false,**两页零重叠、26 条取齐**;封面预签名 GET 回读字节与 +上传原件 `cmp` 一致。`feed_viewed` / `feed_load_failed` 按客户端真实 payload +形状 `POST /api/v1/events` → 双双 202 accepted,`platform.product_events` +落库 props 完整(feedTab/durationMs/impressionCount/loadMoreCount/ +refreshCount;loadType/failureReason)。 + +**(b)Linux 桌面真跑(integration_test)**: +`PATBOND_FEED_LIVE=1 flutter test integration_test/feed_live_test.dart -d linux` +——真实 App 桌面渲染管线 + 真实 HTTP + MinIO 预签名图片(仅注入内存 +token 存储,桌面无 keyring):注册 → UI 登录 → Feed 首屏真数据卡片 +(含单图/多图 +1 角标卡)→ fling 触底游标翻页至「没有更多了」且最早 +一帖(#1)在列(两页取齐直接证据)→ 回顶下拉刷新列表仍健。**一次通过** +(约 25s)。测后 `docker compose down` 干净退出,patbond-api 零改动。 + +**遗留观察**:桌面端 analytics 真实上报因 platform 枚举不含桌面值被服务端 +整批拒绝(既有已知约束,device-verification 通用前置已记载),不影响 +本单验证((a) 已按契约 platform 验真);Feed 图片加载的真机表现(蜂窝 +网络、缓存命中、局域网/公网 MinIO 可达性)预登记补全见 device-verification +M3 第 3 项。 + +## 6. 遗留与交接 + +- T3-15:详情页重写后把 PostCard `onTap` 接 `openPost` 导航(页名 + `post_detail` 既有)、评论钮锚点;LikeButton 接 ToggleSync + §3.5 激活 + 动画与回滚零动画;`AppState.posts` 消费面只剩 create/post_detail。 +- T3-16/17:收藏交互、发布页(PostMediaGrid 编辑态 + UploadProgressOverlay + 已在)。 +- 多图九宫格全量形态:详情页拿到 `Post.media` 全量后启用(PostMediaGrid + 列数规则与 +N 已就绪并有测试)。 + +--- +**Frontend Developer(Flutter)** +**日期**:2026-09-09 diff --git a/docs/development/iterations/iteration-3/25-detail-interactions-report.md b/docs/development/iterations/iteration-3/25-detail-interactions-report.md new file mode 100644 index 0000000..b135ca7 --- /dev/null +++ b/docs/development/iterations/iteration-3/25-detail-interactions-report.md @@ -0,0 +1,184 @@ +# 25 M3 第三波:帖子详情页替换 + 互动接线(T3-15/T3-16) + +**执行日期**:2026-09-09 +**工单**:T3-15 帖子详情页整页替换(demo 数据层退役)+ T3-16 互动接线(ToggleSync UI 层),同域合并交付 +**依赖**:21 号(T3-12 数据层,ToggleSync/CommunityController 就位)、24 号(T3-14 组件与遗留交接)、05 号 UI 规范 §2.2/§3.4/§3.5/§4、22 号事件白名单 v3、17 号后端评论/互动语义 +**提交**:patbond-flutter dev `92524da`(T3-16 基建)+ `f873acf`(T3-15/16 页面与接线),基线 `8aac8c5`,已推送 origin/dev + +--- + +## 0. 概要 + +`post_detail_page.dart` 整页重写为真实数据:四态首屏、媒体全量渲染(真九宫格 + +全屏大图)、作者卡关注双态、评论区(游标列表 / 输入条创建 / 仅本人可删)全部 +落地;点赞/收藏经共享 ToggleSync 接入 Feed 卡片与详情页(同一 controller 实例, +互动状态跨页一致),按 05 号 §4 三层视觉抑制实现;互动域 8 事件挂接完成。 +Feed 整卡点按导航详情接通,T3-14 的占位 SnackBar 与禁用态移除。**未动 +patbond-api**;create 页 demo 留给 T3-17。 + +**质量门禁**:`flutter test` 458/458 全绿(基线 421,+37;另 2 个 env 门控 +compose 冒烟默认跳过)、`flutter analyze` 0 问题、`dart format +--set-exit-if-changed` 无 diff、compose 六容器真链路实测通过(§5,含断网 +点赞回滚)。 + +## 1. 详情页四态与结构(T3-15) + +| 态 | 渲染 | widget 测试 | +|----|------|------------| +| loading(无内存副本) | 居中转圈;评论区独立骨架 2 个(32 圆 + 圆角 16 块高 72,05 §3.7) | ✓ | +| 内存副本先渲染 | 进入即展示 controller 缓存内容,`getPost` 后台拉新静默替换(Feed 卡片互动字段一并回写) | ✓ | +| error | `InlineErrorBanner`(pets 同款话术映射)+ 「重试」;有副本时后台刷新失败不打断阅读 | ✓ | +| **40403 不存在态** | SnackBar「帖子不存在或已被删除」→ **返回 Feed 并触发整体刷新**(失效帖剔除);详情 / 评论 / 评论创建三条路径均可触发,单次守卫防重复 pop | ✓ | +| ready | 媒体区 → 作者卡 → 正文卡 → 操作行 → 评论区,底部固定输入条 | ✓ | + +结构落点(05 §2.2 对照): + +- **媒体区(本单裁定:真九宫格,D11 轮播方案弃用)**:单图原比例通栏、高度 + 钳制 [宽×0.75, 宽×1.33](widthPx/heightPx 缺失回落 4:3);多图走 + `PostMediaGrid` 全量形态(24 号预留的列数规则 2/4→2 列、3/5–9→3 列与 + 超 9 折叠「+N」直接生效)。点格进全屏大图:黑底 + `InteractiveViewer` + (03 号拍板 E 选①内置方案,零依赖)+ 横滑翻页 + 双击定点 2.5x 缩放 + + 右上「n/N」ink 胶囊(13.50:1)与关闭钮。**偏差**:05 §2.2 的「下滑关闭」 + 与 InteractiveViewer 平移手势冲突,本版未做(关闭钮 + 返回手势可退出), + 留待 photo_view 复评(03 号 E 的升级条件「体验不达标」)。 +- **作者卡**:`PetAvatar` md44 + 名字/相对时间;关注双态钮见 §3。 +- **正文卡**:标题 titleMedium + 求助帖 `TagPill(accent)` + 正文 14/1.6 全文 + + 「发布于 …」12 `inkSoft`。契约 Post 无话题字段,TopicChip 不涉本单。 +- **操作行**:与 Feed 卡片同一套组件卡外裸排(demo 的 FilledButton.tonalIcon + 弃用);评论锚点钮点按聚焦底部输入框(唤起键盘直接开写)。 +- **输入条**:surface 底 + 顶部 border 1px 分隔线(demo 缺失,已补)+ isDense + 输入框 + filled 发送钮(空文本禁用;发送中 18 转圈锁尺寸)。 +- **AppBar 分享**:占位 SnackBar「分享功能即将上线」(无契约端点)。 + +## 2. 评论区(T3-15) + +- **游标列表**:`(created_at DESC, id DESC)` 服务端序原样渲染,触底(余量 + 400)携 nextCursor 补页,失败态只走显式重试(Feed 同款守卫);空态 + 「还没有评论,来抢沙发」(装饰图标 muted 合法、文案 inkSoft,DEBT-2 零新增)。 +- **创建**:仓库层 Idempotency-Key 每次提交换新键(21 号已测线上语义);成功 + 插入列表头 + `adjustCommentCount(+1)` 同源写入(详情副本与 Feed 卡片 + commentCount 一并更新)+ 清空输入收起键盘;失败保留输入 + 按类型话术 + SnackBar(40000 →「评论内容不合规」等),撞 40403 走不存在态流程。 +- **仅本人可删(UI 呈现)**:`currentUserId`(app.dart 注入 sessionManager.userId) + 与评论 author.userId 相等才渲染「删除」入口——权限判定只做 UI 自见性, + 服务端 40301/40404 仍是裁决者(17 号 §2.3)。删除经确认弹窗 → 软删成功 + 剔除 + 计数 -1;40404(已在别处删)本地同步剔除;40301 提示无权限。 +- **CommentTile 升共享组件**(`lib/core/widgets/comment_tile.dart`,05 §3.4): + PetAvatar sm32 + 气泡(surface/border 1px/圆角 16/padding 12);@ 回复以 + 「回复 @昵称:」前缀呈现(响应 replyToUser,含降级「宠友」占位);删除 + in-flight 转圈锁定。**取舍**:评论点赞(§3.4 底行右端)无契约端点不渲染; + @ 回复的**发起** UI 与长按操作 sheet(回复/复制/举报)留待后续工单 + (数据层 replyToUserId 已支持,isReply 埋点属性预留)。 + +## 3. 互动视觉实现(T3-16,05 §3.5/§4 三层抑制对照) + +| 层 | 规范 | 实现落点 | +|----|------|---------| +| 即时反馈 | 点按即刻翻转 + 激活动画 | ToggleSync 乐观写入同帧 notify;LikeButton 升 Stateful——点按驱动的激活播 240ms 弹性缩放(1→1.25→1)+ 120ms 图标淡入,取消仅 120ms 颜色渐出无缩放 | +| 连点合并 | 只发最终态 | 由数据层单飞合并承担(在途链只并入 pendingTarget、完成后按最终意图至多补发一次,连点至多两在途);UI 不再叠加 600ms 计时防抖——ToggleSync 已保证「合并后只发最终态」的语义,双状态机会打架(D8 的跨角色确认以 21 号定稿为准) | +| 回滚静默化 | 零动画 + 成对恢复 + SnackBar | 非点按驱动的状态变化(回滚/对账)直接跳变;**计数与展示态成对更新**(不出现「心已灭计数未减」中间帧);激活动画未播完等播完再跳(§4.3a);`toggleError` 一次性消费出 SnackBar「操作失败,请重试」(Feed 页与详情页共用消费口,先消费者清空,同帧恰一条) | +| 对账不打扰 | 静默替换计数 | 服务端权威计数与乐观值不同(他人并发)时数字直接替换、无动画(LikeButton 对「状态不变的计数变化」不播任何过渡) | + +关注钮同策略(§4.5):乐观翻转、失败直接跳回 + SnackBar;取关先确认 +「不再关注 TA?」;本人帖不渲染(自关注 42204 不给触发面);关注状态经 +`getFollowStats.followedByMe` 拉取,拉取失败不渲染钮(不阻塞阅读)。 +系统「减弱动态效果」开启时全部动画降级瞬变(LikeButton 与 FeedSkeleton 同口径)。 + +**跨页一致**:Feed 卡片与详情页共享同一 CommunityController/ToggleSync 实例, +互动写入经 `_writeInteraction` 同帧更新详情副本与 Feed 卡片(widget 测试从 +UI 侧断言「详情点赞、卡片同帧 +1」)。 + +## 4. 埋点挂接清单(8 事件 + 口径) + +新增 `community_interaction_analytics.dart`(22 号白名单键集逐一对齐, +编译期锁死): + +| # | 事件 | 触发点 | props | 挂接位置 | +|---|------|--------|-------|---------| +| 1 | `post_liked` | 点赞**成功响应后** | source(feed / post_detail) | CommunityController send 闭包(触点在 toggle 调用处归因) | +| 2 | `post_unliked` | 取消点赞成功响应后 | source | 同上 | +| 3 | `post_favorited` | 收藏成功响应后 | source | 同上 | +| 4 | `post_unfavorited` | 取消收藏成功响应后 | source | 同上 | +| 5 | `comment_create_succeeded` | 评论创建成功响应后 | durationMs、isReply、textLengthBucket | 详情页提交回调 | +| 6 | `comment_create_failed` | 评论创建失败 | failureReason、errorCode、httpStatus、attemptSeq | 详情页提交回调 | +| 7 | `user_followed` | 关注成功响应后 | source=post_detail | 详情页关注钮 | +| 8 | `user_unfollowed` | 取关成功响应后 | source=post_detail | 详情页关注钮 | + +口径说明(widget/单元测试逐字段断言): + +- **成功才报**:乐观翻转与失败回滚不报(06 §1.4「点赞/收藏/关注不埋失败」); + 单飞合并链每个**实际抵达服务端并成功**的状态变更各报一条(快速连点合并后 + 至多两条、方向相反,与「成功响应后」字典口径一致)。 +- **comment_create_started 不发**(22 号锁死 unknown);`durationMs` 取 + 「输入会话首字符 → 成功响应」(评论无 started 事件,时长随成功事件带出); + `textLengthBucket` 分桶 empty/short(≤50)/medium(51–500)/long(>500),精确 + 字数不出端(红线 1);`attemptSeq` 输入会话内从 1 递增,成功或清空输入重置; + `httpStatus = code ~/ 100`(pet 域同款);会话失效不上报;postId/commentId + 等内容 ID 一律不进 props(红线 2)。 +- **follow UI 判定**:详情页作者卡有关注钮(demo 形态保留升级双态),故 + user_followed/unfollowed 本单接通;user_profile / follow_list 触点随 + 后续页面启用。 + +## 5. compose 真链路实测 + +后端 patbond-api dev@`8089c06` 六容器 `docker compose up -d --build` 全部 +Up、postgres/minio healthy;测毕 `docker compose down` 干净退出,patbond-api +零改动。 + +**(a)互动一轮(`test/smoke/detail_interactions_smoke_test.dart`,env 门控 +`PATBOND_DETAIL_SMOKE=1`,生产 ApiClient/Repository/Controller 全真实现)**: +注册一次性账号 → 发帖(published)→ 点赞(`{liked:true, likeCount:1}`)→ +**重复 PUT 幂等不重复计数** → 收藏/取消(计数 1→0)→ 评论创建 +(Idempotency-Key,`commentCount` 0→1)→ 仅作者删除评论(`commentCount` +回 0、列表剔除)→ 权威计数逐步对账。**一次通过**。 + +**(b)断网点赞回滚(同测试内,生产 CommunityController + ToggleSync)**: +Feed 刷新拿到该帖(likedByMe=true/count=1)→ community 端点整体切至不可达 +端口模拟断网(连接拒绝走生产 ApiClient 的真实 ApiNetworkException 链路)→ +`toggleLike`:乐观翻转**同帧可见**(false/0)→ 请求失败后**快照成对回滚** +(true/1)、`toggleError` 为 ApiNetworkException(SnackBar 消费口就位)→ +恢复网络再 toggle → 服务端权威终态收敛(likedByMe=false/likeCount=0)。 +**一次通过**(首轮实测暴露测试自身竞态:以乐观值判收敛会早退,已改为轮询 +服务端权威终态)。 + +**(c)互动 8 事件白名单验真(curl,客户端真实 payload 形状)**: +`POST /api/v1/events`(user :8082)一批 8 条(platform=android)→ +**202 accepted 8 / rejected 0**,`platform.product_events` 落库 props 完整 +(source / durationMs+isReply+textLengthBucket / failureReason+attemptSeq+ +errorCode+httpStatus 逐键核对无剥离)。 + +真机专属项(乐观更新手感:连点合并请求数、回滚动画帧率、跨页一致、减弱 +动态降级)已补全 device-verification.md「M3 预登记」第 2 项的步骤与通过标准。 + +## 6. 测试数变化 + +| 项 | 基线 | 本单后 | +|----|------|--------| +| flutter test | 421(+1 门控冒烟跳过) | **458(+37,门控冒烟跳过 2)** | +| flutter analyze | 0 | 0 | +| dart format | 无 diff | 无 diff | + +新增分布:post_detail_page widget 测试 17(四态 5 含 40403 弹回刷新与内存 +副本先渲染、媒体九宫格与全屏大图 1、评论区 5——游标补页/失败重试/创建成败 +与 attemptSeq/删除权限与 40301、互动 4——乐观翻转/失败回滚/收藏事件/跨页 +一致、关注 5)、LikeButton 动画 6(点按激活缩放/取消无缩放/外部零动画跳变/ +动画中回滚等播完/对账静默/禁用态)、CommentTile 3、互动埋点单测 6(键集/ +分桶边界/失败原因映射)、home_page 更新 3(导航接通替换 T3-14 占位断言、 +点赞接线成功事件、失败回滚 SnackBar)、helpers 扩展(评论/关注假仓钩子)。 +另 compose 冒烟 1 条(env 门控,默认跳过不计入套件)。 + +## 7. 遗留与交接 + +- **T3-17 发布页**:create 页 demo 数据层(AppState.publishPost/updatePost 与 + `posts` 字段本体)随发布页真实化退役;demo 发布流现只回 Feed 不再导航 + (demo 详情页已消亡);PostMediaGrid 编辑态 + UploadProgressOverlay 已在。 +- **@ 回复发起 UI / 长按操作 sheet(举报)**:数据层与埋点属性(isReply) + 已支持,交互留待范围拍板。 +- **大图浏览下滑关闭**:与 InteractiveViewer 平移手势冲突未做,photo_view + 复评条件不变(03 号 E)。 +- **真机项**:device-verification.md M3 预登记第 2 项待真机执行(连点合并 + 请求数 ≤2 的抓包核对只能在真机/真网完成)。 + +--- +**Frontend Developer(Flutter)** +**日期**:2026-09-09 diff --git a/docs/development/iterations/iteration-3/26-publish-page-report.md b/docs/development/iterations/iteration-3/26-publish-page-report.md new file mode 100644 index 0000000..736da26 --- /dev/null +++ b/docs/development/iterations/iteration-3/26-publish-page-report.md @@ -0,0 +1,303 @@ +# 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 diff --git a/docs/development/iterations/iteration-3/27-wave3-closure.md b/docs/development/iterations/iteration-3/27-wave3-closure.md new file mode 100644 index 0000000..2b1385f --- /dev/null +++ b/docs/development/iterations/iteration-3/27-wave3-closure.md @@ -0,0 +1,40 @@ +# 27 M3 第三波收口:Flutter 社区接入完成 + +**执行日期**:2026-09-09 +**交付**:冻结契约 v1.3.0 下社区全页面族接入真实后端,社区 demo 数据消亡 + +--- + +## 0. 概要 + +| 工单 | 交付 | 提交(flutter dev) | 测试 | +|------|------|------|------| +| T3-12 数据层 | 19 操作 DTO/Client/Repository + 9 新错误码 + ToggleSync + CursorPage 上移 core | 19bd8c1 | 286→347 | +| T3-13 媒体上传客户端 | MediaUploader 六态 + 孤儿防护 + 降质阶梯 + 凭据过期重取 | 1441f01 | →379 | +| T3-14 Feed 替换 | 四态 + 尾部三态 + 曝光浏览段聚合 + SignedNetworkImage | 8aac8c5 | →421 | +| T3-15/16 详情与互动 | 整页替换 + 真九宫格大图 + 评论区 + ToggleSync 跨页一致 + 互动 8 事件 | 92524da / f873acf | →458 | +| T3-17 发布页 | PostComposePage + 草稿两路径 + gating + 发布漏斗 8 事件 | 9892b65 | →502 | +| 字典 v3 白名单(api) | EventDictionary 22→42 事件 + 7 锁死事件边界 | api dev@8089c06 | api 325→334 | + +**波末状态**:patbond-flutter **502 测试**全绿、analyze 0 问题、format 无 diff;patbond-api **334 测试**全绿。 + +## 1. 里程碑意义 + +- **社区 demo 在三页全面消亡**:`AppState.posts/publishPost/updatePost` 及其持久化整体退役;home Feed、post_detail、create 发布半边全部真实后端驱动(create 页仅余 AI 生成模拟,属 M4 范围零改动) +- **M3 验收标准逐条取证**:发布后另一客户端可见(T3-17 双 App 实例实测)、重复点赞不重复计数(后端真并发 + 前端 ToggleSync)、分页不丢不重(T3-14 游标专项 + 26 帖实测)、删除/隐藏不出 Feed(后端谓词 + 40403 防枚举) +- **媒体链路端到端**:选图→压缩→预签名直传 MinIO→confirm→引用发帖→预签名 GET 展示,四次 compose 实测无契约偏差;签名 URL 缓存 key 剥离(SignedNetworkImage)全仓生效 +- **乐观更新完整落地**:ToggleSync(乐观翻转/单飞合并最终意图/代次守卫/服务端权威终态收敛)+ 三层视觉抑制(240ms 弹性动画/失败零动画跳变/对账静默替换),Feed 与详情页共享实例同帧一致 +- **埋点 v3 端到端**:客户端挂接 21 事件(feed 2 + 互动 8 + 媒体 3 + 发布漏斗 5 + page_viewed 页名增量),后端白名单 42 事件承接,7 个被否决事件在字典层锁死 + +## 2. 实现期修正与发现 + +- **T3-13 抓修 T3-12 遗留缺陷**:media 两步上传端点在 user 服务(:8082),T3-12 误挂 community 客户端(:8084 无 media 路由,真链路必 404);compose 实测暴露,增 mediaApi 分端口直连修正 +- **T3-14 测试暴露两处真 bug**:尾部失败态被滚动自动重试冲掉、回前台不开新曝光段 +- **T3-17 两步发布定型**:createPost(draft) → PATCH published,使「发布失败但草稿已保存」成为事实而非话术 +- **桌面替身局限记录**:Linux 桌面无 image_picker/compress 平台实现(用替身)、`platform=linux` 使埋点整批 400(既有预期),两者均已写入真机验证清单前置 + +## 3. 遗留与下波 + +- 完整草稿列表与自动保存(26 号 §7)、大图「下滑关闭」手势(photo_view 复评)、话题功能(ADR-018 剪出) +- uploading 超时清理定时任务、429 Retry-After 分支(待后端限流) +- **第四波收官**:E2E 烟囱(社区全链路 + M3 四条验收标准取证)→ M3 收官总结 + feature-checklist 增补;真机四项已在 device-verification.md 备齐步骤,待设备到位执行 diff --git a/mkdocs.yml b/mkdocs.yml index 20e9d3f..93b7da6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -84,6 +84,13 @@ nav: - 18 契约冻结 v1.3.0: development/iterations/iteration-3/18-contract-freeze-report.md - 19 快照同步与矩阵: development/iterations/iteration-3/19-contract-sync-report.md - 20 第二波收口: development/iterations/iteration-3/20-wave2-closure.md + - 21 社区数据层: development/iterations/iteration-3/21-community-datalayer.md + - 22 埋点白名单 v3: development/iterations/iteration-3/22-event-whitelist-v3.md + - 23 媒体上传客户端: development/iterations/iteration-3/23-media-upload-client.md + - 24 Feed 页接入: development/iterations/iteration-3/24-feed-page-report.md + - 25 详情页与互动: development/iterations/iteration-3/25-detail-interactions-report.md + - 26 发布页与漏斗: development/iterations/iteration-3/26-publish-page-report.md + - 27 第三波收口: development/iterations/iteration-3/27-wave3-closure.md - API: - 契约说明: api/index.md - 架构: